From 02d316f8a0092ab18c96dc8db6badf6a64565a08 Mon Sep 17 00:00:00 2001 From: Stephen Belanger Date: Wed, 29 Jul 2026 03:51:53 +0800 Subject: [PATCH 01/14] Move agent tracing into the Rust daemon Port the Codex and Claude trace state machines, journal recovery, Braintrust delivery, and hook configuration into the shared bt-daemon crate. Replace the legacy TypeScript and shell implementations with fail-open launchers.\n\nAdd Unix-socket and Windows named-pipe transports, detached lifecycle management, cross-platform pipeline tests, and Linux/macOS/Windows CI. Signed-off-by: Stephen Belanger --- .github/workflows/ci.yml | 51 +- AGENTS.md | 4 +- bt-daemon/Cargo.lock | 2384 +++++++++- bt-daemon/Cargo.toml | 44 +- bt-daemon/README.md | 79 +- bt-daemon/docs/protocol.md | 230 + bt-daemon/src/client.rs | 189 + bt-daemon/src/dispatch.rs | 273 ++ bt-daemon/src/ids.rs | 38 + bt-daemon/src/journal.rs | 144 + bt-daemon/src/lib.rs | 449 ++ bt-daemon/src/main.rs | 187 +- bt-daemon/src/paths.rs | 118 + bt-daemon/src/server.rs | 404 ++ bt-daemon/src/sink/braintrust.rs | 376 ++ bt-daemon/src/sink/debug.rs | 62 + bt-daemon/src/sink/mod.rs | 43 + bt-daemon/src/translate/claude.rs | 1275 ++++++ bt-daemon/src/translate/codex.rs | 1589 +++++++ bt-daemon/src/translate/debug.rs | 80 + bt-daemon/src/translate/mod.rs | 148 + bt-daemon/src/transport.rs | 194 + bt-daemon/src/wire/envelope.rs | 231 + bt-daemon/src/wire/methods.rs | 94 + bt-daemon/src/wire/mod.rs | 24 + bt-daemon/src/wire/rpc.rs | 201 + bt-daemon/tests/braintrust_sink.rs | 353 ++ bt-daemon/tests/claude_translator.rs | 518 +++ bt-daemon/tests/codex_translator.rs | 839 ++++ bt-daemon/tests/pipeline.rs | 425 ++ src/plugins/claude/content/CONTRIBUTING.md | 249 +- src/plugins/claude/content/Makefile | 7 +- src/plugins/claude/content/README.md | 24 +- .../.claude-plugin/plugin.json | 2 +- .../trace-claude-code/bin/claude-hook.cmd | 40 + .../trace-claude-code/bin/claude-hook.sh | 75 + .../plugins/trace-claude-code/hooks/common.sh | 1323 ------ .../trace-claude-code/hooks/hooks.json | 60 +- .../hooks/permission_denied.sh | 95 - .../trace-claude-code/hooks/post_tool_use.sh | 217 - .../hooks/post_tool_use_failure.sh | 114 - .../trace-claude-code/hooks/record_event.sh | 41 - .../trace-claude-code/hooks/session_end.sh | 83 - .../trace-claude-code/hooks/session_start.sh | 145 - .../trace-claude-code/hooks/stop_hook.sh | 529 --- .../hooks/user_prompt_expansion.sh | 92 - .../hooks/user_prompt_submit.sh | 158 - .../plugins/trace-claude-code/hooks/worker.sh | 138 - .../plugins/trace-claude-code/setup.sh | 242 +- .../trace-claude-code/test/helpers/assert.sh | 207 - .../test/helpers/curl_stub.sh | 224 - .../test/helpers/fixtures.sh | 127 - .../trace-claude-code/test/helpers/harness.sh | 121 - .../trace-claude-code/test/helpers/replay.sh | 134 - .../test/helpers/span_tree.sh | 88 - .../trace-claude-code/test/reconcile_usage.sh | 166 - .../trace-claude-code/test/record_session.sh | 100 - .../trace-claude-code/test/run_tests.sh | 117 - .../trace-claude-code/test/test_common.sh | 460 -- .../test/test_fixture_replay.sh | 361 -- .../test/test_full_pipeline.sh | 174 - .../test/test_insert_span.sh | 262 -- .../test/test_post_tool_use.sh | 322 -- .../trace-claude-code/test/test_queue.sh | 592 --- .../trace-claude-code/test/test_replay.sh | 150 - .../test/test_session_start.sh | 244 - .../trace-claude-code/test/test_stop_hook.sh | 375 -- .../test/test_user_prompt_expansion.sh | 120 - .../test/test_user_prompt_submit.sh | 155 - src/plugins/claude/validate.sh | 18 + src/plugins/codex/build.sh | 23 +- src/plugins/codex/content/AGENTS.md | 3 +- src/plugins/codex/content/install.sh | 49 +- .../trace-codex/.codex-plugin/plugin.json | 2 +- .../content/plugins/trace-codex/.gitignore | 10 - .../content/plugins/trace-codex/AGENTS.md | 217 +- .../content/plugins/trace-codex/Makefile | 116 +- .../content/plugins/trace-codex/README.md | 154 +- .../plugins/trace-codex/bin/codex-hook.cmd | 43 +- .../plugins/trace-codex/bin/codex-hook.sh | 211 +- .../content/plugins/trace-codex/biome.json | 29 - .../plugins/trace-codex/config.json.example | 16 - .../content/plugins/trace-codex/package.json | 39 - .../plugins/trace-codex/pnpm-lock.yaml | 4059 ----------------- .../plugins/trace-codex/pnpm-workspace.yaml | 11 - .../plugins/trace-codex/scripts/build.ts | 104 - .../trace-codex/scripts/mock-collector.ts | 128 - .../plugins/trace-codex/scripts/smoke-test.sh | 177 - .../trace-codex/scripts/token-proxy.ts | 390 -- .../src/agents/codex/event-builder.test.ts | 171 - .../src/agents/codex/event-builder.ts | 125 - .../src/agents/codex/event-processor.test.ts | 2442 ---------- .../src/agents/codex/event-processor.ts | 2507 ---------- .../trace-codex/src/agents/codex/register.ts | 63 - .../src/agents/codex/resume.test.ts | 671 --- .../src/agents/codex/settings.test.ts | 179 - .../trace-codex/src/agents/codex/settings.ts | 158 - .../src/agents/codex/snapshot-store.test.ts | 103 - .../src/agents/codex/snapshot-store.ts | 161 - .../src/agents/codex/state-snapshot.test.ts | 62 - .../src/agents/codex/state-snapshot.ts | 154 - .../src/agents/codex/test-helpers.ts | 410 -- .../src/agents/codex/transcript-reader.ts | 155 - .../src/agents/codex/transcript.test.ts | 47 - .../src/agents/codex/transcript.ts | 81 - .../trace-codex/src/braintrust/logger.test.ts | 29 - .../trace-codex/src/braintrust/logger.ts | 230 - .../plugins/trace-codex/src/client/client.ts | 107 - .../src/client/ensure-server.test.ts | 121 - .../trace-codex/src/client/ensure-server.ts | 141 - .../trace-codex/src/client/spawn-server.ts | 47 - .../plugins/trace-codex/src/config.test.ts | 37 - .../content/plugins/trace-codex/src/config.ts | 68 - .../trace-codex/src/git-metadata.test.ts | 73 - .../plugins/trace-codex/src/git-metadata.ts | 53 - .../content/plugins/trace-codex/src/index.ts | 84 - .../content/plugins/trace-codex/src/log.ts | 68 - .../src/processor/event-processor.ts | 35 - .../trace-codex/src/processor/lru-map.test.ts | 51 - .../trace-codex/src/processor/lru-map.ts | 62 - .../src/processor/processor-registry.test.ts | 115 - .../src/processor/processor-registry.ts | 113 - .../trace-codex/src/replay/replay.test.ts | 71 - .../plugins/trace-codex/src/replay/replay.ts | 82 - .../trace-codex/src/server/enqueue-client.ts | 63 - .../src/server/event-queue.test.ts | 207 - .../trace-codex/src/server/event-queue.ts | 179 - .../trace-codex/src/server/mutex.test.ts | 60 - .../plugins/trace-codex/src/server/mutex.ts | 35 - .../trace-codex/src/server/recorder.test.ts | 128 - .../trace-codex/src/server/recorder.ts | 73 - .../trace-codex/src/server/routes.test.ts | 163 - .../plugins/trace-codex/src/server/routes.ts | 130 - .../trace-codex/src/server/server.test.ts | 184 - .../plugins/trace-codex/src/server/server.ts | 227 - .../trace-codex/src/server/state.test.ts | 44 - .../plugins/trace-codex/src/server/state.ts | 37 - .../plugins/trace-codex/src/test-helpers.ts | 377 -- .../plugins/trace-codex/src/version.ts | 9 - .../content/plugins/trace-codex/tsconfig.json | 19 - .../plugins/trace-codex/tsup.config.ts | 24 - .../plugins/trace-codex/vitest.config.ts | 11 - src/plugins/codex/validate.sh | 4 + 143 files changed, 11465 insertions(+), 24563 deletions(-) create mode 100644 bt-daemon/docs/protocol.md create mode 100644 bt-daemon/src/client.rs create mode 100644 bt-daemon/src/dispatch.rs create mode 100644 bt-daemon/src/ids.rs create mode 100644 bt-daemon/src/journal.rs create mode 100644 bt-daemon/src/lib.rs create mode 100644 bt-daemon/src/paths.rs create mode 100644 bt-daemon/src/server.rs create mode 100644 bt-daemon/src/sink/braintrust.rs create mode 100644 bt-daemon/src/sink/debug.rs create mode 100644 bt-daemon/src/sink/mod.rs create mode 100644 bt-daemon/src/translate/claude.rs create mode 100644 bt-daemon/src/translate/codex.rs create mode 100644 bt-daemon/src/translate/debug.rs create mode 100644 bt-daemon/src/translate/mod.rs create mode 100644 bt-daemon/src/transport.rs create mode 100644 bt-daemon/src/wire/envelope.rs create mode 100644 bt-daemon/src/wire/methods.rs create mode 100644 bt-daemon/src/wire/mod.rs create mode 100644 bt-daemon/src/wire/rpc.rs create mode 100644 bt-daemon/tests/braintrust_sink.rs create mode 100644 bt-daemon/tests/claude_translator.rs create mode 100644 bt-daemon/tests/codex_translator.rs create mode 100644 bt-daemon/tests/pipeline.rs create mode 100644 src/plugins/claude/content/plugins/trace-claude-code/bin/claude-hook.cmd create mode 100755 src/plugins/claude/content/plugins/trace-claude-code/bin/claude-hook.sh delete mode 100755 src/plugins/claude/content/plugins/trace-claude-code/hooks/common.sh delete mode 100644 src/plugins/claude/content/plugins/trace-claude-code/hooks/permission_denied.sh delete mode 100755 src/plugins/claude/content/plugins/trace-claude-code/hooks/post_tool_use.sh delete mode 100644 src/plugins/claude/content/plugins/trace-claude-code/hooks/post_tool_use_failure.sh delete mode 100755 src/plugins/claude/content/plugins/trace-claude-code/hooks/record_event.sh delete mode 100755 src/plugins/claude/content/plugins/trace-claude-code/hooks/session_end.sh delete mode 100755 src/plugins/claude/content/plugins/trace-claude-code/hooks/session_start.sh delete mode 100755 src/plugins/claude/content/plugins/trace-claude-code/hooks/stop_hook.sh delete mode 100644 src/plugins/claude/content/plugins/trace-claude-code/hooks/user_prompt_expansion.sh delete mode 100755 src/plugins/claude/content/plugins/trace-claude-code/hooks/user_prompt_submit.sh delete mode 100644 src/plugins/claude/content/plugins/trace-claude-code/hooks/worker.sh delete mode 100644 src/plugins/claude/content/plugins/trace-claude-code/test/helpers/assert.sh delete mode 100644 src/plugins/claude/content/plugins/trace-claude-code/test/helpers/curl_stub.sh delete mode 100644 src/plugins/claude/content/plugins/trace-claude-code/test/helpers/fixtures.sh delete mode 100644 src/plugins/claude/content/plugins/trace-claude-code/test/helpers/harness.sh delete mode 100644 src/plugins/claude/content/plugins/trace-claude-code/test/helpers/replay.sh delete mode 100644 src/plugins/claude/content/plugins/trace-claude-code/test/helpers/span_tree.sh delete mode 100755 src/plugins/claude/content/plugins/trace-claude-code/test/reconcile_usage.sh delete mode 100755 src/plugins/claude/content/plugins/trace-claude-code/test/record_session.sh delete mode 100755 src/plugins/claude/content/plugins/trace-claude-code/test/run_tests.sh delete mode 100755 src/plugins/claude/content/plugins/trace-claude-code/test/test_common.sh delete mode 100755 src/plugins/claude/content/plugins/trace-claude-code/test/test_fixture_replay.sh delete mode 100755 src/plugins/claude/content/plugins/trace-claude-code/test/test_full_pipeline.sh delete mode 100755 src/plugins/claude/content/plugins/trace-claude-code/test/test_insert_span.sh delete mode 100755 src/plugins/claude/content/plugins/trace-claude-code/test/test_post_tool_use.sh delete mode 100755 src/plugins/claude/content/plugins/trace-claude-code/test/test_queue.sh delete mode 100755 src/plugins/claude/content/plugins/trace-claude-code/test/test_replay.sh delete mode 100755 src/plugins/claude/content/plugins/trace-claude-code/test/test_session_start.sh delete mode 100755 src/plugins/claude/content/plugins/trace-claude-code/test/test_stop_hook.sh delete mode 100644 src/plugins/claude/content/plugins/trace-claude-code/test/test_user_prompt_expansion.sh delete mode 100755 src/plugins/claude/content/plugins/trace-claude-code/test/test_user_prompt_submit.sh delete mode 100644 src/plugins/codex/content/plugins/trace-codex/.gitignore delete mode 100644 src/plugins/codex/content/plugins/trace-codex/biome.json delete mode 100644 src/plugins/codex/content/plugins/trace-codex/config.json.example delete mode 100644 src/plugins/codex/content/plugins/trace-codex/package.json delete mode 100644 src/plugins/codex/content/plugins/trace-codex/pnpm-lock.yaml delete mode 100644 src/plugins/codex/content/plugins/trace-codex/pnpm-workspace.yaml delete mode 100644 src/plugins/codex/content/plugins/trace-codex/scripts/build.ts delete mode 100644 src/plugins/codex/content/plugins/trace-codex/scripts/mock-collector.ts delete mode 100755 src/plugins/codex/content/plugins/trace-codex/scripts/smoke-test.sh delete mode 100644 src/plugins/codex/content/plugins/trace-codex/scripts/token-proxy.ts delete mode 100644 src/plugins/codex/content/plugins/trace-codex/src/agents/codex/event-builder.test.ts delete mode 100644 src/plugins/codex/content/plugins/trace-codex/src/agents/codex/event-builder.ts delete mode 100644 src/plugins/codex/content/plugins/trace-codex/src/agents/codex/event-processor.test.ts delete mode 100644 src/plugins/codex/content/plugins/trace-codex/src/agents/codex/event-processor.ts delete mode 100644 src/plugins/codex/content/plugins/trace-codex/src/agents/codex/register.ts delete mode 100644 src/plugins/codex/content/plugins/trace-codex/src/agents/codex/resume.test.ts delete mode 100644 src/plugins/codex/content/plugins/trace-codex/src/agents/codex/settings.test.ts delete mode 100644 src/plugins/codex/content/plugins/trace-codex/src/agents/codex/settings.ts delete mode 100644 src/plugins/codex/content/plugins/trace-codex/src/agents/codex/snapshot-store.test.ts delete mode 100644 src/plugins/codex/content/plugins/trace-codex/src/agents/codex/snapshot-store.ts delete mode 100644 src/plugins/codex/content/plugins/trace-codex/src/agents/codex/state-snapshot.test.ts delete mode 100644 src/plugins/codex/content/plugins/trace-codex/src/agents/codex/state-snapshot.ts delete mode 100644 src/plugins/codex/content/plugins/trace-codex/src/agents/codex/test-helpers.ts delete mode 100644 src/plugins/codex/content/plugins/trace-codex/src/agents/codex/transcript-reader.ts delete mode 100644 src/plugins/codex/content/plugins/trace-codex/src/agents/codex/transcript.test.ts delete mode 100644 src/plugins/codex/content/plugins/trace-codex/src/agents/codex/transcript.ts delete mode 100644 src/plugins/codex/content/plugins/trace-codex/src/braintrust/logger.test.ts delete mode 100644 src/plugins/codex/content/plugins/trace-codex/src/braintrust/logger.ts delete mode 100644 src/plugins/codex/content/plugins/trace-codex/src/client/client.ts delete mode 100644 src/plugins/codex/content/plugins/trace-codex/src/client/ensure-server.test.ts delete mode 100644 src/plugins/codex/content/plugins/trace-codex/src/client/ensure-server.ts delete mode 100644 src/plugins/codex/content/plugins/trace-codex/src/client/spawn-server.ts delete mode 100644 src/plugins/codex/content/plugins/trace-codex/src/config.test.ts delete mode 100644 src/plugins/codex/content/plugins/trace-codex/src/config.ts delete mode 100644 src/plugins/codex/content/plugins/trace-codex/src/git-metadata.test.ts delete mode 100644 src/plugins/codex/content/plugins/trace-codex/src/git-metadata.ts delete mode 100644 src/plugins/codex/content/plugins/trace-codex/src/index.ts delete mode 100644 src/plugins/codex/content/plugins/trace-codex/src/log.ts delete mode 100644 src/plugins/codex/content/plugins/trace-codex/src/processor/event-processor.ts delete mode 100644 src/plugins/codex/content/plugins/trace-codex/src/processor/lru-map.test.ts delete mode 100644 src/plugins/codex/content/plugins/trace-codex/src/processor/lru-map.ts delete mode 100644 src/plugins/codex/content/plugins/trace-codex/src/processor/processor-registry.test.ts delete mode 100644 src/plugins/codex/content/plugins/trace-codex/src/processor/processor-registry.ts delete mode 100644 src/plugins/codex/content/plugins/trace-codex/src/replay/replay.test.ts delete mode 100644 src/plugins/codex/content/plugins/trace-codex/src/replay/replay.ts delete mode 100644 src/plugins/codex/content/plugins/trace-codex/src/server/enqueue-client.ts delete mode 100644 src/plugins/codex/content/plugins/trace-codex/src/server/event-queue.test.ts delete mode 100644 src/plugins/codex/content/plugins/trace-codex/src/server/event-queue.ts delete mode 100644 src/plugins/codex/content/plugins/trace-codex/src/server/mutex.test.ts delete mode 100644 src/plugins/codex/content/plugins/trace-codex/src/server/mutex.ts delete mode 100644 src/plugins/codex/content/plugins/trace-codex/src/server/recorder.test.ts delete mode 100644 src/plugins/codex/content/plugins/trace-codex/src/server/recorder.ts delete mode 100644 src/plugins/codex/content/plugins/trace-codex/src/server/routes.test.ts delete mode 100644 src/plugins/codex/content/plugins/trace-codex/src/server/routes.ts delete mode 100644 src/plugins/codex/content/plugins/trace-codex/src/server/server.test.ts delete mode 100644 src/plugins/codex/content/plugins/trace-codex/src/server/server.ts delete mode 100644 src/plugins/codex/content/plugins/trace-codex/src/server/state.test.ts delete mode 100644 src/plugins/codex/content/plugins/trace-codex/src/server/state.ts delete mode 100644 src/plugins/codex/content/plugins/trace-codex/src/test-helpers.ts delete mode 100644 src/plugins/codex/content/plugins/trace-codex/src/version.ts delete mode 100644 src/plugins/codex/content/plugins/trace-codex/tsconfig.json delete mode 100644 src/plugins/codex/content/plugins/trace-codex/tsup.config.ts delete mode 100644 src/plugins/codex/content/plugins/trace-codex/vitest.config.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d94e0dd..83c7705 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,5 +1,50 @@ -# PR checks: build + validate all agents, run integration tests + evals. (placeholder) name: CI -on: { pull_request: {} } + +on: + pull_request: + push: + branches: [main] + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + +env: + CARGO_TERM_COLOR: always + jobs: - build: { runs-on: ubuntu-latest, steps: [ { run: "echo build+validate all agents (placeholder)" } ] } + plugins: + name: Plugin packages + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 + - name: Build and validate plugins + run: make test + + daemon: + name: Daemon (${{ matrix.os }}) + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, macos-latest, windows-latest] + runs-on: ${{ matrix.os }} + steps: + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 + - name: Install Rust + run: | + rustup toolchain install stable --profile minimal + rustup default stable + rustup component add clippy rustfmt + - name: Check formatting + if: runner.os == 'Linux' + run: cargo fmt --manifest-path bt-daemon/Cargo.toml -- --check + - name: Build daemon + run: cargo build --manifest-path bt-daemon/Cargo.toml --all-features --locked + - name: Test daemon + run: cargo test --manifest-path bt-daemon/Cargo.toml --all-features --locked + - name: Lint daemon + run: cargo clippy --manifest-path bt-daemon/Cargo.toml --all-targets --all-features --locked -- -D warnings diff --git a/AGENTS.md b/AGENTS.md index c2bb965..5ada654 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -4,8 +4,8 @@ `build.sh`, `validate.sh`, `publish.sh` - `src/skills/*` canonical skills (Agent Skills spec) - `scripts/publish.sh` reads the PUBLISH_TARGETS map and deploys each plugin -- `bt-daemon/` shared Rust project (self-contained Cargo workspace; - see its README). Placeholder for now. +- `bt-daemon/` shared Rust crate embedded by `bt`, with a + feature-gated standalone binary; see its README. Build one agent locally: `src/plugins/claude/build.sh /tmp/dist-claude` Validate it: `src/plugins/claude/validate.sh /tmp/dist-claude` diff --git a/bt-daemon/Cargo.lock b/bt-daemon/Cargo.lock index 977ecb9..04b317a 100644 --- a/bt-daemon/Cargo.lock +++ b/bt-daemon/Cargo.lock @@ -2,6 +2,2388 @@ # It is not intended for manual editing. version = 4 +[[package]] +name = "aho-corasick" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" +dependencies = [ + "memchr", +] + +[[package]] +name = "android_system_properties" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" +dependencies = [ + "libc", +] + +[[package]] +name = "anstream" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d" +dependencies = [ + "anstyle", + "anstyle-parse", + "anstyle-query", + "anstyle-wincon", + "colorchoice", + "is_terminal_polyfill", + "utf8parse", +] + +[[package]] +name = "anstyle" +version = "1.0.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000" + +[[package]] +name = "anstyle-parse" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e" +dependencies = [ + "utf8parse", +] + +[[package]] +name = "anstyle-query" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "anstyle-wincon" +version = "3.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" +dependencies = [ + "anstyle", + "once_cell_polyfill", + "windows-sys 0.61.2", +] + +[[package]] +name = "anyhow" +version = "1.0.104" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" + +[[package]] +name = "arc-swap" +version = "1.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c049c0be4daef0b145cb3555416b3b8ef5b7888a38aea1a3a155801fe7b0810b" +dependencies = [ + "rustversion", +] + +[[package]] +name = "assert-json-diff" +version = "2.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47e4f2b81832e72834d7518d8487a0396a28cc408186a2e8854c0f98011faf12" +dependencies = [ + "serde", + "serde_json", +] + +[[package]] +name = "async-trait" +version = "0.1.91" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae36dc4177970ef04fde5178d3e2429882def40e57a451f919c098f72baa6cec" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "atomic-waker" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" + +[[package]] +name = "autocfg" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" + +[[package]] +name = "backoff" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b62ddb9cb1ec0a098ad4bbf9344d0713fa193ae1a80af55febcff2627b6a00c1" +dependencies = [ + "futures-core", + "getrandom 0.2.17", + "instant", + "pin-project-lite", + "rand 0.8.7", + "tokio", +] + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "bitflags" +version = "2.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da" + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "bon" +version = "3.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a602c73c7b0148ec6d12af6fd5cc7a46e2eacc8878271a999abac56eed12f561" +dependencies = [ + "bon-macros", + "rustversion", +] + +[[package]] +name = "bon-macros" +version = "3.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dee98b0db6a962de883bf5d20362dee4d7ca0d12fe39a7c6c73c844e1cd7c1f" +dependencies = [ + "darling", + "ident_case", + "prettyplease", + "proc-macro2", + "quote", + "rustversion", + "syn 2.0.119", +] + +[[package]] +name = "braintrust-sdk-rust" +version = "0.1.0-alpha.2" +source = "git+https://github.com/braintrustdata/braintrust-sdk-rust?rev=d33e806bf6ab9548d37355f6a5098a971ef150aa#d33e806bf6ab9548d37355f6a5098a971ef150aa" +dependencies = [ + "anyhow", + "arc-swap", + "async-trait", + "backoff", + "base64", + "bon", + "bytes", + "chrono", + "crossbeam", + "futures", + "indexmap", + "regex", + "reqwest", + "serde", + "serde_json", + "serde_repr", + "thiserror 1.0.69", + "tokio", + "tracing", + "url", + "uuid", +] + [[package]] name = "bt-daemon" -version = "0.0.0" +version = "0.1.0" +dependencies = [ + "anyhow", + "async-trait", + "braintrust-sdk-rust", + "chrono", + "clap", + "regex", + "serde", + "serde_json", + "sha2", + "tempfile", + "thiserror 2.0.19", + "tokio", + "tracing", + "tracing-subscriber", + "uuid", + "wiremock", +] + +[[package]] +name = "bumpalo" +version = "3.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" + +[[package]] +name = "bytes" +version = "1.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" + +[[package]] +name = "cc" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5add81bb678e6cb321aff7fa0dc7689ad82b112dbc032cea19f91d6b8e3582b9" +dependencies = [ + "find-msvc-tools", + "shlex", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "cfg_aliases" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527" + +[[package]] +name = "chacha20" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d524456ba66e72eb8b115ff89e01e497f8e6d11d78b70b1aa13c0fbd97540a81" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.0", + "rand_core 0.10.1", +] + +[[package]] +name = "chrono" +version = "0.4.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327" +dependencies = [ + "iana-time-zone", + "js-sys", + "num-traits", + "serde", + "wasm-bindgen", + "windows-link", +] + +[[package]] +name = "clap" +version = "4.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d91e0c145792ef73a6ad36d27c75ac09f1832222a3c209689d90f534685ee5b7" +dependencies = [ + "clap_builder", + "clap_derive", +] + +[[package]] +name = "clap_builder" +version = "4.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f09628afdcc538b57f3c6341e9c8e9970f18e4a481690a64974d7023bd33548b" +dependencies = [ + "anstream", + "anstyle", + "clap_lex", + "strsim", +] + +[[package]] +name = "clap_derive" +version = "4.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d012d2b9d65aca7f18f4d9878a045bc17899bba951561ba5ec3c2ba1eed9a061" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "clap_lex" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" + +[[package]] +name = "colorchoice" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" + +[[package]] +name = "core-foundation" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "cpufeatures" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b2a41393f66f16b0823bb79094d54ac5fbd34ab292ddafb9a0456ac9f87d201" +dependencies = [ + "libc", +] + +[[package]] +name = "crossbeam" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1137cd7e7fc0fb5d3c5a8678be38ec56e819125d8d7907411fe24ccb943faca8" +dependencies = [ + "crossbeam-channel", + "crossbeam-deque", + "crossbeam-epoch", + "crossbeam-queue", + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-channel" +version = "0.5.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d85363c37faeca707aef026efa9f3b34d077bce547e48f770770625c6013679e" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-deque" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5181e0de7b61eb03a81e347d6dd8797bae9da5146707b51077e2d71a54ec0ceb" +dependencies = [ + "crossbeam-epoch", + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-epoch" +version = "0.9.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2d6914041f254d6e9176c01941b21115dcfb7089e55135a35411081bd106ef3f" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-queue" +version = "0.3.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "803d13fb3b09d88be9f4dbc29062c66b19bf7170867ceb746d2a8689bf6c7a26" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61803da095bee82a81bb1a452ecc25d3b2f1416d1897eb86430c6159ef717c17" + +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "darling" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "25ae13da2f202d56bd7f91c25fba009e7717a1e4a1cc98a76d844b65ae912e9d" +dependencies = [ + "darling_core", + "darling_macro", +] + +[[package]] +name = "darling_core" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9865a50f7c335f53564bb694ef660825eb8610e0a53d3e11bf1b0d3df31e03b0" +dependencies = [ + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn 2.0.119", +] + +[[package]] +name = "darling_macro" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3984ec7bd6cfa798e62b4a642426a5be0e68f9401cfc2a01e3fa9ea2fcdb8d" +dependencies = [ + "darling_core", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "deadpool" +version = "0.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0be2b1d1d6ec8d846f05e137292d0b89133caf95ef33695424c09568bdd39b1b" +dependencies = [ + "deadpool-runtime", + "lazy_static", + "num_cpus", + "tokio", +] + +[[package]] +name = "deadpool-runtime" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "092966b41edc516079bdf31ec78a2e0588d1d0c08f78b91d8307215928642b2b" + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "crypto-common", +] + +[[package]] +name = "displaydoc" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ac70aa55017e108007fbaf5aa0f54b021c98f92ff8af59d42eda9da96e3dd4f" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "fastrand" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223" + +[[package]] +name = "find-msvc-tools" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" + +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + +[[package]] +name = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + +[[package]] +name = "futures" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a88cf1f829d945f548cf8fec32c61b1f202b6d93b45848602fc02af4b12ad218" +dependencies = [ + "futures-channel", + "futures-core", + "futures-executor", + "futures-io", + "futures-sink", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-channel" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "262590f4fe6afeb0bc83be1daa64e52657fe185690a958af7f3ad0e92085c5ae" +dependencies = [ + "futures-core", + "futures-sink", +] + +[[package]] +name = "futures-core" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2cd50c473c80f6d7c3670a752354b8e569b1a7cbfdc0419ec88e5edad85e0dc7" + +[[package]] +name = "futures-executor" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6754879cc9f2c66f88c6e5c35344bb0bdb0708b0352b1201815667c7eabc7458" +dependencies = [ + "futures-core", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-io" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4577ecaa3c4f96589d473f679a71b596316f6641bc350038b962a5daf0085d7a" + +[[package]] +name = "futures-macro" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2d6d3cde68c518367be28956066ddfef33813991b77a55005a69dae04bf3b10b" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "futures-sink" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e34418ac499d6305c2fb5ad0ed2f6ac998c5f8ca209b4510f7f94242c647e307" + +[[package]] +name = "futures-task" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b231ed28831efb4a61a08580c4bc233ec56bc009f4cd8f52da2c3cb97df0c109" + +[[package]] +name = "futures-util" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a77a90a256fce34da66415271e30f94ee91c57b04b8a2c042d9cf3220179deaa" +dependencies = [ + "futures-channel", + "futures-core", + "futures-io", + "futures-macro", + "futures-sink", + "futures-task", + "memchr", + "pin-project-lite", + "slab", +] + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "wasi", + "wasm-bindgen", +] + +[[package]] +name = "getrandom" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "r-efi", + "rand_core 0.10.1", + "wasm-bindgen", +] + +[[package]] +name = "h2" +version = "0.4.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6cb093c84e8bd9b188d4c4a8cb6579fc016968d14c99882163cd3ff402a4f155" +dependencies = [ + "atomic-waker", + "bytes", + "fnv", + "futures-core", + "futures-sink", + "http", + "indexmap", + "slab", + "tokio", + "tokio-util", + "tracing", +] + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "hermit-abi" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c" + +[[package]] +name = "http" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6970f50e31d6fc17d3fa27329444bfa74e196cf62e95052a3f6fee181dba6425" +dependencies = [ + "bytes", + "itoa", +] + +[[package]] +name = "http-body" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca2a8f2913ee65f60facd6a5905613afaa448497a0230cc41ce022d93290bc2c" +dependencies = [ + "bytes", + "http", +] + +[[package]] +name = "http-body-util" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e9f41fd6a08e4d4ec69df65976da761afd5ad5e58a9d4acb46bd1c953a9e3ff2" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "pin-project-lite", +] + +[[package]] +name = "httparse" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" + +[[package]] +name = "httpdate" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" + +[[package]] +name = "hyper" +version = "1.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d22053281f852e11534f5198498373cbb59295120a20771d90f7ed1897490a72" +dependencies = [ + "atomic-waker", + "bytes", + "futures-channel", + "futures-core", + "h2", + "http", + "http-body", + "httparse", + "httpdate", + "itoa", + "pin-project-lite", + "smallvec", + "tokio", + "want", +] + +[[package]] +name = "hyper-rustls" +version = "0.27.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33ca68d021ef39cf6463ab54c1d0f5daf03377b70561305bb89a8f83aab66e0f" +dependencies = [ + "http", + "hyper", + "hyper-util", + "rustls", + "rustls-native-certs", + "tokio", + "tokio-rustls", + "tower-service", +] + +[[package]] +name = "hyper-util" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" +dependencies = [ + "base64", + "bytes", + "futures-channel", + "futures-util", + "http", + "http-body", + "hyper", + "ipnet", + "libc", + "percent-encoding", + "pin-project-lite", + "socket2", + "tokio", + "tower-service", + "tracing", +] + +[[package]] +name = "iana-time-zone" +version = "0.1.65" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "log", + "wasm-bindgen", + "windows-core", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] + +[[package]] +name = "icu_collections" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2984d1cd16c883d7935b9e07e44071dca8d917fd52ecc02c04d5fa0b5a3f191c" +dependencies = [ + "displaydoc", + "potential_utf", + "utf8_iter", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92219b62b3e2b4d88ac5119f8904c10f8f61bf7e95b640d25ba3075e6cac2c29" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c56e5ee99d6e3d33bd91c5d85458b6005a22140021cc324cea84dd0e72cff3b4" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da3be0ae77ea334f4da67c12f149704f19f81d1adf7c51cf482943e84a2bad38" + +[[package]] +name = "icu_properties" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bee3b67d0ea5c2cca5003417989af8996f8604e34fb9ddf96208a033901e70de" +dependencies = [ + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e2bbb201e0c04f7b4b3e14382af113e17ba4f63e2c9d2ee626b720cbce54a14" + +[[package]] +name = "icu_provider" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "139c4cf31c8b5f33d7e199446eff9c1e02decfc2f0eec2c8d71f65befa45b421" +dependencies = [ + "displaydoc", + "icu_locale_core", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + +[[package]] +name = "ident_case" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" + +[[package]] +name = "idna" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + +[[package]] +name = "indexmap" +version = "2.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" +dependencies = [ + "equivalent", + "hashbrown", +] + +[[package]] +name = "instant" +version = "0.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e0242819d153cba4b4b05a5a8f2a7e9bbf97b6055b2a002b395c96b5ff3c0222" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "ipnet" +version = "2.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2" + +[[package]] +name = "is_terminal_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "js-sys" +version = "0.3.103" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53b44bfcdb3f8d5837a46dae1ca9660a837176eee74a28b229bc626816589102" +dependencies = [ + "cfg-if", + "futures-util", + "wasm-bindgen", +] + +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" + +[[package]] +name = "libc" +version = "0.2.189" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" + +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + +[[package]] +name = "litemap" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0" + +[[package]] +name = "log" +version = "0.4.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad" + +[[package]] +name = "lru-slab" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" + +[[package]] +name = "matchers" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1525a2a28c7f4fa0fc98bb91ae755d1e2d1505079e05539e35bc876b5d65ae9" +dependencies = [ + "regex-automata", +] + +[[package]] +name = "memchr" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + +[[package]] +name = "mime" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" + +[[package]] +name = "mime_guess" +version = "2.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f7c44f8e672c00fe5308fa235f821cb4198414e1c77935c1ab6948d3fd78550e" +dependencies = [ + "mime", + "unicase", +] + +[[package]] +name = "mio" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "30d65c71f1ce40ab09135ce117d742b9f8a19ff91a41a8b57ed50bc2de59c427" +dependencies = [ + "libc", + "wasi", + "windows-sys 0.61.2", +] + +[[package]] +name = "nu-ansi-term" +version = "0.50.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[package]] +name = "num_cpus" +version = "1.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91df4bbde75afed763b708b7eee1e8e7651e02d97f6d5dd763e89367e957b23b" +dependencies = [ + "hermit-abi", + "libc", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "once_cell_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" + +[[package]] +name = "openssl-probe" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "potential_utf" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0103b1cef7ec0cf76490e969665504990193874ea05c85ff9bab8b911d0a0564" +dependencies = [ + "zerovec", +] + +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + +[[package]] +name = "prettyplease" +version = "0.2.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" +dependencies = [ + "proc-macro2", + "syn 2.0.119", +] + +[[package]] +name = "proc-macro2" +version = "1.0.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quinn" +version = "0.11.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c1a41e437b6bbd489372cd4971de128e85c855f56c57f283d20ff016cf7c0a8" +dependencies = [ + "bytes", + "cfg_aliases", + "pin-project-lite", + "quinn-proto", + "quinn-udp", + "rustc-hash", + "rustls", + "socket2", + "thiserror 2.0.19", + "tokio", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-proto" +version = "0.11.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f4bfc015262b9df63c8845072ce59068853ff5872180c2ce2f13038b970e560" +dependencies = [ + "bytes", + "getrandom 0.4.3", + "lru-slab", + "rand 0.10.2", + "rand_pcg", + "ring", + "rustc-hash", + "rustls", + "rustls-pki-types", + "slab", + "thiserror 2.0.19", + "tinyvec", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-udp" +version = "0.5.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35a133f956daabe89a61a685c2649f13d82d5aa4bd5d12d1277e1072a21c0694" +dependencies = [ + "cfg_aliases", + "libc", + "once_cell", + "socket2", + "tracing", + "windows-sys 0.61.2", +] + +[[package]] +name = "quote" +version = "1.0.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "rand" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22f6172bdec972074665ed81ed53b71da00bfc44b65a753cfde883ec4c702a1a" +dependencies = [ + "libc", + "rand_chacha", + "rand_core 0.6.4", +] + +[[package]] +name = "rand" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7f5fa3a058cd35567ef9bfa5e75732bee0f9e4c55fa90477bef2dfcdbc4be80" +dependencies = [ + "chacha20", + "getrandom 0.4.3", + "rand_core 0.10.1", +] + +[[package]] +name = "rand_chacha" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" +dependencies = [ + "ppv-lite86", + "rand_core 0.6.4", +] + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom 0.2.17", +] + +[[package]] +name = "rand_core" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" + +[[package]] +name = "rand_pcg" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "caa0f4137e1c0a72f4c651489402276c8e8e1cf081f3b0ba156d2cbeef09e86a" +dependencies = [ + "rand_core 0.10.1", +] + +[[package]] +name = "regex" +version = "1.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f020237b6c8eed93db2e2cb53c00c60a8e1bc73da7d073199a1180401450218d" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fcfdb36bda0c880c5931cdc7a2bcdc8ba4556847b9d912bca70bc94708711ad" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" + +[[package]] +name = "reqwest" +version = "0.12.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147" +dependencies = [ + "base64", + "bytes", + "futures-core", + "futures-util", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-rustls", + "hyper-util", + "js-sys", + "log", + "mime_guess", + "percent-encoding", + "pin-project-lite", + "quinn", + "rustls", + "rustls-native-certs", + "rustls-pki-types", + "serde", + "serde_json", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tokio-rustls", + "tokio-util", + "tower", + "tower-http", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "wasm-streams", + "web-sys", +] + +[[package]] +name = "ring" +version = "0.17.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +dependencies = [ + "cc", + "cfg-if", + "getrandom 0.2.17", + "libc", + "untrusted", + "windows-sys 0.52.0", +] + +[[package]] +name = "rustc-hash" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b1e7f9a428571be2dc5bc0505c13fb6bf936822b894ec87abf8a08a4e51742d" + +[[package]] +name = "rustix" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustls" +version = "0.23.42" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c54fcab019b409d04215d3a17cb438fd7fbf192ee61461f20f4fe18704bc138" +dependencies = [ + "once_cell", + "ring", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-native-certs" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dab5152771c58876a2146916e53e35057e1a4dfa2b9df0f0305b07f611fdea4d" +dependencies = [ + "openssl-probe", + "rustls-pki-types", + "schannel", + "security-framework", +] + +[[package]] +name = "rustls-pki-types" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f4925028c7eb5d1fcdaf196971378ed9d2c1c4efc7dc5d011256f76c99c0a96" +dependencies = [ + "web-time", + "zeroize", +] + +[[package]] +name = "rustls-webpki" +version = "0.103.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e" +dependencies = [ + "ring", + "rustls-pki-types", + "untrusted", +] + +[[package]] +name = "rustversion" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" + +[[package]] +name = "ryu" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" + +[[package]] +name = "schannel" +version = "0.1.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91c1b7e4904c873ef0710c1f407dde2e6287de2bebc1bbbf7d430bb7cbffd939" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "security-framework" +version = "3.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" +dependencies = [ + "bitflags", + "core-foundation", + "core-foundation-sys", + "libc", + "security-framework-sys", +] + +[[package]] +name = "security-framework-sys" +version = "2.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2691df843ecc5d231c0b14ece2acc3efb62c0a398c7e1d875f3983ce020e3" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "serde" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "serde_json" +version = "1.0.151" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "serde_repr" +version = "0.1.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8d3b1629de253c70a0508c3899572da79ca359fdab27c7920ff00406df418906" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "serde_urlencoded" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" +dependencies = [ + "form_urlencoded", + "itoa", + "ryu", + "serde", +] + +[[package]] +name = "sha1_smol" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbfa15b3dddfee50a0fff136974b3e1bde555604ba463834a7eb7deb6417705d" + +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "digest", +] + +[[package]] +name = "sharded-slab" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6" +dependencies = [ + "lazy_static", +] + +[[package]] +name = "shlex" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" + +[[package]] +name = "signal-hook-registry" +version = "1.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" +dependencies = [ + "errno", + "libc", +] + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "smallvec" +version = "1.15.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90" + +[[package]] +name = "socket2" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + +[[package]] +name = "syn" +version = "2.0.119" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "3.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53e9bae58849f64dfa4f5d5ae372c8341f7305f82a3868709269343628b659a3" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "sync_wrapper" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" +dependencies = [ + "futures-core", +] + +[[package]] +name = "synstructure" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "tempfile" +version = "3.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" +dependencies = [ + "fastrand", + "getrandom 0.4.3", + "once_cell", + "rustix", + "windows-sys 0.61.2", +] + +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl 1.0.69", +] + +[[package]] +name = "thiserror" +version = "2.0.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09a43598840e33d5b0331f38c5e30d13bb11c11210a4b58f0d9b18a5a5eefcd9" +dependencies = [ + "thiserror-impl 2.0.19", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43cbfe0cf76104d42a574802844187e84a305e531ed54455f11fbde0f10541cd" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "thread_local" +version = "1.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ad99c4c6d32803332c548b1af0540b357b3f5fc0be8f6c6bfe8b2e6ae784070" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "tinystr" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8323304221c2a851516f22236c5722a72eaa19749016521d6dff0824447d96d" +dependencies = [ + "displaydoc", + "zerovec", +] + +[[package]] +name = "tinyvec" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb4ebadaa0af04fab11ae01eb5f9fdb5f9c5b875506e210e71c07873528baa7f" +dependencies = [ + "tinyvec_macros", +] + +[[package]] +name = "tinyvec_macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" + +[[package]] +name = "tokio" +version = "1.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "202caea871b69668250d242070849eb495be178ed697a3e98aebce5bc81a0bed" +dependencies = [ + "bytes", + "libc", + "mio", + "pin-project-lite", + "signal-hook-registry", + "socket2", + "tokio-macros", + "windows-sys 0.61.2", +] + +[[package]] +name = "tokio-macros" +version = "2.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6328af13490e73a9b4694030fafd93f8c8c6a9dede33e821c3fc63eddf8042ba" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "tokio-rustls" +version = "0.26.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61" +dependencies = [ + "rustls", + "tokio", +] + +[[package]] +name = "tokio-util" +version = "0.7.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "494815d09bf52b5548659851081238f0ca39ff638363907596da739561c62c52" +dependencies = [ + "bytes", + "futures-core", + "futures-sink", + "libc", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "tower" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" +dependencies = [ + "futures-core", + "futures-util", + "pin-project-lite", + "sync_wrapper", + "tokio", + "tower-layer", + "tower-service", +] + +[[package]] +name = "tower-http" +version = "0.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840" +dependencies = [ + "bitflags", + "bytes", + "futures-util", + "http", + "http-body", + "pin-project-lite", + "tower", + "tower-layer", + "tower-service", + "url", +] + +[[package]] +name = "tower-layer" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" + +[[package]] +name = "tower-service" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" + +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "pin-project-lite", + "tracing-attributes", + "tracing-core", +] + +[[package]] +name = "tracing-attributes" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", + "valuable", +] + +[[package]] +name = "tracing-log" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3" +dependencies = [ + "log", + "once_cell", + "tracing-core", +] + +[[package]] +name = "tracing-subscriber" +version = "0.3.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb7f578e5945fb242538965c2d0b04418d38ec25c79d160cd279bf0731c8d319" +dependencies = [ + "matchers", + "nu-ansi-term", + "once_cell", + "regex-automata", + "sharded-slab", + "smallvec", + "thread_local", + "tracing", + "tracing-core", + "tracing-log", +] + +[[package]] +name = "try-lock" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" + +[[package]] +name = "typenum" +version = "1.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" + +[[package]] +name = "unicase" +version = "2.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dbc4bc3a9f746d862c45cb89d705aa10f187bb96c76001afab07a0d35ce60142" + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + +[[package]] +name = "url" +version = "2.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", + "serde", +] + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + +[[package]] +name = "utf8parse" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" + +[[package]] +name = "uuid" +version = "1.24.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf3923a6f5c4c6382e0b653c4117f48d631ea17f38ed86e2a828e6f7412f5239" +dependencies = [ + "getrandom 0.4.3", + "js-sys", + "serde_core", + "sha1_smol", + "wasm-bindgen", +] + +[[package]] +name = "valuable" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "want" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e" +dependencies = [ + "try-lock", +] + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasm-bindgen" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b067c0c11094aef6b7a801c1e34a26affafdf3d051dba08456b868789aaf9a4" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.76" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c62df1340f32221cb9c54d6a27b030e3dba64361d4a95bed55f9aacb44da291d" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "167ce5e579f6bcf889c4f7175a8a5a585de84e8ff93976ce393efa5f2837aab1" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3997c7839262f4ef12cf90b818d6340c18e80f263f1a94bf157d0ec4420380e" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn 2.0.119", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc1b4cb0cc549fcf58d7dfc081778139b3d283a081644e833e84682ad71cea24" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "wasm-streams" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "15053d8d85c7eccdbefef60f06769760a563c7f0a9d6902a13d35c7800b0ad65" +dependencies = [ + "futures-util", + "js-sys", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + +[[package]] +name = "web-sys" +version = "0.3.103" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8622dcb61c0bcc9fffa6938bed81210af2da9a7e4a1a834b2e37a59b6dfb6141" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "web-time" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "windows-core" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link", + "windows-result", + "windows-strings", +] + +[[package]] +name = "windows-implement" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "windows-interface" +version = "0.59.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-result" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-strings" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm", + "windows_aarch64_msvc", + "windows_i686_gnu", + "windows_i686_gnullvm", + "windows_i686_msvc", + "windows_x86_64_gnu", + "windows_x86_64_gnullvm", + "windows_x86_64_msvc", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "wiremock" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08db1edfb05d9b3c1542e521aea074442088292f00b5f28e435c714a98f85031" +dependencies = [ + "assert-json-diff", + "base64", + "deadpool", + "futures", + "http", + "http-body-util", + "hyper", + "hyper-util", + "log", + "once_cell", + "regex", + "serde", + "serde_json", + "tokio", + "url", +] + +[[package]] +name = "writeable" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" + +[[package]] +name = "yoke" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", + "synstructure", +] + +[[package]] +name = "zerocopy" +version = "0.8.55" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5a105cd7b140f6eeec8acff2ea38135d3cab283ada58540f629fe51e46696eb" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.55" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fe976fb70c78cd64cccfe3a6fc142244e8a77b70959b30faf9d0ac37ee228eb" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "zerofrom" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", + "synstructure", +] + +[[package]] +name = "zeroize" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e" + +[[package]] +name = "zerotrie" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f9152d31db0792fa83f70fb2f83148effb5c1f5b8c7686c3459e361d9bc20bf" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90f911cbc359ab6af17377d242225f4d75119aec87ea711a880987b18cd7b239" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "zmij" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" diff --git a/bt-daemon/Cargo.toml b/bt-daemon/Cargo.toml index 67b9971..0a6d87b 100644 --- a/bt-daemon/Cargo.toml +++ b/bt-daemon/Cargo.toml @@ -1,22 +1,38 @@ -# bt-daemon — shared Rust project for Braintrust coding-agent plugins. -# -# The empty [workspace] table makes THIS directory the Cargo workspace root, so -# the crate is not absorbed by any parent manifest and the whole project can be -# lifted into its own repo by copying bt-daemon/ verbatim. Keep the workspace -# root here (not at the monorepo root) to preserve that portability. -# -# Placeholder for now; real crates/modules land here as the shared daemon and -# per-agent tracing move from TypeScript to Rust. - [package] name = "bt-daemon" -version = "0.0.0" +version = "0.1.0" edition = "2021" license = "MIT" -description = "Shared daemon for Braintrust coding-agent plugins (placeholder)." publish = false +description = "Embeddable Braintrust coding-agent tracing daemon." + +[features] +default = [] +# Standalone development/test binary. Production embeds the library in `bt`. +cli = ["dep:tracing-subscriber"] -[workspace] -resolver = "2" +[[bin]] +name = "bt-daemon" +path = "src/main.rs" +required-features = ["cli"] [dependencies] +# Keep this pinned exactly, matching the dependency policy used by `bt`. +braintrust-sdk-rust = { git = "https://github.com/braintrustdata/braintrust-sdk-rust", rev = "d33e806bf6ab9548d37355f6a5098a971ef150aa" } +anyhow = "1" +async-trait = "0.1" +chrono = "0.4" +clap = { version = "4", features = ["derive", "env"] } +regex = "1" +serde = { version = "1", features = ["derive"] } +serde_json = "1" +sha2 = "0.10" +thiserror = "2" +tokio = { version = "1", features = ["rt-multi-thread", "macros", "net", "io-util", "sync", "time", "process", "signal", "fs"] } +tracing = "0.1" +tracing-subscriber = { version = "0.3", features = ["env-filter"], optional = true } +uuid = { version = "1", features = ["v4", "v5"] } + +[dev-dependencies] +tempfile = "3" +wiremock = "0.6" diff --git a/bt-daemon/README.md b/bt-daemon/README.md index 5ede331..52a8693 100644 --- a/bt-daemon/README.md +++ b/bt-daemon/README.md @@ -1,20 +1,79 @@ # bt-daemon -Shared Rust project for Braintrust coding-agent plugins. **Placeholder name** — -the real name is TBD. +Shared Rust project for Braintrust coding-agent tracing plugins. A local, +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. -Long-term, the shared logic behind the per-agent tracing plugins (currently -TypeScript, e.g. `trace-codex`) moves here to Rust. Right now it's a stub. +> **Placeholder name** — the real name is TBD. The subcommand framing +> (`serve` / `hook` / `status` / `replay`) should survive a rename. -## Self-contained +## Layout -This directory is its own Cargo workspace root (see the empty `[workspace]` -table in `Cargo.toml`), so it does not depend on anything else in the monorepo -and can be moved to a standalone repo by copying `bt-daemon/` as-is. +One self-contained Cargo crate, liftable to its own repo by copying +`bt-daemon/` verbatim: -## Build / run +- `src/wire` — the wire protocol module: envelope types + JSON-RPC framing. +- `src/translate` and `src/sink` — agent state machines and Braintrust output. +- `src/lib.rs` — the embeddable library: clap `Args` + async entry points + (`run_serve`, `run_hook`, `run_status`, `run_replay`). This is what `bt` + depends on. +- `src/main.rs` — the standalone **`bt-daemon` binary**, compiled only with + the `cli` feature for isolated testing/development. Env/flag static-token + auth only; not an end-user artifact. + +## Dual consumption + +The daemon core is credential-passive — it only ever *receives* a resolved +`BackendAuth` with each session's config — so two front-ends share all core +behavior. The `cli` feature only enables the standalone binary and its logging +subscriber: + +1. **Embedded in `bt`** (production): `bt` fills `BackendAuth` from its profile + / OAuth / keychain auth. +2. **Standalone binary** (testing): fills it from `BRAINTRUST_API_KEY` etc. + +## Build / test ```bash cd bt-daemon -cargo run +cargo test # library + pipeline tests +cargo test --features cli # also compile/test the CLI +cargo build --features cli --bin bt-daemon # standalone test binary +``` + +CI runs the all-feature build, test suite, and Clippy on Linux, macOS, and +Windows. The pipeline integration tests use Unix-domain sockets on Unix and +real Windows named pipes on Windows. + +## Try it (standalone, debug sink) + +```bash +export BT_DAEMON_SOCKET=/tmp/btd.sock BT_DAEMON_DATA_DIR=/tmp/btd +cargo build --features cli --bin bt-daemon +echo '{"session_id":"s1","hook_event_name":"SessionStart"}' | ./target/debug/bt-daemon hook --source debug +echo '{"session_id":"s1","hook_event_name":"Stop"}' | ./target/debug/bt-daemon hook --source debug +./target/debug/bt-daemon status +# journaled events: $BT_DAEMON_DATA_DIR/journal/s1.ndjson +# emitted span rows: $BT_DAEMON_DATA_DIR/spans/s1.ndjson ``` + +The first `hook` spawns the daemon detached; it idles out after 5 minutes. + +## 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 +deterministic span ids. Claude lifecycle entries embed transcript snapshots, so +recovery does not depend on mutable external paths. Explicit turn/session-end +flushes are bounded, and sessions can target project logs or an experiment. + +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. + +- The Braintrust sink pins `braintrust-sdk-rust` commit `d33e806`, which adds + deterministic span ids, `span_origin`/`span_attributes` passthrough, and + per-session credential isolation. This follows the same exact-revision Git + dependency policy as `bt`. diff --git a/bt-daemon/docs/protocol.md b/bt-daemon/docs/protocol.md new file mode 100644 index 0000000..943e4d1 --- /dev/null +++ b/bt-daemon/docs/protocol.md @@ -0,0 +1,230 @@ +# bt-daemon wire protocol (v1) + +Status: **frozen for the prototype.** This is the contract between plugin shims +(`hook` clients) and the daemon (`serve`), and between the embedded-in-`bt` +front-end and the standalone test binary. + +`PROTOCOL_VERSION = 1`. + +## Transport + +- **Unix domain socket (Linux/macOS).** Default path resolution (first match wins): + 1. `--socket ` flag / `BT_DAEMON_SOCKET` env (explicit override; used by + tests to sandbox a daemon per test). + 2. `$XDG_RUNTIME_DIR/braintrust/daemon.sock` if `XDG_RUNTIME_DIR` is set. + 3. `$HOME/.braintrust/run/daemon.sock`. + The containing directory is created mode `0700`. macOS caps `sockaddr_un` + paths at 104 bytes; all defaults stay well under. +- **Framing: newline-delimited JSON.** Exactly one JSON value per line, + terminated by `\n`. `serde_json` never emits a bare newline inside a value, + so `\n` is an unambiguous frame delimiter. Max line length is bounded + (default 64 MiB) to cap memory on a malformed/huge payload; over-length lines + are a protocol error and close the connection. +- **Windows named pipe.** `--socket` / `BT_DAEMON_SOCKET` may provide an + explicit full pipe name. Otherwise the daemon uses + `\\.\pipe\braintrust-bt-daemon-`, where the suffix is derived + from the Windows domain and user name so concurrent users do not share a + daemon. The pipe is byte-mode, so framing is identical to Unix. + +## RPC: JSON-RPC 2.0 + +Each frame is a JSON-RPC 2.0 Request, Response, or Notification. + +Request: +```json +{ "jsonrpc": "2.0", "id": 1, "method": "event.log", "params": { ... } } +``` +Response (success): +```json +{ "jsonrpc": "2.0", "id": 1, "result": { ... } } +``` +Response (error): +```json +{ "jsonrpc": "2.0", "id": 1, "error": { "code": -32602, "message": "...", "data": { ... } } } +``` +Notification (no `id`, no response): +```json +{ "jsonrpc": "2.0", "method": "event.log", "params": { ... } } +``` + +`id` is an integer or string. Error `code` uses the JSON-RPC reserved ranges +for protocol errors (`-32700` parse, `-32600` invalid request, `-32601` method +not found, `-32602` invalid params, `-32603` internal); application errors use +`-32000 … -32099`. + +### Ordering & delivery + +A subprocess-style shim opens a connection, does one `event.log`, and exits. +Per-session ordering is guaranteed because (a) the agent runs hooks in blocking +mode, so it does not fire the next hook until the current one returns, and (b) +`event.log` is a **request** whose success response means *the event has been +appended to that session's ordered queue* (not that it has been delivered to +Braintrust). The shim must await that response before exiting. Long-lived +in-process clients (opencode/pi, later) hold one connection and may send +`event.log` as a **notification** for the hot path, relying on the single +connection for ordering. + +## Methods + +### `initialize` (request) + +First message on every connection. + +Params: +```json +{ + "protocol_version": 1, + "client": { "source": "codex", "plugin_version": "1.2.3", "pid": 12345 } +} +``` +Result: +```json +{ + "protocol_version": 1, + "daemon_version": "0.1.0", + "capabilities": { "sources": ["codex", "claude-code", "debug"] } +} +``` +If `protocol_version` is incompatible the daemon returns an application error; +the client decides whether to drop events or (if the client is newer) trigger a +version handover (`daemon.shutdown` → respawn). + +### `event.log` (request or notification) + +The hot path. Params are the **Envelope** (see below). Request result: +```json +{ "accepted": true } +``` +`accepted: true` means enqueued to the session's ordered queue and journaled. +The daemon never fails the caller's turn for a downstream (Braintrust) error; +those are handled asynchronously and surfaced via `status.get`. + +### `session.flush` (request) + +Block until the session's spans are delivered, or `timeout_ms` elapses. + +Params: +```json +{ "session_id": "…", "timeout_ms": 10000 } +``` +Result: +```json +{ "flushed": true, "pending": 0 } +``` +`flushed: false` with `pending > 0` means the timeout was hit with work +outstanding. Used by session-end hooks and flush-on-turn-end mode. + +### `status.get` (request) + +Params: `{ "session_id": "…" }` (omit `session_id` for daemon-wide status). +Result: +```json +{ + "daemon_version": "0.1.0", + "uptime_ms": 123456, + "sessions": [ + { + "session_id": "…", + "source": "codex", + "queued": 0, + "spans_emitted": 42, + "permalink": "https://www.braintrust.dev/app/…", + "last_error": null + } + ] +} +``` +Powers a `status` CLI and pi's trace-link widget. + +### `daemon.shutdown` (request) + +Graceful: stop accepting new events, drain all session queues, flush sinks, +release the local endpoint, exit. Result `{ "ok": true }` is sent before exit. +Used for version handover and by tests. + +## Envelope (`event.log` params) + +```json +{ + "source": "codex", + "source_version": "1.2.3", + "session_id": "0f9d…", + "event": "PostToolUse", + "ts_ms": 1753639552123, + "payload": { "…raw agent-native hook payload…": true }, + "config": { + "auth": { + "token": "sk-…", + "api_url": "https://api.braintrust.dev", + "app_url": "https://www.braintrust.dev", + "org_name": "acme" + }, + "project": "codex", + "parent_span_id": null, + "root_span_id": null, + "flush_mode": "fire_and_forget", + "additional_metadata": { "…": "…" } + } +} +``` + +Field notes: + +- **`source`** selects the daemon-side translator. `debug` is a built-in + pass-through translator used by the prototype and tests. +- **`session_id`** is the per-session queue + state key. The shim extracts it + from the payload (default JSON field `session_id`, overridable with + `--session-id-field`); both Claude Code and Codex use `session_id`. +- **`event`** is the agent-native hook name (not normalized). Extracted from + the payload (default field `hook_event_name`, overridable with `--event`). +- **`ts_ms`** is stamped by the shim **at capture time** (epoch millis), + because the daemon processes later than the hook fired. Never stamped by the + daemon. +- **`payload`** is opaque to transport and to everything except the translator + for `source`. +- **`config`** carries shim-resolved credentials and trace settings. The shim + attaches it on **every** event (stateless shim); the daemon keeps the latest + per session and only re-inits the Braintrust sink when it changes. `auth` is + filled by `bt`'s `resolve_auth` when embedded, or from env/flags in the + standalone binary. `flush_mode` ∈ `fire_and_forget` | `flush_on_turn_end`. + +### Redaction + +`config.auth.token` (and any nested secret) is **never** written to the +journal or logs. The journal stores the envelope with `config.auth` reduced to +a non-secret fingerprint (`{ "api_url", "app_url", "org_name", "token_sha256_prefix" }`) +so replay can detect a credential change without persisting the secret; on +replay the live credentials must be re-supplied. + +## Daemon lifecycle + +- **Spawn-on-demand.** The shim connects; when no endpoint is available it + spawns the daemon detached (a separate process group on Unix; a detached + process group on Windows; stdio → log file) using a host-supplied argv + (`[bt, daemon, serve]` when embedded; `[bt-daemon, serve]` standalone), then + retries connect with backoff (~50 × 20 ms). `--no-spawn` turns spawning off + (tests / diagnostics) and makes a missing daemon a hard error. +- **Bind race.** Two shims may spawn simultaneously. The daemon claims the + endpoint exclusively and probes a rival with `initialize`. Unix removes an + unresponsive stale socket before rebinding. Windows uses + `FILE_FLAG_FIRST_PIPE_INSTANCE`; named-pipe names disappear with their last + handle, so it retries the exclusive claim without filesystem cleanup. +- **Idle exit.** The daemon exits after `--idle-timeout` (default 300 s) with + zero active sessions and empty queues. +- **Version handover.** `initialize` compares versions. A newer client sends + `daemon.shutdown`, waits until the endpoint no longer accepts connections, + and spawns its own daemon. In-flight session state is rebuilt from the + journal. + +## Durability & idempotence + +- **Journal (WAL).** Every accepted event is appended (auth-redacted) to + `/journal/.ndjson` before/at enqueue. `data_dir` + defaults to `$XDG_STATE_HOME/braintrust/bt-daemon` or + `$HOME/.braintrust/state/bt-daemon` on Unix, and + `%LOCALAPPDATA%\Braintrust\bt-daemon` on Windows. On restart the daemon + rebuilds a session's state by replaying its journal through the translator. + Journals are GC'd after 7 days. +- **Deterministic span ids.** Translators derive span ids as UUIDv5 over stable + keys (`session_id`, `turn_id`, `call_id`, …) so a replayed re-emit merges + server-side (`_is_merge`) instead of duplicating. diff --git a/bt-daemon/src/client.rs b/bt-daemon/src/client.rs new file mode 100644 index 0000000..41ee2d6 --- /dev/null +++ b/bt-daemon/src/client.rs @@ -0,0 +1,189 @@ +//! Client side: ensure a daemon is running (spawn detached if not) and do +//! JSON-RPC round-trips over the socket. Used by the `hook` and `status` +//! entry points, and by tests. + +use crate::wire::{Message, Request, RequestId, Response}; +use std::ffi::OsString; +use std::path::Path; +use std::time::Duration; +use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader, Lines, ReadHalf, WriteHalf}; + +use crate::transport::ClientStream; + +/// Host-specific bits the client needs to (re)launch the daemon: the argv that +/// runs `serve` (e.g. `[bt, daemon, serve]` embedded, `[bt-daemon, serve]` +/// standalone) and the host binary's version string. +#[derive(Debug, Clone)] +pub struct HostInfo { + pub serve_argv: Vec, + pub version: String, +} + +/// A framed JSON-RPC connection with request/response correlation. +pub struct Conn { + reader: Lines>>, + writer: WriteHalf, + next_id: i64, +} + +impl Conn { + pub fn new(stream: ClientStream) -> Self { + let (r, w) = tokio::io::split(stream); + Conn { + reader: BufReader::new(r).lines(), + writer: w, + next_id: 1, + } + } + + /// Send a request and await its matching response (ignoring any interleaved + /// notifications). Returns the `result` value or an error on `error`. + pub async fn request( + &mut self, + method: &str, + params: T, + ) -> anyhow::Result { + let id = self.next_id; + self.next_id += 1; + let req = Request::new(RequestId::Int(id), method, serde_json::to_value(params)?); + self.write(&Message::Request(req)).await?; + + loop { + let line = + self.reader.next_line().await?.ok_or_else(|| { + anyhow::anyhow!("connection closed before response to {method}") + })?; + if let Message::Response(Response { + id: rid, + result, + error, + .. + }) = Message::from_line(&line)? + { + if rid != RequestId::Int(id) { + continue; + } + if let Some(err) = error { + anyhow::bail!("rpc error {} on {method}: {}", err.code, err.message); + } + return Ok(result.unwrap_or(serde_json::Value::Null)); + } + } + } + + async fn write(&mut self, msg: &Message) -> anyhow::Result<()> { + let mut line = msg.to_line()?; + line.push('\n'); + self.writer.write_all(line.as_bytes()).await?; + self.writer.flush().await?; + Ok(()) + } +} + +pub(crate) async fn connect(socket: &Path) -> std::io::Result { + crate::transport::connect(socket).await +} + +/// Connect to the daemon, spawning it (detached) if it isn't up yet. With +/// `no_spawn`, a missing daemon is a hard error (tests / diagnostics). +pub async fn ensure_daemon( + socket: &Path, + host: &HostInfo, + no_spawn: bool, +) -> anyhow::Result { + if let Ok(s) = connect(socket).await { + return Ok(s); + } + if no_spawn { + anyhow::bail!("no daemon at {} and --no-spawn is set", socket.display()); + } + spawn_daemon(host, socket)?; + for _ in 0..50 { + tokio::time::sleep(Duration::from_millis(20)).await; + if let Ok(s) = connect(socket).await { + return Ok(s); + } + } + anyhow::bail!("daemon did not come up at {}", socket.display()) +} + +#[cfg(unix)] +fn spawn_daemon(host: &HostInfo, socket: &Path) -> anyhow::Result<()> { + use std::os::unix::process::CommandExt; + use std::process::{Command, Stdio}; + + let (exe, rest) = host + .serve_argv + .split_first() + .ok_or_else(|| anyhow::anyhow!("empty serve_argv"))?; + + let data_dir = crate::paths::data_dir(None); + let _ = crate::paths::ensure_private_dir(&data_dir); + let log = std::fs::OpenOptions::new() + .create(true) + .append(true) + .open(data_dir.join("serve.log")) + .ok(); + + let mut cmd = Command::new(exe); + cmd.args(rest); + cmd.arg("--socket").arg(socket); + cmd.stdin(Stdio::null()); + match log { + Some(f) => { + let f2 = f.try_clone()?; + cmd.stdout(Stdio::from(f)); + cmd.stderr(Stdio::from(f2)); + } + None => { + cmd.stdout(Stdio::null()); + cmd.stderr(Stdio::null()); + } + } + // Detach into our own process group so the daemon outlives the hook (and + // the agent's) process and its controlling terminal. + cmd.process_group(0); + cmd.spawn()?; + Ok(()) +} + +#[cfg(windows)] +fn spawn_daemon(host: &HostInfo, socket: &Path) -> anyhow::Result<()> { + use std::os::windows::process::CommandExt; + use std::process::{Command, Stdio}; + + const DETACHED_PROCESS: u32 = 0x0000_0008; + const CREATE_NEW_PROCESS_GROUP: u32 = 0x0000_0200; + + let (exe, rest) = host + .serve_argv + .split_first() + .ok_or_else(|| anyhow::anyhow!("empty serve_argv"))?; + + let data_dir = crate::paths::data_dir(None); + let _ = crate::paths::ensure_private_dir(&data_dir); + let log = std::fs::OpenOptions::new() + .create(true) + .append(true) + .open(data_dir.join("serve.log")) + .ok(); + + let mut cmd = Command::new(exe); + cmd.args(rest); + cmd.arg("--socket").arg(socket); + cmd.stdin(Stdio::null()); + match log { + Some(file) => { + let stderr = file.try_clone()?; + cmd.stdout(Stdio::from(file)); + cmd.stderr(Stdio::from(stderr)); + } + None => { + cmd.stdout(Stdio::null()); + cmd.stderr(Stdio::null()); + } + } + cmd.creation_flags(DETACHED_PROCESS | CREATE_NEW_PROCESS_GROUP); + cmd.spawn()?; + Ok(()) +} diff --git a/bt-daemon/src/dispatch.rs b/bt-daemon/src/dispatch.rs new file mode 100644 index 0000000..2e5859e --- /dev/null +++ b/bt-daemon/src/dispatch.rs @@ -0,0 +1,273 @@ +//! Per-session dispatch. Each session owns an ordered queue and a single actor +//! task that runs its translator + sink serially, so events for one session +//! are processed strictly in arrival order. Different sessions run +//! concurrently. +//! +//! Ack semantics: `event.log` is acked once the event is journaled and handed +//! to the session's queue (see [`Session::append_and_enqueue`]). Delivery to +//! Braintrust happens later in the actor; a downstream error never fails the +//! caller's turn. + +use crate::journal::JournalWriter; +use crate::sink::SinkFactory; +use crate::translate::{Registry, SessionCtx}; +use crate::wire::Envelope; +use std::sync::atomic::{AtomicU64, Ordering}; +use std::sync::{Arc, Mutex}; +use tokio::sync::{mpsc, oneshot}; + +#[derive(Default)] +pub struct Counters { + pub queued: AtomicU64, + pub spans_emitted: AtomicU64, +} + +enum SessionMsg { + Event(Box), + Flush(oneshot::Sender), + Shutdown(oneshot::Sender<()>), +} + +/// Handle to one live session: its queue plus observable counters/state. +pub struct Session { + pub source: String, + tx: mpsc::UnboundedSender, + journal: tokio::sync::Mutex, + pub counters: Arc, + pub last_error: Arc>>, + pub permalink: Arc>>, +} + +impl Session { + /// Spawn a session's actor task and return its handle. + pub fn spawn( + session_id: String, + source: String, + journal: JournalWriter, + replay: Vec, + translators: Arc, + sink_factory: Arc, + ) -> Arc { + let (tx, rx) = mpsc::unbounded_channel(); + let counters = Arc::new(Counters::default()); + let last_error = Arc::new(Mutex::new(None)); + let permalink = Arc::new(Mutex::new(None)); + + let actor = SessionActor { + session_id: session_id.clone(), + source: source.clone(), + translators, + sink_factory, + counters: counters.clone(), + last_error: last_error.clone(), + permalink: permalink.clone(), + replay, + }; + tokio::spawn(actor.run(rx)); + + Arc::new(Session { + source, + tx, + journal: tokio::sync::Mutex::new(journal), + counters, + last_error, + permalink, + }) + } + + /// Journal (redacted) then enqueue. Both complete before the caller acks. + pub async fn append_and_enqueue(&self, mut env: Envelope) -> anyhow::Result<()> { + hydrate_transcript_snapshot(&mut env).await; + { + let mut j = self.journal.lock().await; + j.append(&env).await?; + } + self.counters.queued.fetch_add(1, Ordering::Relaxed); + self.tx + .send(SessionMsg::Event(Box::new(env))) + .map_err(|_| anyhow::anyhow!("session actor is gone"))?; + Ok(()) + } + + /// Ask the actor to drain and flush its sink, bounded by `timeout`. + /// Returns `(flushed, pending)`. + pub async fn flush(&self, timeout: std::time::Duration) -> (bool, u64) { + let (reply_tx, reply_rx) = oneshot::channel(); + if self.tx.send(SessionMsg::Flush(reply_tx)).is_err() { + return (false, self.counters.queued.load(Ordering::Relaxed)); + } + match tokio::time::timeout(timeout, reply_rx).await { + Ok(Ok(pending)) => (pending == 0, pending), + _ => (false, self.counters.queued.load(Ordering::Relaxed)), + } + } + + /// Drain, flush, and stop the actor (used on daemon shutdown). + pub async fn shutdown(&self) { + let (reply_tx, reply_rx) = oneshot::channel(); + if self.tx.send(SessionMsg::Shutdown(reply_tx)).is_ok() { + let _ = reply_rx.await; + } + } +} + +/// Claude transcript files are external mutable state. Capture them in the +/// journal at lifecycle boundaries so recovery/replay does not depend on a +/// path that Claude may later rewrite or delete. Fail open: a missing file is +/// handled by the translator exactly as before. +async fn hydrate_transcript_snapshot(env: &mut Envelope) { + if env.source != "claude-code" + || !matches!( + env.event.as_str(), + "UserPromptSubmit" | "Stop" | "StopFailure" | "SubagentStop" | "SessionEnd" + ) + { + return; + } + let field = if env.event == "SubagentStop" { + "agent_transcript_path" + } else { + "transcript_path" + }; + let Some(path) = env + .payload + .get(field) + .and_then(serde_json::Value::as_str) + .map(str::to_owned) + else { + return; + }; + let Ok(contents) = tokio::fs::read_to_string(&path).await else { + return; + }; + if let Some(payload) = env.payload.as_object_mut() { + payload.insert( + "_bt_transcript_snapshot".to_string(), + serde_json::json!({ "path": path, "contents": contents }), + ); + } +} + +struct SessionActor { + session_id: String, + source: String, + translators: Arc, + sink_factory: Arc, + counters: Arc, + last_error: Arc>>, + permalink: Arc>>, + replay: Vec, +} + +impl SessionActor { + async fn run(self, mut rx: mpsc::UnboundedReceiver) { + let mut translator = self.translators.create(&self.source, &self.session_id); + let mut sink = match self.sink_factory.create(&self.session_id, &self.source) { + Ok(s) => s, + Err(e) => { + self.set_error(format!("sink init failed: {e}")); + // Still drain the queue so the daemon's counters settle and + // callers waiting on flush don't hang. + while let Some(msg) = rx.recv().await { + if let SessionMsg::Event(_) = msg { + self.counters.queued.fetch_sub(1, Ordering::Relaxed); + } else if let SessionMsg::Flush(r) = msg { + let _ = r.send(0); + } else if let SessionMsg::Shutdown(r) = msg { + let _ = r.send(()); + break; + } + } + return; + } + }; + let mut ctx = SessionCtx { + session_id: self.session_id.clone(), + config: None, + }; + // Rebuild translator state before accepting the first new event. Keep + // the deterministic replay ops buffered until live credentials arrive; + // then re-emitting them repairs any rows lost by a prior crash. + let mut replay_ops = Vec::new(); + for env in &self.replay { + if let Some(cfg) = &env.config { + ctx.config = Some(cfg.clone()); + } + match translator.handle(env, &ctx) { + Ok(mut ops) => replay_ops.append(&mut ops), + Err(e) => self.set_error(format!("journal replay failed: {e}")), + } + } + + while let Some(msg) = rx.recv().await { + match msg { + SessionMsg::Event(env) => { + if let Some(cfg) = &env.config { + sink.configure(cfg); + ctx.config = Some(cfg.clone()); + self.refresh_permalink(sink.as_ref()); + } + if !replay_ops.is_empty() { + match sink.emit(&replay_ops).await { + Ok(n) => { + self.counters.spans_emitted.fetch_add(n, Ordering::Relaxed); + replay_ops.clear(); + } + Err(e) => self.set_error(format!("sink replay emit failed: {e}")), + } + } + match translator.handle(&env, &ctx) { + Ok(ops) => match sink.emit(&ops).await { + Ok(n) => { + self.counters.spans_emitted.fetch_add(n, Ordering::Relaxed); + } + Err(e) => self.set_error(format!("sink emit failed: {e}")), + }, + Err(e) => self.set_error(format!("translate failed: {e}")), + } + self.counters.queued.fetch_sub(1, Ordering::Relaxed); + } + SessionMsg::Flush(reply) => { + self.drain_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; + let _ = reply.send(()); + break; + } + } + } + } + + async fn drain_flush( + &self, + translator: &mut Box, + sink: &mut Box, + ctx: &SessionCtx, + ) { + match translator.flush(ctx) { + Ok(ops) => { + if let Err(e) = sink.emit(&ops).await { + self.set_error(format!("sink emit (flush) failed: {e}")); + } + } + Err(e) => self.set_error(format!("translate flush failed: {e}")), + } + if let Err(e) = sink.flush().await { + self.set_error(format!("sink flush failed: {e}")); + } + self.refresh_permalink(sink.as_ref()); + } + + fn refresh_permalink(&self, sink: &dyn crate::sink::Sink) { + if let Some(link) = sink.permalink() { + *self.permalink.lock().unwrap() = Some(link); + } + } + + fn set_error(&self, msg: String) { + tracing::warn!(session_id = %self.session_id, "{msg}"); + *self.last_error.lock().unwrap() = Some(msg); + } +} diff --git a/bt-daemon/src/ids.rs b/bt-daemon/src/ids.rs new file mode 100644 index 0000000..6167207 --- /dev/null +++ b/bt-daemon/src/ids.rs @@ -0,0 +1,38 @@ +//! Deterministic span-id derivation. +//! +//! Translators derive span ids as UUIDv5 over stable keys so that replaying a +//! session's journal re-creates the same ids, and the re-emit merges +//! server-side (`_is_merge`) instead of duplicating. The exact string format +//! the Braintrust sink requires is reconciled in the sink layer (some SDK +//! paths want hex span ids); this module is the single place that mints them. + +use uuid::Uuid; + +/// Fixed namespace for all bt-daemon span ids ("btdaemon-span-id-ns" hashed to +/// a v4 uuid, pinned as a constant so it never changes across builds). +const NAMESPACE: Uuid = Uuid::from_u128(0x8f2b_4e11_9c7a_4d3e_b6a1_5f0c_2d84_71ae); + +const SEP: char = '\u{1f}'; // ASCII unit separator; will not appear in ids/keys. + +/// 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 { + let name = format!("{session_id}{SEP}{key}"); + Uuid::new_v5(&NAMESPACE, name.as_bytes()).to_string() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn stable_and_distinct() { + let a1 = span_id("s1", "turn:1"); + let a2 = span_id("s1", "turn:1"); + let b = span_id("s1", "turn:2"); + let c = span_id("s2", "turn:1"); + assert_eq!(a1, a2, "same inputs must be stable across calls"); + assert_ne!(a1, b); + assert_ne!(a1, c); + } +} diff --git a/bt-daemon/src/journal.rs b/bt-daemon/src/journal.rs new file mode 100644 index 0000000..52c7758 --- /dev/null +++ b/bt-daemon/src/journal.rs @@ -0,0 +1,144 @@ +//! Per-session write-ahead journal. Every accepted event is appended +//! (auth-redacted) before the caller is acked, so a restarted daemon can +//! rebuild session state by replaying the journal through the translator. +//! +//! Format: one [`RedactedEnvelope`] JSON value per line in +//! `/journal/.ndjson`. + +use crate::wire::{ + AuthFingerprint, BackendAuth, Envelope, RedactedConfig, RedactedEnvelope, SessionConfig, +}; +use std::path::{Path, PathBuf}; +use tokio::io::AsyncWriteExt; + +pub fn journal_dir(data_dir: &Path) -> PathBuf { + data_dir.join("journal") +} + +fn sanitize(s: &str) -> String { + s.chars() + .map(|c| { + if c.is_ascii_alphanumeric() || c == '-' || c == '_' { + c + } else { + '_' + } + }) + .collect() +} + +pub fn journal_path(data_dir: &Path, session_id: &str) -> PathBuf { + journal_dir(data_dir).join(format!("{}.ndjson", sanitize(session_id))) +} + +/// Append-only journal writer for one session. +pub struct JournalWriter { + file: tokio::fs::File, +} + +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); + let file = tokio::fs::OpenOptions::new() + .create(true) + .append(true) + .open(&path) + .await?; + Ok(Self { file }) + } + + /// Append one event in redacted form and flush to the OS. Not fsync'd per + /// event (that would dominate hook latency); an OS crash can lose the last + /// few lines, which replay tolerates. + pub async fn append(&mut self, env: &Envelope) -> anyhow::Result<()> { + let mut line = serde_json::to_vec(&env.redacted())?; + line.push(b'\n'); + self.file.write_all(&line).await?; + self.file.flush().await?; + Ok(()) + } +} + +/// Read a journal file back into redacted envelopes (for replay/rebuild). +pub async fn read_journal(path: &Path) -> anyhow::Result> { + let data = tokio::fs::read_to_string(path).await?; + let mut out = Vec::new(); + for (i, line) in data.lines().enumerate() { + if line.trim().is_empty() { + continue; + } + let env: RedactedEnvelope = serde_json::from_str(line) + .map_err(|e| anyhow::anyhow!("journal {}:{}: {e}", path.display(), i + 1))?; + out.push(env); + } + Ok(out) +} + +/// Best-effort age-based journal collection. A failed stat/remove is logged +/// and ignored; stale state must never prevent the daemon from serving hooks. +pub async fn gc_old_journals(data_dir: &Path, max_age: std::time::Duration) { + let dir = journal_dir(data_dir); + let Ok(mut entries) = tokio::fs::read_dir(&dir).await else { + return; + }; + let now = std::time::SystemTime::now(); + while let Ok(Some(entry)) = entries.next_entry().await { + let path = entry.path(); + if path.extension().and_then(|v| v.to_str()) != Some("ndjson") { + continue; + } + let old = entry + .metadata() + .await + .ok() + .and_then(|m| m.modified().ok()) + .and_then(|modified| now.duration_since(modified).ok()) + .is_some_and(|age| age > max_age); + if old { + if let Err(e) = tokio::fs::remove_file(&path).await { + tracing::warn!(path = %path.display(), "failed to remove stale journal: {e}"); + } + } + } +} + +/// Reconstruct a translator-usable [`Envelope`] from a redacted journal entry. +/// The live token is gone (redacted), so `auth.token` is empty — fine for +/// rebuilding translator state; the sink must be re-supplied live credentials +/// if replay needs to actually deliver. +pub fn envelope_from_redacted(r: RedactedEnvelope) -> Envelope { + Envelope { + source: r.source, + source_version: r.source_version, + session_id: r.session_id, + event: r.event, + ts_ms: r.ts_ms, + payload: r.payload, + config: r.config.map(config_from_redacted), + } +} + +fn config_from_redacted(c: RedactedConfig) -> SessionConfig { + let AuthFingerprint { + api_url, + app_url, + org_name, + .. + } = c.auth; + SessionConfig { + auth: BackendAuth { + token: String::new(), + api_url, + app_url, + org_name, + org_id: None, + }, + project: c.project, + parent_span_id: c.parent_span_id, + root_span_id: c.root_span_id, + flush_mode: c.flush_mode, + additional_metadata: c.additional_metadata, + } +} diff --git a/bt-daemon/src/lib.rs b/bt-daemon/src/lib.rs new file mode 100644 index 0000000..301aee2 --- /dev/null +++ b/bt-daemon/src/lib.rs @@ -0,0 +1,449 @@ +//! bt-daemon: the embeddable library behind the Braintrust coding-agent +//! tracing daemon. Two front-ends consume it (see `../DESIGN.md`): +//! * `bt` wires the [`clap::Args`] structs into its command tree and fills +//! [`wire::SessionConfig`] from its own auth resolution. +//! * the feature-gated standalone `bt-daemon` binary does the same with +//! env/flag token auth only, for isolated testing. +//! +//! The core is credential-passive: it only ever *receives* a resolved +//! [`wire::BackendAuth`] with the session config, so both front-ends share all +//! core behavior. The `cli` feature only gates the standalone binary and its +//! logging subscriber. + +pub mod paths; + +mod client; +mod dispatch; +mod ids; +mod journal; +mod server; +mod sink; +mod translate; +mod transport; + +pub mod wire; +pub use client::HostInfo; +pub use server::ServeOptions; +pub use sink::{BraintrustSinkConfig, BraintrustSinkFactory, DebugSinkFactory, Sink, SinkFactory}; +pub use translate::{ + AgentTranslator, Registry, SessionCtx, SpanOp, SpanRow, SpanType, TranslatorFactory, +}; + +use clap::Args; +use std::path::PathBuf; +use std::sync::Arc; +use std::time::{SystemTime, UNIX_EPOCH}; +use wire::{method, Envelope, SessionConfig, StatusResult, PROTOCOL_VERSION}; + +/// Arguments for `serve`. +#[derive(Debug, Clone, Args)] +pub struct ServeArgs { + /// Socket path override (default: see docs/protocol.md). + #[arg(long)] + pub socket: Option, + /// Data/journal directory override. + #[arg(long)] + pub data_dir: Option, + /// Exit after this many seconds idle (no activity, empty queues). 0 + /// disables the watchdog. + #[arg(long, default_value_t = 300)] + pub idle_timeout_secs: u64, +} + +/// Arguments for `hook`. +#[derive(Debug, Clone, Args)] +pub struct HookArgs { + /// Which translator should interpret this event's payload. + #[arg(long)] + pub source: String, + /// Optional agent version, forwarded for payload-drift handling. + #[arg(long)] + pub source_version: Option, + /// Socket path override. + #[arg(long)] + pub socket: Option, + /// JSON field in the payload holding the session id. + #[arg(long, default_value = "session_id")] + pub session_id_field: String, + /// JSON field in the payload holding the event name. + #[arg(long, default_value = "hook_event_name")] + pub event_field: String, + /// Explicit event name (overrides `--event-field` lookup). + #[arg(long)] + pub event: Option, + /// Fail instead of spawning a daemon if none is running. + #[arg(long)] + pub no_spawn: bool, + /// Flush the session after a turn-ending event. Intended for short-lived + /// CI hosts; SessionEnd is always flushed. + #[arg(long)] + pub flush_on_turn_end: bool, + /// Bound an explicit turn/session-end flush. + #[arg(long, default_value_t = 10_000)] + pub flush_timeout_ms: u64, + /// Attach the agent session below an existing Braintrust span. + #[arg(long)] + pub parent_span_id: Option, + /// Existing trace root when attaching below a non-root parent. + #[arg(long)] + pub root_span_id: Option, + /// JSON object merged into root-span metadata. + #[arg(long)] + pub additional_metadata: Option, + /// Route spans to an existing Braintrust experiment instead of project + /// logs. The Claude shim supplies this from CC_EXPERIMENT_ID. + #[arg(long)] + pub experiment_id: Option, +} + +/// Arguments for `status`. +#[derive(Debug, Clone, Args)] +pub struct StatusArgs { + #[arg(long)] + pub socket: Option, + /// Limit to one session. + #[arg(long)] + pub session_id: Option, +} + +/// Arguments for `replay`. +#[derive(Debug, Clone, Args)] +pub struct ReplayArgs { + /// A journal NDJSON file to replay through the translators + sink. + pub file: PathBuf, +} + +/// Run the daemon until shutdown. +pub async fn run_serve(args: ServeArgs, opts: ServeOptions) -> anyhow::Result<()> { + server::run(args, opts).await +} + +/// Capture one hook event from `stdin` and forward it to the daemon. +/// +/// `config` is the caller-resolved session config (auth + trace settings). +/// Returns `Ok` once the daemon has acked (journaled + enqueued). Callers that +/// must never fail the agent's turn should treat any `Err` as non-fatal and +/// exit 0. +pub async fn run_hook( + args: HookArgs, + mut config: SessionConfig, + host: HostInfo, +) -> anyhow::Result<()> { + let payload = read_stdin_json()?; + + let session_id = json_str_field(&payload, &args.session_id_field) + .ok_or_else(|| anyhow::anyhow!("no `{}` field in hook payload", args.session_id_field))?; + let event = args + .event + .clone() + .or_else(|| json_str_field(&payload, &args.event_field)) + .unwrap_or_default(); + + if args.flush_on_turn_end { + config.flush_mode = wire::FlushMode::FlushOnTurnEnd; + } + if args.parent_span_id.is_some() { + config.parent_span_id = args.parent_span_id.clone(); + } + if args.root_span_id.is_some() { + config.root_span_id = args.root_span_id.clone(); + } + match (config.parent_span_id.clone(), config.root_span_id.clone()) { + (Some(parent), None) => config.root_span_id = Some(parent), + (None, Some(root)) => config.parent_span_id = Some(root), + _ => {} + } + if let Some(metadata) = &args.additional_metadata { + let value: serde_json::Value = serde_json::from_str(metadata) + .map_err(|e| anyhow::anyhow!("invalid --additional-metadata JSON: {e}"))?; + if !value.is_object() { + anyhow::bail!("--additional-metadata must be a JSON object"); + } + config.additional_metadata = Some(value); + } + if let Some(experiment_id) = &args.experiment_id { + let mut metadata = config + .additional_metadata + .take() + .and_then(|value| value.as_object().cloned()) + .unwrap_or_default(); + metadata.insert( + "_bt_experiment_id".to_string(), + serde_json::Value::String(experiment_id.clone()), + ); + config.additional_metadata = Some(serde_json::Value::Object(metadata)); + } + let env = Envelope { + source: args.source.clone(), + source_version: args.source_version.clone(), + session_id, + event, + ts_ms: now_ms(), + payload, + config: Some(config), + }; + + let socket = paths::socket_path(args.socket.as_deref()); + forward_envelope(&env, &socket, &host, args.no_spawn).await?; + let should_flush = env.event == "SessionEnd" + || (matches!( + env.config.as_ref().map(|c| c.flush_mode), + Some(wire::FlushMode::FlushOnTurnEnd) + ) && matches!(env.event.as_str(), "Stop" | "SubagentStop")); + if should_flush { + flush_session(&env.session_id, &socket, args.flush_timeout_ms).await?; + } + Ok(()) +} + +/// Ensure a daemon is up and forward one already-built [`Envelope`] to it +/// (`initialize` handshake + `event.log`). Also the seam in-process clients and +/// tests use to send events without going through stdin. +pub async fn forward_envelope( + env: &Envelope, + socket: &std::path::Path, + host: &HostInfo, + no_spawn: bool, +) -> anyhow::Result<()> { + let stream = client::ensure_daemon(socket, host, no_spawn).await?; + let mut conn = client::Conn::new(stream); + let initialized = conn + .request( + method::INITIALIZE, + serde_json::json!({ + "protocol_version": PROTOCOL_VERSION, + "client": { + "source": env.source, + "plugin_version": env.source_version, + "pid": std::process::id() + } + }), + ) + .await?; + let initialized: wire::InitializeResult = serde_json::from_value(initialized)?; + if initialized.daemon_version != host.version { + if no_spawn { + anyhow::bail!( + "daemon version {} does not match client {} and --no-spawn is set", + initialized.daemon_version, + host.version + ); + } + conn.request(method::DAEMON_SHUTDOWN, serde_json::json!({})) + .await?; + drop(conn); + for _ in 0..100 { + if client::connect(socket).await.is_err() { + break; + } + tokio::time::sleep(std::time::Duration::from_millis(20)).await; + } + let stream = client::ensure_daemon(socket, host, false).await?; + conn = client::Conn::new(stream); + conn.request( + method::INITIALIZE, + serde_json::json!({ + "protocol_version": PROTOCOL_VERSION, + "client": { + "source": env.source, + "plugin_version": env.source_version, + "pid": std::process::id() + } + }), + ) + .await?; + } + conn.request(method::EVENT_LOG, env).await?; + Ok(()) +} + +/// Ask the daemon to flush a session, bounded by `timeout_ms`. A reliable +/// barrier: on return, every event enqueued before the call has been processed +/// and its spans emitted to the sink. +pub async fn flush_session( + session_id: &str, + socket: &std::path::Path, + timeout_ms: u64, +) -> anyhow::Result { + let stream = client::connect(socket).await?; + let mut conn = client::Conn::new(stream); + conn.request( + method::INITIALIZE, + serde_json::json!({ "protocol_version": PROTOCOL_VERSION, "client": { "source": "flush" } }), + ) + .await?; + let params = wire::FlushParams { + session_id: session_id.to_string(), + timeout_ms, + }; + let value = conn.request(method::SESSION_FLUSH, params).await?; + Ok(serde_json::from_value(value)?) +} + +/// Query daemon status. `Ok(None)` means no daemon is running. +pub async fn run_status(args: StatusArgs) -> anyhow::Result> { + let socket = paths::socket_path(args.socket.as_deref()); + let stream = match client::connect(&socket).await { + Ok(s) => s, + Err(_) => return Ok(None), + }; + let mut conn = client::Conn::new(stream); + conn.request( + method::INITIALIZE, + serde_json::json!({ + "protocol_version": PROTOCOL_VERSION, + "client": { "source": "status" } + }), + ) + .await?; + let params = wire::StatusParams { + session_id: args.session_id.clone(), + }; + let value = conn.request(method::STATUS_GET, params).await?; + Ok(Some(serde_json::from_value(value)?)) +} + +/// Request a graceful daemon shutdown. Primarily useful for lifecycle +/// management and transport integration tests. +pub async fn shutdown_daemon(socket: &std::path::Path) -> anyhow::Result<()> { + let stream = client::connect(socket).await?; + let mut conn = client::Conn::new(stream); + conn.request(method::DAEMON_SHUTDOWN, serde_json::json!({})) + .await?; + Ok(()) +} + +/// Replay a journal file through the translators + sink (no daemon, no +/// network unless the sink talks to one). Deterministic; used for fixture +/// tests and translator-vs-translator diffing. +pub async fn run_replay(args: ReplayArgs, opts: ServeOptions) -> anyhow::Result<()> { + use std::collections::HashMap; + let entries = journal::read_journal(&args.file).await?; + + struct Live { + translator: Box, + sink: Box, + ctx: SessionCtx, + } + let mut sessions: HashMap = HashMap::new(); + + for redacted in entries { + let env = journal::envelope_from_redacted(redacted); + let sid = env.session_id.clone(); + let live = match sessions.get_mut(&sid) { + Some(l) => l, + None => { + let translator = opts.translators.create(&env.source, &sid); + let sink = opts.sink_factory.create(&sid, &env.source)?; + sessions.insert( + sid.clone(), + Live { + translator, + sink, + ctx: SessionCtx { + session_id: sid.clone(), + config: None, + }, + }, + ); + sessions.get_mut(&sid).unwrap() + } + }; + if let Some(cfg) = &env.config { + live.sink.configure(cfg); + live.ctx.config = Some(cfg.clone()); + } + let ops = live.translator.handle(&env, &live.ctx)?; + live.sink.emit(&ops).await?; + } + + for (_sid, mut live) in sessions { + let ops = live.translator.flush(&live.ctx)?; + live.sink.emit(&ops).await?; + live.sink.flush().await?; + } + Ok(()) +} + +/// Build a Phase-1 debug [`ServeOptions`]: debug translator registry + a debug +/// sink writing NDJSON under `/spans/`. +pub fn debug_serve_options(version: impl Into, data_dir: &std::path::Path) -> ServeOptions { + ServeOptions { + version: version.into(), + translators: Arc::new(Registry::debug_only()), + sink_factory: Arc::new(DebugSinkFactory { + dir: data_dir.join("spans"), + }), + } +} + +/// Build [`ServeOptions`] with the Braintrust sink. `translators` lets the +/// caller choose the translator registry (debug-only until Phase 3 adds the +/// Codex/Claude translators). Clients are built lazily per session URL, so this +/// is cheap and infallible. +pub fn braintrust_serve_options( + version: impl Into, + sink_config: BraintrustSinkConfig, + translators: Arc, +) -> ServeOptions { + ServeOptions { + version: version.into(), + translators, + sink_factory: Arc::new(BraintrustSinkFactory::new(sink_config)), + } +} + +fn now_ms() -> i64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|d| d.as_millis() as i64) + .unwrap_or(0) +} + +fn read_stdin_json() -> anyhow::Result { + use std::io::Read; + let mut buf = String::new(); + std::io::stdin().read_to_string(&mut buf)?; + if buf.trim().is_empty() { + anyhow::bail!("empty stdin (expected a JSON hook payload)"); + } + Ok(serde_json::from_str(&buf)?) +} + +/// Read a string-ish field (`session_id` / event name) from the payload, +/// coercing a JSON number to its string form. +fn json_str_field(payload: &serde_json::Value, field: &str) -> Option { + match payload.get(field) { + Some(serde_json::Value::String(s)) => Some(s.clone()), + Some(serde_json::Value::Number(n)) => Some(n.to_string()), + _ => None, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn json_string_fields_accept_strings_and_numbers_only() { + let payload = serde_json::json!({ + "string": "session", + "number": 42, + "boolean": true, + "null": null + }); + assert_eq!( + json_str_field(&payload, "string").as_deref(), + Some("session") + ); + assert_eq!(json_str_field(&payload, "number").as_deref(), Some("42")); + assert_eq!(json_str_field(&payload, "boolean"), None); + assert_eq!(json_str_field(&payload, "null"), None); + assert_eq!(json_str_field(&payload, "missing"), None); + } + + #[test] + fn clock_returns_a_positive_epoch_timestamp() { + assert!(now_ms() > 0); + } +} diff --git a/bt-daemon/src/main.rs b/bt-daemon/src/main.rs index 422ff81..d3468ec 100644 --- a/bt-daemon/src/main.rs +++ b/bt-daemon/src/main.rs @@ -1,8 +1,183 @@ -// bt-daemon — placeholder entrypoint. -// -// This will grow into the shared daemon / event server that the coding-agent -// plugins talk to. For now it just proves the crate builds. +//! Standalone `bt-daemon` binary — the isolated-testing front-end over the +//! `bt-daemon` library. Built only with the `cli` feature. Auth is env/flag +//! static-token only; there are no +//! profiles, OAuth, or keychain here (that lives in `bt`). See +//! the crate README's "Dual consumption" section. -fn main() { - println!("bt-daemon: placeholder"); +use bt_daemon::wire::{BackendAuth, FlushMode, SessionConfig}; +use bt_daemon::{ + braintrust_serve_options, paths, run_hook, run_replay, run_serve, run_status, + BraintrustSinkConfig, DebugSinkFactory, HookArgs, HostInfo, Registry, ReplayArgs, ServeArgs, + ServeOptions, StatusArgs, +}; +use clap::{Args, Parser, Subcommand}; +use std::ffi::OsString; +use std::sync::Arc; + +/// A debug-sink [`ServeOptions`] with all real agent translators registered +/// (Braintrust delivery off — NDJSON to `/spans/`). +fn debug_serve_options(version: &str, data_dir: &std::path::Path) -> ServeOptions { + ServeOptions { + version: version.to_string(), + translators: Arc::new(Registry::default_agents()), + sink_factory: Arc::new(DebugSinkFactory { + dir: data_dir.join("spans"), + }), + } +} + +const VERSION: &str = env!("CARGO_PKG_VERSION"); + +#[derive(Parser)] +#[command( + name = "bt-daemon", + version, + about = "Braintrust coding-agent tracing daemon (standalone test binary)" +)] +struct Cli { + #[command(subcommand)] + command: Command, +} + +#[derive(Subcommand)] +// Clap parse structs; sizes are irrelevant for a one-shot CLI dispatch. +#[allow(clippy::large_enum_variant)] +enum Command { + /// Run the daemon (foreground). + Serve { + #[command(flatten)] + args: ServeArgs, + /// Use the debug sink (NDJSON to disk) instead of sending to Braintrust. + /// For offline isolated testing. + #[arg(long)] + debug_sink: bool, + /// Braintrust API URL for the sink (default: SDK default). + #[arg(long, env = "BRAINTRUST_API_URL")] + api_url: Option, + /// Braintrust app URL for the sink (default: SDK default). + #[arg(long, env = "BRAINTRUST_APP_URL")] + app_url: Option, + }, + /// Forward one hook event (read from stdin) to the daemon. + Hook { + #[command(flatten)] + args: HookArgs, + #[command(flatten)] + auth: AuthArgs, + }, + /// Print daemon/session status. + Status(StatusArgs), + /// Replay a journal file through the translators + sink. + Replay(ReplayArgs), +} + +/// Static-token backend auth from env/flags (no profile resolution). +#[derive(Args)] +struct AuthArgs { + #[arg(long, env = "BRAINTRUST_API_KEY")] + api_key: Option, + #[arg(long, env = "BRAINTRUST_API_URL")] + api_url: Option, + #[arg(long, env = "BRAINTRUST_APP_URL")] + app_url: Option, + #[arg(long = "org", env = "BRAINTRUST_ORG_NAME")] + org_name: Option, + #[arg(long = "org-id", env = "BRAINTRUST_ORG_ID")] + org_id: Option, + #[arg(long, env = "BRAINTRUST_PROJECT")] + project: Option, +} + +impl AuthArgs { + fn into_config(self) -> SessionConfig { + SessionConfig { + auth: BackendAuth { + token: self.api_key.unwrap_or_default(), + api_url: self.api_url, + app_url: self.app_url, + org_name: self.org_name, + org_id: self.org_id, + }, + project: self.project, + parent_span_id: None, + root_span_id: None, + flush_mode: FlushMode::FireAndForget, + additional_metadata: None, + } + } +} + +fn host_info() -> HostInfo { + let exe = std::env::current_exe() + .map(OsString::from) + .unwrap_or_else(|_| OsString::from("bt-daemon")); + HostInfo { + serve_argv: vec![exe, OsString::from("serve")], + version: VERSION.to_string(), + } +} + +#[tokio::main] +async fn main() { + tracing_subscriber::fmt() + .with_env_filter( + tracing_subscriber::EnvFilter::try_from_default_env() + .unwrap_or_else(|_| tracing_subscriber::EnvFilter::new("info")), + ) + .with_writer(std::io::stderr) + .init(); + + let cli = Cli::parse(); + match cli.command { + Command::Serve { + args, + debug_sink, + api_url, + app_url, + } => { + let data_dir = paths::data_dir(args.data_dir.as_deref()); + let opts = if debug_sink { + debug_serve_options(VERSION, &data_dir) + } else { + let cfg = BraintrustSinkConfig { + api_url, + app_url, + version: VERSION.to_string(), + }; + braintrust_serve_options(VERSION, cfg, Arc::new(Registry::default_agents())) + }; + if let Err(e) = run_serve(args, opts).await { + eprintln!("bt-daemon serve: {e}"); + std::process::exit(1); + } + } + Command::Hook { args, auth } => { + // A hook must NEVER fail the agent's turn: log and exit 0 on error. + let config = auth.into_config(); + if let Err(e) = run_hook(args, config, host_info()).await { + eprintln!("bt-daemon hook (non-fatal): {e}"); + } + std::process::exit(0); + } + Command::Status(args) => match run_status(args).await { + Ok(Some(status)) => { + println!("{}", serde_json::to_string_pretty(&status).unwrap()); + } + Ok(None) => { + println!("bt-daemon is not running"); + } + Err(e) => { + eprintln!("bt-daemon status: {e}"); + std::process::exit(1); + } + }, + Command::Replay(args) => { + let data_dir = paths::data_dir(None); + let opts = debug_serve_options(VERSION, &data_dir); + if let Err(e) = run_replay(args, opts).await { + eprintln!("bt-daemon replay: {e}"); + std::process::exit(1); + } + } + } } diff --git a/bt-daemon/src/paths.rs b/bt-daemon/src/paths.rs new file mode 100644 index 0000000..f37a0ab --- /dev/null +++ b/bt-daemon/src/paths.rs @@ -0,0 +1,118 @@ +//! Socket and data-directory resolution. Both `serve` and `hook` must agree on +//! the defaults, so the logic lives here. See `docs/protocol.md`. + +use std::path::{Path, PathBuf}; + +/// Env override for the socket path (also settable via `--socket`). +pub const SOCKET_ENV: &str = "BT_DAEMON_SOCKET"; +/// Env override for the data/journal directory. +pub const DATA_DIR_ENV: &str = "BT_DAEMON_DATA_DIR"; + +fn home() -> PathBuf { + std::env::var_os("HOME") + .or_else(|| std::env::var_os("USERPROFILE")) + .map(PathBuf::from) + .unwrap_or_else(|| PathBuf::from(".")) +} + +/// Resolve the socket path: explicit `override` → `$BT_DAEMON_SOCKET` → +/// `$XDG_RUNTIME_DIR/braintrust/daemon.sock` → `~/.braintrust/run/daemon.sock`. +pub fn socket_path(explicit: Option<&Path>) -> PathBuf { + if let Some(p) = explicit { + return p.to_path_buf(); + } + if let Some(p) = std::env::var_os(SOCKET_ENV) { + return PathBuf::from(p); + } + #[cfg(windows)] + { + use sha2::{Digest, Sha256}; + let identity = format!( + "{}\\{}", + std::env::var("USERDOMAIN").unwrap_or_default(), + std::env::var("USERNAME").unwrap_or_default() + ); + let digest = Sha256::digest(identity.as_bytes()); + let suffix: String = digest[..8] + .iter() + .map(|byte| format!("{byte:02x}")) + .collect(); + PathBuf::from(format!(r"\\.\pipe\braintrust-bt-daemon-{suffix}")) + } + #[cfg(unix)] + if let Some(rt) = std::env::var_os("XDG_RUNTIME_DIR") { + if !rt.is_empty() { + return PathBuf::from(rt).join("braintrust").join("daemon.sock"); + } + } + #[cfg(unix)] + { + home().join(".braintrust").join("run").join("daemon.sock") + } +} + +/// Resolve the data dir: explicit `override` → `$BT_DAEMON_DATA_DIR` → +/// `$XDG_STATE_HOME/braintrust/bt-daemon` → `~/.braintrust/state/bt-daemon`. +pub fn data_dir(explicit: Option<&Path>) -> PathBuf { + if let Some(p) = explicit { + return p.to_path_buf(); + } + if let Some(p) = std::env::var_os(DATA_DIR_ENV) { + return PathBuf::from(p); + } + #[cfg(windows)] + if let Some(local) = std::env::var_os("LOCALAPPDATA") { + if !local.is_empty() { + return PathBuf::from(local).join("Braintrust").join("bt-daemon"); + } + } + #[cfg(unix)] + if let Some(s) = std::env::var_os("XDG_STATE_HOME") { + if !s.is_empty() { + return PathBuf::from(s).join("braintrust").join("bt-daemon"); + } + } + home().join(".braintrust").join("state").join("bt-daemon") +} + +/// Create `dir` (and parents) mode 0700 on unix. +pub fn ensure_private_dir(dir: &Path) -> std::io::Result<()> { + std::fs::create_dir_all(dir)?; + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + let perms = std::fs::Permissions::from_mode(0o700); + std::fs::set_permissions(dir, perms)?; + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn explicit_paths_take_precedence() { + let socket = Path::new("custom-endpoint"); + let data = Path::new("custom-data"); + assert_eq!(socket_path(Some(socket)), socket); + assert_eq!(data_dir(Some(data)), data); + } + + #[test] + fn private_directory_is_created() { + let temp = tempfile::tempdir().unwrap(); + let nested = temp.path().join("one/two"); + ensure_private_dir(&nested).unwrap(); + assert!(nested.is_dir()); + + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + assert_eq!( + std::fs::metadata(&nested).unwrap().permissions().mode() & 0o777, + 0o700 + ); + } + } +} diff --git a/bt-daemon/src/server.rs b/bt-daemon/src/server.rs new file mode 100644 index 0000000..a7ad540 --- /dev/null +++ b/bt-daemon/src/server.rs @@ -0,0 +1,404 @@ +//! The daemon: owns the session map + shared deps, binds the UDS listener, +//! serves JSON-RPC connections, and shuts down gracefully (idle timeout, +//! `daemon.shutdown`, or SIGINT/SIGTERM). + +use crate::dispatch::Session; +use crate::journal::{self, JournalWriter}; +use crate::sink::SinkFactory; +use crate::translate::Registry; +use crate::transport::{self, Listener, ServerStream}; +use crate::wire::{ + error_code, method, Capabilities, Envelope, EventLogResult, FlushParams, FlushResult, + InitializeParams, InitializeResult, Message, Request, Response, RpcError, SessionStatus, + ShutdownResult, StatusParams, StatusResult, PROTOCOL_VERSION, +}; +use crate::{paths, ServeArgs}; +use std::collections::HashMap; +use std::path::PathBuf; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::{Arc, Mutex}; +use std::time::{Duration, Instant}; +use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader}; +use tokio::sync::Notify; + +/// Injected dependencies for `serve`, so `bt` / tests can supply a sink +/// factory (Braintrust in production, debug in tests) and a version string. +pub struct ServeOptions { + pub version: String, + pub translators: Arc, + pub sink_factory: Arc, +} + +pub struct Daemon { + version: String, + data_dir: PathBuf, + translators: Arc, + sink_factory: Arc, + sessions: Mutex>>, + started: Instant, + last_activity: Mutex, + shutting_down: AtomicBool, + shutdown: Notify, +} + +impl Daemon { + fn new(opts: ServeOptions, data_dir: PathBuf) -> Arc { + Arc::new(Daemon { + version: opts.version, + data_dir, + translators: opts.translators, + sink_factory: opts.sink_factory, + sessions: Mutex::new(HashMap::new()), + started: Instant::now(), + last_activity: Mutex::new(Instant::now()), + shutting_down: AtomicBool::new(false), + shutdown: Notify::new(), + }) + } + + fn touch(&self) { + *self.last_activity.lock().unwrap() = Instant::now(); + } + + async fn session_for(&self, env: &Envelope) -> anyhow::Result> { + { + let map = self.sessions.lock().unwrap(); + if let Some(s) = map.get(&env.session_id) { + return Ok(s.clone()); + } + } + // Open the journal outside the lock (async I/O), then insert under it, + // resolving a race where two connections create the same session. + let replay = + match journal::read_journal(&journal::journal_path(&self.data_dir, &env.session_id)) + .await + { + Ok(entries) => entries + .into_iter() + .map(journal::envelope_from_redacted) + .collect(), + Err(e) + if e.downcast_ref::() + .is_some_and(|io| io.kind() == std::io::ErrorKind::NotFound) => + { + Vec::new() + } + Err(e) => { + tracing::warn!(session_id = %env.session_id, "journal replay skipped: {e}"); + Vec::new() + } + }; + let journal = JournalWriter::open(&self.data_dir, &env.session_id).await?; + let mut map = self.sessions.lock().unwrap(); + if let Some(s) = map.get(&env.session_id) { + return Ok(s.clone()); + } + let session = Session::spawn( + env.session_id.clone(), + env.source.clone(), + journal, + replay, + self.translators.clone(), + self.sink_factory.clone(), + ); + map.insert(env.session_id.clone(), session.clone()); + Ok(session) + } + + fn total_queued(&self) -> u64 { + self.sessions + .lock() + .unwrap() + .values() + .map(|s| s.counters.queued.load(Ordering::Relaxed)) + .sum() + } + + fn trigger_shutdown(&self) { + self.shutting_down.store(true, Ordering::SeqCst); + self.shutdown.notify_waiters(); + } +} + +/// Bind the socket (handling a stale/rival socket), serve until shutdown, then +/// drain sessions and remove the socket. +pub async fn run(args: ServeArgs, opts: ServeOptions) -> anyhow::Result<()> { + let socket = paths::socket_path(args.socket.as_deref()); + let data_dir = paths::data_dir(args.data_dir.as_deref()); + paths::ensure_private_dir(&data_dir)?; + #[cfg(unix)] + if let Some(parent) = socket.parent() { + paths::ensure_private_dir(parent)?; + } + + let listener = match transport::claim(&socket, || probe_alive(&socket)).await? { + Some(l) => l, + None => { + tracing::info!( + "another daemon is already serving {}; exiting", + socket.display() + ); + return Ok(()); + } + }; + tracing::info!(socket = %socket.display(), "bt-daemon listening"); + + let daemon = Daemon::new(opts, data_dir); + journal::gc_old_journals(&daemon.data_dir, Duration::from_secs(7 * 24 * 60 * 60)).await; + let idle_timeout = Duration::from_secs(args.idle_timeout_secs); + spawn_idle_watchdog(daemon.clone(), idle_timeout); + + let accept_result = accept_loop(daemon.clone(), listener).await; + + // Graceful drain regardless of why we stopped. + drain_all(&daemon).await; + transport::cleanup(&socket); + accept_result +} + +async fn accept_loop(daemon: Arc, mut listener: Listener) -> anyhow::Result<()> { + loop { + tokio::select! { + _ = daemon.shutdown.notified() => { + tracing::info!("shutdown requested"); + return Ok(()); + } + _ = tokio::signal::ctrl_c() => { + tracing::info!("interrupt received"); + return Ok(()); + } + accepted = listener.accept() => { + match accepted { + Ok(stream) => { + let d = daemon.clone(); + tokio::spawn(async move { + if let Err(e) = serve_connection(d, stream).await { + tracing::debug!("connection ended: {e}"); + } + }); + } + Err(e) => { + tracing::warn!("accept error: {e}"); + } + } + } + } + } +} + +async fn serve_connection(daemon: Arc, stream: ServerStream) -> anyhow::Result<()> { + let (read_half, mut write_half) = tokio::io::split(stream); + let mut lines = BufReader::new(read_half).lines(); + + while let Some(line) = lines.next_line().await? { + if line.trim().is_empty() { + continue; + } + let response = match Message::from_line(&line) { + Ok(Message::Request(req)) => Some(handle_request(&daemon, req).await), + Ok(Message::Notification(note)) => { + // Hot-path notifications (in-process clients): process, no reply. + if note.method == method::EVENT_LOG { + if let Some(params) = note.params { + if let Ok(env) = serde_json::from_value::(params) { + daemon.touch(); + if let Ok(session) = daemon.session_for(&env).await { + let _ = session.append_and_enqueue(env).await; + } + } + } + } + None + } + Ok(Message::Response(_)) => None, // clients don't send us responses + Err(e) => Some(Response::err( + crate::wire::RequestId::Int(0), + RpcError::new(error_code::PARSE, format!("parse error: {e}")), + )), + }; + + if let Some(resp) = response { + let mut buf = Message::Response(resp).to_line()?; + buf.push('\n'); + write_half.write_all(buf.as_bytes()).await?; + write_half.flush().await?; + } + } + Ok(()) +} + +async fn handle_request(daemon: &Arc, req: Request) -> Response { + let id = req.id.clone(); + let params = req.params.unwrap_or(serde_json::Value::Null); + + macro_rules! parse { + ($t:ty) => { + match serde_json::from_value::<$t>(params) { + Ok(v) => v, + Err(e) => { + return Response::err( + id, + RpcError::new(error_code::INVALID_PARAMS, format!("invalid params: {e}")), + ) + } + } + }; + } + + match req.method.as_str() { + method::INITIALIZE => { + let p = parse!(InitializeParams); + if p.protocol_version != PROTOCOL_VERSION { + return Response::err( + id, + RpcError::new( + error_code::APP, + format!( + "protocol version mismatch: client {} daemon {}", + p.protocol_version, PROTOCOL_VERSION + ), + ), + ); + } + let result = InitializeResult { + protocol_version: PROTOCOL_VERSION, + daemon_version: daemon.version.clone(), + capabilities: Capabilities { + sources: daemon.translators.sources(), + }, + }; + Response::ok(id, serde_json::to_value(result).unwrap()) + } + method::EVENT_LOG => { + let env = parse!(Envelope); + daemon.touch(); + match daemon.session_for(&env).await { + Ok(session) => match session.append_and_enqueue(env).await { + Ok(()) => Response::ok( + id, + serde_json::to_value(EventLogResult { accepted: true }).unwrap(), + ), + Err(e) => Response::err( + id, + RpcError::new(error_code::INTERNAL, format!("enqueue failed: {e}")), + ), + }, + Err(e) => Response::err( + id, + RpcError::new(error_code::INTERNAL, format!("session init failed: {e}")), + ), + } + } + method::SESSION_FLUSH => { + let p = parse!(FlushParams); + let session = { daemon.sessions.lock().unwrap().get(&p.session_id).cloned() }; + let (flushed, pending) = match session { + Some(s) => s.flush(Duration::from_millis(p.timeout_ms)).await, + None => (true, 0), + }; + Response::ok( + id, + serde_json::to_value(FlushResult { flushed, pending }).unwrap(), + ) + } + method::STATUS_GET => { + let p = parse!(StatusParams); + Response::ok(id, serde_json::to_value(daemon.status(p)).unwrap()) + } + method::DAEMON_SHUTDOWN => { + let resp = Response::ok( + id, + serde_json::to_value(ShutdownResult { ok: true }).unwrap(), + ); + daemon.trigger_shutdown(); + resp + } + other => Response::err( + id, + RpcError::new( + error_code::METHOD_NOT_FOUND, + format!("unknown method: {other}"), + ), + ), + } +} + +impl Daemon { + fn status(&self, p: StatusParams) -> StatusResult { + let map = self.sessions.lock().unwrap(); + let sessions = map + .iter() + .filter(|(sid, _)| p.session_id.as_ref().is_none_or(|want| *want == **sid)) + .map(|(sid, s)| SessionStatus { + session_id: sid.clone(), + source: s.source.clone(), + queued: s.counters.queued.load(Ordering::Relaxed), + spans_emitted: s.counters.spans_emitted.load(Ordering::Relaxed), + permalink: s.permalink.lock().unwrap().clone(), + last_error: s.last_error.lock().unwrap().clone(), + }) + .collect(); + StatusResult { + daemon_version: self.version.clone(), + uptime_ms: self.started.elapsed().as_millis() as u64, + sessions, + } + } +} + +fn spawn_idle_watchdog(daemon: Arc, idle_timeout: Duration) { + if idle_timeout.is_zero() { + return; // 0 disables the watchdog (useful in tests) + } + tokio::spawn(async move { + let tick = (idle_timeout / 4).max(Duration::from_secs(1)); + loop { + tokio::select! { + _ = daemon.shutdown.notified() => return, + _ = tokio::time::sleep(tick) => {} + } + let idle_for = daemon.last_activity.lock().unwrap().elapsed(); + if idle_for >= idle_timeout && daemon.total_queued() == 0 { + tracing::info!("idle for {:?}; shutting down", idle_for); + daemon.trigger_shutdown(); + return; + } + } + }); +} + +async fn drain_all(daemon: &Arc) { + let sessions: Vec> = daemon.sessions.lock().unwrap().values().cloned().collect(); + for s in sessions { + s.shutdown().await; + } +} + +/// Is a live daemon answering at the endpoint? Connect and expect any line +/// back from a well-formed `initialize`. +async fn probe_alive(endpoint: &std::path::Path) -> bool { + let Ok(stream) = transport::connect(endpoint).await else { + return false; + }; + let (read_half, mut write_half) = tokio::io::split(stream); + let init = Request::new( + crate::wire::RequestId::Int(0), + method::INITIALIZE, + serde_json::json!({ + "protocol_version": PROTOCOL_VERSION, + "client": { "source": "probe" } + }), + ); + let Ok(mut line) = Message::Request(init).to_line() else { + return false; + }; + line.push('\n'); + if write_half.write_all(line.as_bytes()).await.is_err() { + return false; + } + let mut lines = BufReader::new(read_half).lines(); + matches!( + tokio::time::timeout(Duration::from_secs(1), lines.next_line()).await, + Ok(Ok(Some(_))) + ) +} diff --git a/bt-daemon/src/sink/braintrust.rs b/bt-daemon/src/sink/braintrust.rs new file mode 100644 index 0000000..da8cd6c --- /dev/null +++ b/bt-daemon/src/sink/braintrust.rs @@ -0,0 +1,376 @@ +//! The Braintrust sink: maps sink-neutral [`SpanOp`]s onto `braintrust-sdk-rust`. +//! +//! Multi-profile: a session's backend URLs come from its own config (bt +//! resolves them per profile), so clients are built lazily and cached by +//! `(api_url, app_url)` — sessions on the same instance share a client, +//! sessions on different instances get their own. Within a client, each +//! session's token/org travel per span (`span_builder_with_credentials`) and +//! never leak across sessions. Span ids are the translator's deterministic +//! UUIDv5 strings, reused as the SDK `row_id` merge key so journal replay +//! re-emits idempotently. + +use super::{Sink, SinkFactory}; +use crate::translate::{SpanOp, SpanRow, SpanType}; +use crate::wire::SessionConfig; +use braintrust_sdk_rust::{ + BraintrustClient, ParentSpanInfo, SpanHandle, SpanLog, SpanObjectType, SpanOrigin, + SpanType as SdkSpanType, DEFAULT_API_URL, DEFAULT_APP_URL, +}; +use serde_json::{Map, Value}; +use std::collections::HashMap; +use std::sync::Arc; +use tokio::sync::Mutex as AsyncMutex; + +/// Daemon-level Braintrust settings. `api_url`/`app_url` are *fallbacks* used +/// when a session's config doesn't carry its own (e.g. the standalone binary's +/// env); `bt` supplies per-session URLs, so these are usually `None`. +#[derive(Debug, Clone, Default)] +pub struct BraintrustSinkConfig { + pub api_url: Option, + pub app_url: Option, + pub version: String, +} + +/// Lazily-built, shared-by-URL client pool. +struct ClientCache { + clients: AsyncMutex>>, + version: String, +} + +impl ClientCache { + fn new(version: String) -> Self { + Self { + clients: AsyncMutex::new(HashMap::new()), + version, + } + } + + async fn get_or_build( + &self, + api_url: &str, + app_url: &str, + ) -> anyhow::Result> { + let key = (api_url.to_string(), app_url.to_string()); + // Hold the lock across build so two sessions on a new URL don't build + // duplicate clients. Build is cheap (skip_login: no network). + let mut map = self.clients.lock().await; + if let Some(c) = map.get(&key) { + return Ok(c.clone()); + } + let client = BraintrustClient::builder() + .skip_login(true) + .span_origin(SpanOrigin::new().version(self.version.clone())) + .api_url(api_url.to_string()) + .app_url(app_url.to_string()) + .build() + .await + .map_err(|e| anyhow::anyhow!("braintrust client build failed: {e}"))?; + let arc = Arc::new(client); + map.insert(key, arc.clone()); + Ok(arc) + } +} + +/// Hands out a per-session sink over the shared client pool. +pub struct BraintrustSinkFactory { + cache: Arc, + default_api_url: Option, + default_app_url: Option, + version: String, +} + +impl BraintrustSinkFactory { + pub fn new(cfg: BraintrustSinkConfig) -> Self { + Self { + cache: Arc::new(ClientCache::new(cfg.version.clone())), + default_api_url: cfg.api_url, + default_app_url: cfg.app_url, + version: cfg.version, + } + } +} + +impl SinkFactory for BraintrustSinkFactory { + fn create(&self, _session_id: &str, source: &str) -> anyhow::Result> { + Ok(Box::new(BraintrustSink { + cache: self.cache.clone(), + default_api_url: self.default_api_url.clone(), + default_app_url: self.default_app_url.clone(), + version: self.version.clone(), + source: source.to_string(), + creds: None, + urls: None, + client: None, + open: HashMap::new(), + })) + } +} + +/// Per-session resolved credentials + trace-attach settings. +struct Creds { + token: String, + org_id: String, + org_name: Option, + project: Option, + experiment_id: Option, + parent_span_id: Option, + root_span_id: Option, +} + +struct BraintrustSink { + cache: Arc, + default_api_url: Option, + default_app_url: Option, + version: String, + source: String, + creds: Option, + /// Resolved `(api_url, app_url)` for this session, from its config. + urls: Option<(String, String)>, + /// The client for `urls`, obtained from the cache on first emit. + client: Option>, + /// Live span handles keyed by deterministic span id, so a later op (e.g. + /// setting `end`) merges onto the same row the SDK already knows. + open: HashMap>, +} + +impl BraintrustSink { + fn project(&self, creds: &Creds) -> String { + creds.project.clone().unwrap_or_else(|| self.source.clone()) + } + + fn parent_info(&self, row: &SpanRow, creds: &Creds, project: &str) -> ParentSpanInfo { + if row.parent_span_ids.is_empty() { + // Session root: attach under an external trace if the shim supplied + // one, else land it directly in the project's logs. + match (&creds.parent_span_id, &creds.root_span_id) { + (Some(p), Some(r)) => full_span(creds, project, p.clone(), r.clone()), + _ if creds.experiment_id.is_some() => ParentSpanInfo::Experiment { + object_id: creds.experiment_id.clone().unwrap(), + }, + _ => ParentSpanInfo::ProjectName { + project_name: project.to_string(), + }, + } + } else { + full_span( + creds, + project, + row.parent_span_ids[0].clone(), + creds + .root_span_id + .clone() + .unwrap_or_else(|| row.root_span_id.clone()), + ) + } + } + + async fn ensure_client(&mut self) -> anyhow::Result> { + if let Some(c) = &self.client { + return Ok(c.clone()); + } + let urls = self + .urls + .as_ref() + .ok_or_else(|| anyhow::anyhow!("session has no config/URLs yet"))?; + let client = self.cache.get_or_build(&urls.0, &urls.1).await?; + self.client = Some(client.clone()); + Ok(client) + } + + fn ensure_handle(&mut self, client: &BraintrustClient, row: &SpanRow) -> anyhow::Result<()> { + if self.open.contains_key(&row.span_id) { + return Ok(()); + } + let creds = self + .creds + .as_ref() + .ok_or_else(|| anyhow::anyhow!("session has no credentials/config yet"))?; + let project = self.project(creds); + let parent = self.parent_info(row, creds, &project); + + let mut builder = client + .span_builder_with_credentials(creds.token.clone(), creds.org_id.clone()) + .span_type(map_span_type(row.span_type)) + .span_id(row.span_id.clone()) + .row_id(row.span_id.clone()) + .project_name(project) + .parent_info(parent) + .span_origin( + SpanOrigin::new() + .name(format!("braintrust.plugin.{}", self.source)) + .version(self.version.clone()) + .instrumentation("braintrust-plugin"), + ); + if let Some(org_name) = &creds.org_name { + builder = builder.org_name(org_name.clone()); + } + if let Some(start) = row.start_ms { + builder = builder.start_time(ms_to_secs(start)); + } + self.open.insert(row.span_id.clone(), builder.build()); + Ok(()) + } + + fn upsert(&mut self, client: &BraintrustClient, row: &SpanRow) -> anyhow::Result<()> { + self.ensure_handle(client, row)?; + let handle = self.open.get(&row.span_id).expect("just inserted"); + handle.log(build_log(row)?); + if let Some(end) = row.end_ms { + handle.end_with_time(ms_to_secs(end)); + } + Ok(()) + } +} + +#[async_trait::async_trait] +impl Sink for BraintrustSink { + fn configure(&mut self, config: &SessionConfig) { + let api = config + .auth + .api_url + .clone() + .or_else(|| self.default_api_url.clone()) + .unwrap_or_else(|| DEFAULT_API_URL.to_string()); + let app = config + .auth + .app_url + .clone() + .or_else(|| self.default_app_url.clone()) + .unwrap_or_else(|| DEFAULT_APP_URL.to_string()); + let new_urls = (api, app); + if self.urls.as_ref() != Some(&new_urls) { + // A session shouldn't change backend URLs mid-flight; if it does, + // rebind the client on the next emit. Pre-change open handles stay + // bound to the old client (pathological; just noted). + if self.client.is_some() { + tracing::warn!( + source = %self.source, + "session changed backend URLs mid-session; rebinding client" + ); + } + self.urls = Some(new_urls); + self.client = None; + } + self.creds = Some(Creds { + token: config.auth.token.clone(), + org_id: config.auth.org_id.clone().unwrap_or_default(), + org_name: config.auth.org_name.clone(), + project: config.project.clone(), + experiment_id: config + .additional_metadata + .as_ref() + .and_then(|v| v.get("_bt_experiment_id")) + .and_then(Value::as_str) + .map(ToOwned::to_owned), + parent_span_id: config.parent_span_id.clone(), + root_span_id: config.root_span_id.clone(), + }); + } + + async fn emit(&mut self, ops: &[SpanOp]) -> anyhow::Result { + let client = self.ensure_client().await?; + let mut n = 0u64; + for op in ops { + let row = match op { + SpanOp::Insert(r) | SpanOp::Merge(r) => r, + }; + self.upsert(&client, row)?; + n += 1; + } + Ok(n) + } + + async fn flush(&mut self) -> anyhow::Result<()> { + match &self.client { + Some(client) => client + .flush() + .await + .map_err(|e| anyhow::anyhow!("braintrust flush failed: {e}")), + None => Ok(()), + } + } +} + +fn full_span( + creds: &Creds, + project: &str, + span_id: String, + root_span_id: String, +) -> ParentSpanInfo { + if let Some(experiment_id) = &creds.experiment_id { + return ParentSpanInfo::FullSpan { + object_type: SpanObjectType::Experiment, + object_id: Some(experiment_id.clone()), + compute_object_metadata_args: None, + span_id, + root_span_id, + span_parents: None, + propagated_event: None, + }; + } + let mut cma = Map::new(); + cma.insert( + "project_name".to_string(), + Value::String(project.to_string()), + ); + ParentSpanInfo::FullSpan { + object_type: SpanObjectType::ProjectLogs, + object_id: None, + compute_object_metadata_args: Some(cma), + span_id, + root_span_id, + span_parents: None, + propagated_event: None, + } +} + +fn map_span_type(t: SpanType) -> SdkSpanType { + match t { + SpanType::Task => SdkSpanType::Task, + SpanType::Llm => SdkSpanType::Llm, + SpanType::Tool => SdkSpanType::Tool, + } +} + +fn ms_to_secs(ms: i64) -> f64 { + ms as f64 / 1000.0 +} + +fn build_log(row: &SpanRow) -> anyhow::Result { + // The span's display name is carried on the log event, not the builder. + // An empty name means "unchanged" (many merge ops use `..Default::default()` + // and don't rename the span) — omitting `.name()` avoids overwriting the + // already-set name with an empty string on merge. + let mut lb = SpanLog::builder(); + if !row.name.is_empty() { + lb = lb.name(row.name.clone()); + } + if let Some(input) = &row.input { + lb = lb.input(input.clone()); + } + if let Some(output) = &row.output { + lb = lb.output(output.clone()); + } + if let Some(Value::Object(md)) = &row.metadata { + lb = lb.metadata(md.clone()); + } + if let Some(Value::Object(metrics)) = &row.metrics { + let hm: HashMap = metrics + .iter() + .filter_map(|(k, v)| v.as_f64().map(|f| (k.clone(), f))) + .collect(); + if !hm.is_empty() { + lb = lb.metrics(hm); + } + } + if let Some(err) = &row.error { + lb = lb.error(Value::String(err.clone())); + } + if let Some(tags) = &row.tags { + if !tags.is_empty() { + lb = lb.tags(tags.clone()); + } + } + lb.build() + .map_err(|e| anyhow::anyhow!("span log build failed: {e}")) +} diff --git a/bt-daemon/src/sink/debug.rs b/bt-daemon/src/sink/debug.rs new file mode 100644 index 0000000..f61c534 --- /dev/null +++ b/bt-daemon/src/sink/debug.rs @@ -0,0 +1,62 @@ +//! Debug sink: appends each emitted [`SpanOp`] as one NDJSON line to +//! `/spans/.ndjson`. Lets tests assert on exactly what +//! the pipeline produced without touching Braintrust. + +use super::{Sink, SinkFactory}; +use crate::translate::SpanOp; +use std::fs::{File, OpenOptions}; +use std::io::{BufWriter, Write}; +use std::path::PathBuf; + +pub struct DebugSinkFactory { + pub dir: PathBuf, +} + +impl SinkFactory for DebugSinkFactory { + fn create(&self, session_id: &str, _source: &str) -> anyhow::Result> { + std::fs::create_dir_all(&self.dir)?; + let path = self.dir.join(format!("{}.ndjson", sanitize(session_id))); + let file = OpenOptions::new().create(true).append(true).open(&path)?; + Ok(Box::new(DebugSink { + writer: BufWriter::new(file), + written: 0, + })) + } +} + +struct DebugSink { + writer: BufWriter, + written: u64, +} + +#[async_trait::async_trait] +impl Sink for DebugSink { + async fn emit(&mut self, ops: &[SpanOp]) -> anyhow::Result { + for op in ops { + serde_json::to_writer(&mut self.writer, op)?; + self.writer.write_all(b"\n")?; + self.written += 1; + } + // Flush per batch so a reader (test) sees rows promptly. + self.writer.flush()?; + Ok(ops.len() as u64) + } + + async fn flush(&mut self) -> anyhow::Result<()> { + self.writer.flush()?; + Ok(()) + } +} + +/// Keep session ids filesystem-safe for the per-session file name. +fn sanitize(s: &str) -> String { + s.chars() + .map(|c| { + if c.is_ascii_alphanumeric() || c == '-' || c == '_' { + c + } else { + '_' + } + }) + .collect() +} diff --git a/bt-daemon/src/sink/mod.rs b/bt-daemon/src/sink/mod.rs new file mode 100644 index 0000000..f6b9a32 --- /dev/null +++ b/bt-daemon/src/sink/mod.rs @@ -0,0 +1,43 @@ +//! Sinks consume [`SpanOp`]s. Phase 1 shipped the debug sink (dumps ops to +//! NDJSON); Phase 2 adds the Braintrust sink over `braintrust-sdk-rust`. +//! +//! The trait is async so the Braintrust sink can drive the SDK's async +//! `flush`. `emit` is called on the per-session hot path; the SDK's `log`/`end` +//! are synchronous fire-and-forget (queue-backed), so `emit` rarely awaits. + +mod braintrust; +mod debug; + +pub use braintrust::{BraintrustSinkConfig, BraintrustSinkFactory}; +pub use debug::DebugSinkFactory; + +use crate::translate::SpanOp; +use crate::wire::SessionConfig; + +/// A per-session sink. Created once per session; `configure` supplies the +/// resolved credentials/project/trace-attach settings (and may be re-called if +/// they change). +#[async_trait::async_trait] +pub trait Sink: Send { + /// Called when the session's config is (re)resolved. + fn configure(&mut self, config: &SessionConfig) { + let _ = config; + } + + /// Emit span ops. Returns the number of rows written, for status counters. + async fn emit(&mut self, ops: &[SpanOp]) -> anyhow::Result; + + /// Deliver everything buffered (bounded by the caller's flush timeout). + async fn flush(&mut self) -> anyhow::Result<()>; + + /// A user-facing trace permalink, once known. + fn permalink(&self) -> Option { + None + } +} + +/// Builds a sink per session. `source` is the agent id (e.g. `codex`), used by +/// the Braintrust sink to stamp `context.span_origin`. +pub trait SinkFactory: Send + Sync { + fn create(&self, session_id: &str, source: &str) -> anyhow::Result>; +} diff --git a/bt-daemon/src/translate/claude.rs b/bt-daemon/src/translate/claude.rs new file mode 100644 index 0000000..4fa4bcd --- /dev/null +++ b/bt-daemon/src/translate/claude.rs @@ -0,0 +1,1275 @@ +//! Claude Code hook/transcript translator. +//! +//! Hook events own lifecycle and timing. Transcript rows supply model calls, +//! conversation history, usage, and a recovery path for tool calls whose hook +//! event was missed. Transcript cursors advance on every hook, but never past +//! the hook timestamp; this is essential when replaying a journal against a +//! transcript that already contains the completed session. + +use super::{AgentTranslator, SessionCtx, SpanOp, SpanRow, SpanType, TranslatorFactory}; +use crate::ids; +use crate::wire::Envelope; +use serde_json::{json, Map, Value}; +use std::collections::{HashMap, HashSet}; +use std::io::{BufRead, Seek, SeekFrom}; +use std::process::Command; + +pub struct ClaudeTranslatorFactory; + +impl TranslatorFactory for ClaudeTranslatorFactory { + fn source(&self) -> &str { + "claude-code" + } + + fn create(&self, session_id: &str) -> Box { + Box::new(ClaudeTranslator::new(session_id)) + } +} + +struct Turn { + id: String, + number: u32, +} + +#[derive(Default)] +struct TranscriptCursor { + offset: u64, + buffered: Vec, +} + +struct Subagent { + span_id: String, + transcript_path: Option, +} + +struct PendingTool { + span_id: String, + parent_id: String, +} + +struct ClaudeTranslator { + session_id: String, + session_span_id: String, + root_span_id: String, + root_open: bool, + root_ended: bool, + turn: Option, + last_turn_id: Option, + turn_count: u32, + tool_seq: u32, + main_transcript: Option, + transcripts: HashMap, + main_history: Vec, + emitted_requests: HashSet, + emitted_tools: HashSet, + pending_tools: HashMap, + subagents: HashMap, + pending_skills: Vec, + claude_version: Option, + claude_version_logged: bool, +} + +impl ClaudeTranslator { + fn new(session_id: &str) -> Self { + let root = ids::span_id(session_id, "root"); + Self { + session_id: session_id.to_string(), + session_span_id: root.clone(), + root_span_id: root, + root_open: false, + root_ended: false, + turn: None, + last_turn_id: None, + turn_count: 0, + tool_seq: 0, + main_transcript: None, + transcripts: HashMap::new(), + main_history: Vec::new(), + emitted_requests: HashSet::new(), + emitted_tools: HashSet::new(), + pending_tools: HashMap::new(), + subagents: HashMap::new(), + pending_skills: Vec::new(), + claude_version: None, + claude_version_logged: false, + } + } + + fn ensure_root(&mut self, event: &Envelope, ctx: &SessionCtx, ops: &mut Vec) { + if self.root_open { + return; + } + self.root_open = true; + if let Some(root) = ctx.config.as_ref().and_then(|c| c.root_span_id.as_ref()) { + self.root_span_id = root.clone(); + } + 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.extend(git_metadata(&cwd)); + metadata.insert("session_id".into(), json!(self.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( + "os".into(), + json!(command_output("", "uname", &["-s"]) + .unwrap_or_else(|| std::env::consts::OS.to_string())), + ); + if let Some(version) = &event.source_version { + metadata.insert("trace_claude_code_version".into(), json!(version)); + } + if let Some(version) = &self.claude_version { + metadata.insert("claude_code_version".into(), json!(version)); + } + if let Some(source) = string_field(&event.payload, "source") { + metadata.insert("session_source".into(), json!(source)); + } + if let Some(model) = string_field(&event.payload, "model") { + metadata.insert("model".into(), json!(model)); + } + ops.push(SpanOp::Insert(SpanRow { + span_id: self.session_span_id.clone(), + root_span_id: self.root_span_id.clone(), + parent_span_ids: ctx + .config + .as_ref() + .and_then(|c| c.parent_span_id.clone()) + .into_iter() + .collect(), + name: format!("Claude Code: {workspace}"), + span_type: SpanType::Task, + start_ms: Some(event.ts_ms), + input: Some(json!(format!("Session: {workspace}"))), + metadata: Some(Value::Object(metadata)), + ..Default::default() + })); + } + + fn tail_main(&mut self, event: &Envelope) { + if let Some(path) = string_field(&event.payload, "transcript_path") { + self.main_transcript = Some(path); + } + let Some(path) = self.main_transcript.clone() else { + return; + }; + let cursor = self.transcripts.entry(path.clone()).or_default(); + let rows = read_event_records(event, &path, &mut cursor.offset); + if self.claude_version.is_none() { + self.claude_version = rows.iter().find_map(|row| string_field(row, "version")); + } + cursor.buffered.extend(rows); + } + + fn open_turn(&mut self, event: &Envelope, ops: &mut Vec) { + if let Some(old) = self.turn.take() { + self.close_pending_tools( + &old.id, + event.ts_ms, + "Turn ended before tool completion", + ops, + ); + ops.push(SpanOp::Merge(SpanRow { + span_id: old.id, + root_span_id: self.root_span_id.clone(), + end_ms: Some(event.ts_ms), + ..Default::default() + })); + } + self.turn_count += 1; + let id = ids::span_id(&self.session_id, &format!("turn:{}", self.turn_count)); + let skill_metadata = explicit_skill_metadata(&self.pending_skills); + self.pending_skills.clear(); + ops.push(SpanOp::Insert(SpanRow { + span_id: id.clone(), + root_span_id: self.root_span_id.clone(), + parent_span_ids: vec![self.session_span_id.clone()], + name: format!("Turn {}", self.turn_count), + span_type: SpanType::Task, + start_ms: Some(event.ts_ms), + input: event.payload.get("prompt").cloned(), + metadata: skill_metadata, + ..Default::default() + })); + self.turn = Some(Turn { + id, + number: self.turn_count, + }); + } + + fn record_skill(&mut self, event: &Envelope, ops: &mut Vec) { + if string_field(&event.payload, "expansion_type") + .or_else(|| string_field(&event.payload, "type")) + .is_some_and(|kind| kind != "slash_command") + { + return; + } + let direct = string_field(&event.payload, "skill_name") + .or_else(|| string_field(&event.payload, "skillName")) + .or_else(|| { + event + .payload + .pointer("/skill/name") + .and_then(Value::as_str) + .map(str::to_owned) + }) + .or_else(|| string_field(&event.payload, "skill")); + let command = string_field(&event.payload, "command_name") + .or_else(|| string_field(&event.payload, "command")) + .or_else(|| string_field(&event.payload, "slash_command")) + .or_else(|| string_field(&event.payload, "name")) + .map(|name| normalize_skill_name(&name)); + let name = direct.map(|name| normalize_skill_name(&name)).or_else(|| { + let command = command?; + let path = string_field(&event.payload, "transcript_path")?; + skill_listing_contains(&path, &command).then_some(command) + }); + let Some(name) = name.filter(|name| !name.is_empty()) else { + return; + }; + if !self.pending_skills.contains(&name) { + self.pending_skills.push(name); + } + if let Some(turn) = &self.turn { + ops.push(SpanOp::Merge(SpanRow { + span_id: turn.id.clone(), + root_span_id: self.root_span_id.clone(), + metadata: explicit_skill_metadata(&self.pending_skills), + ..Default::default() + })); + } + } + + fn parent_for(&mut self, event: &Envelope, ops: &mut Vec) -> Option { + if let Some(agent_id) = string_field(&event.payload, "agent_id") { + return Some(self.ensure_subagent(&agent_id, event, ops)); + } + self.turn.as_ref().map(|turn| turn.id.clone()) + } + + fn ensure_subagent( + &mut self, + agent_id: &str, + event: &Envelope, + ops: &mut Vec, + ) -> String { + if let Some(agent) = self.subagents.get(agent_id) { + return agent.span_id.clone(); + } + let parent_id = self + .turn + .as_ref() + .map(|turn| turn.id.clone()) + .or_else(|| self.last_turn_id.clone()) + .unwrap_or_else(|| self.session_span_id.clone()); + let agent_type = + string_field(&event.payload, "agent_type").unwrap_or_else(|| "agent".into()); + let span_id = ids::span_id(&self.session_id, &format!("subagent:{agent_id}")); + ops.push(SpanOp::Insert(SpanRow { + span_id: span_id.clone(), + root_span_id: self.root_span_id.clone(), + parent_span_ids: vec![parent_id.clone()], + name: format!("subagent: {agent_type}"), + span_type: SpanType::Task, + start_ms: Some(event.ts_ms), + metadata: Some(json!({ "agent_id": agent_id, "agent_type": agent_type })), + ..Default::default() + })); + self.subagents.insert( + agent_id.to_string(), + Subagent { + span_id: span_id.clone(), + transcript_path: None, + }, + ); + span_id + } + + fn pre_tool(&mut self, event: &Envelope, ops: &mut Vec) { + let Some(parent_id) = self.parent_for(event, ops) else { + return; + }; + let Some(tool_name) = tool_name(&event.payload) else { + return; + }; + let call_id = self.call_id(event); + if self.pending_tools.contains_key(&call_id) || self.emitted_tools.contains(&call_id) { + return; + } + let input = tool_input(&event.payload); + let span_id = ids::span_id(&self.session_id, &format!("tool:{call_id}")); + let metadata = tool_metadata(event, &tool_name, &call_id, "approved", &input); + ops.push(SpanOp::Insert(SpanRow { + span_id: span_id.clone(), + root_span_id: self.root_span_id.clone(), + parent_span_ids: vec![parent_id.clone()], + name: tool_span_name(&tool_name, &input), + span_type: SpanType::Tool, + start_ms: Some(event.ts_ms), + input: Some(input.clone()), + metadata: Some(metadata), + ..Default::default() + })); + self.pending_tools + .insert(call_id, PendingTool { span_id, parent_id }); + } + + fn finish_tool( + &mut self, + event: &Envelope, + approval: &str, + forced_error: Option, + ops: &mut Vec, + ) { + let Some(tool_name) = tool_name(&event.payload) else { + return; + }; + let call_id = self.call_id(event); + let input = tool_input(&event.payload); + let output = tool_output(&event.payload); + let error = forced_error.or_else(|| tool_error(&event.payload)); + let metadata = tool_metadata(event, &tool_name, &call_id, approval, &input); + if let Some(pending) = self.pending_tools.remove(&call_id) { + ops.push(SpanOp::Merge(SpanRow { + span_id: pending.span_id, + root_span_id: self.root_span_id.clone(), + end_ms: Some(event.ts_ms), + output, + metadata: Some(metadata), + error, + ..Default::default() + })); + } else if !self.emitted_tools.contains(&call_id) { + let Some(parent_id) = self.parent_for(event, ops) else { + return; + }; + let duration = event + .payload + .get("duration_ms") + .and_then(Value::as_i64) + .unwrap_or(0); + ops.push(SpanOp::Insert(SpanRow { + span_id: ids::span_id(&self.session_id, &format!("tool:{call_id}")), + root_span_id: self.root_span_id.clone(), + parent_span_ids: vec![parent_id], + name: tool_span_name(&tool_name, &input), + span_type: SpanType::Tool, + start_ms: Some(event.ts_ms.saturating_sub(duration)), + end_ms: Some(event.ts_ms), + input: Some(input), + output, + metadata: Some(metadata), + error, + ..Default::default() + })); + } + self.emitted_tools.insert(call_id); + } + + fn call_id(&mut self, event: &Envelope) -> String { + string_field(&event.payload, "tool_use_id").unwrap_or_else(|| { + self.tool_seq += 1; + let turn = self.turn.as_ref().map(|t| t.number).unwrap_or(0); + format!("{turn}:{}", self.tool_seq) + }) + } + + fn stop_subagent(&mut self, event: &Envelope, ops: &mut Vec) { + let Some(agent_id) = string_field(&event.payload, "agent_id") else { + return; + }; + let parent = self.ensure_subagent(&agent_id, event, ops); + let path = string_field(&event.payload, "agent_transcript_path"); + if let Some(agent) = self.subagents.get_mut(&agent_id) { + agent.transcript_path = path.clone(); + } + if let Some(path) = path { + let cursor = self.transcripts.entry(path.clone()).or_default(); + cursor + .buffered + .extend(read_event_records(event, &path, &mut cursor.offset)); + let records = std::mem::take(&mut cursor.buffered); + self.emit_transcript(&records, &format!("subagent:{agent_id}"), &parent, ops); + } + self.close_pending_tools( + &parent, + event.ts_ms, + "Subagent ended before tool completion", + ops, + ); + ops.push(SpanOp::Merge(SpanRow { + span_id: parent, + root_span_id: self.root_span_id.clone(), + end_ms: Some(event.ts_ms), + output: event.payload.get("last_assistant_message").cloned(), + ..Default::default() + })); + } + + fn emit_main(&mut self, parent: &str, ops: &mut Vec) { + let Some(path) = self.main_transcript.clone() else { + return; + }; + let records = self + .transcripts + .get_mut(&path) + .map(|cursor| std::mem::take(&mut cursor.buffered)) + .unwrap_or_default(); + let parsed = parse_transcript(&records, std::mem::take(&mut self.main_history)); + self.main_history = parsed.history.clone(); + self.emit_parsed(parsed, "main", parent, ops); + } + + fn emit_transcript( + &mut self, + records: &[Value], + scope: &str, + parent: &str, + ops: &mut Vec, + ) { + let parsed = parse_transcript(records, Vec::new()); + self.emit_parsed(parsed, scope, parent, ops); + } + + fn emit_parsed( + &mut self, + parsed: ParsedTranscript, + scope: &str, + parent: &str, + ops: &mut Vec, + ) { + for call in parsed.calls { + let request_key = format!("{scope}:{}", call.request_id); + if self.emitted_requests.insert(request_key.clone()) { + let span_key = format!("{scope}:llm:{}", call.request_id); + ops.push(SpanOp::Insert(call.into_row( + ids::span_id(&self.session_id, &span_key), + self.root_span_id.clone(), + parent.to_string(), + ))); + } + } + for tool in parsed.tools { + if self.emitted_tools.insert(tool.call_id.clone()) { + let span_key = format!("tool:{}", tool.call_id); + ops.push(SpanOp::Insert(tool.into_row( + ids::span_id(&self.session_id, &span_key), + self.root_span_id.clone(), + parent.to_string(), + ))); + } + } + } + + fn flush_previous_turn_rows(&mut self, ops: &mut Vec) { + let (Some(path), Some(parent)) = (self.main_transcript.clone(), self.last_turn_id.clone()) + else { + return; + }; + let Some(cursor) = self.transcripts.get_mut(&path) else { + return; + }; + let split = cursor + .buffered + .iter() + .rposition(is_real_user_record) + .unwrap_or(cursor.buffered.len()); + let current_turn_rows = cursor.buffered.split_off(split); + let previous_rows = std::mem::replace(&mut cursor.buffered, current_turn_rows); + let parsed = parse_transcript(&previous_rows, std::mem::take(&mut self.main_history)); + self.main_history = parsed.history.clone(); + self.emit_parsed(parsed, "main", &parent, ops); + } + + fn stop_turn(&mut self, event: &Envelope, error: Option, ops: &mut Vec) { + let Some(turn_id) = self.turn.as_ref().map(|turn| turn.id.clone()) else { + return; + }; + self.emit_main(&turn_id, ops); + self.close_pending_tools( + &turn_id, + event.ts_ms, + "Turn ended before tool completion", + ops, + ); + ops.push(SpanOp::Merge(SpanRow { + span_id: turn_id.clone(), + root_span_id: self.root_span_id.clone(), + end_ms: Some(event.ts_ms), + output: event + .payload + .get("last_assistant_message") + .cloned() + .or_else(|| event.payload.get("output").cloned()), + error, + ..Default::default() + })); + self.last_turn_id = Some(turn_id); + self.turn = None; + self.pending_skills.clear(); + } + + fn close_pending_tools( + &mut self, + parent_id: &str, + end_ms: i64, + error: &str, + ops: &mut Vec, + ) { + let ids: Vec = self + .pending_tools + .iter() + .filter(|(_, tool)| tool.parent_id == parent_id) + .map(|(id, _)| id.clone()) + .collect(); + for id in ids { + if let Some(tool) = self.pending_tools.remove(&id) { + self.emitted_tools.insert(id); + 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(error.to_string()), + ..Default::default() + })); + } + } + } + + fn end_session(&mut self, event: &Envelope, ops: &mut Vec) { + if let Some(turn_id) = self + .turn + .as_ref() + .map(|turn| turn.id.clone()) + .or_else(|| self.last_turn_id.clone()) + { + self.emit_main(&turn_id, ops); + } + if let Some(turn) = self.turn.take() { + self.close_pending_tools( + &turn.id, + event.ts_ms, + "Session ended before tool completion", + ops, + ); + ops.push(SpanOp::Merge(SpanRow { + span_id: turn.id.clone(), + root_span_id: self.root_span_id.clone(), + end_ms: Some(event.ts_ms), + ..Default::default() + })); + self.last_turn_id = Some(turn.id); + } + 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(event.ts_ms), + ..Default::default() + })); + } + } +} + +impl AgentTranslator for ClaudeTranslator { + fn handle(&mut self, event: &Envelope, ctx: &SessionCtx) -> anyhow::Result> { + let mut ops = Vec::new(); + self.tail_main(event); + self.ensure_root(event, ctx, &mut ops); + if !self.claude_version_logged { + if let Some(version) = &self.claude_version { + self.claude_version_logged = true; + ops.push(SpanOp::Merge(SpanRow { + span_id: self.session_span_id.clone(), + root_span_id: self.root_span_id.clone(), + metadata: Some(json!({ "claude_code_version": version })), + ..Default::default() + })); + } + } + match event.event.as_str() { + "SessionStart" => {} + "UserPromptSubmit" => { + self.flush_previous_turn_rows(&mut ops); + self.open_turn(event, &mut ops); + } + "UserPromptExpansion" => self.record_skill(event, &mut ops), + "PreToolUse" => self.pre_tool(event, &mut ops), + "PostToolUse" => self.finish_tool(event, "approved", None, &mut ops), + "PostToolUseFailure" => self.finish_tool( + event, + "approved", + tool_error(&event.payload) + .or_else(|| { + event + .payload + .pointer("/tool_response/output") + .and_then(Value::as_str) + .filter(|value| !value.is_empty()) + .map(str::to_owned) + }) + .or_else(|| Some("Tool execution failed".into())), + &mut ops, + ), + "PermissionDenied" => self.finish_tool(event, "denied", None, &mut ops), + "SubagentStart" => { + if let Some(agent_id) = string_field(&event.payload, "agent_id") { + self.ensure_subagent(&agent_id, event, &mut ops); + } + } + "SubagentStop" => self.stop_subagent(event, &mut ops), + "Stop" => self.stop_turn(event, None, &mut ops), + "StopFailure" => self.stop_turn( + event, + tool_error(&event.payload).or_else(|| Some("Claude Code turn failed".into())), + &mut ops, + ), + "SessionEnd" => self.end_session(event, &mut ops), + _ => {} + } + Ok(ops) + } + + fn flush(&mut self, _ctx: &SessionCtx) -> anyhow::Result> { + Ok(Vec::new()) + } +} + +struct ParsedTranscript { + calls: Vec, + tools: Vec, + history: Vec, +} + +fn parse_transcript(records: &[Value], mut history: Vec) -> ParsedTranscript { + let mut calls = Vec::::new(); + let mut call_indexes = HashMap::::new(); + let mut assistant_history_indexes = HashMap::::new(); + let mut tools = HashMap::::new(); + let mut tool_order = Vec::::new(); + + for record in records { + match record.get("type").and_then(Value::as_str) { + Some("assistant") => { + let request_id = record + .get("message") + .and_then(|message| string_field(message, "id")) + .or_else(|| string_field(record, "requestId")) + .or_else(|| string_field(record, "uuid")) + .unwrap_or_default(); + if request_id.is_empty() { + continue; + } + let index = *call_indexes.entry(request_id.clone()).or_insert_with(|| { + let index = calls.len(); + calls.push(LlmCall::new( + request_id.clone(), + parse_timestamp_ms(record).unwrap_or(0), + history.clone(), + )); + index + }); + calls[index].observe(record); + let output = calls[index].output_message(); + if let Some(history_index) = assistant_history_indexes.get(&request_id) { + history[*history_index] = output; + } else { + assistant_history_indexes.insert(request_id.clone(), history.len()); + history.push(output); + } + if let Some(content) = record.pointer("/message/content").and_then(Value::as_array) + { + for block in content { + if block.get("type").and_then(Value::as_str) == Some("tool_use") { + let Some(call_id) = string_field(block, "id") else { + continue; + }; + if !tools.contains_key(&call_id) { + tool_order.push(call_id.clone()); + tools.insert( + call_id.clone(), + TranscriptTool { + call_id, + tool_name: string_field(block, "name") + .unwrap_or_else(|| "Tool".into()), + input: block + .get("input") + .cloned() + .unwrap_or_else(|| json!({})), + output: None, + error: None, + start_ms: parse_timestamp_ms(record).unwrap_or(0), + end_ms: parse_timestamp_ms(record).unwrap_or(0), + }, + ); + } + } + } + } + } + Some("user") => { + let content = record + .pointer("/message/content") + .cloned() + .unwrap_or(Value::Null); + if let Some(blocks) = content.as_array() { + let mut had_tool_result = false; + for block in blocks { + if block.get("type").and_then(Value::as_str) != Some("tool_result") { + continue; + } + had_tool_result = true; + let call_id = string_field(block, "tool_use_id").unwrap_or_default(); + let result = block.get("content").cloned().unwrap_or(Value::Null); + history.push(json!({ + "role": "tool", + "tool_call_id": call_id, + "content": result + })); + if let Some(tool) = tools.get_mut(&call_id) { + tool.output = Some(result); + tool.end_ms = parse_timestamp_ms(record).unwrap_or(tool.start_ms); + if block + .get("is_error") + .and_then(Value::as_bool) + .unwrap_or(false) + { + tool.error = Some("Tool execution failed".into()); + } + } + } + if !had_tool_result { + history.push(json!({ "role": "user", "content": content })); + } + } else if !content.is_null() { + history.push(json!({ "role": "user", "content": content })); + } + } + _ => {} + } + } + ParsedTranscript { + calls, + tools: tool_order + .into_iter() + .filter_map(|id| tools.remove(&id)) + .collect(), + history, + } +} + +fn is_real_user_record(record: &Value) -> bool { + if record.get("type").and_then(Value::as_str) != Some("user") { + return false; + } + !record + .pointer("/message/content") + .and_then(Value::as_array) + .is_some_and(|blocks| { + blocks + .iter() + .any(|block| block.get("type").and_then(Value::as_str) == Some("tool_result")) + }) +} + +struct LlmCall { + request_id: String, + model: String, + start_ms: i64, + end_ms: i64, + input: Vec, + text: Vec, + tool_calls: Vec, + prompt_tokens: u64, + completion_tokens: u64, + cache_creation_tokens: u64, + cache_creation_5m_tokens: u64, + cache_creation_1h_tokens: u64, + cache_read_tokens: u64, +} + +impl LlmCall { + fn new(request_id: String, start_ms: i64, input: Vec) -> Self { + Self { + request_id, + model: "claude".into(), + start_ms, + end_ms: start_ms, + input, + text: Vec::new(), + tool_calls: Vec::new(), + prompt_tokens: 0, + completion_tokens: 0, + cache_creation_tokens: 0, + cache_creation_5m_tokens: 0, + cache_creation_1h_tokens: 0, + cache_read_tokens: 0, + } + } + + fn observe(&mut self, record: &Value) { + self.end_ms = parse_timestamp_ms(record).unwrap_or(self.end_ms); + let Some(message) = record.get("message") else { + return; + }; + if let Some(model) = string_field(message, "model") { + self.model = model; + } + if let Some(content) = message.get("content").and_then(Value::as_array) { + for block in content { + match block.get("type").and_then(Value::as_str) { + Some("text") => { + if let Some(text) = block.get("text").and_then(Value::as_str) { + if !self.text.iter().any(|seen| seen == text) { + self.text.push(text.to_string()); + } + } + } + Some("tool_use") => { + let arguments = + serde_json::to_string(block.get("input").unwrap_or(&Value::Null)) + .unwrap_or_else(|_| "{}".into()); + let call = json!({ + "id": block.get("id").cloned().unwrap_or(Value::Null), + "type": "function", + "function": { + "name": block.get("name").cloned().unwrap_or(Value::Null), + "arguments": arguments + } + }); + if !self.tool_calls.contains(&call) { + self.tool_calls.push(call); + } + } + _ => {} + } + } + } + if let Some(usage) = message.get("usage") { + self.prompt_tokens = self.prompt_tokens.max(u64_field(usage, "input_tokens")); + self.completion_tokens = self + .completion_tokens + .max(u64_field(usage, "output_tokens")); + self.cache_creation_tokens = self + .cache_creation_tokens + .max(u64_field(usage, "cache_creation_input_tokens")); + self.cache_read_tokens = self + .cache_read_tokens + .max(u64_field(usage, "cache_read_input_tokens")); + if let Some(cache) = usage.get("cache_creation") { + self.cache_creation_5m_tokens = self + .cache_creation_5m_tokens + .max(u64_field(cache, "ephemeral_5m_input_tokens")); + self.cache_creation_1h_tokens = self + .cache_creation_1h_tokens + .max(u64_field(cache, "ephemeral_1h_input_tokens")); + } + } + } + + fn output_message(&self) -> Value { + let content = self.text.join("\n"); + if self.tool_calls.is_empty() { + json!({ "role": "assistant", "content": content }) + } else { + json!({ "role": "assistant", "content": content, "tool_calls": self.tool_calls }) + } + } + + fn into_row(self, span_id: String, root_span_id: String, parent: String) -> SpanRow { + let has_split = self.cache_creation_5m_tokens > 0 || self.cache_creation_1h_tokens > 0; + let creation = if has_split { + self.cache_creation_5m_tokens + self.cache_creation_1h_tokens + } else { + self.cache_creation_tokens + }; + let prompt = self.prompt_tokens + self.cache_read_tokens + creation; + let mut metrics = Map::new(); + metrics.insert("prompt_tokens".into(), json!(prompt)); + metrics.insert("completion_tokens".into(), json!(self.completion_tokens)); + metrics.insert("tokens".into(), json!(prompt + self.completion_tokens)); + metrics.insert("prompt_cached_tokens".into(), json!(self.cache_read_tokens)); + if has_split { + metrics.insert( + "prompt_cache_creation_5m_tokens".into(), + json!(self.cache_creation_5m_tokens), + ); + metrics.insert( + "prompt_cache_creation_1h_tokens".into(), + json!(self.cache_creation_1h_tokens), + ); + } else { + metrics.insert( + "prompt_cache_creation_tokens".into(), + json!(self.cache_creation_tokens), + ); + } + let output = self.output_message(); + SpanRow { + span_id, + root_span_id, + parent_span_ids: vec![parent], + name: self.model.clone(), + span_type: SpanType::Llm, + start_ms: Some(self.start_ms), + end_ms: Some(self.end_ms), + input: Some(Value::Array(self.input)), + output: Some(output), + metadata: Some(json!({ "model": self.model, "request_id": self.request_id })), + metrics: Some(Value::Object(metrics)), + ..Default::default() + } + } +} + +struct TranscriptTool { + call_id: String, + tool_name: String, + input: Value, + output: Option, + error: Option, + start_ms: i64, + end_ms: i64, +} + +impl TranscriptTool { + fn into_row(self, span_id: String, root_span_id: String, parent: String) -> SpanRow { + SpanRow { + span_id, + root_span_id, + parent_span_ids: vec![parent], + name: tool_span_name(&self.tool_name, &self.input), + span_type: SpanType::Tool, + start_ms: Some(self.start_ms), + end_ms: Some(self.end_ms), + input: Some(self.input), + output: self.output, + metadata: Some(json!({ + "tool_name": self.tool_name, + "tool_approval": "approved", + "tool_call_id": self.call_id, + "recovered_from_transcript": true + })), + error: self.error, + ..Default::default() + } + } +} + +fn read_records_until(path: &str, offset: &mut u64, cutoff_ms: i64) -> Vec { + let Ok(mut file) = std::fs::File::open(path) else { + return Vec::new(); + }; + let len = file.metadata().map(|m| m.len()).unwrap_or(0); + if *offset > len { + *offset = 0; + } + if file.seek(SeekFrom::Start(*offset)).is_err() { + return Vec::new(); + } + let mut reader = std::io::BufReader::new(file); + read_buffered_until(&mut reader, offset, cutoff_ms) +} + +fn read_buffered_until( + reader: &mut std::io::BufReader, + offset: &mut u64, + cutoff_ms: i64, +) -> Vec { + let mut records = Vec::new(); + let mut line = String::new(); + loop { + line.clear(); + let start = *offset; + let Ok(read) = reader.read_line(&mut line) else { + break; + }; + if read == 0 { + break; + } + let Ok(value) = serde_json::from_str::(line.trim()) else { + *offset += read as u64; + continue; + }; + if parse_timestamp_ms(&value).is_some_and(|timestamp| timestamp > cutoff_ms) { + *offset = start; + break; + } + *offset += read as u64; + records.push(value); + } + records +} + +fn read_event_records(event: &Envelope, path: &str, offset: &mut u64) -> Vec { + let snapshot = event + .payload + .get("_bt_transcript_snapshot") + .filter(|snapshot| snapshot.get("path").and_then(Value::as_str) == Some(path)) + .and_then(|snapshot| snapshot.get("contents")) + .and_then(Value::as_str); + match snapshot { + Some(contents) => read_snapshot_until(contents, offset, event.ts_ms), + None => read_records_until(path, offset, event.ts_ms), + } +} + +fn read_snapshot_until(contents: &str, offset: &mut u64, cutoff_ms: i64) -> Vec { + if *offset > contents.len() as u64 { + *offset = 0; + } + let mut reader = std::io::BufReader::new(std::io::Cursor::new(contents.as_bytes())); + if reader.seek(SeekFrom::Start(*offset)).is_err() { + return Vec::new(); + } + read_buffered_until(&mut reader, offset, cutoff_ms) +} + +fn tool_metadata( + event: &Envelope, + tool_name: &str, + call_id: &str, + approval: &str, + input: &Value, +) -> Value { + let mut metadata = Map::new(); + metadata.insert("tool_name".into(), json!(tool_name)); + metadata.insert("tool_approval".into(), json!(approval)); + metadata.insert("tool_call_id".into(), json!(call_id)); + for (target, direct, nested) in [ + ("permission_id", "permission_id", "/permission/id"), + ("permission_type", "permission_type", "/permission/type"), + ("permission_title", "permission_title", "/permission/title"), + ] { + if let Some(value) = string_field(&event.payload, direct).or_else(|| { + event + .payload + .pointer(nested) + .and_then(Value::as_str) + .map(str::to_owned) + }) { + metadata.insert(target.into(), json!(value)); + } + } + if tool_name == "Skill" { + let skill_name = ["name", "skill", "skill_name", "skillName"] + .iter() + .find_map(|key| string_field(input, key)); + metadata.insert("tool_kind".into(), json!("skill")); + metadata.insert("skill_name".into(), json!(skill_name)); + metadata.insert("skill_load_trigger".into(), json!("explicit")); + } + Value::Object(metadata) +} + +fn tool_input(payload: &Value) -> Value { + payload + .get("tool_input") + .or_else(|| payload.get("input")) + .cloned() + .unwrap_or_else(|| json!({})) +} + +fn tool_output(payload: &Value) -> Option { + payload + .get("tool_response") + .or_else(|| payload.get("output")) + .cloned() + .or_else(|| payload.pointer("/tool_response/output").cloned()) +} + +fn tool_name(payload: &Value) -> Option { + string_field(payload, "tool_name").or_else(|| string_field(payload, "tool")) +} + +fn parse_timestamp_ms(value: &Value) -> Option { + chrono::DateTime::parse_from_rfc3339(value.get("timestamp")?.as_str()?) + .ok() + .map(|timestamp| timestamp.timestamp_millis()) +} + +fn string_field(value: &Value, key: &str) -> Option { + value.get(key).and_then(|value| match value { + Value::String(text) if !text.is_empty() => Some(text.clone()), + Value::Number(number) => Some(number.to_string()), + _ => None, + }) +} + +fn u64_field(value: &Value, key: &str) -> u64 { + value.get(key).and_then(Value::as_u64).unwrap_or(0) +} + +fn basename(path: &str) -> String { + std::path::Path::new(path) + .file_name() + .and_then(|value| value.to_str()) + .filter(|value| !value.is_empty()) + .unwrap_or("workspace") + .to_string() +} + +fn normalize_skill_name(name: &str) -> String { + name.trim() + .trim_start_matches('/') + .trim_end_matches([',', ')', '.', ';', ':']) + .trim() + .to_string() +} + +fn skill_listing_contains(path: &str, name: &str) -> bool { + let Ok(file) = std::fs::File::open(path) else { + return false; + }; + std::io::BufReader::new(file) + .lines() + .map_while(Result::ok) + .any(|line| { + serde_json::from_str::(&line) + .ok() + .and_then(|row| { + row.pointer("/attachment/names") + .and_then(Value::as_array) + .cloned() + }) + .is_some_and(|names| { + names + .iter() + .any(|candidate| candidate.as_str() == Some(name)) + }) + }) +} + +fn explicit_skill_metadata(names: &[String]) -> Option { + (!names.is_empty()).then(|| { + json!({ + "loaded_skill_names": names, + "loaded_skills": names.iter().map(|name| json!({ "name": name })).collect::>() + }) + }) +} + +fn hostname() -> String { + std::env::var("HOSTNAME") + .ok() + .filter(|value| !value.is_empty()) + .or_else(|| command_output("", "hostname", &[])) + .unwrap_or_default() +} + +fn username() -> String { + std::env::var("USER") + .or_else(|_| std::env::var("USERNAME")) + .unwrap_or_default() +} + +fn command_output(cwd: &str, command: &str, args: &[&str]) -> Option { + let mut process = Command::new(command); + if !cwd.is_empty() { + process.current_dir(cwd); + } + let output = process + .args(args) + .env("GIT_OPTIONAL_LOCKS", "0") + .output() + .ok()?; + if !output.status.success() { + return None; + } + let value = String::from_utf8(output.stdout).ok()?.trim().to_string(); + (!value.is_empty()).then_some(value) +} + +fn git_metadata(cwd: &str) -> Map { + if cwd.is_empty() { + return Map::new(); + } + let mut metadata = Map::new(); + if let Some(mut origin) = command_output(cwd, "git", &["remote", "get-url", "origin"]) { + if let Some(scheme) = origin.find("://") { + let start = scheme + 3; + let end = origin[start..] + .find('/') + .map(|offset| start + offset) + .unwrap_or(origin.len()); + if let Some(at) = origin[start..end].rfind('@') { + origin = format!("{}{}", &origin[..start], &origin[start + at + 1..]); + } + } + metadata.insert("git_origin_url".into(), json!(origin)); + } + if let Some(branch) = + command_output(cwd, "git", &["symbolic-ref", "--quiet", "--short", "HEAD"]) + { + metadata.insert("git_branch".into(), json!(branch)); + } + if let Some(commit) = command_output(cwd, "git", &["rev-parse", "HEAD"]) { + metadata.insert("git_commit_sha".into(), json!(commit)); + } + metadata +} + +fn tool_span_name(tool: &str, input: &Value) -> String { + match tool { + "Skill" => string_field(input, "name") + .or_else(|| string_field(input, "skill")) + .map(|name| format!("skill: {name}")) + .unwrap_or_else(|| "skill".into()), + "Read" | "Write" | "Edit" | "MultiEdit" => string_field(input, "file_path") + .or_else(|| string_field(input, "path")) + .map(|path| format!("{tool}: {}", basename(&path))) + .unwrap_or_else(|| tool.to_string()), + "Bash" | "Terminal" => { + let command = string_field(input, "command").unwrap_or_else(|| "command".into()); + format!("Terminal: {}", command.chars().take(50).collect::()) + } + name if name.starts_with("mcp__") => { + format!( + "MCP: {}", + name.trim_start_matches("mcp__").replace("__", " - ") + ) + } + _ => tool.to_string(), + } +} + +fn tool_error(payload: &Value) -> Option { + for value in [ + payload.get("error"), + payload.get("message"), + payload.pointer("/tool_response/error"), + payload.pointer("/tool_response/stderr"), + payload.pointer("/tool_response/message"), + ] + .into_iter() + .flatten() + { + if let Some(text) = value.as_str().filter(|value| !value.is_empty()) { + return Some(text.lines().next().unwrap_or(text).to_string()); + } + } + let response = payload.get("tool_response")?; + let failed = response + .get("interrupted") + .and_then(Value::as_bool) + .unwrap_or(false) + || response + .get("is_error") + .or_else(|| response.get("isError")) + .and_then(Value::as_bool) + .unwrap_or(false) + || response + .get("status") + .and_then(Value::as_str) + .is_some_and(|value| matches!(value, "error" | "failed")); + failed.then(|| "Tool execution failed".to_string()) +} diff --git a/bt-daemon/src/translate/codex.rs b/bt-daemon/src/translate/codex.rs new file mode 100644 index 0000000..7ad4a53 --- /dev/null +++ b/bt-daemon/src/translate/codex.rs @@ -0,0 +1,1589 @@ +//! Codex translator. +//! +//! Ported from the TS `trace-codex` event-processor. Codex hook events are only +//! *triggers*; the session transcript ("rollout" JSONL at `transcript_path`) is +//! the source of truth for LLM calls, token usage, and execution order. On each +//! hook event this reads the relevant transcript from a saved byte offset and +//! turns new records into spans. +//! +//! Hierarchy: root (session, task) → turn (task) → { llm, tool } spans. +//! Subagents get their own transcript *scope* whose turns hang under a +//! `subagent` root span that is a sibling of the `spawn_agent` tool span (both +//! under the spawning turn). Compaction turns are relabeled `compaction` with a +//! 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 +//! newer turn has already started. + +use super::{AgentTranslator, SessionCtx, SpanOp, SpanRow, SpanType, TranslatorFactory}; +use crate::ids; +use crate::wire::Envelope; +use regex::Regex; +use serde_json::{json, Map, Value}; +use std::collections::{HashMap, HashSet}; +use std::path::Path; +use std::process::Command; +use std::sync::OnceLock; + +const SPAWN_AGENT_TOOL: &str = "spawn_agent"; +const MISSING_TOOL_OUTPUT_ERROR: &str = "Tool output missing before turn ended"; + +pub struct CodexTranslatorFactory; + +impl TranslatorFactory for CodexTranslatorFactory { + fn source(&self) -> &str { + "codex" + } + fn create(&self, session_id: &str) -> Box { + Box::new(CodexTranslator { + session_id: session_id.to_string(), + root_span_id: ids::span_id(session_id, "root"), + root_opened: false, + root_ended: false, + source: None, + permission_mode: None, + root_cwd: None, + project: None, + additional_metadata: Map::new(), + main_path: None, + // The main scope is created lazily once we learn its transcript path. + scopes: HashMap::new(), + spawn_turn_by_call_id: HashMap::new(), + spawn_turn_by_agent_id: HashMap::new(), + compaction_trigger_by_turn: HashMap::new(), + compaction_spans: HashSet::new(), + }) + } +} + +#[derive(PartialEq, Eq, Clone, Copy)] +enum ScopeKind { + Main, + Subagent, +} + +struct OpenTurn { + turn_id: String, + span_id: String, + start_ms: i64, + last_child_end_ms: Option, + llm_seq: u32, + explicit_skill_names: Vec, +} + +struct OpenLlm { + span_id: String, + turn_id: String, + start_ms: i64, + last_output_ms: i64, + output: Vec, + output_preset: bool, +} + +struct Scope { + path: String, + kind: ScopeKind, + offset: u64, + /// Parent span id for this scope's turn spans (main root, or subagent root). + turn_parent_span_id: String, + /// Whether this scope's session/root span has been emitted. + root_created: bool, + model: Option, + open_turns: Vec, + conversation_history: Vec, + open_llm: Option, + open_tools: HashMap, // call_id -> (tool span_id, turn_id) + last_turn_end_ms: Option, + turn_seq: u32, + // Subagent-only: + agent_id: Option, + agent_type: Option, + spawning_turn_span_id: Option, + subagent_ended: bool, +} + +struct CodexTranslator { + session_id: String, + root_span_id: String, + root_opened: bool, + root_ended: bool, + source: Option, + permission_mode: Option, + root_cwd: Option, + project: Option, + additional_metadata: Map, + main_path: Option, + scopes: HashMap, + spawn_turn_by_call_id: HashMap, + spawn_turn_by_agent_id: HashMap, + compaction_trigger_by_turn: HashMap, + compaction_spans: HashSet, +} + +impl AgentTranslator for CodexTranslator { + fn handle(&mut self, event: &Envelope, ctx: &SessionCtx) -> anyhow::Result> { + let payload = &event.payload; + let mut ops = Vec::new(); + + if let Some(config) = &ctx.config { + self.project = config.project.clone(); + self.additional_metadata = config + .additional_metadata + .as_ref() + .and_then(Value::as_object) + .cloned() + .unwrap_or_default(); + } + + // --- hook-specific side effects (before catch-up) --- + match event.event.as_str() { + "SessionStart" => { + self.source = str_field(payload, "source"); + self.permission_mode = str_field(payload, "permission_mode"); + } + "SubagentStart" => self.handle_subagent_start(payload), + "PreCompact" | "PostCompact" => self.record_compaction_trigger(payload, &mut ops), + _ => {} + } + + // --- pick the scope and catch up its transcript --- + let agent_id = str_field(payload, "agent_id"); + let path = if event.event == "SubagentStop" { + str_field(payload, "agent_transcript_path") + } else { + str_field(payload, "transcript_path") + .or_else(|| str_field(payload, "agent_transcript_path")) + }; + + if let Some(path) = path { + if agent_id.is_none() { + self.main_path.get_or_insert(path.clone()); + self.ensure_main_scope(&path); + } + self.catch_up(&path, event.ts_ms, &mut ops); + } + + // --- hook-specific handling (after catch-up) --- + match event.event.as_str() { + // Catch up first: this same hook may be the first observation of + // the spawn_agent transcript record that establishes call -> turn. + "PostToolUse" if agent_id.is_none() => self.record_spawned_agent(payload), + "SubagentStop" => { + if let Some(p) = str_field(payload, "agent_transcript_path") { + self.close_subagent(&p, event.ts_ms, &mut ops); + } + } + "Stop" if agent_id.is_none() => { + // Codex writes task_complete slightly after the Stop hook in + // real sessions. Close the active turn from the hook payload + // now so a short-lived process cannot flush an open turn. + self.close_main_turn(payload, event.ts_ms, &mut ops); + self.end_main_root(event.ts_ms, &mut ops); + } + "PostCompact" => self.close_compaction_turn(payload, event.ts_ms, &mut ops), + _ => {} + } + + Ok(ops) + } + + fn flush(&mut self, _ctx: &SessionCtx) -> anyhow::Result> { + let mut ops = Vec::new(); + // Re-read each scope to catch a late task_complete, then close dangling. + let paths: Vec = self.scopes.keys().cloned().collect(); + for path in paths { + self.catch_up(&path, 0, &mut ops); + if let Some(mut scope) = self.scopes.remove(&path) { + self.close_dangling(&mut scope, None, &mut ops); + self.scopes.insert(path, scope); + } + } + Ok(ops) + } +} + +impl CodexTranslator { + fn ensure_main_scope(&mut self, path: &str) { + if self.scopes.contains_key(path) { + return; + } + let turn_parent = self.root_span_id.clone(); + self.scopes.insert( + path.to_string(), + Scope::new(path, ScopeKind::Main, turn_parent), + ); + } + + fn record_compaction_trigger(&mut self, payload: &Value, ops: &mut Vec) { + let Some(turn_id) = str_field(payload, "turn_id") else { + return; + }; + let trigger = str_field(payload, "trigger").unwrap_or_else(|| "manual".to_string()); + self.compaction_trigger_by_turn + .insert(turn_id.clone(), trigger.clone()); + // Back-fill onto an already-built compaction span. + if self.compaction_spans.contains(&turn_id) { + 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(), + metadata: Some(json!({ "compaction": { "trigger": trigger } })), + ..Default::default() + })); + } + } + + fn record_spawned_agent(&mut self, payload: &Value) { + if str_field(payload, "tool_name").as_deref() != Some(SPAWN_AGENT_TOOL) { + return; + } + let Some(call_id) = str_field(payload, "tool_use_id") else { + return; + }; + let agent_id = payload.get("tool_response").and_then(|r| match r { + Value::String(s) => serde_json::from_str::(s) + .ok() + .and_then(|v| v.get("agent_id").and_then(Value::as_str).map(String::from)), + Value::Object(_) => r.get("agent_id").and_then(Value::as_str).map(String::from), + _ => None, + }); + let Some(agent_id) = agent_id else { return }; + if let Some(turn_span) = self.spawn_turn_by_call_id.get(&call_id) { + self.spawn_turn_by_agent_id + .insert(agent_id, turn_span.clone()); + } + } + + fn handle_subagent_start(&mut self, payload: &Value) { + let (Some(agent_id), Some(path)) = ( + str_field(payload, "agent_id"), + str_field(payload, "transcript_path"), + ) else { + return; + }; + if self.scopes.contains_key(&path) { + return; + } + let parent = self + .spawn_turn_by_agent_id + .get(&agent_id) + .cloned() + .unwrap_or_else(|| self.root_span_id.clone()); + let subagent_root = ids::span_id(&self.session_id, &format!("subagent:{agent_id}")); + let mut scope = Scope::new(&path, ScopeKind::Subagent, subagent_root); + scope.agent_id = Some(agent_id); + scope.agent_type = str_field(payload, "agent_type"); + scope.spawning_turn_span_id = Some(parent); + self.scopes.insert(path, scope); + } + + /// Read new transcript lines for `path` and process them against its scope. + fn catch_up(&mut self, path: &str, hook_ts: i64, ops: &mut Vec) { + let Some(mut scope) = self.scopes.remove(path) else { + return; + }; + let lines = read_new_lines(&scope.path, &mut scope.offset); + for line in lines { + if let Ok(rec) = serde_json::from_str::(&line) { + self.process_record(&mut scope, &rec, hook_ts, ops); + } + } + self.scopes.insert(path.to_string(), scope); + } + + fn process_record( + &mut self, + scope: &mut Scope, + rec: &Value, + hook_ts: i64, + ops: &mut Vec, + ) { + let ts = parse_ts(rec).unwrap_or(hook_ts); + let ty = rec.get("type").and_then(Value::as_str).unwrap_or(""); + let payload = rec.get("payload").cloned().unwrap_or(Value::Null); + + match ty { + "session_meta" => self.open_root(scope, &payload, ts, ops), + "turn_context" => { + if let Some(m) = str_field(&payload, "model") { + scope.model = Some(m.clone()); + if scope.root_created { + let input = if scope.kind == ScopeKind::Main { + json!({ + "model": m, + "cwd": self.root_cwd, + "source": self.source, + }) + } else { + json!({ "model": m }) + }; + ops.push(SpanOp::Merge(SpanRow { + span_id: scope.turn_parent_span_id.clone(), + root_span_id: self.root_span_id.clone(), + input: Some(input), + metadata: Some(json!({ "model": m })), + ..Default::default() + })); + } + } + } + "event_msg" => { + let sub = payload.get("type").and_then(Value::as_str).unwrap_or(""); + match sub { + "task_started" => self.open_turn(scope, &payload, ts, ops), + "user_message" => self.set_turn_input(scope, &payload, ops), + "token_count" => self.close_llm_with_tokens(scope, &payload, ts, ops), + "task_complete" => self.close_turn(scope, &payload, ts, ops), + _ => {} + } + } + "response_item" => { + let sub = payload.get("type").and_then(Value::as_str).unwrap_or(""); + match sub { + "message" => self.on_message(scope, &payload, ts, ops), + "reasoning" => self.on_reasoning(scope, &payload, ts, ops), + "function_call" | "custom_tool_call" | "tool_search_call" => { + self.on_tool_call(scope, &payload, ts, ops) + } + "function_call_output" | "custom_tool_call_output" | "tool_search_output" => { + self.on_tool_output(scope, &payload, ts, ops) + } + _ => {} + } + } + "compacted" => self.on_compacted(scope, rec, &payload, ts, ops), + _ => {} + } + } + + fn open_root(&mut self, scope: &mut Scope, payload: &Value, ts: i64, ops: &mut Vec) { + match scope.kind { + ScopeKind::Main => { + if self.root_opened { + return; + } + self.root_opened = true; + let name = match str_field(payload, "cwd") { + Some(cwd) => format!("codex: {}", basename(&cwd)), + None => "codex session".to_string(), + }; + let cwd = str_field(payload, "cwd"); + self.root_cwd = cwd.clone(); + let mut md = self.additional_metadata.clone(); + for k in ["id", "cwd", "cli_version"] { + if let Some(v) = str_field(payload, k) { + md.insert( + if k == "id" { + "session_id".into() + } else { + k.to_string() + }, + json!(v), + ); + } + } + if let Some(s) = &self.source { + md.insert("source".into(), json!(s)); + } + if let Some(pm) = &self.permission_mode { + md.insert("permission_mode".into(), json!(pm)); + } + if let Some(tp) = &self.main_path { + md.insert("transcript_path".into(), json!(tp)); + } + if let Some(m) = &scope.model { + md.insert("model".into(), json!(m)); + } + if let Some(project) = &self.project { + md.insert("project".into(), json!(project)); + } + if let Some(cwd) = &cwd { + for (key, value) in git_metadata(cwd) { + md.insert(key, value); + } + } + scope.root_created = true; + ops.push(SpanOp::Insert(SpanRow { + span_id: self.root_span_id.clone(), + root_span_id: self.root_span_id.clone(), + name, + span_type: SpanType::Task, + start_ms: Some(ts), + input: Some(json!({ + "model": scope.model, + "cwd": cwd, + "source": self.source, + })), + metadata: Some(Value::Object(md)), + ..Default::default() + })); + } + ScopeKind::Subagent => { + if scope.root_created { + return; + } + scope.root_created = true; + let agent_id = scope.agent_id.clone().unwrap_or_default(); + let parent = scope + .spawning_turn_span_id + .clone() + .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(), + parent_span_ids: vec![parent], + name: format!("subagent: {agent_id}"), + span_type: SpanType::Task, + start_ms: Some(ts), + metadata: Some(json!({ + "agent_id": agent_id, + "agent_type": scope.agent_type, + "transcript_path": scope.path, + })), + ..Default::default() + })); + } + } + } + + fn open_turn(&mut self, scope: &mut Scope, payload: &Value, ts: i64, ops: &mut Vec) { + let turn_id = str_field(payload, "turn_id").unwrap_or_else(|| { + scope.turn_seq += 1; + format!("turn-{}", scope.turn_seq) + }); + if scope.open_turns.iter().any(|turn| turn.turn_id == turn_id) { + return; + } + 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(), + parent_span_ids: vec![scope.turn_parent_span_id.clone()], + name: format!("turn: {turn_id}"), + span_type: SpanType::Task, + start_ms: Some(ts), + metadata: Some(json!({ "turn_id": turn_id, "model": scope.model })), + ..Default::default() + })); + scope.open_turns.push(OpenTurn { + turn_id, + span_id, + start_ms: ts, + last_child_end_ms: None, + llm_seq: 0, + explicit_skill_names: Vec::new(), + }); + } + + fn set_turn_input(&mut self, scope: &mut Scope, payload: &Value, ops: &mut Vec) { + let text = str_field(payload, "message") + .or_else(|| str_field(payload, "text")) + .or_else(|| str_field(payload, "prompt")); + let Some(text) = text else { return }; + // Explicit skill mentions in the prompt (e.g. "$skill", "/skills name"). + let names = explicit_skill_names(&text); + if let Some(turn) = scope.open_turns.last_mut() { + for n in names { + if !turn.explicit_skill_names.contains(&n) { + turn.explicit_skill_names.push(n); + } + } + ops.push(SpanOp::Merge(SpanRow { + span_id: turn.span_id.clone(), + root_span_id: self.root_span_id.clone(), + input: Some(json!(text)), + metadata: explicit_skill_metadata(&turn.explicit_skill_names), + ..Default::default() + })); + } + } + + fn ensure_llm( + &mut self, + scope: &mut Scope, + turn_id: Option<&str>, + _ts: i64, + ops: &mut Vec, + ) { + if scope.open_llm.is_some() { + return; + } + let index = turn_id + .and_then(|id| scope.open_turns.iter().position(|turn| turn.turn_id == id)) + .or_else(|| scope.open_turns.len().checked_sub(1)); + let Some(index) = index else { + return; + }; + let input = Value::Array(scope.conversation_history.clone()); + let turn = &mut scope.open_turns[index]; + let seq = turn.llm_seq; + turn.llm_seq += 1; + // Start where the model's work began — end of the turn's last child, or + // the turn's start for the first child — not the record time (which is + // when output landed, yielding a near-instant span). + let start = turn.last_child_end_ms.unwrap_or(turn.start_ms); + let span_id = ids::span_id(&self.session_id, &format!("llm:{}:{}", turn.turn_id, seq)); + let name = scope.model.clone().unwrap_or_else(|| "llm".to_string()); + let turn_span = turn.span_id.clone(); + 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(), + parent_span_ids: vec![turn_span], + name, + span_type: SpanType::Llm, + start_ms: Some(start), + input: Some(input), + metadata: Some(json!({ "model": scope.model, "turn_id": turn_id })), + ..Default::default() + })); + scope.open_llm = Some(OpenLlm { + span_id, + turn_id, + start_ms: start, + last_output_ms: start, + output: Vec::new(), + output_preset: false, + }); + } + + fn on_message(&mut self, scope: &mut Scope, payload: &Value, ts: i64, ops: &mut Vec) { + let role = str_field(payload, "role").unwrap_or_else(|| "user".to_string()); + let text = message_text(payload); + if text.is_empty() { + return; + } + let msg = json!({ "role": role, "content": text }); + if role == "assistant" { + self.ensure_llm(scope, None, ts, ops); + if let Some(llm) = &mut scope.open_llm { + llm.output.push(msg.clone()); + llm.last_output_ms = llm.last_output_ms.max(ts); + } + } else if role == "user" { + let names = explicit_skill_names(&text); + if let Some(turn) = scope.open_turns.last_mut() { + for name in names { + if !turn.explicit_skill_names.contains(&name) { + turn.explicit_skill_names.push(name); + } + } + 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(), + metadata: Some(metadata), + ..Default::default() + })); + } + } + } + scope.conversation_history.push(msg); + } + + fn on_reasoning(&mut self, scope: &mut Scope, payload: &Value, ts: i64, ops: &mut Vec) { + self.ensure_llm(scope, None, ts, ops); + if let Some(llm) = &mut scope.open_llm { + llm.last_output_ms = llm.last_output_ms.max(ts); + } + let summary: Vec = payload + .get("summary") + .and_then(Value::as_array) + .map(|a| { + a.iter() + .filter_map(|s| { + str_field(s, "text") + .filter(|text| !text.is_empty()) + .map(|text| json!({ "type": "summary_text", "text": text })) + }) + .collect() + }) + .unwrap_or_default(); + if summary.is_empty() { + return; // encrypted reasoning: only opens/advances the span + } + let item = json!({ "type": "reasoning", "summary": summary }); + if let Some(llm) = &mut scope.open_llm { + llm.output.push(item.clone()); + } + scope.conversation_history.push(item); + } + + fn on_tool_call(&mut self, scope: &mut Scope, payload: &Value, ts: i64, ops: &mut Vec) { + let call_id = str_field(payload, "call_id"); + let tool_name = str_field(payload, "name").unwrap_or_else(|| { + payload + .get("type") + .and_then(Value::as_str) + .unwrap_or("tool") + .trim_end_matches("_call") + .to_string() + }); + let input = payload + .get("arguments") + .cloned() + .or_else(|| payload.get("input").cloned()); + let args_string = input + .as_ref() + .and_then(Value::as_str) + .map(str::to_string) + .unwrap_or_else(|| { + serde_json::to_string(input.as_ref().unwrap_or(&Value::Null)).unwrap() + }); + let tool_call_message = json!({ + "role": "assistant", + "content": Value::Null, + "tool_calls": [{ + "id": call_id.clone().unwrap_or_default(), + "type": "function", + "function": { "name": tool_name, "arguments": args_string }, + }], + }); + scope.conversation_history.push(tool_call_message.clone()); + + let Some(call_id) = call_id else { return }; + let turn_id = payload + .get("metadata") + .and_then(|metadata| str_field(metadata, "turn_id")) + .or_else(|| scope.open_turns.last().map(|turn| turn.turn_id.clone())); + let Some(turn_id) = turn_id else { return }; + let Some(turn_index) = scope + .open_turns + .iter() + .position(|turn| turn.turn_id == turn_id) + else { + return; + }; + if scope.open_tools.contains_key(&call_id) { + return; + } + let turn_span = scope.open_turns[turn_index].span_id.clone(); + let explicit_skills = scope.open_turns[turn_index].explicit_skill_names.clone(); + + self.ensure_llm(scope, Some(&turn_id), ts, ops); + if let Some(llm) = &mut scope.open_llm { + llm.output.push(tool_call_message); + llm.last_output_ms = llm.last_output_ms.max(ts); + } + + let span_id = ids::span_id(&self.session_id, &format!("tool:{call_id}")); + // spawn_agent: remember which turn ran it, so a later SubagentStart can + // nest the subagent root under this turn (main scope only). + if scope.kind == ScopeKind::Main && tool_name == SPAWN_AGENT_TOOL { + self.spawn_turn_by_call_id + .insert(call_id.clone(), turn_span.clone()); + } + + // Skill / permission classification. + let skill = detect_skill(&tool_name, input.as_ref()); + let permission = permission_info(input.as_ref()); + let mut name = tool_name.clone(); + let mut tags: Vec = Vec::new(); + let mut metadata = Map::new(); + metadata.insert("tool_name".into(), json!(tool_name)); + metadata.insert("call_id".into(), json!(call_id)); + metadata.insert("turn_id".into(), json!(turn_id)); + if let Some(skill) = &skill { + if let Some(skill_name) = &skill.name { + name = format!("skill: {skill_name}"); + metadata.insert("skill_name".into(), json!(skill_name)); + if explicit_skills.contains(skill_name) { + metadata.insert("skill_load_trigger".into(), json!("explicit")); + } + } + if let Some(skill_path) = &skill.path { + metadata.insert("skill_path".into(), json!(skill_path)); + } + metadata.insert("tool_kind".into(), json!("skill")); + } + if let Some(permission) = permission { + metadata.insert("permission".into(), permission); + tags.push("permission-request".to_string()); + } + + ops.push(SpanOp::Insert(SpanRow { + span_id: span_id.clone(), + root_span_id: self.root_span_id.clone(), + parent_span_ids: vec![turn_span], + name, + span_type: SpanType::Tool, + start_ms: Some(ts), + input, + metadata: Some(Value::Object(metadata)), + tags: if tags.is_empty() { None } else { Some(tags) }, + ..Default::default() + })); + scope.open_tools.insert(call_id, (span_id, turn_id)); + } + + fn on_tool_output( + &mut self, + scope: &mut Scope, + payload: &Value, + ts: i64, + ops: &mut Vec, + ) { + let Some(call_id) = str_field(payload, "call_id") else { + push_tool_result(scope, None, payload); + return; + }; + push_tool_result(scope, Some(&call_id), payload); + let Some((span_id, _turn_id)) = scope.open_tools.remove(&call_id) else { + return; + }; + if let Some(turn) = scope + .open_turns + .iter_mut() + .find(|turn| turn.turn_id == _turn_id) + { + turn.last_child_end_ms = Some(turn.last_child_end_ms.map_or(ts, |p| p.max(ts))); + } + let output = payload + .get("output") + .or_else(|| payload.get("result")) + .cloned(); + 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(), + end_ms: Some(ts), + output, + metadata: Some(json!({ "tool_approval": "approved" })), + error, + ..Default::default() + })); + } + + fn close_llm_with_tokens( + &mut self, + scope: &mut Scope, + payload: &Value, + ts: i64, + ops: &mut Vec, + ) { + let Some(llm) = scope.open_llm.take() else { + return; + }; + let usage = payload.get("info").and_then(|i| i.get("last_token_usage")); + let metrics = usage.map(token_metrics).filter(|m| !m.is_empty()); + let usage_metadata = if metrics.is_none() { + Some(json!({ + "usage_unavailable_reason": if usage + .and_then(Value::as_object) + .is_none_or(Map::is_empty) + { + "codex_token_count_missing_usage" + } else { + "codex_token_count_unrecognized_usage" + } + })) + } else { + None + }; + let end = llm.last_output_ms.max(llm.start_ms); + if let Some(turn) = scope + .open_turns + .iter_mut() + .find(|turn| turn.turn_id == llm.turn_id) + { + turn.last_child_end_ms = Some(turn.last_child_end_ms.map_or(end, |p| p.max(end))); + } + let output = if llm.output_preset { + None + } else { + Some(llm_output(&llm.output)) + }; + ops.push(SpanOp::Merge(SpanRow { + span_id: llm.span_id, + root_span_id: self.root_span_id.clone(), + end_ms: Some(end), + output, + metadata: usage_metadata, + metrics: metrics.map(Value::Object), + ..Default::default() + })); + let _ = ts; + } + + fn close_turn(&mut self, scope: &mut Scope, payload: &Value, ts: i64, ops: &mut Vec) { + let requested_turn_id = str_field(payload, "turn_id"); + let turn_index = requested_turn_id + .as_deref() + .and_then(|id| scope.open_turns.iter().position(|turn| turn.turn_id == id)) + .or_else(|| { + if requested_turn_id.is_none() { + scope.open_turns.len().checked_sub(1) + } else { + None + } + }); + let Some(turn_index) = turn_index else { + return; + }; + let turn_id = scope.open_turns[turn_index].turn_id.clone(); + + if scope + .open_llm + .as_ref() + .is_some_and(|llm| llm.turn_id == turn_id) + { + let llm = scope.open_llm.take().expect("checked above"); + let output = if llm.output_preset { + None + } else { + Some(llm_output(&llm.output)) + }; + ops.push(SpanOp::Merge(SpanRow { + span_id: llm.span_id, + root_span_id: self.root_span_id.clone(), + end_ms: Some(llm.last_output_ms), + output, + metadata: Some(json!({ + "usage_unavailable_reason": "codex_transcript_missing_token_count" + })), + ..Default::default() + })); + } + self.close_tools_for_turn(scope, &turn_id, Some(ts), ops); + let turn = scope.open_turns.remove(turn_index); + scope.last_turn_end_ms = Some(ts); + let output = str_field(payload, "last_agent_message") + .or_else(|| str_field(payload, "last_assistant_message")) + .map(|s| json!(s)); + ops.push(SpanOp::Merge(SpanRow { + span_id: turn.span_id, + root_span_id: self.root_span_id.clone(), + end_ms: Some(ts), + output, + ..Default::default() + })); + } + + fn close_tools_for_turn( + &mut self, + scope: &mut Scope, + turn_id: &str, + end_ms: Option, + ops: &mut Vec, + ) { + let call_ids: Vec = scope + .open_tools + .iter() + .filter(|(_, (_, owner))| owner == turn_id) + .map(|(call_id, _)| call_id.clone()) + .collect(); + for call_id in call_ids { + 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(), + end_ms, + metadata: Some(json!({ "tool_approval": "approved" })), + error: Some(MISSING_TOOL_OUTPUT_ERROR.to_string()), + ..Default::default() + })); + } + } + } + + fn close_main_turn(&mut self, payload: &Value, ts: i64, ops: &mut Vec) { + let Some(path) = self.main_path.clone() else { + return; + }; + let Some(mut scope) = self.scopes.remove(&path) else { + return; + }; + self.close_turn(&mut scope, payload, ts, ops); + self.scopes.insert(path, scope); + } + + fn on_compacted( + &mut self, + scope: &mut Scope, + _rec: &Value, + payload: &Value, + ts: i64, + ops: &mut Vec, + ) { + let Some(turn) = scope.open_turns.last() else { + return; + }; + let turn_id = turn.turn_id.clone(); + let turn_span = turn.span_id.clone(); + let turn_start = turn.start_ms; + let turn_last_child = turn.last_child_end_ms; + let replacement = payload + .get("replacement_history") + .and_then(Value::as_array) + .cloned(); + let trigger = self.compaction_trigger_by_turn.get(&turn_id).cloned(); + self.compaction_spans.insert(turn_id.clone()); + + // 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(), + name: "compaction".to_string(), + span_type: SpanType::Task, + metadata: Some(json!({ "compaction": { + "trigger": trigger, + "replaced_message_count": replacement.as_ref().map(|r| r.len()), + "window_id": payload.get("window_id"), + }})), + tags: Some(vec!["compaction".to_string()]), + ..Default::default() + })); + + // Synthetic llm span for the compaction call: before/after context. + let start = turn_last_child.unwrap_or(turn_start); + let before = scope.conversation_history.clone(); + let span_id = ids::span_id(&self.session_id, &format!("llm:{turn_id}:compaction")); + let name = scope + .model + .clone() + .unwrap_or_else(|| "compaction".to_string()); + ops.push(SpanOp::Insert(SpanRow { + span_id: span_id.clone(), + root_span_id: self.root_span_id.clone(), + parent_span_ids: vec![turn_span.clone()], + name: name.clone(), + span_type: SpanType::Llm, + start_ms: Some(start), + input: Some(json!({ "messages_before_compaction": before.len(), "history": before })), + output: Some(compaction_output(replacement.as_ref())), + metadata: Some(json!({ "model": scope.model, "turn_id": turn_id, "compaction": true })), + ..Default::default() + })); + let _ = (turn_span, name); + scope.open_llm = Some(OpenLlm { + span_id, + turn_id, + start_ms: start, + last_output_ms: ts, + output: Vec::new(), + output_preset: true, + }); + } + + fn end_main_root(&mut self, fallback_ts: i64, ops: &mut Vec) { + if self.root_ended || !self.root_opened { + return; + } + self.root_ended = true; + let end_ms = self + .main_path + .as_ref() + .and_then(|path| self.scopes.get(path)) + .and_then(|scope| scope.last_turn_end_ms) + .unwrap_or(fallback_ts); + ops.push(SpanOp::Merge(SpanRow { + span_id: self.root_span_id.clone(), + root_span_id: self.root_span_id.clone(), + end_ms: Some(end_ms), + ..Default::default() + })); + } + + fn close_compaction_turn(&mut self, payload: &Value, ts: i64, ops: &mut Vec) { + let Some(path) = self.main_path.clone() else { + return; + }; + let Some(mut scope) = self.scopes.remove(&path) else { + return; + }; + // The compaction turn may not get a task_complete of its own. + self.close_turn(&mut scope, payload, ts, ops); + self.scopes.insert(path, scope); + } + + fn close_subagent(&mut self, path: &str, ts: i64, ops: &mut Vec) { + let Some(mut scope) = self.scopes.remove(path) else { + return; + }; + if !scope.subagent_ended && scope.root_created { + scope.subagent_ended = true; + let end = scope.last_turn_end_ms.unwrap_or(ts); + self.close_dangling(&mut scope, Some(end), ops); + // 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(), + end_ms: Some(end), + ..Default::default() + })); + } + self.scopes.insert(path.to_string(), scope); + } + + /// Close any open llm/tool/turn in `scope` (used on subagent stop + flush). + 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() { + let output = if llm.output_preset { + None + } else { + Some(llm_output(&llm.output)) + }; + ops.push(SpanOp::Merge(SpanRow { + span_id: llm.span_id, + root_span_id: self.root_span_id.clone(), + end_ms: end_ms.or(Some(llm.last_output_ms)), + output, + metadata: Some(json!({ + "usage_unavailable_reason": "codex_transcript_missing_token_count" + })), + ..Default::default() + })); + } + let tools: Vec<(String, String)> = scope + .open_tools + .drain() + .map(|(_, (span_id, _))| (span_id, MISSING_TOOL_OUTPUT_ERROR.to_string())) + .collect(); + for (sid, error) in tools { + ops.push(SpanOp::Merge(SpanRow { + span_id: sid, + root_span_id: self.root_span_id.clone(), + end_ms, + metadata: Some(json!({ "tool_approval": "approved" })), + error: Some(error), + ..Default::default() + })); + } + 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(), + end_ms, + ..Default::default() + })); + } + } +} + +impl Scope { + fn new(path: &str, kind: ScopeKind, turn_parent_span_id: String) -> Self { + Scope { + path: path.to_string(), + kind, + offset: 0, + turn_parent_span_id, + root_created: false, + model: None, + open_turns: Vec::new(), + conversation_history: Vec::new(), + open_llm: None, + open_tools: HashMap::new(), + last_turn_end_ms: None, + turn_seq: 0, + agent_id: None, + agent_type: None, + spawning_turn_span_id: None, + subagent_ended: false, + } + } +} + +// ---- helpers --------------------------------------------------------------- + +fn str_field(v: &Value, key: &str) -> Option { + v.get(key).and_then(Value::as_str).map(|s| s.to_string()) +} + +fn basename(path: &str) -> String { + let trimmed = path.trim_end_matches(['/', '\\']); + trimmed + .rsplit(['/', '\\']) + .next() + .unwrap_or(trimmed) + .to_string() +} + +fn message_text(payload: &Value) -> String { + payload + .get("content") + .and_then(Value::as_array) + .map(|parts| { + parts + .iter() + .filter_map(|p| str_field(p, "text")) + .collect::>() + .join("") + }) + .unwrap_or_default() +} + +fn llm_output(items: &[Value]) -> Value { + if items.len() == 1 { + items[0].clone() + } else { + Value::Array(items.to_vec()) + } +} + +fn push_tool_result(scope: &mut Scope, call_id: Option<&str>, payload: &Value) { + let output = payload + .get("output") + .or_else(|| payload.get("result")) + .cloned() + .unwrap_or(Value::Null); + let content = output + .as_str() + .map(str::to_string) + .unwrap_or_else(|| serde_json::to_string(&output).unwrap_or_else(|_| "null".to_string())); + scope.conversation_history.push(json!({ + "role": "tool", + "content": content, + "tool_call_id": call_id.unwrap_or_default(), + })); +} + +fn args_object(args: Option<&Value>) -> Option> { + match args? { + Value::Object(map) => Some(map.clone()), + Value::String(raw) => serde_json::from_str::(raw) + .ok() + .and_then(|value| value.as_object().cloned()), + _ => None, + } +} + +fn concise_error(value: &Value, fallback: &str) -> String { + if let Some(text) = value.as_str() { + return text.lines().next().unwrap_or(fallback).to_string(); + } + if let Some(object) = value.as_object() { + for key in ["error", "message", "stderr", "output", "result"] { + if let Some(text) = object.get(key).and_then(Value::as_str) { + return text.lines().next().unwrap_or(fallback).to_string(); + } + } + } + fallback.to_string() +} + +fn classify_tool_output(output: &Value) -> Option { + if let Some(object) = output.as_object() { + if object.get("is_error").and_then(Value::as_bool) == Some(true) + || object.get("isError").and_then(Value::as_bool) == Some(true) + || matches!( + object.get("status").and_then(Value::as_str), + Some("error" | "failed") + ) + { + return Some(concise_error(output, "Tool execution failed")); + } + if let Some(error) = object.get("error") { + return Some(concise_error(error, "Tool execution failed")); + } + if let Some(exit_code) = object + .get("exit_code") + .or_else(|| object.get("exitCode")) + .and_then(Value::as_i64) + { + if exit_code != 0 { + return Some(concise_error(output, &format!("Exit code {exit_code}"))); + } + } + } + if let Some(text) = output.as_str() { + let first = text.lines().next().unwrap_or(text); + if first.to_ascii_lowercase().starts_with("error:") { + return Some(first.to_string()); + } + if let Some(code) = first + .strip_prefix("Exit code ") + .and_then(|value| value.split_whitespace().next()) + .and_then(|value| value.parse::().ok()) + { + if code != 0 { + return Some(first.to_string()); + } + } + } + None +} + +#[derive(Default)] +struct SkillLoad { + name: Option, + path: Option, +} + +fn string_candidates(args: Option<&Value>) -> Vec { + let mut candidates = Vec::new(); + if let Some(raw) = args.and_then(Value::as_str) { + candidates.push(raw.to_string()); + } + if let Some(object) = args_object(args) { + for key in [ + "path", + "file_path", + "filePath", + "file", + "command", + "cmd", + "resource", + ] { + if let Some(value) = object.get(key).and_then(Value::as_str) { + candidates.push(value.to_string()); + } + } + } + candidates +} + +fn detect_skill(tool_name: &str, args: Option<&Value>) -> Option { + if tool_name == "skills.read" { + if let Some(object) = args_object(args) { + return Some(SkillLoad { + name: object + .get("name") + .or_else(|| object.get("package")) + .and_then(Value::as_str) + .map(str::to_string), + path: None, + }); + } + } + static SKILL_PATH: OnceLock = OnceLock::new(); + static SCRIPT_PATH: OnceLock = OnceLock::new(); + let skill_path = + SKILL_PATH.get_or_init(|| Regex::new(r#"(?i)([^\s"']*SKILL\.md)"#).expect("regex")); + let script_path = SCRIPT_PATH + .get_or_init(|| Regex::new(r#"(?i)([^\s"']*[\\/]scripts[\\/][^\s"']+)"#).expect("regex")); + for candidate in string_candidates(args) { + if let Some(path) = skill_path + .captures(&candidate) + .and_then(|capture| capture.get(1)) + .map(|capture| capture.as_str().to_string()) + { + let normalized = path.replace('\\', "/"); + let name = Path::new(&normalized) + .parent() + .and_then(Path::file_name) + .and_then(|value| value.to_str()) + .map(str::to_string); + return Some(SkillLoad { + name, + path: Some(path), + }); + } + if let Some(path) = script_path + .captures(&candidate) + .and_then(|capture| capture.get(1)) + .map(|capture| capture.as_str().to_string()) + { + let normalized = path.replace('\\', "/"); + let name = Path::new(&normalized) + .parent() + .and_then(Path::parent) + .and_then(Path::file_name) + .and_then(|value| value.to_str()) + .map(str::to_string); + return Some(SkillLoad { + name, + path: Some(path), + }); + } + } + None +} + +fn permission_info(args: Option<&Value>) -> Option { + let object = args_object(args)?; + let sandbox_permissions = object.get("sandbox_permissions")?.as_str()?; + if sandbox_permissions.is_empty() { + return None; + } + let mut permission = Map::new(); + permission.insert("sandbox_permissions".into(), json!(sandbox_permissions)); + if let Some(justification) = object.get("justification").and_then(Value::as_str) { + permission.insert("justification".into(), json!(justification)); + } + if let Some(prefix_rule) = object.get("prefix_rule") { + permission.insert("prefix_rule".into(), prefix_rule.clone()); + } + Some(Value::Object(permission)) +} + +fn explicit_skill_names(text: &str) -> Vec { + static EXPLICIT_SKILLS: OnceLock> = OnceLock::new(); + static SKILL_XML: OnceLock = OnceLock::new(); + static SKILL_XML_NAME: OnceLock = OnceLock::new(); + static SKILL_FRONTMATTER_NAME: OnceLock = OnceLock::new(); + let patterns = EXPLICIT_SKILLS.get_or_init(|| { + [ + r#"\$([A-Za-z0-9_.:-]+)"#, + r#"(?:^|\s)/skills\s+([A-Za-z0-9_.:-]+)"#, + r#"skill://([A-Za-z0-9_.:-]+)"#, + r#"(?i)(?:^|[\s"'])([^\s"']*SKILL\.md)(?:$|[\s"'])"#, + r#"UserInput::Skill\([^)]*(?:name|skill|id)\s*[:=]\s*["']?([A-Za-z0-9_.:-]+)"#, + ] + .into_iter() + .map(|pattern| Regex::new(pattern).expect("regex")) + .collect() + }); + let mut names = Vec::new(); + for (index, pattern) in patterns.iter().enumerate() { + for capture in pattern.captures_iter(text) { + let Some(value) = capture.get(1).map(|capture| capture.as_str()) else { + continue; + }; + let name = if index == 3 { + let normalized = value.replace('\\', "/"); + Path::new(&normalized) + .parent() + .and_then(Path::file_name) + .and_then(|value| value.to_str()) + .unwrap_or(value) + .to_string() + } else { + value + .trim() + .trim_start_matches('$') + .trim_end_matches([',', ')', '.', ';']) + .to_string() + }; + if !name.is_empty() && !names.contains(&name) { + names.push(name); + } + } + } + let xml = SKILL_XML + .get_or_init(|| Regex::new(r#"(?is)]*)>(.*?)"#).expect("regex")); + let attr_name = SKILL_XML_NAME + .get_or_init(|| Regex::new(r#"(?:name|id)=["']([^"']+)["']"#).expect("regex")); + let frontmatter_name = SKILL_FRONTMATTER_NAME + .get_or_init(|| Regex::new(r#"(?m)(?:^|\n)name:\s*([A-Za-z0-9_.:-]+)"#).expect("regex")); + for capture in xml.captures_iter(text) { + let candidate = capture + .get(1) + .and_then(|attrs| attr_name.captures(attrs.as_str())) + .and_then(|capture| capture.get(1)) + .or_else(|| { + capture + .get(2) + .and_then(|body| frontmatter_name.captures(body.as_str())) + .and_then(|capture| capture.get(1)) + }) + .map(|capture| capture.as_str().to_string()); + if let Some(name) = candidate { + if !name.is_empty() && !names.contains(&name) { + names.push(name); + } + } + } + names +} + +fn explicit_skill_metadata(names: &[String]) -> Option { + (!names.is_empty()).then(|| { + json!({ + "loaded_skill_names": names, + "loaded_skills": names.iter().map(|name| json!({ "name": name })).collect::>(), + }) + }) +} + +fn git_metadata(cwd: &str) -> Map { + fn git(cwd: &str, args: &[&str]) -> Option { + let output = Command::new("git") + .arg("-C") + .arg(cwd) + .args(args) + .env("GIT_OPTIONAL_LOCKS", "0") + .output() + .ok()?; + if !output.status.success() { + return None; + } + let value = String::from_utf8(output.stdout).ok()?.trim().to_string(); + (!value.is_empty()).then_some(value) + } + fn redact_remote(remote: String) -> String { + let Some(scheme) = remote.find("://") else { + return remote; + }; + let authority_start = scheme + 3; + let authority_end = remote[authority_start..] + .find('/') + .map(|offset| authority_start + offset) + .unwrap_or(remote.len()); + if let Some(at) = remote[authority_start..authority_end].rfind('@') { + let at = authority_start + at; + return format!("{}{}", &remote[..authority_start], &remote[at + 1..]); + } + remote + } + let mut metadata = Map::new(); + if let Some(origin) = git(cwd, &["remote", "get-url", "origin"]) { + metadata.insert("git_origin_url".into(), json!(redact_remote(origin))); + } + if let Some(branch) = git(cwd, &["symbolic-ref", "--quiet", "--short", "HEAD"]) { + metadata.insert("git_branch".into(), json!(branch)); + } + if let Some(commit) = git(cwd, &["rev-parse", "HEAD"]) { + metadata.insert("git_commit_sha".into(), json!(commit)); + } + metadata +} + +fn compaction_output(replacement: Option<&Vec>) -> Value { + let Some(items) = replacement else { + return json!({ "summary": "[unavailable]", "kept_messages": [] }); + }; + let mut kept = Vec::new(); + let mut summary_encrypted = false; + for item in items { + if item.get("type").and_then(Value::as_str) == Some("compaction") { + if item + .get("encrypted_content") + .and_then(Value::as_str) + .is_some() + { + summary_encrypted = true; + } + continue; + } + kept.push(item.clone()); + } + json!({ + "summary": if summary_encrypted { + "[summary unavailable — encrypted by Codex]" + } else { + "[no summary]" + }, + "kept_messages": kept, + }) +} + +fn parse_ts(rec: &Value) -> Option { + let s = rec.get("timestamp").and_then(Value::as_str)?; + chrono::DateTime::parse_from_rfc3339(s) + .ok() + .map(|dt| dt.timestamp_millis()) +} + +fn read_new_lines(path: &str, offset: &mut u64) -> Vec { + use std::io::{Read, Seek, SeekFrom}; + let Ok(mut f) = std::fs::File::open(path) else { + return Vec::new(); + }; + if let Ok(meta) = f.metadata() { + if *offset > meta.len() { + *offset = 0; + } + } + if f.seek(SeekFrom::Start(*offset)).is_err() { + return Vec::new(); + } + let mut buf = String::new(); + if f.read_to_string(&mut buf).is_err() { + return Vec::new(); + } + let Some(last_nl) = buf.rfind('\n') else { + return Vec::new(); + }; + let complete = &buf[..=last_nl]; + *offset += complete.len() as u64; + complete + .lines() + .filter(|l| !l.trim().is_empty()) + .map(|s| s.to_string()) + .collect() +} + +fn token_metrics(usage: &Value) -> Map { + const MAP: &[(&str, &str)] = &[ + ("input_tokens", "prompt_tokens"), + ("prompt_tokens", "prompt_tokens"), + ("output_tokens", "completion_tokens"), + ("completion_tokens", "completion_tokens"), + ("total_tokens", "tokens"), + ("tokens", "tokens"), + ("cached_input_tokens", "prompt_cached_tokens"), + ("prompt_cached_tokens", "prompt_cached_tokens"), + ("input_tokens_details.cached_tokens", "prompt_cached_tokens"), + ( + "prompt_tokens_details.cached_tokens", + "prompt_cached_tokens", + ), + ( + "prompt_cache_creation_tokens", + "prompt_cache_creation_tokens", + ), + ( + "input_tokens_details.cache_creation_tokens", + "prompt_cache_creation_tokens", + ), + ( + "input_tokens_details.cache_write_tokens", + "prompt_cache_creation_tokens", + ), + ( + "prompt_tokens_details.cache_creation_tokens", + "prompt_cache_creation_tokens", + ), + ( + "prompt_tokens_details.cache_write_tokens", + "prompt_cache_creation_tokens", + ), + ("reasoning_output_tokens", "completion_reasoning_tokens"), + ("completion_reasoning_tokens", "completion_reasoning_tokens"), + ("reasoning_tokens", "completion_reasoning_tokens"), + ( + "output_tokens_details.reasoning_tokens", + "completion_reasoning_tokens", + ), + ( + "completion_tokens_details.reasoning_tokens", + "completion_reasoning_tokens", + ), + ("cost", "cost"), + ("cost", "estimated_cost"), + ("estimated_cost", "estimated_cost"), + ("total_cost", "estimated_cost"), + ("cost_usd", "estimated_cost"), + ]; + let mut metrics = Map::new(); + for (from, to) in MAP { + if metrics.contains_key(*to) { + continue; + } + if let Some(v) = num_at(usage, from) { + metrics.insert((*to).to_string(), json!(v)); + } + } + if !metrics.contains_key("tokens") { + if let (Some(p), Some(c)) = ( + metrics.get("prompt_tokens").and_then(Value::as_f64), + metrics.get("completion_tokens").and_then(Value::as_f64), + ) { + metrics.insert("tokens".to_string(), json!(p + c)); + } + } + metrics +} + +fn num_at(v: &Value, path: &str) -> Option { + let mut cur = v; + for part in path.split('.') { + cur = cur.get(part)?; + } + cur.as_f64().filter(|n| n.is_finite()) +} + +#[cfg(test)] +mod tests { + use super::basename; + + #[test] + fn basename_accepts_unix_and_windows_paths() { + assert_eq!(basename("/tmp/project"), "project"); + assert_eq!(basename(r"C:\Users\agent\project"), "project"); + assert_eq!(basename(r"C:\Users\agent\project\\"), "project"); + } +} diff --git a/bt-daemon/src/translate/debug.rs b/bt-daemon/src/translate/debug.rs new file mode 100644 index 0000000..2bbaf5c --- /dev/null +++ b/bt-daemon/src/translate/debug.rs @@ -0,0 +1,80 @@ +//! A pass-through translator used by the prototype and tests. It builds a +//! minimal but real span tree — one session root plus one `tool`-typed span +//! per event — so the end-to-end pipeline (transport → dispatch → journal → +//! translate → sink) can be exercised before any agent-specific translator +//! exists. + +use super::{AgentTranslator, SessionCtx, SpanOp, SpanRow, SpanType, TranslatorFactory}; +use crate::ids; +use crate::wire::Envelope; + +pub struct DebugTranslatorFactory; + +impl TranslatorFactory for DebugTranslatorFactory { + fn source(&self) -> &str { + "debug" + } + fn create(&self, session_id: &str) -> Box { + Box::new(DebugTranslator { + root_span_id: ids::span_id(session_id, "root"), + root_emitted: false, + event_seq: 0, + }) + } +} + +struct DebugTranslator { + root_span_id: String, + root_emitted: bool, + event_seq: u64, +} + +impl AgentTranslator for DebugTranslator { + fn handle(&mut self, event: &Envelope, ctx: &SessionCtx) -> anyhow::Result> { + let mut ops = Vec::new(); + + if !self.root_emitted { + self.root_emitted = true; + ops.push(SpanOp::Insert(SpanRow { + span_id: self.root_span_id.clone(), + root_span_id: self.root_span_id.clone(), + parent_span_ids: Vec::new(), + name: format!("{}: {}", event.source, ctx.session_id), + span_type: SpanType::Task, + start_ms: Some(event.ts_ms), + end_ms: None, + input: None, + output: None, + metadata: Some(serde_json::json!({ "session_id": ctx.session_id })), + metrics: None, + error: None, + tags: None, + })); + } + + let seq = self.event_seq; + self.event_seq += 1; + let span_id = ids::span_id(&ctx.session_id, &format!("event:{seq}")); + ops.push(SpanOp::Insert(SpanRow { + span_id, + root_span_id: self.root_span_id.clone(), + parent_span_ids: vec![self.root_span_id.clone()], + name: event.event.clone(), + span_type: SpanType::Tool, + start_ms: Some(event.ts_ms), + end_ms: Some(event.ts_ms), + input: Some(event.payload.clone()), + output: None, + metadata: Some(serde_json::json!({ "seq": seq, "source": event.source })), + metrics: None, + error: None, + tags: None, + })); + + Ok(ops) + } + + fn flush(&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 new file mode 100644 index 0000000..d376f38 --- /dev/null +++ b/bt-daemon/src/translate/mod.rs @@ -0,0 +1,148 @@ +//! Translators turn agent-native hook events into a sink-neutral span +//! representation ([`SpanOp`]). Each session gets its own stateful translator +//! instance (created by a [`TranslatorFactory`]); the state machine that pairs +//! start/stop events and builds the span tree lives inside that instance. +//! +//! Keeping the output ([`SpanRow`]) independent of the Braintrust SDK lets the +//! whole pipeline be exercised with a debug sink and makes translators unit- +//! testable without any network. + +mod claude; +mod codex; +mod debug; + +pub use claude::ClaudeTranslatorFactory; +pub use codex::CodexTranslatorFactory; +pub use debug::DebugTranslatorFactory; + +use crate::wire::{Envelope, SessionConfig}; +use serde::{Deserialize, Serialize}; +use std::collections::HashMap; + +/// Braintrust span kinds we emit. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum SpanType { + #[default] + Task, + Llm, + Tool, +} + +/// A resolved span row, ready for a sink to insert or merge. Field set is the +/// subset every current plugin uses; extend as translators need more. +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +pub struct SpanRow { + pub span_id: String, + pub root_span_id: String, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub parent_span_ids: Vec, + pub name: String, + pub span_type: SpanType, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub start_ms: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub end_ms: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub input: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub output: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub metadata: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub metrics: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub error: Option, + /// Labels for filtering in Braintrust (e.g. `compaction`, `permission-request`). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub tags: Option>, +} + +/// A span operation. `Insert` creates (or replaces) a row; `Merge` updates an +/// existing row by id (maps to `_is_merge` at the sink). Re-emitting an +/// `Insert` after journal replay merges server-side thanks to deterministic +/// ids, so replay is idempotent. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub enum SpanOp { + Insert(SpanRow), + Merge(SpanRow), +} + +/// Cross-cutting per-session context handed to the translator on each call. +/// The translator's own state lives in the translator instance; this carries +/// only what the dispatcher owns. +pub struct SessionCtx { + pub session_id: String, + /// Latest config seen for this session (auth, project, span-attach ids). + pub config: Option, +} + +/// 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>; + + /// Emit any pending spans (e.g. close dangling turns) at flush/shutdown. + fn flush(&mut self, ctx: &SessionCtx) -> anyhow::Result> { + let _ = ctx; + Ok(Vec::new()) + } +} + +/// Builds translator instances for a given `source`. +pub trait TranslatorFactory: Send + Sync { + fn source(&self) -> &str; + fn create(&self, session_id: &str) -> Box; +} + +/// Maps a `source` string to its factory, with a fallback for unknown sources. +pub struct Registry { + factories: HashMap>, + fallback: Box, +} + +impl Registry { + /// A registry whose only translator (and fallback) is the debug + /// pass-through. This is the Phase 1 default. + pub fn debug_only() -> Self { + let mut r = Registry { + factories: HashMap::new(), + fallback: Box::new(DebugTranslatorFactory), + }; + r.register(Box::new(DebugTranslatorFactory)); + r + } + + /// The production registry: all real agent translators registered, debug + /// as the fallback for unknown sources. + pub fn default_agents() -> Self { + let mut r = Registry::debug_only(); + r.register(Box::new(ClaudeTranslatorFactory)); + r.register(Box::new(CodexTranslatorFactory)); + r + } + + pub fn register(&mut self, factory: Box) { + self.factories.insert(factory.source().to_string(), factory); + } + + /// Known sources, for the `initialize` capabilities list. + pub fn sources(&self) -> Vec { + let mut v: Vec = self.factories.keys().cloned().collect(); + v.sort(); + 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) + } + } + } +} diff --git a/bt-daemon/src/transport.rs b/bt-daemon/src/transport.rs new file mode 100644 index 0000000..36bda78 --- /dev/null +++ b/bt-daemon/src/transport.rs @@ -0,0 +1,194 @@ +//! Local daemon transport. +//! +//! Unix hosts use a Unix-domain socket. Windows uses a byte-mode named pipe. +//! Both transports expose the same async byte stream to the JSON-lines RPC +//! layer, keeping framing and daemon behavior platform-independent. + +use std::future::Future; +use std::path::Path; +#[cfg(windows)] +use std::time::Duration; + +#[cfg(unix)] +pub(crate) type ClientStream = tokio::net::UnixStream; +#[cfg(windows)] +pub(crate) type ClientStream = tokio::net::windows::named_pipe::NamedPipeClient; + +#[cfg(unix)] +pub(crate) type ServerStream = tokio::net::UnixStream; +#[cfg(windows)] +pub(crate) type ServerStream = tokio::net::windows::named_pipe::NamedPipeServer; + +/// Connect to the local daemon. Windows retries briefly when all named-pipe +/// instances are occupied (`ERROR_PIPE_BUSY`) so normal concurrent hooks do +/// not spuriously conclude that the daemon is absent. +#[cfg(unix)] +pub(crate) async fn connect(endpoint: &Path) -> std::io::Result { + tokio::net::UnixStream::connect(endpoint).await +} + +#[cfg(windows)] +pub(crate) async fn connect(endpoint: &Path) -> std::io::Result { + use tokio::net::windows::named_pipe::ClientOptions; + + const ERROR_PIPE_BUSY: i32 = 231; + let mut last_busy = None; + for _ in 0..20 { + match ClientOptions::new().open(endpoint) { + Ok(stream) => return Ok(stream), + Err(error) if error.raw_os_error() == Some(ERROR_PIPE_BUSY) => { + last_busy = Some(error); + tokio::time::sleep(Duration::from_millis(10)).await; + } + Err(error) => return Err(error), + } + } + Err(last_busy.unwrap_or_else(|| { + std::io::Error::new(std::io::ErrorKind::WouldBlock, "named pipe remained busy") + })) +} + +#[cfg(unix)] +pub(crate) struct Listener { + inner: tokio::net::UnixListener, +} + +#[cfg(unix)] +impl Listener { + fn bind_raw(endpoint: &Path) -> std::io::Result { + Ok(Self { + inner: tokio::net::UnixListener::bind(endpoint)?, + }) + } + + pub(crate) async fn accept(&mut self) -> std::io::Result { + self.inner.accept().await.map(|(stream, _)| stream) + } +} + +#[cfg(windows)] +pub(crate) struct Listener { + endpoint: std::ffi::OsString, + next: tokio::net::windows::named_pipe::NamedPipeServer, +} + +#[cfg(windows)] +impl Listener { + /// Create the first server instance exclusively. This is the named-pipe + /// equivalent of binding a Unix socket and is what resolves daemon races. + fn bind_raw(endpoint: &Path) -> std::io::Result { + use tokio::net::windows::named_pipe::ServerOptions; + + let next = ServerOptions::new() + .first_pipe_instance(true) + .create(endpoint)?; + Ok(Self { + endpoint: endpoint.as_os_str().to_owned(), + next, + }) + } + + pub(crate) async fn accept(&mut self) -> std::io::Result { + use tokio::net::windows::named_pipe::ServerOptions; + + self.next.connect().await?; + // Install another listening instance before handing the connected + // stream to a task, avoiding a gap where concurrent hook clients see + // ERROR_PIPE_BUSY. + let next = ServerOptions::new().create(&self.endpoint)?; + Ok(std::mem::replace(&mut self.next, next)) + } +} + +/// Claim the daemon endpoint. Returns `None` when another healthy daemon +/// already owns it. +pub(crate) async fn claim( + endpoint: &Path, + mut probe_alive: F, +) -> anyhow::Result> +where + F: FnMut() -> Fut, + Fut: Future, +{ + #[cfg(unix)] + { + claim_unix(endpoint, &mut probe_alive).await + } + #[cfg(windows)] + { + claim_windows(endpoint, &mut probe_alive).await + } +} + +#[cfg(unix)] +async fn claim_unix( + endpoint: &Path, + probe_alive: &mut F, +) -> anyhow::Result> +where + F: FnMut() -> Fut, + Fut: Future, +{ + if endpoint.exists() { + if probe_alive().await { + return Ok(None); + } + cleanup(endpoint); + } + match Listener::bind_raw(endpoint) { + Ok(listener) => Ok(Some(listener)), + Err(error) if error.kind() == std::io::ErrorKind::AddrInUse => { + if probe_alive().await { + Ok(None) + } else { + cleanup(endpoint); + Ok(Some(Listener::bind_raw(endpoint)?)) + } + } + Err(error) => Err(error.into()), + } +} + +#[cfg(windows)] +async fn claim_windows( + endpoint: &Path, + probe_alive: &mut F, +) -> anyhow::Result> +where + F: FnMut() -> Fut, + Fut: Future, +{ + if probe_alive().await { + return Ok(None); + } + + // Named pipes have no stale filesystem node: the name is released when + // the last server handle closes. Retry briefly to cover a rival daemon + // winning the probe/create race or a just-terminated daemon unwinding. + let mut last_error = None; + for _ in 0..50 { + match Listener::bind_raw(endpoint) { + Ok(listener) => return Ok(Some(listener)), + Err(error) => { + last_error = Some(error); + if probe_alive().await { + return Ok(None); + } + tokio::time::sleep(Duration::from_millis(20)).await; + } + } + } + Err(last_error + .unwrap_or_else(|| std::io::Error::other("could not create named pipe")) + .into()) +} + +/// Unix socket nodes survive process death and need explicit cleanup. Windows +/// named-pipe names disappear automatically when their final handle closes. +#[cfg(unix)] +pub(crate) fn cleanup(endpoint: &Path) { + let _ = std::fs::remove_file(endpoint); +} + +#[cfg(windows)] +pub(crate) fn cleanup(_endpoint: &Path) {} diff --git a/bt-daemon/src/wire/envelope.rs b/bt-daemon/src/wire/envelope.rs new file mode 100644 index 0000000..ae50463 --- /dev/null +++ b/bt-daemon/src/wire/envelope.rs @@ -0,0 +1,231 @@ +//! The `event.log` envelope and its session config, plus auth redaction for +//! the journal. + +use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; + +/// One captured hook event, forwarded from a shim to the daemon. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Envelope { + /// Which daemon-side translator interprets `payload` (e.g. `codex`, + /// `claude-code`, `debug`). + pub source: String, + /// The agent version, for payload-drift handling. Optional. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub source_version: Option, + /// Per-session queue + state key. + pub session_id: String, + /// Agent-native hook event name (not normalized). + pub event: String, + /// Epoch milliseconds, stamped by the shim at capture time. + pub ts_ms: i64, + /// The raw agent-native hook payload; opaque except to the translator. + pub payload: serde_json::Value, + /// Shim-resolved credentials + trace settings. Present on every event from + /// a stateless shim; the daemon keeps the latest per session. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub config: Option, +} + +/// Trace settings and backend credentials resolved by the shim. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SessionConfig { + pub auth: BackendAuth, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub project: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub parent_span_id: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub root_span_id: Option, + #[serde(default)] + pub flush_mode: FlushMode, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub additional_metadata: Option, +} + +/// Backend credentials. `token` is an API key or an OAuth access token; the +/// daemon does not care which. Never persisted (see [`SessionConfig::redacted`]). +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct BackendAuth { + pub token: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub api_url: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub app_url: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub org_name: Option, + /// Optional org id. `bt` may or may not know it; the SDK's project + /// registration works from `org_name` alone, so this is best-effort and + /// only feeds the SDK's per-session credential/batch key. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub org_id: Option, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum FlushMode { + /// Deliver in the background; flush on session end / idle. The default. + #[default] + FireAndForget, + /// Additionally block on `session.flush` at each turn boundary. + FlushOnTurnEnd, +} + +/// A non-secret fingerprint of [`BackendAuth`], written to the journal in +/// place of the token so replay can detect a credential change without ever +/// persisting the secret. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct AuthFingerprint { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub api_url: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub app_url: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub org_name: Option, + /// First 12 hex chars of SHA-256(token). Enough to detect rotation, far + /// too little to recover the token. + pub token_sha256_prefix: String, +} + +impl BackendAuth { + pub fn fingerprint(&self) -> AuthFingerprint { + let digest = Sha256::digest(self.token.as_bytes()); + let hex = digest.iter().fold(String::with_capacity(64), |mut s, b| { + use std::fmt::Write; + let _ = write!(s, "{b:02x}"); + s + }); + AuthFingerprint { + api_url: self.api_url.clone(), + app_url: self.app_url.clone(), + org_name: self.org_name.clone(), + token_sha256_prefix: hex[..12].to_string(), + } + } +} + +impl Envelope { + /// A copy of this envelope safe to write to the journal: the live token is + /// replaced by an [`AuthFingerprint`]. The rest of `config` (project, + /// span-attach ids, flush mode, metadata) is retained — none of it secret. + pub fn redacted(&self) -> RedactedEnvelope { + RedactedEnvelope { + source: self.source.clone(), + source_version: self.source_version.clone(), + session_id: self.session_id.clone(), + event: self.event.clone(), + ts_ms: self.ts_ms, + payload: self.payload.clone(), + config: self.config.as_ref().map(|c| RedactedConfig { + auth: c.auth.fingerprint(), + project: c.project.clone(), + parent_span_id: c.parent_span_id.clone(), + root_span_id: c.root_span_id.clone(), + flush_mode: c.flush_mode, + additional_metadata: c.additional_metadata.clone(), + }), + } + } +} + +/// Journal form of [`Envelope`] with the token redacted. Deserializable so a +/// replay pass can read it back (and re-supply live credentials separately). +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct RedactedEnvelope { + pub source: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub source_version: Option, + pub session_id: String, + pub event: String, + pub ts_ms: i64, + pub payload: serde_json::Value, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub config: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct RedactedConfig { + pub auth: AuthFingerprint, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub project: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub parent_span_id: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub root_span_id: Option, + #[serde(default)] + pub flush_mode: FlushMode, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub additional_metadata: Option, +} + +#[cfg(test)] +mod tests { + use super::*; + + fn sample() -> Envelope { + Envelope { + source: "codex".into(), + source_version: Some("1.2.3".into()), + session_id: "sess-1".into(), + event: "PostToolUse".into(), + ts_ms: 1_753_639_552_123, + payload: serde_json::json!({ "session_id": "sess-1", "tool_name": "shell" }), + config: Some(SessionConfig { + auth: BackendAuth { + token: "sk-super-secret".into(), + api_url: Some("https://api.braintrust.dev".into()), + app_url: None, + org_name: Some("acme".into()), + org_id: None, + }, + project: Some("codex".into()), + parent_span_id: None, + root_span_id: None, + flush_mode: FlushMode::FireAndForget, + additional_metadata: None, + }), + } + } + + #[test] + fn envelope_round_trips() { + let e = sample(); + let s = serde_json::to_string(&e).unwrap(); + let back: Envelope = serde_json::from_str(&s).unwrap(); + assert_eq!(back.session_id, "sess-1"); + assert_eq!(back.config.unwrap().auth.token, "sk-super-secret"); + } + + #[test] + fn redaction_drops_the_token_but_keeps_settings() { + let e = sample(); + let r = e.redacted(); + let s = serde_json::to_string(&r).unwrap(); + assert!( + !s.contains("sk-super-secret"), + "token leaked into journal form: {s}" + ); + let cfg = r.config.unwrap(); + assert_eq!(cfg.project.as_deref(), Some("codex")); + assert_eq!(cfg.auth.org_name.as_deref(), Some("acme")); + assert_eq!(cfg.auth.token_sha256_prefix.len(), 12); + } + + #[test] + fn fingerprint_changes_with_token() { + let mut a = sample().config.unwrap().auth; + let f1 = a.fingerprint(); + a.token = "sk-different".into(); + let f2 = a.fingerprint(); + assert_ne!(f1.token_sha256_prefix, f2.token_sha256_prefix); + } + + #[test] + fn flush_mode_defaults_to_fire_and_forget() { + let json = serde_json::json!({ + "auth": { "token": "t" }, + }); + let cfg: SessionConfig = serde_json::from_value(json).unwrap(); + assert_eq!(cfg.flush_mode, FlushMode::FireAndForget); + } +} diff --git a/bt-daemon/src/wire/methods.rs b/bt-daemon/src/wire/methods.rs new file mode 100644 index 0000000..c4c0fbb --- /dev/null +++ b/bt-daemon/src/wire/methods.rs @@ -0,0 +1,94 @@ +//! Method names and their param/result types. + +use serde::{Deserialize, Serialize}; + +/// Method name constants — one source of truth for both sides. +pub mod method { + pub const INITIALIZE: &str = "initialize"; + pub const EVENT_LOG: &str = "event.log"; + pub const SESSION_FLUSH: &str = "session.flush"; + pub const STATUS_GET: &str = "status.get"; + pub const DAEMON_SHUTDOWN: &str = "daemon.shutdown"; +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct InitializeParams { + pub protocol_version: u32, + pub client: ClientInfo, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ClientInfo { + pub source: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub plugin_version: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub pid: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct InitializeResult { + pub protocol_version: u32, + pub daemon_version: String, + #[serde(default)] + pub capabilities: Capabilities, +} + +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +pub struct Capabilities { + #[serde(default)] + pub sources: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct EventLogResult { + pub accepted: bool, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct FlushParams { + pub session_id: String, + #[serde(default = "default_flush_timeout_ms")] + pub timeout_ms: u64, +} + +fn default_flush_timeout_ms() -> u64 { + 10_000 +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct FlushResult { + pub flushed: bool, + pub pending: u64, +} + +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +pub struct StatusParams { + /// Omit for daemon-wide status. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub session_id: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct StatusResult { + pub daemon_version: String, + pub uptime_ms: u64, + pub sessions: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SessionStatus { + pub session_id: String, + pub source: String, + pub queued: u64, + pub spans_emitted: u64, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub permalink: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub last_error: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ShutdownResult { + pub ok: bool, +} diff --git a/bt-daemon/src/wire/mod.rs b/bt-daemon/src/wire/mod.rs new file mode 100644 index 0000000..ff3e7c4 --- /dev/null +++ b/bt-daemon/src/wire/mod.rs @@ -0,0 +1,24 @@ +//! bt-daemon wire protocol: the envelope types and JSON-RPC framing shared by +//! the daemon (`serve`) and the plugin shims (`hook`). +//! +//! This module is pure data + (de)serialization — no I/O, no async. The +//! canonical description of the protocol lives in `docs/protocol.md`; keep +//! the two in sync. + +mod envelope; +mod methods; +mod rpc; + +pub use envelope::{ + AuthFingerprint, BackendAuth, Envelope, FlushMode, RedactedConfig, RedactedEnvelope, + SessionConfig, +}; +pub use methods::{ + method, Capabilities, ClientInfo, EventLogResult, FlushParams, FlushResult, InitializeParams, + InitializeResult, SessionStatus, ShutdownResult, StatusParams, StatusResult, +}; +pub use rpc::{error_code, Message, Request, RequestId, Response, RpcError}; + +/// The protocol version this build speaks. Bumped on any breaking change to +/// the envelope or method contracts. See `docs/protocol.md`. +pub const PROTOCOL_VERSION: u32 = 1; diff --git a/bt-daemon/src/wire/rpc.rs b/bt-daemon/src/wire/rpc.rs new file mode 100644 index 0000000..3a836ba --- /dev/null +++ b/bt-daemon/src/wire/rpc.rs @@ -0,0 +1,201 @@ +//! JSON-RPC 2.0 message types, newline-delimited on the wire. + +use serde::{Deserialize, Serialize}; + +/// A JSON-RPC request id: an integer or a string. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(untagged)] +pub enum RequestId { + Int(i64), + Str(String), +} + +/// A single JSON-RPC frame. Untagged so one type round-trips a request, a +/// response, or a notification; disambiguated by which fields are present. +/// +/// Note: `Response` must come before `Notification` in the enum. A response +/// carries `id` but no `method`; a notification carries `method` but no `id`; +/// a request carries both. Serde's untagged matching tries variants in order, +/// so ordering here plus `deny_unknown_fields`-free structs keeps them +/// unambiguous. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(untagged)] +pub enum Message { + Request(Request), + Response(Response), + Notification(Notification), +} + +impl Message { + /// Parse one newline-delimited frame. + pub fn from_line(line: &str) -> Result { + serde_json::from_str(line) + } + + /// Serialize to a single line (no trailing newline). + pub fn to_line(&self) -> Result { + serde_json::to_string(self) + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Request { + pub jsonrpc: JsonRpcV2, + pub id: RequestId, + pub method: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub params: Option, +} + +impl Request { + pub fn new(id: RequestId, method: impl Into, params: serde_json::Value) -> Self { + Self { + jsonrpc: JsonRpcV2, + id, + method: method.into(), + params: Some(params), + } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Notification { + pub jsonrpc: JsonRpcV2, + pub method: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub params: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Response { + pub jsonrpc: JsonRpcV2, + pub id: RequestId, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub result: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub error: Option, +} + +impl Response { + pub fn ok(id: RequestId, result: serde_json::Value) -> Self { + Self { + jsonrpc: JsonRpcV2, + id, + result: Some(result), + error: None, + } + } + + pub fn err(id: RequestId, error: RpcError) -> Self { + Self { + jsonrpc: JsonRpcV2, + id, + result: None, + error: Some(error), + } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct RpcError { + pub code: i32, + pub message: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub data: Option, +} + +impl RpcError { + pub fn new(code: i32, message: impl Into) -> Self { + Self { + code, + message: message.into(), + data: None, + } + } +} + +/// Reserved JSON-RPC error codes plus the application range. +pub mod error_code { + pub const PARSE: i32 = -32700; + pub const INVALID_REQUEST: i32 = -32600; + pub const METHOD_NOT_FOUND: i32 = -32601; + pub const INVALID_PARAMS: i32 = -32602; + pub const INTERNAL: i32 = -32603; + /// Application errors: -32000 ..= -32099. + pub const APP: i32 = -32000; +} + +/// A zero-sized marker that serializes to the string `"2.0"` and refuses any +/// other value, so the `jsonrpc` field is validated for free. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct JsonRpcV2; + +impl Serialize for JsonRpcV2 { + fn serialize(&self, s: S) -> Result { + s.serialize_str("2.0") + } +} + +impl<'de> Deserialize<'de> for JsonRpcV2 { + fn deserialize>(d: D) -> Result { + let v = String::deserialize(d)?; + if v == "2.0" { + Ok(JsonRpcV2) + } else { + Err(serde::de::Error::custom(format!( + "unsupported jsonrpc version {v:?}" + ))) + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn request_round_trips() { + let req = Request::new( + RequestId::Int(7), + "event.log", + serde_json::json!({ "a": 1 }), + ); + let line = Message::Request(req).to_line().unwrap(); + match Message::from_line(&line).unwrap() { + Message::Request(r) => { + assert_eq!(r.method, "event.log"); + assert_eq!(r.id, RequestId::Int(7)); + } + other => panic!("expected request, got {other:?}"), + } + } + + #[test] + fn response_and_notification_disambiguate() { + let resp = Message::Response(Response::ok(RequestId::Int(1), serde_json::json!({}))) + .to_line() + .unwrap(); + assert!(matches!( + Message::from_line(&resp).unwrap(), + Message::Response(_) + )); + + let note = Message::Notification(Notification { + jsonrpc: JsonRpcV2, + method: "event.log".into(), + params: Some(serde_json::json!({})), + }) + .to_line() + .unwrap(); + assert!(matches!( + Message::from_line(¬e).unwrap(), + Message::Notification(_) + )); + } + + #[test] + fn bad_jsonrpc_version_rejected() { + let line = r#"{"jsonrpc":"1.0","id":1,"method":"x"}"#; + assert!(Message::from_line(line).is_err()); + } +} diff --git a/bt-daemon/tests/braintrust_sink.rs b/bt-daemon/tests/braintrust_sink.rs new file mode 100644 index 0000000..d36dbf8 --- /dev/null +++ b/bt-daemon/tests/braintrust_sink.rs @@ -0,0 +1,353 @@ +//! Phase 2: the Braintrust sink actually delivers spans. Runs against a +//! wiremock stand-in for the Braintrust backend (the endpoints the SDK hits +//! with `skip_login`: GET /version, POST /api/project/register, POST /logs3). + +use bt_daemon::wire::{BackendAuth, FlushMode, SessionConfig}; +use bt_daemon::{ + BraintrustSinkConfig, BraintrustSinkFactory, SinkFactory, SpanOp, SpanRow, SpanType, +}; +use serde_json::json; +use wiremock::matchers::{method, path}; +use wiremock::{Mock, MockServer, ResponseTemplate}; + +fn session_config(base: &str) -> SessionConfig { + SessionConfig { + auth: BackendAuth { + token: "sk-test".into(), + api_url: Some(base.to_string()), + app_url: Some(base.to_string()), + org_name: Some("acme".into()), + org_id: None, + }, + project: Some("my-project".into()), + parent_span_id: None, + root_span_id: None, + flush_mode: FlushMode::FireAndForget, + additional_metadata: None, + } +} + +fn row( + span_id: &str, + root: &str, + parents: &[&str], + name: &str, + ty: SpanType, + start: i64, + end: Option, +) -> SpanRow { + SpanRow { + span_id: span_id.into(), + root_span_id: root.into(), + parent_span_ids: parents.iter().map(|s| s.to_string()).collect(), + name: name.into(), + span_type: ty, + start_ms: Some(start), + end_ms: end, + input: None, + output: None, + metadata: None, + metrics: None, + error: None, + tags: None, + } +} + +/// Mount the three endpoints the SDK hits under `skip_login`. +async fn mock_backend() -> MockServer { + let server = MockServer::start().await; + Mock::given(method("GET")) + .and(path("/version")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({}))) + .mount(&server) + .await; + Mock::given(method("POST")) + .and(path("/api/project/register")) + .respond_with( + ResponseTemplate::new(200).set_body_json(json!({ "project": { "id": "proj-1" } })), + ) + .mount(&server) + .await; + Mock::given(method("POST")) + .and(path("/logs3")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({}))) + .mount(&server) + .await; + server +} + +async fn logs3_bodies(server: &MockServer) -> String { + server + .received_requests() + .await + .unwrap() + .iter() + .filter(|r| r.url.path() == "/logs3") + .map(|r| String::from_utf8_lossy(&r.body).into_owned()) + .collect::>() + .join("\n") +} + +/// Two sessions on two different backend URLs, from one factory, each deliver +/// only to their own collector — the per-`(api_url, app_url)` client cache. +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn multi_profile_sessions_route_to_their_own_backend() { + let server_a = mock_backend().await; + let server_b = mock_backend().await; + let (base_a, base_b) = (server_a.uri(), server_b.uri()); + + // No daemon-level default URLs: each session brings its own (as bt does). + let factory = BraintrustSinkFactory::new(BraintrustSinkConfig { + api_url: None, + app_url: None, + version: "test".into(), + }); + + let mut sink_a = factory.create("sess-a", "codex").unwrap(); + sink_a.configure(&session_config(&base_a)); + sink_a + .emit(&[SpanOp::Insert(row( + "span-A", + "span-A", + &[], + "A", + SpanType::Task, + 1, + Some(2), + ))]) + .await + .unwrap(); + sink_a.flush().await.unwrap(); + + let mut sink_b = factory.create("sess-b", "codex").unwrap(); + sink_b.configure(&session_config(&base_b)); + sink_b + .emit(&[SpanOp::Insert(row( + "span-B", + "span-B", + &[], + "B", + SpanType::Task, + 1, + Some(2), + ))]) + .await + .unwrap(); + sink_b.flush().await.unwrap(); + + let a = logs3_bodies(&server_a).await; + let b = logs3_bodies(&server_b).await; + assert!(a.contains("span-A"), "server A missing its span"); + assert!(!a.contains("span-B"), "server A leaked session B's span"); + assert!(b.contains("span-B"), "server B missing its span"); + assert!(!b.contains("span-A"), "server B leaked session A's span"); +} + +/// Regression: an `Insert` that names a span, followed by a `Merge` that +/// doesn't (the common "close/annotate" pattern, which builds `SpanRow` with +/// `..Default::default()` and an empty `name`), must not clobber the name. +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn merge_with_empty_name_does_not_clobber_the_original_name() { + let server = mock_backend().await; + let base = server.uri(); + let factory = BraintrustSinkFactory::new(BraintrustSinkConfig { + api_url: Some(base.clone()), + app_url: Some(base.clone()), + version: "test".into(), + }); + let mut sink = factory.create("sess-1", "codex").unwrap(); + sink.configure(&session_config(&base)); + + let named = row("s1", "s1", &[], "codex: myapp", SpanType::Task, 1, None); + let mut closing = row("s1", "s1", &[], "", SpanType::Task, 1, Some(2)); + closing.name = String::new(); // as produced by `..Default::default()` + + sink.emit(&[SpanOp::Insert(named), SpanOp::Merge(closing)]) + .await + .unwrap(); + sink.flush().await.unwrap(); + + let bodies = logs3_bodies(&server).await; + assert!( + bodies.contains("codex: myapp"), + "name lost after merge: {bodies}" + ); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn attached_trace_children_keep_the_external_root() { + let server = mock_backend().await; + let base = server.uri(); + let factory = BraintrustSinkFactory::new(BraintrustSinkConfig { + api_url: Some(base.clone()), + app_url: Some(base.clone()), + version: "test".into(), + }); + let mut sink = factory.create("sess-1", "codex").unwrap(); + let mut config = session_config(&base); + config.parent_span_id = Some("external-parent".into()); + config.root_span_id = Some("external-root".into()); + sink.configure(&config); + sink.emit(&[SpanOp::Insert(row( + "child", + "daemon-internal-root", + &["daemon-parent"], + "tool", + SpanType::Tool, + 1, + Some(2), + ))]) + .await + .unwrap(); + sink.flush().await.unwrap(); + + let bodies = logs3_bodies(&server).await; + assert!( + bodies.contains("external-root"), + "child lost attached trace root: {bodies}" + ); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn braintrust_sink_delivers_spans_to_collector() { + let server = MockServer::start().await; + Mock::given(method("GET")) + .and(path("/version")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({}))) + .mount(&server) + .await; + Mock::given(method("POST")) + .and(path("/api/project/register")) + .respond_with( + ResponseTemplate::new(200).set_body_json(json!({ "project": { "id": "proj-1" } })), + ) + .mount(&server) + .await; + Mock::given(method("POST")) + .and(path("/logs3")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({}))) + .mount(&server) + .await; + + let base = server.uri(); + let factory = BraintrustSinkFactory::new(BraintrustSinkConfig { + api_url: Some(base.clone()), + app_url: Some(base.clone()), + version: "test".into(), + }); + + let mut sink = factory.create("sess-1", "codex").unwrap(); + sink.configure(&session_config(&base)); + + // A session root (task) and a child tool span under it. + let root = row( + "rootspan1", + "rootspan1", + &[], + "codex: sess-1", + SpanType::Task, + 1000, + None, + ); + let tool = row( + "toolspan1", + "rootspan1", + &["rootspan1"], + "shell", + SpanType::Tool, + 1001, + Some(1002), + ); + let mut tool = tool; + tool.input = Some(json!({ "command": "ls" })); + tool.output = Some(json!("ok")); + + sink.emit(&[SpanOp::Insert(root), SpanOp::Insert(tool)]) + .await + .unwrap(); + sink.flush().await.unwrap(); + + let requests = server.received_requests().await.unwrap(); + let logs: Vec<_> = requests + .iter() + .filter(|r| r.url.path() == "/logs3") + .collect(); + assert!(!logs.is_empty(), "expected at least one POST /logs3"); + + let bodies: String = logs + .iter() + .map(|r| String::from_utf8_lossy(&r.body).into_owned()) + .collect::>() + .join("\n"); + + // Both spans, our deterministic ids, and the parent linkage made it into a + // logs3 payload. + assert!( + bodies.contains("rootspan1"), + "root span id missing from logs3 body" + ); + assert!( + bodies.contains("toolspan1"), + "tool span id missing from logs3 body" + ); + assert!(bodies.contains("codex: sess-1"), "root span name missing"); + assert!(bodies.contains("\"command\""), "tool input missing"); + + // Project registration happened (org_name path, no login). + assert!( + requests + .iter() + .any(|r| r.url.path() == "/api/project/register"), + "expected project registration" + ); + // skip_login: no apikey login call. + assert!( + !requests.iter().any(|r| r.url.path() == "/api/apikey/login"), + "should not have called login with skip_login" + ); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn experiment_sessions_use_experiment_object_type_and_id() { + let server = mock_backend().await; + let base = server.uri(); + let factory = BraintrustSinkFactory::new(BraintrustSinkConfig { + api_url: Some(base.clone()), + app_url: Some(base.clone()), + version: "test".into(), + }); + let mut sink = factory.create("sess-exp", "claude-code").unwrap(); + let mut config = session_config(&base); + config.additional_metadata = Some(json!({"_bt_experiment_id":"exp-42"})); + sink.configure(&config); + sink.emit(&[ + SpanOp::Insert(row( + "exp-root", + "exp-root", + &[], + "Claude Code", + SpanType::Task, + 1, + None, + )), + SpanOp::Insert(row( + "exp-child", + "exp-root", + &["exp-root"], + "Turn 1", + SpanType::Task, + 2, + Some(3), + )), + ]) + .await + .unwrap(); + sink.flush().await.unwrap(); + + let bodies = logs3_bodies(&server).await; + assert!(bodies.contains("exp-42"), "experiment id absent: {bodies}"); + assert!( + !bodies.contains("\"project_id\""), + "experiment spans were routed as project logs: {bodies}" + ); +} diff --git a/bt-daemon/tests/claude_translator.rs b/bt-daemon/tests/claude_translator.rs new file mode 100644 index 0000000..51fe3fc --- /dev/null +++ b/bt-daemon/tests/claude_translator.rs @@ -0,0 +1,518 @@ +use bt_daemon::wire::Envelope; +use bt_daemon::{Registry, SessionCtx, SpanOp, SpanRow, SpanType}; +use serde_json::{json, Value}; +use std::collections::HashMap; +use std::path::{Path, PathBuf}; + +fn fixture(name: &str) -> PathBuf { + Path::new(env!("CARGO_MANIFEST_DIR")) + .join("../src/plugins/claude/content/plugins/trace-claude-code/test/fixtures/sessions") + .join(name) +} + +fn replay(name: &str) -> Vec { + let dir = fixture(name); + let contents = std::fs::read_to_string(dir.join("events.ndjson")).unwrap(); + let first: Value = serde_json::from_str(contents.lines().next().unwrap()).unwrap(); + let session_id = first["payload"]["session_id"].as_str().unwrap(); + let registry = Registry::default_agents(); + let mut translator = registry.create("claude-code", session_id); + let ctx = SessionCtx { + session_id: session_id.to_string(), + config: None, + }; + let mut ops = Vec::new(); + + for line in contents.lines() { + let record: Value = serde_json::from_str(line).unwrap(); + let mut payload = record["payload"].clone(); + for field in ["transcript_path", "agent_transcript_path"] { + let Some(original) = payload.get(field).and_then(Value::as_str) else { + continue; + }; + let basename = Path::new(original).file_name().unwrap(); + let local = dir.join("transcripts").join(basename); + if local.exists() { + payload[field] = json!(local.to_str().unwrap()); + payload["_bt_transcript_snapshot"] = json!({ + "path": local.to_str().unwrap(), + "contents": std::fs::read_to_string(&local).unwrap() + }); + } + } + let ts_ms = chrono::DateTime::parse_from_rfc3339(record["ts"].as_str().unwrap()) + .unwrap() + .timestamp_millis(); + let env = Envelope { + source: "claude-code".into(), + source_version: None, + session_id: session_id.into(), + event: record["hook"].as_str().unwrap().into(), + ts_ms, + payload, + config: None, + }; + ops.extend(translator.handle(&env, &ctx).unwrap()); + } + ops.extend(translator.flush(&ctx).unwrap()); + ops +} + +fn reduce(ops: Vec) -> HashMap { + let mut rows = HashMap::::new(); + for op in ops { + match op { + SpanOp::Insert(row) => { + rows.insert(row.span_id.clone(), row); + } + SpanOp::Merge(update) => { + let row = rows.entry(update.span_id.clone()).or_default(); + if update.end_ms.is_some() { + row.end_ms = update.end_ms; + } + if update.output.is_some() { + row.output = update.output; + } + if update.metadata.is_some() { + row.metadata = update.metadata; + } + if update.error.is_some() { + row.error = update.error; + } + } + } + } + rows +} + +#[test] +fn claude_real_fixture_matches_session_turn_tool_and_token_contract() { + let rows = reduce(replay("test-fixture")); + let roots: Vec<_> = rows + .values() + .filter(|row| row.name.starts_with("Claude Code:")) + .collect(); + let turns: Vec<_> = rows + .values() + .filter(|row| row.name.starts_with("Turn ")) + .collect(); + let tools: Vec<_> = rows + .values() + .filter(|row| row.span_type == SpanType::Tool) + .collect(); + let llms: Vec<_> = rows + .values() + .filter(|row| row.span_type == SpanType::Llm) + .collect(); + + assert_eq!(roots.len(), 1); + assert_eq!(turns.len(), 4); + assert_eq!(tools.len(), 13); + assert_eq!(llms.len(), 7, "one LLM span per unique requestId"); + assert!(turns.iter().all(|turn| turn.end_ms.is_some())); + assert!(tools.iter().all(|tool| { + tool.metadata.as_ref().and_then(|m| m.get("tool_approval")) == Some(&json!("approved")) + })); + + let total = |key: &str| -> u64 { + llms.iter() + .map(|row| { + row.metrics + .as_ref() + .and_then(|m| m.get(key)) + .and_then(Value::as_u64) + .unwrap_or(0) + }) + .sum() + }; + assert_eq!(total("prompt_tokens"), 187_216); + assert_eq!(total("completion_tokens"), 1_867); + assert_eq!(total("prompt_cached_tokens"), 165_784); + assert_eq!(total("tokens"), 189_083); + + let mut llms_per_turn = turns + .iter() + .map(|turn| { + ( + turn.name.clone(), + llms.iter() + .filter(|llm| llm.parent_span_ids.first() == Some(&turn.span_id)) + .count(), + ) + }) + .collect::>(); + llms_per_turn.sort(); + assert_eq!( + llms_per_turn, + vec![ + ("Turn 1".into(), 2), + ("Turn 2".into(), 1), + ("Turn 3".into(), 3), + ("Turn 4".into(), 1), + ], + "late transcript rows must remain attached to the turn that produced them" + ); + assert!(llms.iter().any(|llm| { + let roles = llm + .input + .as_ref() + .and_then(Value::as_array) + .into_iter() + .flatten() + .filter_map(|message| message.get("role").and_then(Value::as_str)) + .collect::>(); + roles.contains(&"assistant") && roles.contains(&"tool") + })); + assert!(llms.iter().any(|llm| { + llm.output + .as_ref() + .and_then(|output| output.get("tool_calls")) + .and_then(Value::as_array) + .into_iter() + .flatten() + .any(|call| { + call.pointer("/function/arguments") + .is_some_and(Value::is_string) + }) + })); +} + +#[test] +fn claude_subagent_fixture_builds_nested_subagent_llms() { + let rows = reduce(replay("subagent-compact")); + let subagents: Vec<_> = rows + .values() + .filter(|row| row.name.starts_with("subagent:")) + .collect(); + assert!(subagents.len() >= 2); + assert!(subagents.iter().all(|row| row.end_ms.is_some())); + + let subagent_ids: Vec<_> = subagents.iter().map(|row| row.span_id.as_str()).collect(); + let nested_llms: Vec<_> = rows + .values() + .filter(|row| { + row.span_type == SpanType::Llm + && row + .parent_span_ids + .first() + .is_some_and(|parent| subagent_ids.contains(&parent.as_str())) + }) + .collect(); + assert!( + !nested_llms.is_empty(), + "subagent transcripts should produce LLM children" + ); + let nested_tools = rows + .values() + .filter(|row| { + row.span_type == SpanType::Tool + && row + .parent_span_ids + .first() + .is_some_and(|parent| subagent_ids.contains(&parent.as_str())) + }) + .count(); + assert!( + nested_tools >= 20, + "subagent hook tools should be children of their subagent task" + ); +} + +#[test] +fn claude_permission_denied_and_failed_tools_are_first_class_spans() { + let registry = Registry::default_agents(); + let mut translator = registry.create("claude-code", "s"); + let ctx = SessionCtx { + session_id: "s".into(), + config: None, + }; + let event = |name: &str, payload: Value| Envelope { + source: "claude-code".into(), + source_version: None, + session_id: "s".into(), + event: name.into(), + ts_ms: 1, + payload, + config: None, + }; + let mut ops = translator + .handle( + &event( + "UserPromptSubmit", + json!({"session_id":"s","cwd":"/tmp/x","prompt":"go"}), + ), + &ctx, + ) + .unwrap(); + ops.extend( + translator + .handle( + &event( + "PermissionDenied", + json!({ + "session_id":"s", + "tool_name":"Bash", + "tool_use_id":"a", + "tool_input":{"command":"no"}, + "permission":{"id":"p1","type":"tool","title":"Run command"} + }), + ), + &ctx, + ) + .unwrap(), + ); + ops.extend( + translator + .handle( + &event( + "PostToolUseFailure", + json!({"session_id":"s","tool_name":"Read","tool_use_id":"b","tool_input":{"file_path":"x"},"error":"missing"}), + ), + &ctx, + ) + .unwrap(), + ); + let rows = reduce(ops); + let tools: Vec<_> = rows + .values() + .filter(|row| row.span_type == SpanType::Tool) + .collect(); + assert_eq!(tools.len(), 2); + assert!(tools + .iter() + .any(|row| { row.metadata.as_ref().unwrap()["tool_approval"] == json!("denied") })); + let denied = tools + .iter() + .find(|row| row.metadata.as_ref().unwrap()["tool_approval"] == json!("denied")) + .unwrap(); + assert_eq!( + denied.metadata.as_ref().unwrap()["permission_id"], + json!("p1") + ); + assert_eq!( + denied.metadata.as_ref().unwrap()["permission_title"], + json!("Run command") + ); + assert!(tools + .iter() + .any(|row| row.error.as_deref() == Some("missing"))); +} + +#[test] +fn claude_pairs_tool_lifecycle_and_marks_explicit_skills_and_stop_failures() { + let registry = Registry::default_agents(); + let mut translator = registry.create("claude-code", "lifecycle"); + let ctx = SessionCtx { + session_id: "lifecycle".into(), + config: None, + }; + let event = |name: &str, ts_ms: i64, payload: Value| Envelope { + source: "claude-code".into(), + source_version: Some("2.0.0".into()), + session_id: "lifecycle".into(), + event: name.into(), + ts_ms, + payload, + config: None, + }; + let mut ops = Vec::new(); + for envelope in [ + event( + "UserPromptSubmit", + 10, + json!({"session_id":"lifecycle","cwd":"/tmp/x","prompt":"go"}), + ), + event( + "PreToolUse", + 20, + json!({"session_id":"lifecycle","tool_name":"Skill","tool_use_id":"skill-1","tool_input":{"skill":"review"}}), + ), + event( + "PostToolUse", + 30, + json!({"session_id":"lifecycle","tool_name":"Skill","tool_use_id":"skill-1","tool_input":{"skill":"review"},"tool_response":{"output":"loaded"}}), + ), + event( + "StopFailure", + 40, + json!({"session_id":"lifecycle","error":"model process exited"}), + ), + ] { + ops.extend(translator.handle(&envelope, &ctx).unwrap()); + } + let rows = reduce(ops); + let skill = rows + .values() + .find(|row| row.span_type == SpanType::Tool) + .unwrap(); + assert_eq!(skill.start_ms, Some(20)); + assert_eq!(skill.end_ms, Some(30)); + assert_eq!(skill.error, None); + assert_eq!( + skill.metadata.as_ref().unwrap()["skill_load_trigger"], + json!("explicit") + ); + let turn = rows.values().find(|row| row.name == "Turn 1").unwrap(); + assert_eq!(turn.error.as_deref(), Some("model process exited")); +} + +#[test] +fn claude_groups_streamed_rows_and_reads_late_final_output_at_session_end() { + let base = chrono::DateTime::parse_from_rfc3339("2026-07-28T16:00:00Z") + .unwrap() + .timestamp_millis(); + let dir = tempfile::tempdir().unwrap(); + let transcript = dir.path().join("session.jsonl"); + let usage = json!({ + "input_tokens": 10, + "output_tokens": 5, + "cache_creation_input_tokens": 0, + "cache_read_input_tokens": 0 + }); + let records = [ + json!({ + "type": "user", + "uuid": "user-1", + "timestamp": "2026-07-28T16:00:00Z", + "message": {"role": "user", "content": "run it"} + }), + json!({ + "type": "assistant", + "uuid": "assistant-thinking-row", + "timestamp": "2026-07-28T16:00:01Z", + "message": { + "id": "msg-native-request", + "model": "claude-test", + "role": "assistant", + "content": [{"type": "thinking", "thinking": ""}], + "usage": usage + } + }), + json!({ + "type": "assistant", + "uuid": "assistant-tool-row", + "timestamp": "2026-07-28T16:00:02Z", + "message": { + "id": "msg-native-request", + "model": "claude-test", + "role": "assistant", + "content": [{ + "type": "tool_use", + "id": "tool-1", + "name": "Bash", + "input": {"command": "true"} + }], + "usage": usage + } + }), + ]; + std::fs::write( + &transcript, + records + .iter() + .map(Value::to_string) + .collect::>() + .join("\n"), + ) + .unwrap(); + + let registry = Registry::default_agents(); + let mut translator = registry.create("claude-code", "streamed"); + let ctx = SessionCtx { + session_id: "streamed".into(), + config: None, + }; + let event = |name: &str, ts_ms: i64, payload: Value| Envelope { + source: "claude-code".into(), + source_version: None, + session_id: "streamed".into(), + event: name.into(), + ts_ms, + payload, + config: None, + }; + let mut ops = translator + .handle( + &event( + "UserPromptSubmit", + base, + json!({"session_id":"streamed","cwd":"/tmp/x","prompt":"run it"}), + ), + &ctx, + ) + .unwrap(); + ops.extend( + translator + .handle( + &event( + "Stop", + base + 2_500, + json!({ + "session_id": "streamed", + "transcript_path": transcript, + "last_assistant_message": "" + }), + ), + &ctx, + ) + .unwrap(), + ); + + let final_record = json!({ + "type": "assistant", + "uuid": "assistant-final-row", + "timestamp": "2026-07-28T16:00:03Z", + "message": { + "id": "msg-final-request", + "model": "claude-test", + "role": "assistant", + "content": [{"type": "text", "text": "done"}], + "usage": { + "input_tokens": 11, + "output_tokens": 1, + "cache_creation_input_tokens": 0, + "cache_read_input_tokens": 0 + } + } + }); + let previous = std::fs::read_to_string(&transcript).unwrap(); + std::fs::write(&transcript, format!("{previous}\n{final_record}")).unwrap(); + ops.extend( + translator + .handle( + &event( + "SessionEnd", + base + 4_000, + json!({ + "session_id": "streamed", + "transcript_path": transcript + }), + ), + &ctx, + ) + .unwrap(), + ); + + let rows = reduce(ops); + let llms = rows + .values() + .filter(|row| row.span_type == SpanType::Llm) + .collect::>(); + assert_eq!(llms.len(), 2); + let streamed = llms + .iter() + .find(|row| row.metadata.as_ref().unwrap()["request_id"] == json!("msg-native-request")) + .unwrap(); + assert_eq!( + streamed.output.as_ref().unwrap()["tool_calls"][0]["id"], + json!("tool-1") + ); + let final_output = llms + .iter() + .find(|row| row.metadata.as_ref().unwrap()["request_id"] == json!("msg-final-request")) + .unwrap(); + assert_eq!( + final_output.output.as_ref().unwrap()["content"], + json!("done") + ); +} diff --git a/bt-daemon/tests/codex_translator.rs b/bt-daemon/tests/codex_translator.rs new file mode 100644 index 0000000..689f770 --- /dev/null +++ b/bt-daemon/tests/codex_translator.rs @@ -0,0 +1,839 @@ +//! Phase 3 core: the Codex translator turns a transcript ("rollout" JSONL) plus +//! hook triggers into a session → turn → {llm, tool} span tree. Mirrors the +//! happy-path shape of the TS `event-processor` tests. + +use bt_daemon::wire::{BackendAuth, Envelope, FlushMode, SessionConfig}; +use bt_daemon::{Registry, SessionCtx, SpanOp, SpanRow, SpanType}; +use serde_json::{json, Value}; +use std::collections::HashMap; +use std::io::Write; + +fn line(v: Value) -> String { + serde_json::to_string(&v).unwrap() +} + +/// Write the full happy-path transcript to `path`. +fn write_transcript(path: &std::path::Path) { + let records = vec![ + json!({ "timestamp": "2026-01-01T00:00:01Z", "type": "session_meta", + "payload": { "id": "session-1", "cwd": "/whatever/myapp", "cli_version": "1.2.3" } }), + json!({ "timestamp": "2026-01-01T00:00:02Z", "type": "turn_context", + "payload": { "model": "gpt-5.5" } }), + json!({ "timestamp": "2026-01-01T00:00:03Z", "type": "event_msg", + "payload": { "type": "task_started", "turn_id": "t1" } }), + json!({ "timestamp": "2026-01-01T00:00:04Z", "type": "event_msg", + "payload": { "type": "user_message", "message": "list the files" } }), + json!({ "timestamp": "2026-01-01T00:00:05Z", "type": "response_item", + "payload": { "type": "reasoning", + "summary": [{ "type": "summary_text", "text": "I'll run ls" }], + "encrypted_content": "opaque" } }), + json!({ "timestamp": "2026-01-01T00:00:06Z", "type": "response_item", + "payload": { "type": "message", "role": "assistant", + "content": [{ "type": "output_text", "text": "Running ls." }] } }), + json!({ "timestamp": "2026-01-01T00:00:07Z", "type": "response_item", + "payload": { "type": "function_call", "call_id": "c1", "name": "shell", + "arguments": "{\"command\":\"ls\"}", "metadata": { "turn_id": "t1" } } }), + json!({ "timestamp": "2026-01-01T00:00:08Z", "type": "event_msg", + "payload": { "type": "token_count", + "info": { "last_token_usage": { "input_tokens": 100, "output_tokens": 20, "total_tokens": 120 } } } }), + json!({ "timestamp": "2026-01-01T00:00:09Z", "type": "response_item", + "payload": { "type": "function_call_output", "call_id": "c1", "output": "README.md\nsrc" } }), + json!({ "timestamp": "2026-01-01T00:00:10Z", "type": "event_msg", + "payload": { "type": "task_complete", "last_agent_message": "Here are the files." } }), + ]; + let mut f = std::fs::File::create(path).unwrap(); + for r in records { + writeln!(f, "{}", line(r)).unwrap(); + } +} + +fn envelope(session: &str, event: &str, transcript_path: &str, extra: Value) -> Envelope { + let mut payload = json!({ "session_id": session, "hook_event_name": event, "transcript_path": transcript_path }); + if let (Value::Object(p), Value::Object(e)) = (&mut payload, &extra) { + for (k, v) in e { + p.insert(k.clone(), v.clone()); + } + } + Envelope { + source: "codex".into(), + source_version: None, + session_id: session.into(), + event: event.into(), + ts_ms: 0, + payload, + config: None, + } +} + +/// Reduce a stream of span ops into final rows, applying merges by span_id. +fn reduce(ops: Vec) -> HashMap { + let mut map: HashMap = HashMap::new(); + for op in ops { + match op { + SpanOp::Insert(r) => { + map.insert(r.span_id.clone(), r); + } + SpanOp::Merge(r) => { + let e = map.entry(r.span_id.clone()).or_insert_with(|| r.clone()); + if r.end_ms.is_some() { + e.end_ms = r.end_ms; + } + if r.output.is_some() { + e.output = r.output.clone(); + } + if r.input.is_some() { + e.input = r.input.clone(); + } + if r.metrics.is_some() { + e.metrics = r.metrics.clone(); + } + if r.error.is_some() { + e.error = r.error.clone(); + } + if !r.parent_span_ids.is_empty() { + e.parent_span_ids = r.parent_span_ids.clone(); + } + if !r.name.is_empty() { + e.name = r.name.clone(); + } + if let Some(t) = &r.tags { + e.tags = Some(t.clone()); + } + // Merge metadata objects key-by-key. + if let Some(Value::Object(incoming)) = &r.metadata { + let base = match e.metadata.take() { + Some(Value::Object(m)) => m, + _ => serde_json::Map::new(), + }; + let mut merged = base; + for (k, v) in incoming { + merged.insert(k.clone(), v.clone()); + } + e.metadata = Some(Value::Object(merged)); + } + } + } + } + map +} + +fn find<'a>(rows: &'a HashMap, ty: SpanType, name: &str) -> &'a SpanRow { + rows.values() + .find(|r| r.span_type == ty && r.name == name) + .unwrap_or_else(|| { + panic!( + "no {ty:?} span named {name:?}; have: {:?}", + rows.values() + .map(|r| (&r.name, r.span_type)) + .collect::>() + ) + }) +} + +#[test] +fn codex_happy_path_builds_session_turn_llm_tool_tree() { + let tmp = tempfile::tempdir().unwrap(); + let transcript = tmp.path().join("rollout.jsonl"); + write_transcript(&transcript); + let tpath = transcript.to_str().unwrap(); + + let reg = Registry::default_agents(); + let mut tr = reg.create("codex", "sess-1"); + let ctx = SessionCtx { + session_id: "sess-1".into(), + config: None, + }; + + let mut ops = Vec::new(); + // SessionStart carries source/permission_mode and triggers the first read. + ops.extend( + tr.handle( + &envelope( + "sess-1", + "SessionStart", + tpath, + json!({ "source": "startup", "permission_mode": "auto" }), + ), + &ctx, + ) + .unwrap(), + ); + // A later trigger (Stop) — nothing new in the transcript here. + ops.extend( + tr.handle(&envelope("sess-1", "Stop", tpath, json!({})), &ctx) + .unwrap(), + ); + ops.extend(tr.flush(&ctx).unwrap()); + + let rows = reduce(ops); + + // Root (session). + let root = find(&rows, SpanType::Task, "codex: myapp"); + assert!( + root.parent_span_ids.is_empty(), + "root should have no parent" + ); + let md = root.metadata.as_ref().unwrap(); + assert_eq!(md["session_id"], json!("session-1")); + assert_eq!( + md["model"], + json!("gpt-5.5"), + "model backfilled from turn_context" + ); + assert_eq!(md["source"], json!("startup")); + assert_eq!(md["permission_mode"], json!("auto")); + + // Turn. + let turn = find(&rows, SpanType::Task, "turn: t1"); + assert_eq!(turn.parent_span_ids, vec![root.span_id.clone()]); + assert_eq!(turn.input, Some(json!("list the files"))); + assert_eq!(turn.output, Some(json!("Here are the files."))); + assert!( + turn.end_ms.is_some(), + "turn should be closed by task_complete" + ); + + // LLM span under the turn, with token metrics. + let llm = find(&rows, SpanType::Llm, "gpt-5.5"); + assert_eq!(llm.parent_span_ids, vec![turn.span_id.clone()]); + assert!(llm.end_ms.is_some(), "llm closed by token_count"); + let m = llm.metrics.as_ref().unwrap(); + assert_eq!(m["prompt_tokens"], json!(100.0)); + assert_eq!(m["completion_tokens"], json!(20.0)); + assert_eq!(m["tokens"], json!(120.0)); + assert_eq!( + llm.output.as_ref().unwrap()[0]["summary"][0], + json!({ "type": "summary_text", "text": "I'll run ls" }) + ); + + // Tool span under the turn. + let tool = find(&rows, SpanType::Tool, "shell"); + assert_eq!(tool.parent_span_ids, vec![turn.span_id.clone()]); + assert_eq!(tool.input, Some(json!("{\"command\":\"ls\"}"))); + assert_eq!(tool.output, Some(json!("README.md\nsrc"))); + assert!(tool.end_ms.is_some(), "tool closed by function_call_output"); + + // Exactly one of each in this trace. + assert_eq!( + rows.values() + .filter(|r| r.span_type == SpanType::Task) + .count(), + 2 + ); + assert_eq!( + rows.values() + .filter(|r| r.span_type == SpanType::Llm) + .count(), + 1 + ); + assert_eq!( + rows.values() + .filter(|r| r.span_type == SpanType::Tool) + .count(), + 1 + ); +} + +#[test] +fn codex_incremental_reads_advance_offset() { + // Two reads: the second only sees records appended after the first. + let tmp = tempfile::tempdir().unwrap(); + let transcript = tmp.path().join("rollout.jsonl"); + let tpath = transcript.to_str().unwrap(); + + let reg = Registry::default_agents(); + let mut tr = reg.create("codex", "s"); + let ctx = SessionCtx { + session_id: "s".into(), + config: None, + }; + + let mut f = std::fs::File::create(&transcript).unwrap(); + writeln!(f, "{}", line(json!({ "timestamp": "2026-01-01T00:00:01Z", "type": "session_meta", "payload": { "id": "s", "cwd": "/x/app" } }))).unwrap(); + f.flush().unwrap(); + + let first = tr + .handle(&envelope("s", "SessionStart", tpath, json!({})), &ctx) + .unwrap(); + assert_eq!(first.len(), 1, "first read: just the root insert"); + + writeln!(f, "{}", line(json!({ "timestamp": "2026-01-01T00:00:02Z", "type": "event_msg", "payload": { "type": "task_started", "turn_id": "t1" } }))).unwrap(); + f.flush().unwrap(); + + let second = tr + .handle(&envelope("s", "UserPromptSubmit", tpath, json!({})), &ctx) + .unwrap(); + assert_eq!(second.len(), 1, "second read: only the new turn insert"); + match &second[0] { + SpanOp::Insert(r) => assert_eq!(r.name, "turn: t1"), + _ => panic!("expected a turn insert"), + } +} + +#[test] +fn codex_stop_closes_turn_before_late_task_complete() { + let tmp = tempfile::tempdir().unwrap(); + let transcript = tmp.path().join("rollout.jsonl"); + let tpath = transcript.to_str().unwrap(); + for v in [ + json!({ "timestamp": "2026-01-01T00:00:01Z", "type": "session_meta", + "payload": { "id": "s", "cwd": "/x/app" } }), + json!({ "timestamp": "2026-01-01T00:00:02Z", "type": "event_msg", + "payload": { "type": "task_started", "turn_id": "t1" } }), + json!({ "timestamp": "2026-01-01T00:00:03Z", "type": "event_msg", + "payload": { "type": "user_message", "message": "say done" } }), + ] { + append(&transcript, v); + } + + let reg = Registry::default_agents(); + let mut tr = reg.create("codex", "s"); + let ctx = SessionCtx { + session_id: "s".into(), + config: None, + }; + let mut ops = tr + .handle( + &envelope( + "s", + "Stop", + tpath, + json!({ "last_assistant_message": "done" }), + ), + &ctx, + ) + .unwrap(); + ops.extend(tr.flush(&ctx).unwrap()); + let rows = reduce(ops); + + let turn = find(&rows, SpanType::Task, "turn: t1"); + assert_eq!(turn.end_ms, Some(0), "Stop hook closes the active turn"); + assert_eq!(turn.output, Some(json!("done"))); +} + +// ---- compaction & subagent coverage -------------------------------------- + +fn append(path: &std::path::Path, v: Value) { + use std::io::Write; + let mut f = std::fs::OpenOptions::new() + .create(true) + .append(true) + .open(path) + .unwrap(); + writeln!(f, "{}", line(v)).unwrap(); +} + +fn configured_ctx(session_id: &str, additional_metadata: Value) -> SessionCtx { + SessionCtx { + session_id: session_id.into(), + config: Some(SessionConfig { + auth: BackendAuth { + token: "test-token".into(), + api_url: None, + app_url: None, + org_name: None, + org_id: None, + }, + project: Some("team-project".into()), + parent_span_id: None, + root_span_id: None, + flush_mode: FlushMode::FireAndForget, + additional_metadata: Some(additional_metadata), + }), + } +} + +#[test] +fn late_task_complete_is_correlated_by_turn_id() { + let tmp = tempfile::tempdir().unwrap(); + let transcript = tmp.path().join("rollout.jsonl"); + let path = transcript.to_str().unwrap(); + for record in [ + json!({ "timestamp": "2026-01-01T00:00:01Z", "type": "session_meta", + "payload": { "id": "s", "cwd": "/x/app" } }), + json!({ "timestamp": "2026-01-01T00:00:02Z", "type": "event_msg", + "payload": { "type": "task_started", "turn_id": "t1" } }), + ] { + append(&transcript, record); + } + + let reg = Registry::default_agents(); + let mut translator = reg.create("codex", "s"); + let ctx = SessionCtx { + session_id: "s".into(), + config: None, + }; + let mut ops = translator + .handle(&envelope("s", "SessionStart", path, json!({})), &ctx) + .unwrap(); + ops.extend( + translator + .handle( + &envelope( + "s", + "Stop", + path, + json!({ "turn_id": "t1", "last_assistant_message": "one" }), + ), + &ctx, + ) + .unwrap(), + ); + + // t2 begins before Codex appends t1's delayed task_complete. + append( + &transcript, + json!({ "timestamp": "2026-01-01T00:00:03Z", "type": "event_msg", + "payload": { "type": "task_started", "turn_id": "t2" } }), + ); + append( + &transcript, + json!({ "timestamp": "2026-01-01T00:00:04Z", "type": "event_msg", + "payload": { "type": "task_complete", "turn_id": "t1", + "last_agent_message": "one" } }), + ); + ops.extend( + translator + .handle(&envelope("s", "UserPromptSubmit", path, json!({})), &ctx) + .unwrap(), + ); + + let rows = reduce(ops); + let t1 = find(&rows, SpanType::Task, "turn: t1"); + let t2 = find(&rows, SpanType::Task, "turn: t2"); + assert!(t1.end_ms.is_some()); + assert_eq!(t1.output, Some(json!("one"))); + assert_eq!(t2.end_ms, None, "late t1 completion must not close t2"); +} + +#[test] +fn root_preserves_config_input_and_git_metadata() { + let tmp = tempfile::tempdir().unwrap(); + let repo = tmp.path().join("repo"); + std::fs::create_dir(&repo).unwrap(); + let git = |args: &[&str]| { + let status = std::process::Command::new("git") + .arg("-C") + .arg(&repo) + .args(args) + .status() + .unwrap(); + assert!(status.success(), "git command failed: {args:?}"); + }; + git(&["init", "-b", "main"]); + git(&["config", "user.email", "test@example.com"]); + git(&["config", "user.name", "Test"]); + std::fs::write(repo.join("README.md"), "test").unwrap(); + git(&["add", "README.md"]); + git(&["commit", "-m", "initial"]); + git(&[ + "remote", + "add", + "origin", + "https://secret@example.com/acme/app.git", + ]); + let commit = std::process::Command::new("git") + .arg("-C") + .arg(&repo) + .args(["rev-parse", "HEAD"]) + .output() + .unwrap(); + let commit = String::from_utf8(commit.stdout).unwrap().trim().to_string(); + + let transcript = tmp.path().join("rollout.jsonl"); + for record in [ + json!({ "timestamp": "2026-01-01T00:00:01Z", "type": "session_meta", + "payload": { "id": "s", "cwd": repo, "cli_version": "1.2.3" } }), + json!({ "timestamp": "2026-01-01T00:00:02Z", "type": "turn_context", + "payload": { "model": "gpt-5.5" } }), + ] { + append(&transcript, record); + } + + let reg = Registry::default_agents(); + let mut translator = reg.create("codex", "s"); + let ctx = configured_ctx("s", json!({ "team": "platform", "model": "wrong" })); + let rows = reduce( + translator + .handle( + &envelope( + "s", + "SessionStart", + transcript.to_str().unwrap(), + json!({ "source": "resume", "permission_mode": "acceptEdits" }), + ), + &ctx, + ) + .unwrap(), + ); + let root = find(&rows, SpanType::Task, "codex: repo"); + let metadata = root.metadata.as_ref().unwrap(); + assert_eq!(metadata["team"], json!("platform")); + assert_eq!(metadata["model"], json!("gpt-5.5")); + assert_eq!(metadata["project"], json!("team-project")); + assert_eq!( + metadata["git_origin_url"], + json!("https://example.com/acme/app.git") + ); + assert_eq!(metadata["git_branch"], json!("main")); + assert_eq!(metadata["git_commit_sha"], json!(commit)); + assert_eq!(root.input.as_ref().unwrap()["model"], json!("gpt-5.5")); + assert_eq!(root.input.as_ref().unwrap()["source"], json!("resume")); + assert_eq!(root.input.as_ref().unwrap()["cwd"], json!(repo)); +} + +#[test] +fn tool_and_llm_payloads_preserve_original_contract() { + let tmp = tempfile::tempdir().unwrap(); + let transcript = tmp.path().join("rollout.jsonl"); + let path = transcript.to_str().unwrap(); + for record in [ + json!({ "timestamp": "2026-01-01T00:00:01Z", "type": "session_meta", + "payload": { "id": "s", "cwd": "/x/app" } }), + json!({ "timestamp": "2026-01-01T00:00:02Z", "type": "turn_context", + "payload": { "model": "gpt-5.5" } }), + json!({ "timestamp": "2026-01-01T00:00:03Z", "type": "event_msg", + "payload": { "type": "task_started", "turn_id": "t1" } }), + json!({ "timestamp": "2026-01-01T00:00:04Z", "type": "event_msg", + "payload": { "type": "user_message", "message": "$review inspect this" } }), + json!({ "timestamp": "2026-01-01T00:00:04Z", "type": "response_item", + "payload": { "type": "message", "role": "user", + "content": [{ "type": "input_text", "text": "$review inspect this" }] } }), + json!({ "timestamp": "2026-01-01T00:00:05Z", "type": "response_item", + "payload": { "type": "function_call", "call_id": "c1", "name": "exec_command", + "arguments": "{\"cmd\":\"cat /tmp/review/SKILL.md\",\"sandbox_permissions\":\"require_escalated\",\"justification\":\"Need access\",\"prefix_rule\":[\"cat\"]}", + "metadata": { "turn_id": "t1" } } }), + json!({ "timestamp": "2026-01-01T00:00:06Z", "type": "event_msg", + "payload": { "type": "token_count", "info": { "last_token_usage": { + "input_tokens": 10, "output_tokens": 2, "cost": 0.25 + } } } }), + json!({ "timestamp": "2026-01-01T00:00:07Z", "type": "response_item", + "payload": { "type": "function_call_output", "call_id": "c1", + "output": { "status": "failed", "error": "boom" } } }), + json!({ "timestamp": "2026-01-01T00:00:08Z", "type": "response_item", + "payload": { "type": "message", "role": "assistant", + "content": [{ "type": "output_text", "text": "Recovered" }] } }), + json!({ "timestamp": "2026-01-01T00:00:09Z", "type": "event_msg", + "payload": { "type": "token_count", "info": { "last_token_usage": {} } } }), + json!({ "timestamp": "2026-01-01T00:00:10Z", "type": "event_msg", + "payload": { "type": "task_complete", "turn_id": "t1", + "last_agent_message": "done" } }), + ] { + append(&transcript, record); + } + + let reg = Registry::default_agents(); + let mut translator = reg.create("codex", "s"); + let ctx = SessionCtx { + session_id: "s".into(), + config: None, + }; + let rows = reduce( + translator + .handle(&envelope("s", "SessionStart", path, json!({})), &ctx) + .unwrap(), + ); + + let turn = find(&rows, SpanType::Task, "turn: t1"); + assert_eq!(turn.input, Some(json!("$review inspect this"))); + assert_eq!( + turn.metadata.as_ref().unwrap()["loaded_skill_names"], + json!(["review"]) + ); + + let tool = find(&rows, SpanType::Tool, "skill: review"); + assert!(tool.input.as_ref().unwrap().is_string()); + let metadata = tool.metadata.as_ref().unwrap(); + assert_eq!(metadata["tool_name"], json!("exec_command")); + assert_eq!(metadata["call_id"], json!("c1")); + assert_eq!(metadata["turn_id"], json!("t1")); + assert_eq!(metadata["tool_kind"], json!("skill")); + assert_eq!(metadata["skill_name"], json!("review")); + assert_eq!(metadata["skill_path"], json!("/tmp/review/SKILL.md")); + assert_eq!(metadata["skill_load_trigger"], json!("explicit")); + assert_eq!( + metadata["permission"]["sandbox_permissions"], + json!("require_escalated") + ); + assert_eq!( + metadata["permission"]["justification"], + json!("Need access") + ); + assert_eq!(metadata["permission"]["prefix_rule"], json!(["cat"])); + assert_eq!(metadata["tool_approval"], json!("approved")); + assert_eq!(tool.tags, Some(vec!["permission-request".into()])); + assert_eq!(tool.error.as_deref(), Some("boom")); + + let mut llms: Vec<&SpanRow> = rows + .values() + .filter(|row| row.span_type == SpanType::Llm) + .collect(); + llms.sort_by_key(|row| row.start_ms); + assert_eq!(llms.len(), 2); + assert_eq!( + llms[0].output.as_ref().unwrap()["tool_calls"][0]["function"]["arguments"], + json!("{\"cmd\":\"cat /tmp/review/SKILL.md\",\"sandbox_permissions\":\"require_escalated\",\"justification\":\"Need access\",\"prefix_rule\":[\"cat\"]}") + ); + assert_eq!(llms[0].metrics.as_ref().unwrap()["cost"], json!(0.25)); + assert_eq!( + llms[0].metrics.as_ref().unwrap()["estimated_cost"], + json!(0.25) + ); + let second_input = llms[1].input.as_ref().unwrap().as_array().unwrap(); + assert_eq!(second_input.last().unwrap()["role"], json!("tool")); + assert_eq!(second_input.last().unwrap()["tool_call_id"], json!("c1")); + assert_eq!( + llms[1].metadata.as_ref().unwrap()["usage_unavailable_reason"], + json!("codex_token_count_missing_usage") + ); +} + +#[test] +fn missing_tool_output_is_an_error() { + let tmp = tempfile::tempdir().unwrap(); + let transcript = tmp.path().join("rollout.jsonl"); + for record in [ + json!({ "timestamp": "2026-01-01T00:00:01Z", "type": "session_meta", + "payload": { "id": "s", "cwd": "/x/app" } }), + json!({ "timestamp": "2026-01-01T00:00:02Z", "type": "event_msg", + "payload": { "type": "task_started", "turn_id": "t1" } }), + json!({ "timestamp": "2026-01-01T00:00:03Z", "type": "response_item", + "payload": { "type": "function_call", "call_id": "c1", "name": "shell", + "arguments": "{}", "metadata": { "turn_id": "t1" } } }), + json!({ "timestamp": "2026-01-01T00:00:04Z", "type": "event_msg", + "payload": { "type": "task_complete", "turn_id": "t1", + "last_agent_message": "done" } }), + ] { + append(&transcript, record); + } + let reg = Registry::default_agents(); + let mut translator = reg.create("codex", "s"); + let ctx = SessionCtx { + session_id: "s".into(), + config: None, + }; + let rows = reduce( + translator + .handle( + &envelope("s", "SessionStart", transcript.to_str().unwrap(), json!({})), + &ctx, + ) + .unwrap(), + ); + let tool = find(&rows, SpanType::Tool, "shell"); + assert_eq!( + tool.error.as_deref(), + Some("Tool output missing before turn ended") + ); + assert_eq!( + tool.metadata.as_ref().unwrap()["tool_approval"], + json!("approved") + ); +} + +#[test] +fn codex_compaction_relabels_turn_and_adds_compaction_llm() { + let tmp = tempfile::tempdir().unwrap(); + let t = tmp.path().join("rollout.jsonl"); + let tpath = t.to_str().unwrap(); + for v in [ + json!({ "timestamp": "2026-01-01T00:00:01Z", "type": "session_meta", "payload": { "id": "s", "cwd": "/x/app" } }), + json!({ "timestamp": "2026-01-01T00:00:02Z", "type": "turn_context", "payload": { "model": "gpt-5.5" } }), + json!({ "timestamp": "2026-01-01T00:00:03Z", "type": "event_msg", "payload": { "type": "task_started", "turn_id": "t1" } }), + json!({ "timestamp": "2026-01-01T00:00:04Z", "type": "compacted", "payload": { + "window_id": "w1", + "replacement_history": [ + { "role": "user", "content": "kept" }, + { "type": "compaction", "encrypted_content": "opaque" } + ] + } }), + json!({ "timestamp": "2026-01-01T00:00:05Z", "type": "event_msg", "payload": { "type": "token_count", "info": { "last_token_usage": { "input_tokens": 5000, "output_tokens": 50 } } } }), + ] { + append(&t, v); + } + + let reg = Registry::default_agents(); + let mut tr = reg.create("codex", "s"); + let ctx = SessionCtx { + session_id: "s".into(), + config: None, + }; + + let mut ops = Vec::new(); + ops.extend( + tr.handle(&envelope("s", "SessionStart", tpath, json!({})), &ctx) + .unwrap(), + ); + // PostCompact closes the compaction turn and supplies the trigger. + ops.extend( + tr.handle( + &envelope( + "s", + "PostCompact", + tpath, + json!({ "turn_id": "t1", "trigger": "auto" }), + ), + &ctx, + ) + .unwrap(), + ); + let rows = reduce(ops); + + let compaction = find(&rows, SpanType::Task, "compaction"); + assert!(compaction + .tags + .as_ref() + .unwrap() + .contains(&"compaction".to_string())); + assert_eq!( + compaction.metadata.as_ref().unwrap()["compaction"]["trigger"], + json!("auto") + ); + assert!( + compaction.end_ms.is_some(), + "compaction turn closed by PostCompact" + ); + + // The synthetic compaction llm span carries before/after context + metrics. + let llm = find(&rows, SpanType::Llm, "gpt-5.5"); + assert_eq!(llm.parent_span_ids, vec![compaction.span_id.clone()]); + assert!(llm.output.as_ref().unwrap()["kept_messages"].is_array()); + assert_eq!( + llm.output.as_ref().unwrap()["summary"], + json!("[summary unavailable — encrypted by Codex]") + ); + assert_eq!( + llm.output.as_ref().unwrap()["kept_messages"] + .as_array() + .unwrap() + .len(), + 1, + "encrypted compaction entry should not be exposed as a kept message" + ); + assert_eq!( + llm.metrics.as_ref().unwrap()["prompt_tokens"], + json!(5000.0) + ); + assert!(llm.end_ms.is_some()); +} + +#[test] +fn codex_subagent_nests_under_spawning_turn() { + let tmp = tempfile::tempdir().unwrap(); + let main_t = tmp.path().join("main.jsonl"); + let sub_t = tmp.path().join("sub.jsonl"); + let main_p = main_t.to_str().unwrap(); + let sub_p = sub_t.to_str().unwrap(); + + // Main session opens a turn and runs a spawn_agent tool. + for v in [ + json!({ "timestamp": "2026-01-01T00:00:01Z", "type": "session_meta", "payload": { "id": "s", "cwd": "/x/app" } }), + json!({ "timestamp": "2026-01-01T00:00:02Z", "type": "turn_context", "payload": { "model": "gpt-5.5" } }), + json!({ "timestamp": "2026-01-01T00:00:03Z", "type": "event_msg", "payload": { "type": "task_started", "turn_id": "t1" } }), + json!({ "timestamp": "2026-01-01T00:00:04Z", "type": "response_item", "payload": { "type": "function_call", "call_id": "c1", "name": "spawn_agent", "arguments": "{}" } }), + ] { + append(&main_t, v); + } + + let reg = Registry::default_agents(); + let mut tr = reg.create("codex", "s"); + let ctx = SessionCtx { + session_id: "s".into(), + config: None, + }; + + let mut ops = Vec::new(); + // The spawn_agent transcript record is first observed on this same + // PostToolUse. Catch-up must establish call -> turn before mapping agent_id. + ops.extend( + tr.handle( + &envelope("s", "PostToolUse", main_p, json!({ "tool_name": "spawn_agent", "tool_use_id": "c1", "tool_response": { "agent_id": "a1" } })), + &ctx, + ) + .unwrap(), + ); + // SubagentStart registers the subagent scope (its own transcript). + ops.extend( + tr.handle( + &envelope( + "s", + "SubagentStart", + main_p, + json!({ "agent_id": "a1", "transcript_path": sub_p, "agent_type": "reviewer" }), + ), + &ctx, + ) + .unwrap(), + ); + + // The subagent runs and writes its own transcript. + for v in [ + json!({ "timestamp": "2026-01-01T00:00:05Z", "type": "session_meta", "payload": { "id": "a1", "cwd": "/x/app" } }), + json!({ "timestamp": "2026-01-01T00:00:06Z", "type": "turn_context", "payload": { "model": "gpt-5.5-mini" } }), + json!({ "timestamp": "2026-01-01T00:00:07Z", "type": "event_msg", "payload": { "type": "task_started", "turn_id": "st1" } }), + json!({ "timestamp": "2026-01-01T00:00:08Z", "type": "response_item", "payload": { "type": "message", "role": "assistant", "content": [{ "type": "output_text", "text": "reviewed" }] } }), + json!({ "timestamp": "2026-01-01T00:00:09Z", "type": "event_msg", "payload": { "type": "token_count", "info": { "last_token_usage": { "input_tokens": 10, "output_tokens": 3 } } } }), + json!({ "timestamp": "2026-01-01T00:00:10Z", "type": "event_msg", "payload": { "type": "task_complete", "last_agent_message": "done" } }), + ] { + append(&sub_t, v); + } + + // A subagent-scoped event (carries agent_id + its transcript_path) drives + // the subagent catch-up, then SubagentStop closes it. + ops.extend( + tr.handle( + &envelope( + "s", + "PostToolUse", + main_p, + json!({ "agent_id": "a1", "transcript_path": sub_p }), + ), + &ctx, + ) + .unwrap(), + ); + ops.extend( + tr.handle( + &envelope( + "s", + "SubagentStop", + main_p, + json!({ "agent_id": "a1", "agent_transcript_path": sub_p }), + ), + &ctx, + ) + .unwrap(), + ); + + let rows = reduce(ops); + + let root = find(&rows, SpanType::Task, "codex: app"); + let main_turn = find(&rows, SpanType::Task, "turn: t1"); + let subagent = find(&rows, SpanType::Task, "subagent: a1"); + let sub_turn = find(&rows, SpanType::Task, "turn: st1"); + + // Whole thing is one trace under the main root. + for r in [main_turn, subagent, sub_turn] { + assert_eq!( + r.root_span_id, root.span_id, + "span {:?} not in main trace", + r.name + ); + } + // subagent root is a sibling of the spawn_agent tool, under the spawning turn. + assert_eq!(subagent.parent_span_ids, vec![main_turn.span_id.clone()]); + assert_eq!( + subagent.metadata.as_ref().unwrap()["agent_type"], + json!("reviewer") + ); + assert!( + subagent.end_ms.is_some(), + "subagent root closed by SubagentStop" + ); + // subagent turn hangs under the subagent root. + assert_eq!(sub_turn.parent_span_ids, vec![subagent.span_id.clone()]); + assert_eq!(sub_turn.output, Some(json!("done"))); + // subagent's llm is its own model, under its turn. + let sub_llm = find(&rows, SpanType::Llm, "gpt-5.5-mini"); + assert_eq!(sub_llm.parent_span_ids, vec![sub_turn.span_id.clone()]); +} diff --git a/bt-daemon/tests/pipeline.rs b/bt-daemon/tests/pipeline.rs new file mode 100644 index 0000000..0bc4d29 --- /dev/null +++ b/bt-daemon/tests/pipeline.rs @@ -0,0 +1,425 @@ +//! Phase 1 end-to-end: hook client → UDS → dispatch → journal → translate → +//! debug sink. Runs the daemon in-process on a temp socket (no process +//! spawning, so it's deterministic). + +use bt_daemon::wire::{BackendAuth, Envelope, FlushMode, SessionConfig}; +use bt_daemon::{ + debug_serve_options, flush_session, forward_envelope, run_serve, run_status, shutdown_daemon, + HostInfo, ServeArgs, StatusArgs, +}; +use std::ffi::OsString; +use std::path::{Path, PathBuf}; +use std::time::Duration; + +fn dummy_host() -> HostInfo { + // The daemon is started in-process, so the client never spawns; serve_argv + // is unused but must be non-empty. + HostInfo { + serve_argv: vec![OsString::from("unused")], + version: "test".into(), + } +} + +fn config_with_secret() -> SessionConfig { + SessionConfig { + auth: BackendAuth { + token: "sk-TOP-SECRET-abc123".into(), + api_url: Some("https://api.braintrust.dev".into()), + app_url: None, + org_name: Some("acme".into()), + org_id: None, + }, + project: Some("codex".into()), + parent_span_id: None, + root_span_id: None, + flush_mode: FlushMode::FireAndForget, + additional_metadata: None, + } +} + +fn envelope(session_id: &str, event: &str, ts_ms: i64) -> Envelope { + Envelope { + source: "debug".into(), + source_version: Some("0.0.0".into()), + session_id: session_id.into(), + event: event.into(), + ts_ms, + payload: serde_json::json!({ "session_id": session_id, "hook_event_name": event, "n": ts_ms }), + config: Some(config_with_secret()), + } +} + +fn test_endpoint(tmp: &Path) -> PathBuf { + #[cfg(unix)] + { + tmp.join("d.sock") + } + #[cfg(windows)] + { + let _ = tmp; + PathBuf::from(format!(r"\\.\pipe\bt-daemon-test-{}", uuid::Uuid::new_v4())) + } +} + +async fn wait_for(endpoint: &Path) { + for _ in 0..200 { + if let Ok(Some(_)) = run_status(StatusArgs { + socket: Some(endpoint.to_path_buf()), + session_id: None, + }) + .await + { + return; + } + tokio::time::sleep(Duration::from_millis(10)).await; + } + panic!("daemon never answered at: {}", endpoint.display()); +} + +async fn wait_until_gone(endpoint: &Path) { + for _ in 0..1000 { + match run_status(StatusArgs { + socket: Some(endpoint.to_path_buf()), + session_id: None, + }) + .await + { + Ok(Some(_)) => {} + // A Windows named pipe can still accept a client while the + // daemon is unwinding, then close before answering initialize. + // Either that or an unavailable endpoint means it is no longer + // serving status requests. + Ok(None) | Err(_) => return, + } + tokio::time::sleep(Duration::from_millis(10)).await; + } + panic!("daemon still answered at: {}", endpoint.display()); +} + +/// Start an in-process daemon on a fresh temp socket/data dir. Returns +/// (data_dir, socket, serve task handle, tempdir guard). +async fn start_daemon() -> ( + PathBuf, + PathBuf, + tokio::task::JoinHandle<()>, + tempfile::TempDir, +) { + let tmp = tempfile::tempdir().unwrap(); + let data_dir = tmp.path().join("data"); + let socket = test_endpoint(tmp.path()); + std::fs::create_dir_all(&data_dir).unwrap(); + + let args = ServeArgs { + socket: Some(socket.clone()), + data_dir: Some(data_dir.clone()), + idle_timeout_secs: 0, // disable the watchdog for the test + }; + let opts = debug_serve_options("test", &data_dir); + let handle = tokio::spawn(async move { + let _ = run_serve(args, opts).await; + }); + wait_for(&socket).await; + (data_dir, socket, handle, tmp) +} + +async fn start_daemon_at(data_dir: PathBuf, socket: PathBuf) -> tokio::task::JoinHandle<()> { + let args = ServeArgs { + socket: Some(socket.clone()), + data_dir: Some(data_dir.clone()), + idle_timeout_secs: 0, + }; + let opts = debug_serve_options("test", &data_dir); + let handle = tokio::spawn(async move { + let _ = run_serve(args, opts).await; + }); + wait_for(&socket).await; + handle +} + +async fn shutdown(socket: &Path) { + shutdown_daemon(socket).await.unwrap(); +} + +#[tokio::test] +async fn events_are_ordered_journaled_and_emitted() { + let (data_dir, socket, handle, _tmp) = start_daemon().await; + let host = dummy_host(); + let session = "sess-1"; + + for (i, event) in ["SessionStart", "PostToolUse", "Stop"].iter().enumerate() { + let env = envelope(session, event, 1000 + i as i64); + forward_envelope(&env, &socket, &host, false).await.unwrap(); + } + + let flushed = flush_session(session, &socket, 5000).await.unwrap(); + assert!(flushed.flushed, "flush did not complete: {flushed:?}"); + assert_eq!(flushed.pending, 0); + + // Journal: three events, in order, token redacted. + let journal = data_dir.join("journal").join("sess-1.ndjson"); + let jtext = std::fs::read_to_string(&journal).unwrap(); + let jlines: Vec<&str> = jtext.lines().filter(|l| !l.trim().is_empty()).collect(); + assert_eq!( + jlines.len(), + 3, + "expected 3 journal lines, got {}", + jlines.len() + ); + assert!( + !jtext.contains("sk-TOP-SECRET-abc123"), + "token leaked into journal!" + ); + assert!( + jtext.contains("token_sha256_prefix"), + "journal missing auth fingerprint" + ); + + let events: Vec = jlines + .iter() + .map(|l| { + serde_json::from_str::(l).unwrap()["event"] + .as_str() + .unwrap() + .to_string() + }) + .collect(); + assert_eq!(events, vec!["SessionStart", "PostToolUse", "Stop"]); + + // Spans: debug translator emits a root once + one span per event = 4. + let spans = data_dir.join("spans").join("sess-1.ndjson"); + let stext = std::fs::read_to_string(&spans).unwrap(); + let slines: Vec<&str> = stext.lines().filter(|l| !l.trim().is_empty()).collect(); + assert_eq!( + slines.len(), + 4, + "expected 4 span rows, got {}: {stext}", + slines.len() + ); + // The span rows carry the raw payload but never the auth token. + assert!( + !stext.contains("sk-TOP-SECRET-abc123"), + "token leaked into spans!" + ); + + handle.abort(); +} + +#[tokio::test] +async fn distinct_sessions_are_isolated() { + let (data_dir, socket, handle, _tmp) = start_daemon().await; + let host = dummy_host(); + + forward_envelope(&envelope("a", "SessionStart", 1), &socket, &host, false) + .await + .unwrap(); + forward_envelope(&envelope("b", "SessionStart", 1), &socket, &host, false) + .await + .unwrap(); + forward_envelope(&envelope("a", "Stop", 2), &socket, &host, false) + .await + .unwrap(); + + 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(); + assert_eq!(a.lines().filter(|l| !l.trim().is_empty()).count(), 2); + assert_eq!(b.lines().filter(|l| !l.trim().is_empty()).count(), 1); + + handle.abort(); +} + +#[tokio::test] +async fn status_reports_sessions_and_filters_by_session_id() { + let (_data_dir, socket, handle, _tmp) = start_daemon().await; + let host = dummy_host(); + forward_envelope( + &envelope("visible", "SessionStart", 1), + &socket, + &host, + false, + ) + .await + .unwrap(); + forward_envelope(&envelope("other", "SessionStart", 1), &socket, &host, false) + .await + .unwrap(); + + let all = run_status(StatusArgs { + socket: Some(socket.clone()), + session_id: None, + }) + .await + .unwrap() + .unwrap(); + assert_eq!(all.daemon_version, "test"); + assert_eq!(all.sessions.len(), 2); + + let filtered = run_status(StatusArgs { + socket: Some(socket.clone()), + session_id: Some("visible".into()), + }) + .await + .unwrap() + .unwrap(); + assert_eq!(filtered.sessions.len(), 1); + assert_eq!(filtered.sessions[0].session_id, "visible"); + + shutdown(&socket).await; + handle.await.unwrap(); + wait_until_gone(&socket).await; +} + +#[tokio::test] +async fn a_second_server_detects_the_existing_daemon() { + let (data_dir, socket, first, _tmp) = start_daemon().await; + let args = ServeArgs { + socket: Some(socket.clone()), + data_dir: Some(data_dir.clone()), + idle_timeout_secs: 0, + }; + let result = tokio::time::timeout( + Duration::from_secs(2), + run_serve(args, debug_serve_options("rival", &data_dir)), + ) + .await + .expect("rival server should resolve ownership promptly"); + result.unwrap(); + + let status = run_status(StatusArgs { + socket: Some(socket.clone()), + session_id: None, + }) + .await + .unwrap() + .unwrap(); + assert_eq!(status.daemon_version, "test"); + + shutdown(&socket).await; + first.await.unwrap(); +} + +#[tokio::test] +async fn no_spawn_errors_when_daemon_absent() { + let tmp = tempfile::tempdir().unwrap(); + let socket = test_endpoint(tmp.path()); + let host = dummy_host(); + let err = forward_envelope(&envelope("x", "y", 1), &socket, &host, true).await; + assert!(err.is_err(), "expected error with --no-spawn and no daemon"); +} + +#[tokio::test] +async fn no_spawn_rejects_a_mismatched_daemon_version() { + let (_data_dir, socket, handle, _tmp) = start_daemon().await; + let host = HostInfo { + serve_argv: vec![OsString::from("unused")], + version: "newer-client".into(), + }; + let err = forward_envelope(&envelope("x", "y", 1), &socket, &host, true) + .await + .unwrap_err(); + assert!(err.to_string().contains("does not match client")); + shutdown(&socket).await; + handle.await.unwrap(); +} + +#[cfg(feature = "cli")] +#[tokio::test] +async fn spawn_on_demand_runs_the_real_standalone_daemon() { + let tmp = tempfile::tempdir().unwrap(); + let data_dir = tmp.path().join("spawned-data"); + let socket = test_endpoint(tmp.path()); + let host = HostInfo { + serve_argv: vec![ + OsString::from(env!("CARGO_BIN_EXE_bt-daemon")), + OsString::from("serve"), + OsString::from("--debug-sink"), + OsString::from("--data-dir"), + data_dir.as_os_str().to_owned(), + OsString::from("--idle-timeout-secs"), + OsString::from("5"), + ], + version: env!("CARGO_PKG_VERSION").into(), + }; + + forward_envelope( + &envelope("spawned", "SessionStart", 1), + &socket, + &host, + false, + ) + .await + .unwrap(); + flush_session("spawned", &socket, 5000).await.unwrap(); + let spans = std::fs::read_to_string(data_dir.join("spans/spawned.ndjson")).unwrap(); + assert_eq!(spans.lines().count(), 2); + + shutdown(&socket).await; + wait_until_gone(&socket).await; +} + +#[tokio::test] +async fn restart_replays_journal_before_processing_new_events() { + let (data_dir, socket, first, _tmp) = start_daemon().await; + let host = dummy_host(); + forward_envelope( + &envelope("resume", "SessionStart", 1), + &socket, + &host, + false, + ) + .await + .unwrap(); + flush_session("resume", &socket, 5000).await.unwrap(); + shutdown(&socket).await; + first.await.unwrap(); + + let second = start_daemon_at(data_dir.clone(), socket.clone()).await; + forward_envelope(&envelope("resume", "Stop", 2), &socket, &host, false) + .await + .unwrap(); + flush_session("resume", &socket, 5000).await.unwrap(); + + let journal = std::fs::read_to_string(data_dir.join("journal/resume.ndjson")).unwrap(); + assert_eq!(journal.lines().count(), 2); + let spans = std::fs::read_to_string(data_dir.join("spans/resume.ndjson")).unwrap(); + assert_eq!( + spans.lines().count(), + 5, + "first delivery (2) + replay repair (2) + resumed event (1)" + ); + + shutdown(&socket).await; + second.await.unwrap(); +} + +#[tokio::test] +async fn claude_boundary_journal_contains_a_self_contained_transcript_snapshot() { + let (data_dir, socket, handle, tmp) = start_daemon().await; + let transcript = tmp.path().join("claude.jsonl"); + std::fs::write( + &transcript, + r#"{"type":"assistant","timestamp":"2026-07-29T00:00:00Z","message":{"id":"m1","model":"claude","content":[{"type":"text","text":"durable"}]}}"#, + ) + .unwrap(); + let mut env = envelope("claude-journal", "Stop", 1_775_000_000_000); + env.source = "claude-code".into(); + env.payload = serde_json::json!({ + "session_id":"claude-journal", + "hook_event_name":"Stop", + "transcript_path":transcript + }); + forward_envelope(&env, &socket, &dummy_host(), false) + .await + .unwrap(); + flush_session("claude-journal", &socket, 5000) + .await + .unwrap(); + + let journal = std::fs::read_to_string(data_dir.join("journal/claude-journal.ndjson")).unwrap(); + assert!(journal.contains("_bt_transcript_snapshot")); + assert!(journal.contains("durable")); + assert!(!journal.contains("sk-TOP-SECRET-abc123")); + handle.abort(); +} diff --git a/src/plugins/claude/content/CONTRIBUTING.md b/src/plugins/claude/content/CONTRIBUTING.md index dbe4473..adb2185 100644 --- a/src/plugins/claude/content/CONTRIBUTING.md +++ b/src/plugins/claude/content/CONTRIBUTING.md @@ -1,246 +1,89 @@ -# Development of the plugin itself +# Development of the plugins ## Prerequisites -- Python 3.12+ -- [uv](https://docs.astral.sh/uv/) package manager +- Python 3.12+ and [uv](https://docs.astral.sh/uv/) for the Braintrust skill + evals. +- Rust for the shared tracing daemon. +- `jq` for plugin manifest validation and the optional fixture recorder. ## Local testing -Test a plugin without installing from marketplace: +Load a plugin directly without installing it from the marketplace: ```bash -claude --plugin-dir /path/to/thisrepo/plugins/{plugin dir here} -# example -claude --plugin-dir /path/to/thisrepo/plugins/braintrust +claude --plugin-dir /path/to/repo/plugins/braintrust +claude --plugin-dir /path/to/repo/plugins/trace-claude-code ``` ## Running evals -The `evals/` directory contains tests that verify the plugin works correctly (e.g., Claude generates valid SQL queries, logs data properly). +The `evals/` directory verifies that Claude can use Braintrust workflows: ```bash cd evals export BRAINTRUST_API_KEY="your-key" - -# Run all evals uv run braintrust eval . - -# Run specific eval -uv run braintrust eval eval_e2e_log_fetch.py ``` -## Pre-commit hooks - -```bash -# Install hooks -uv run pre-commit install +## Testing `trace-claude-code` -# Run all hooks -uv run pre-commit run --all-files -``` +The plugin contains only a fail-open `bt` hook shim. All event translation and +Braintrust delivery live in the shared Rust daemon at `bt-daemon/`. -## Testing the `trace-claude-code` plugin +From the monorepo root: -Bash test suite for the hook scripts. Tests run the hooks against a -stubbed `curl`, capture the resulting HTTP requests, and assert on the -inferred span tree. - -### Running - -```sh -# From the repo root: +```bash +cargo test --manifest-path bt-daemon/Cargo.toml --all-features +cargo clippy --manifest-path bt-daemon/Cargo.toml --all-targets --all-features -- -D warnings make test - -# Or run a specific test file: -bash plugins/trace-claude-code/test/run_tests.sh test_e2e -bash plugins/trace-claude-code/test/run_tests.sh test_replay test_queue ``` -### Layout - -``` -plugins/trace-claude-code/test/ -├── helpers/ -│ ├── assert.sh # describe / it / assert_eq / assert_contains, color output -│ ├── harness.sh # setup_test_env, teardown_test_env, run_hook -│ ├── curl_stub.sh # curl() shell function that captures requests + returns canned responses -│ ├── fixtures.sh # builders for hook input JSON (fixture_session_start, etc.) -│ ├── span_tree.sh # all_spans, span_count_by_type, span_by_name, children_of, ... -│ └── replay.sh # replay_session, describe_fixture -├── fixtures/ -│ └── sessions/ # captured Claude sessions used by test_replay.sh -├── test_*.sh # one file per area -├── record_session.sh # CLI to prep a fixture directory for capturing -└── run_tests.sh # entry point -``` +`bt-daemon/tests/claude_translator.rs` covers synthetic lifecycle cases and +replays the immutable captured sessions under +`plugins/trace-claude-code/test/fixtures/sessions/`. Add translator behavior and +assertions there, not as another hook script. -### Writing a test +### Capturing a fixture -Each `test_*.sh` follows this pattern: +Set `BRAINTRUST_RECORD_DIR` to a new absolute directory before running Claude: ```bash -#!/bin/bash -SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -source "$SCRIPT_DIR/helpers/assert.sh" -source "$SCRIPT_DIR/helpers/harness.sh" - -describe "my feature" - -t_my_test_body() { - # setup_test_env has already created an isolated $HOME and stubbed curl - stub_response_for "*/v1/project_logs/*/insert" 200 '{"row_ids":["row_1"]}' - - run_hook session_start.sh "$(fixture_session_start "s1" "/tmp/x")" - - assert_eq "$(span_count_by_type task)" "1" -} - -it "does the thing" t_my_test_body -``` - -Key conventions: - -- `describe "..."` is a section header (purely visual). -- `it "name" function_name` runs `function_name` between `setup_test_env` - and `teardown_test_env`, then prints a ✓ or ✗. -- Assertions (`assert_eq`, `assert_contains`, `assert_failure`, ...) record - failures into the current test but do **not** abort. Multiple assertions - per test are fine. -- Hooks are run synchronously in tests via `BRAINTRUST_SYNC_QUEUE=true` - set by `setup_test_env`. Span queue tests opt out of this when needed. - -### Capturing a real session as a test fixture - -The hooks support recording every invocation to disk when the env var -`BRAINTRUST_RECORD_DIR` is set. The recorded data can then be replayed -in a test. - -#### 1. Prepare a fixture directory - -```sh -plugins/trace-claude-code/test/record_session.sh my-fixture +export BRAINTRUST_RECORD_DIR=/absolute/path/to/new-fixture +claude --plugin-dir /path/to/plugins/trace-claude-code ``` -This prints a `BRAINTRUST_RECORD_DIR` value pointing at -`test/fixtures/sessions/my-fixture/`. - -#### 2. Run Claude Code with recording on +The shim appends `{ts, hook, payload}` records to `events.ndjson` and copies +referenced main/subagent transcripts under `transcripts/`. Move a reviewed, +credential-free capture under `test/fixtures/sessions/`, add its contract to +the Rust test, and run the full daemon suite. The daemon’s normal recovery +journal independently embeds transcript snapshots at lifecycle boundaries. -```sh -export BRAINTRUST_RECORD_DIR=/abs/path/to/test/fixtures/sessions/my-fixture -claude -# ... use Claude Code normally ... -``` - -While `BRAINTRUST_RECORD_DIR` is set: - -- Every hook invocation appends one NDJSON record to - `events.ndjson` containing `{ts, hook, payload}`. -- The `stop_hook` also copies the referenced transcript file into - `transcripts/.jsonl`. - -You do not need to modify hook scripts or set anything else - the recorder -runs inside the existing hooks. - -#### 3. Inspect the fixture - -```sh -plugins/trace-claude-code/test/record_session.sh --describe my-fixture -``` - -Output: - -``` -Fixture: .../test/fixtures/sessions/my-fixture - Events: 14 - Hook counts: - post_tool_use: 8 - session_end: 1 - session_start: 1 - stop_hook: 3 - user_prompt_submit: 1 - Transcripts: 1 -``` - -#### 4. Replay it in a test +## Pre-commit hooks ```bash -t_replay_my_fixture() { - stub_response_for "*/v1/project_logs/*/insert" 200 '{"row_ids":["row_1"]}' - - local n - n=$(replay_session "$SCRIPT_DIR/fixtures/sessions/my-fixture") - assert_success "$?" - assert_eq "$n" "14" - - # Now assert on the span tree the hooks produced - assert_eq "$(span_count_by_type tool)" "8" - assert_eq "$(span_count_by_type llm)" "3" -} - -it "my real-world fixture produces the expected spans" t_replay_my_fixture +uv run pre-commit install +uv run pre-commit run --all-files ``` -The replayer: - -- Reads `events.ndjson` line by line in order. -- For `stop_hook` events, rewrites `payload.transcript_path` to point at - the bundled transcript so the replayed hook can read it. -- Invokes the matching hook script via `run_hook` with the recorded - payload. - -#### When to use replay vs. synthetic fixtures - -- **Synthetic fixtures** (`fixture_session_start`, etc.) - fast to write, - test specific scenarios in isolation, no real Claude needed. -- **Replayed fixtures** - high-fidelity regression tests of real-world - interactions. Use when you want to lock in behavior on a specific - pattern of hooks you saw in the wild (e.g. a session with parallel - tool calls, or a long multi-turn conversation). - -### Span-tree queries - -The captured HTTP requests are parsed to extract the inserted spans. Available helpers: - -| Function | Returns | -|---|---| -| `all_spans` | JSON array of every span sent to any `/insert` endpoint | -| `span_count` | total number of spans | -| `span_count_by_type "tool"` | count of spans with `span_attributes.type == "tool"` | -| `spans_named "^Turn "` | array of spans whose name matches the regex | -| `span_by_name "^Turn 1$"` | first matching span (or `null`) | -| `span_by_type "llm"` | first span of that type | -| `span_by_id "..."` | span with the given `span_id` | -| `children_of ""` | array of spans whose first parent is the given id | -| `is_child_of "" ""` | exit 0 if true | - -All return JSON on stdout; combine with `jq` for further drilling. - # Releasing a plugin -Releases are manual and git-driven. There are no git tags or publish automation: pushing to `main` is the release. - -## How version resolution works - -Claude Code resolves a plugin's version from the first of these that is set: - -1. `version` in the plugin's `plugins//.claude-plugin/plugin.json` -2. `version` in the plugin's entry in `.claude-plugin/marketplace.json` -3. The git commit SHA of the plugin's source +Releases are manual and git-driven. There are no git tags or publish +automation: pushing to `main` is the release. -Both plugins set `version` in their own `plugin.json`, and the marketplace entries do **not** declare a per-plugin `version`. So **each plugin's `plugin.json` is the sole authority for its version**, and bumping it is what triggers updates for users. +Claude Code resolves a plugin version from the first available source: -The top-level `version` field in `marketplace.json` is just marketplace-manifest metadata. It does **not** gate plugin updates. +1. `version` in `plugins//.claude-plugin/plugin.json` +2. `version` in its marketplace entry +3. the source commit SHA -> [!WARNING] -> Do not add a `version` field to a plugin's entry in `marketplace.json`. The `plugin.json` value always wins silently, so a stale marketplace version can mask the real one. Keep the version in `plugin.json` only. +Each plugin’s `plugin.json` is authoritative. Do not add a per-plugin version +to `marketplace.json`; a stale duplicate can mask the real version. -## Release steps +Release steps: -1. Bump `version` in the plugin's manifest: - - `plugins/braintrust/.claude-plugin/plugin.json`, or - - `plugins/trace-claude-code/.claude-plugin/plugin.json` -2. (Optional) Bump the top-level `version` in `.claude-plugin/marketplace.json` for bookkeeping. This is cosmetic and does not affect whether users receive the update. -3. Commit and push to `main` (via PR). -4. Users update with: `claude plugin marketplace update braintrust-claude-plugin` +1. Bump the plugin’s `.claude-plugin/plugin.json` version. +2. Optionally bump the marketplace manifest’s top-level bookkeeping version. +3. Commit and merge through a PR. +4. Users update with + `claude plugin marketplace update braintrust-claude-plugin`. diff --git a/src/plugins/claude/content/Makefile b/src/plugins/claude/content/Makefile index a30d4c4..3bd027b 100644 --- a/src/plugins/claude/content/Makefile +++ b/src/plugins/claude/content/Makefile @@ -1,8 +1,9 @@ .PHONY: test test-trace-claude-code -# Run all plugin tests test: test-trace-claude-code -# Run trace-claude-code plugin tests test-trace-claude-code: - @bash plugins/trace-claude-code/test/run_tests.sh + @sh -n plugins/trace-claude-code/bin/claude-hook.sh + @jq empty plugins/trace-claude-code/hooks/hooks.json + @grep -q "'daemon','hook','--source','claude-code'" plugins/trace-claude-code/bin/claude-hook.cmd + @echo "trace-claude-code shims OK" diff --git a/src/plugins/claude/content/README.md b/src/plugins/claude/content/README.md index 3a2792e..0fa3b01 100644 --- a/src/plugins/claude/content/README.md +++ b/src/plugins/claude/content/README.md @@ -5,7 +5,8 @@ A Claude Code plugin marketplace for [Braintrust](https://braintrust.dev) integr ## Prerequisites - A [Braintrust account](https://braintrust.dev) -- `BRAINTRUST_API_KEY` exported in your environment +- The [`bt` CLI](https://bt.dev/cli/install.sh), authenticated with `bt auth login`, + or `BRAINTRUST_API_KEY` exported in your environment ## Installation @@ -32,7 +33,9 @@ claude plugin install braintrust@braintrust-claude-plugin ### trace-claude-code -Automatically traces Claude Code conversations to Braintrust. Captures sessions, conversation turns, and tool calls as hierarchical traces. +Automatically traces Claude Code conversations to Braintrust through the +shared local `bt` daemon. Captures sessions, turns, model calls, tool calls, +subagents, tool failures, and permission denials as hierarchical traces. ```bash claude plugin install trace-claude-code@braintrust-claude-plugin @@ -42,6 +45,10 @@ $HOME/.claude/plugins/marketplaces/braintrust-claude-plugin/plugins/trace-claude Traces are sent to the `claude-code` project by default. +The tracing launchers live under `plugins/trace-claude-code/bin/` as +`claude-hook.sh` and `claude-hook.cmd`. The Windows launcher forwards the same +configuration to `bt`, whose daemon uses a local named pipe on Windows. + #### manual configuration Instead of running `setup.sh`, you can manually edit `~/.claude/settings.json` or your project's `.claude/settings.local.json`: @@ -50,9 +57,7 @@ Instead of running `setup.sh`, you can manually edit `~/.claude/settings.json` o { "env": { "TRACE_TO_BRAINTRUST": "true", - "BRAINTRUST_CC_PROJECT": "project-name-to-send-cc-traces-to", - "BRAINTRUST_API_KEY": "sk-yourkey", - "BRAINTRUST_DEBUG": "false" + "BRAINTRUST_CC_PROJECT": "project-name-to-send-cc-traces-to" } } ``` @@ -73,11 +78,12 @@ claude --settings '{"env":{"CC_PARENT_SPAN_ID":"parent-span-id","CC_ROOT_SPAN_ID The Claude Code session and all its turns/tools will appear as children of your parent span in Braintrust. -To attach claude code to an experiment's trace, specify CC_EXPERIMENT_ID as well: +To route the session into an existing experiment instead of project logs, set +`CC_EXPERIMENT_ID`. -```bash -claude --settings '{"env":{"CC_PARENT_SPAN_ID":"parent-span-id","CC_ROOT_SPAN_ID":"root-span-id", "CC_EXPERIMENT_ID":"the-experiment-id"}}' -p "task" -``` +Set `BRAINTRUST_RECORD_DIR` to capture native hook events and transcript +snapshots for a reproducible local fixture. The daemon also embeds transcript +snapshots in its redacted recovery journal at lifecycle boundaries. #### token accounting diff --git a/src/plugins/claude/content/plugins/trace-claude-code/.claude-plugin/plugin.json b/src/plugins/claude/content/plugins/trace-claude-code/.claude-plugin/plugin.json index ae258fc..4e0b29b 100644 --- a/src/plugins/claude/content/plugins/trace-claude-code/.claude-plugin/plugin.json +++ b/src/plugins/claude/content/plugins/trace-claude-code/.claude-plugin/plugin.json @@ -1,7 +1,7 @@ { "name": "trace-claude-code", "description": "Automatically trace Claude Code conversations to Braintrust for observability. Captures sessions, conversation turns, and tool calls as hierarchical traces.", - "version": "1.5.0", + "version": "2.0.0", "author": { "name": "Braintrust" } diff --git a/src/plugins/claude/content/plugins/trace-claude-code/bin/claude-hook.cmd b/src/plugins/claude/content/plugins/trace-claude-code/bin/claude-hook.cmd new file mode 100644 index 0000000..9ba8c99 --- /dev/null +++ b/src/plugins/claude/content/plugins/trace-claude-code/bin/claude-hook.cmd @@ -0,0 +1,40 @@ +@echo off +REM Thin, fail-open Claude Code hook shim for the shared Braintrust daemon. +REM Invokes: bt daemon hook --source claude-code +setlocal EnableExtensions DisableDelayedExpansion + +set "TRACE_ENABLED=" +for %%V in (1 true yes on) do if /I "%TRACE_TO_BRAINTRUST%"=="%%V" set "TRACE_ENABLED=1" +if not defined TRACE_ENABLED exit /b 0 + +set "BT_HOOK_BIN=" +for /f "delims=" %%B in ('where bt 2^>nul') do if not defined BT_HOOK_BIN set "BT_HOOK_BIN=%%B" +if not defined BT_HOOK_BIN if exist "%USERPROFILE%\.local\bin\bt.exe" set "BT_HOOK_BIN=%USERPROFILE%\.local\bin\bt.exe" +if not defined BT_HOOK_BIN ( + echo trace-claude-code: bt CLI is unavailable; tracing disabled for this event.>&2 + exit /b 0 +) + +"%BT_HOOK_BIN%" daemon hook --help >nul 2>&1 +if errorlevel 1 ( + echo trace-claude-code: a daemon-capable bt CLI is unavailable; tracing disabled for this event.>&2 + exit /b 0 +) + +if not defined BRAINTRUST_PROJECT if defined BRAINTRUST_CC_PROJECT set "BRAINTRUST_PROJECT=%BRAINTRUST_CC_PROJECT%" +if not defined BRAINTRUST_DEFAULT_PROJECT if defined BRAINTRUST_PROJECT set "BRAINTRUST_DEFAULT_PROJECT=%BRAINTRUST_PROJECT%" + +set "BT_PLUGIN_JSON=%~dp0..\.claude-plugin\plugin.json" +powershell.exe -NoLogo -NoProfile -NonInteractive -ExecutionPolicy Bypass -Command ^ + "$ErrorActionPreference='SilentlyContinue';" ^ + "$a=@('daemon','hook','--source','claude-code');" ^ + "if(Test-Path $env:BT_PLUGIN_JSON){$v=(Get-Content -Raw $env:BT_PLUGIN_JSON|ConvertFrom-Json).version;if($v){$a+=@('--source-version',[string]$v)}};" ^ + "if($env:BRAINTRUST_FLUSH_ON_TURN_END -match '^(?i:1|true|yes|on)$'){$a+='--flush-on-turn-end'};" ^ + "if($env:CC_PARENT_SPAN_ID){$a+=@('--parent-span-id',$env:CC_PARENT_SPAN_ID)};" ^ + "if($env:CC_ROOT_SPAN_ID){$a+=@('--root-span-id',$env:CC_ROOT_SPAN_ID)};" ^ + "if($env:BRAINTRUST_ADDITIONAL_METADATA){$a+=@('--additional-metadata',$env:BRAINTRUST_ADDITIONAL_METADATA)};" ^ + "if($env:CC_EXPERIMENT_ID){$a+=@('--experiment-id',$env:CC_EXPERIMENT_ID)};" ^ + "& $env:BT_HOOK_BIN @a;" ^ + "if($LASTEXITCODE -ne 0){[Console]::Error.WriteLine('trace-claude-code: bt daemon hook failed non-fatally')};exit 0" + +exit /b 0 diff --git a/src/plugins/claude/content/plugins/trace-claude-code/bin/claude-hook.sh b/src/plugins/claude/content/plugins/trace-claude-code/bin/claude-hook.sh new file mode 100755 index 0000000..b9200bd --- /dev/null +++ b/src/plugins/claude/content/plugins/trace-claude-code/bin/claude-hook.sh @@ -0,0 +1,75 @@ +#!/bin/sh +# Thin, fail-open Claude Code hook shim for the shared Braintrust daemon. + +set -u + +log() { printf 'trace-claude-code: %s\n' "$1" >&2; } +truthy() { + case "$(printf '%s' "${1:-}" | tr '[:upper:]' '[:lower:]')" in + 1 | true | yes | on) return 0 ;; + *) return 1 ;; + esac +} + +truthy "${TRACE_TO_BRAINTRUST:-false}" || exit 0 + +compatible_bt() { + [ -n "${1:-}" ] && [ -x "$1" ] && "$1" daemon hook --help >/dev/null 2>&1 +} + +BT_BIN=$(command -v bt 2>/dev/null || true) +LOCAL_BT="${XDG_BIN_HOME:-$HOME/.local/bin}/bt" +if ! compatible_bt "$BT_BIN" && compatible_bt "$LOCAL_BT"; then + BT_BIN="$LOCAL_BT" +fi +if ! compatible_bt "$BT_BIN"; then + INSTALL_LOCK="${TMPDIR:-/tmp}/braintrust-bt-daemon-install-${UID:-user}" + if command -v curl >/dev/null 2>&1 && mkdir "$INSTALL_LOCK" 2>/dev/null; then + nohup sh -c 'curl -fsSL --max-time 20 https://bt.dev/cli/install.sh | sh; rmdir "$1"' \ + sh "$INSTALL_LOCK" /dev/null 2>&1 & + log "a daemon-capable bt CLI is unavailable; started a background install or upgrade" + else + log "a daemon-capable bt CLI is unavailable; tracing disabled for this event" + fi + exit 0 +fi + +# Preserve the Claude plugin's project-name compatibility while bt owns auth. +if [ -z "${BRAINTRUST_PROJECT:-}" ] && [ -n "${BRAINTRUST_CC_PROJECT:-}" ]; then + export BRAINTRUST_PROJECT="$BRAINTRUST_CC_PROJECT" +fi +if [ -z "${BRAINTRUST_DEFAULT_PROJECT:-}" ] && [ -n "${BRAINTRUST_PROJECT:-}" ]; then + export BRAINTRUST_DEFAULT_PROJECT="$BRAINTRUST_PROJECT" +fi + +PLUGIN_JSON="$(dirname "$0")/../.claude-plugin/plugin.json" +PLUGIN_VERSION="" +if [ -f "$PLUGIN_JSON" ]; then + PLUGIN_VERSION=$(sed -n 's/.*"version"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' "$PLUGIN_JSON" | head -1) +fi + +set -- daemon hook --source claude-code +[ -z "$PLUGIN_VERSION" ] || set -- "$@" --source-version "$PLUGIN_VERSION" +truthy "${BRAINTRUST_FLUSH_ON_TURN_END:-false}" && set -- "$@" --flush-on-turn-end +[ -z "${CC_PARENT_SPAN_ID:-}" ] || set -- "$@" --parent-span-id "$CC_PARENT_SPAN_ID" +[ -z "${CC_ROOT_SPAN_ID:-}" ] || set -- "$@" --root-span-id "$CC_ROOT_SPAN_ID" +[ -z "${BRAINTRUST_ADDITIONAL_METADATA:-}" ] \ + || set -- "$@" --additional-metadata "$BRAINTRUST_ADDITIONAL_METADATA" +[ -z "${CC_EXPERIMENT_ID:-}" ] || set -- "$@" --experiment-id "$CC_EXPERIMENT_ID" + +INPUT=$(cat) +if [ -n "${BRAINTRUST_RECORD_DIR:-}" ] && command -v jq >/dev/null 2>&1; then + mkdir -p "$BRAINTRUST_RECORD_DIR/transcripts" 2>/dev/null || true + printf '%s' "$INPUT" | jq -c --arg ts "$(date -u +%Y-%m-%dT%H:%M:%SZ)" \ + '{hook:(.hook_event_name // ""),ts:$ts,payload:.}' \ + >>"$BRAINTRUST_RECORD_DIR/events.ndjson" 2>/dev/null || true + for field in transcript_path agent_transcript_path; do + transcript=$(printf '%s' "$INPUT" | jq -r --arg field "$field" '.[$field] // empty' 2>/dev/null) + if [ -n "$transcript" ] && [ -f "$transcript" ]; then + cp "$transcript" "$BRAINTRUST_RECORD_DIR/transcripts/$(basename "$transcript")" 2>/dev/null || true + fi + done +fi + +printf '%s' "$INPUT" | "$BT_BIN" "$@" || log "bt daemon hook failed non-fatally" +exit 0 diff --git a/src/plugins/claude/content/plugins/trace-claude-code/hooks/common.sh b/src/plugins/claude/content/plugins/trace-claude-code/hooks/common.sh deleted file mode 100755 index 151c1c8..0000000 --- a/src/plugins/claude/content/plugins/trace-claude-code/hooks/common.sh +++ /dev/null @@ -1,1323 +0,0 @@ -#!/bin/bash -### -# Common utilities for Braintrust Claude Code tracing hooks -### - -# Config -export LOG_FILE="$HOME/.claude/state/braintrust_hook.log" -export CACHE_FILE="$HOME/.claude/state/braintrust_cache.json" -export SESSION_STATE_DIR="$HOME/.claude/state/braintrust_sessions" -export QUEUE_DIR="$HOME/.claude/state/braintrust_queue" -export DEBUG="${BRAINTRUST_CC_DEBUG:-false}" -export API_KEY="${BRAINTRUST_API_KEY}" -export PROJECT="${BRAINTRUST_CC_PROJECT:-claude-code}" -export APP_URL="${BRAINTRUST_APP_URL:-https://www.braintrust.dev}" - -# If true, enqueue_span processes jobs inline rather than spawning a worker. -# Used by tests and as a debugging knob. Default: false (async worker). -export BRAINTRUST_SYNC_QUEUE="${BRAINTRUST_SYNC_QUEUE:-false}" - -# How long drain_queue will wait (seconds) before giving up. Hooks must -# never block Claude Code forever. -export BRAINTRUST_DRAIN_TIMEOUT="${BRAINTRUST_DRAIN_TIMEOUT:-60}" - -# How long the worker.lock mtime can be stale (seconds) before a session -# is considered crashed and its queue dir is swept on the next -# session_start. The worker refreshes its lock every loop iteration -# (~0.2s), so 5 minutes is ~1500x margin against transient slowness. -export BRAINTRUST_WORKER_STALE_SECS="${BRAINTRUST_WORKER_STALE_SECS:-300}" - -# Parent span configuration (for attaching to an existing trace) -# If either is set, we're attaching to an existing trace -# Each defaults to the other if not set -if [ -n "${CC_PARENT_SPAN_ID:-}" ] && [ -z "${CC_ROOT_SPAN_ID:-}" ]; then - export CC_ROOT_SPAN_ID="$CC_PARENT_SPAN_ID" -elif [ -n "${CC_ROOT_SPAN_ID:-}" ] && [ -z "${CC_PARENT_SPAN_ID:-}" ]; then - export CC_PARENT_SPAN_ID="$CC_ROOT_SPAN_ID" -fi -export CC_PARENT_SPAN_ID="${CC_PARENT_SPAN_ID:-}" -export CC_ROOT_SPAN_ID="${CC_ROOT_SPAN_ID:-}" - -# Experiment mode configuration -# If CC_EXPERIMENT_ID is set, spans are inserted into the experiment instead of project_logs -export CC_EXPERIMENT_ID="${CC_EXPERIMENT_ID:-}" - -# Ensure top-level directories exist. Per-session sub-trees under -# $QUEUE_DIR// are created on demand by enqueue_span. -mkdir -p "$(dirname "$LOG_FILE")" -mkdir -p "$(dirname "$CACHE_FILE")" -mkdir -p "$SESSION_STATE_DIR" -mkdir -p "$QUEUE_DIR" - -# Logging (defined early so other functions can use it) -log() { echo "$(date '+%Y-%m-%d %H:%M:%S') [$1] $2" >> "$LOG_FILE"; } - -# Check if a value is truthy (true, 1, yes, on - case insensitive) -is_truthy() { - local val="$(echo "$1" | tr '[:upper:]' '[:lower:]')" - [[ "$val" == "true" || "$val" == "1" || "$val" == "yes" || "$val" == "on" ]] -} - -debug() { is_truthy "$DEBUG" && log "DEBUG" "$1" || true; } - -### -# Hook input recording (for capturing real Claude Code sessions to use -# as test fixtures). -# -# When BRAINTRUST_RECORD_DIR is set, every hook calls record_hook_input -# right after reading stdin. The function appends an NDJSON record to -# $BRAINTRUST_RECORD_DIR/events.ndjson and, for the Stop hook, copies the -# transcript file referenced in the payload to -# $BRAINTRUST_RECORD_DIR/transcripts/. -# -# To capture a session: -# export BRAINTRUST_RECORD_DIR=~/my-session-fixture -# -# -# Recordings are then replayed in tests via test/helpers/replay.sh. -### -record_hook_input() { - local hook_name="$1" - local payload="$2" - - [ -z "${BRAINTRUST_RECORD_DIR:-}" ] && return 0 - - mkdir -p "$BRAINTRUST_RECORD_DIR" "$BRAINTRUST_RECORD_DIR/transcripts" 2>/dev/null || return 0 - - local ts events_file - ts=$(get_timestamp 2>/dev/null || date -u +"%Y-%m-%dT%H:%M:%S.000Z") - events_file="$BRAINTRUST_RECORD_DIR/events.ndjson" - - # Build the record. payload may already be valid JSON; if not, treat as string. - local payload_field - if [ -n "$payload" ] && echo "$payload" | jq -e . >/dev/null 2>&1; then - payload_field=$(echo "$payload" | jq -c .) - else - payload_field=$(jq -nc --arg p "$payload" '$p') - fi - - # Always label the event with Claude Code's canonical event name. The - # payload carries it as `hook_event_name` (e.g. "SessionStart", - # "PostToolUse", "PreCompact"); fall back to the caller-supplied name - # only when the payload doesn't include it. This gives the recording a - # single CamelCase namespace so replay can dispatch every event through - # hooks.json exactly the way Claude Code does. - local event_name - event_name=$(echo "$payload_field" | jq -r '.hook_event_name // empty' 2>/dev/null) - [ -z "$event_name" ] && event_name="$hook_name" - hook_name="$event_name" - - local record - record=$(jq -nc \ - --arg ts "$ts" \ - --arg hook "$hook_name" \ - --argjson payload "$payload_field" \ - '{ts: $ts, hook: $hook, payload: $payload}' 2>/dev/null) || return 0 - - # Atomic append: claim an exclusive lock via mkdir, write, release. - # Parallel PostToolUse hooks can otherwise interleave bytes into the - # NDJSON file, corrupting fixture lines (one torn line makes jq reject - # the whole file at replay time). mkdir is the only portable lock - # primitive we have (flock is not available on macOS by default). - # - # If we can't acquire the lock within ~500ms we DROP the record and - # log a warning rather than fall back to an unlocked write. Recording - # is opt-in and best-effort; losing one record on contention is - # strictly better than producing a fixture that won't parse. - # - # If a previous writer was killed and left the lock dir behind, the - # directory's mtime will be older than RECORD_LOCK_STALE_SECS and we - # forcibly remove it before retrying (same pattern as worker.lock). - # - # 30s threshold balances two concerns: - # - Crash recovery: a SIGKILLed holder needs to be reclaimed. - # - False preemption: the recording lock has no heartbeat, so a - # healthy holder stuck on slow disk I/O (Time Machine snapshots, - # disk pressure) must not be preempted while still working. The - # critical section is one printf >> file, so 30s is ~6 orders of - # magnitude above the expected duration. - local lock_dir="$events_file.lock" - local stale_secs=30 - local i acquired=0 - for i in 1 2 3 4 5 6 7 8 9 10; do - if mkdir "$lock_dir" 2>/dev/null; then - acquired=1 - break - fi - # Lock is held - check whether it's stale. - local lock_mtime now age - lock_mtime=$(stat -f '%m' "$lock_dir" 2>/dev/null \ - || stat -c '%Y' "$lock_dir" 2>/dev/null \ - || echo 0) - now=$(date +%s) - age=$((now - lock_mtime)) - if [ "$age" -gt "$stale_secs" ]; then - # Lock is stale; reclaim it. - rmdir "$lock_dir" 2>/dev/null || true - continue - fi - sleep 0.05 - done - - if [ "$acquired" -eq 1 ]; then - printf '%s\n' "$record" >> "$events_file" 2>/dev/null || true - rmdir "$lock_dir" 2>/dev/null || true - else - log "WARN" "record_hook_input: dropped $hook_name record after lock contention timeout" - return 0 - fi - - # Snapshot any transcript files referenced by this event so the - # recording can be replayed deterministically. (Path rewriting from - # absolute to fixture-relative happens at replay time in - # test/helpers/replay.sh, not here; we only copy the files.) - # - # Two cases: - # - Stop carries `transcript_path` (the main conversation transcript). - # - SubagentStop carries `agent_transcript_path` (the sub-agent's own - # transcript, which holds its model calls - e.g. haiku - and which - # Claude Code may clean up shortly after the agent finishes, so we - # must snapshot it now, while it still exists). - # - # All transcripts land flat in transcripts/. Basenames are globally - # unique (main: ".jsonl"; agent: "agent-.jsonl"), so there - # is no collision and replay can resolve any of them by basename. - _snapshot_transcript() { - local src="$1" - [ -n "$src" ] && [ -f "$src" ] || return 0 - local base - base=$(basename "$src") - cp "$src" "$BRAINTRUST_RECORD_DIR/transcripts/$base" 2>/dev/null || true - } - - case "$hook_name" in - Stop) - _snapshot_transcript "$(echo "$payload" | jq -r '.transcript_path // empty' 2>/dev/null)" - ;; - SubagentStop) - _snapshot_transcript "$(echo "$payload" | jq -r '.agent_transcript_path // empty' 2>/dev/null)" - ;; - esac -} - -### -# Cache management (shared across sessions, used for API URL and project IDs) -# Uses simple file-based caching - minor races here are harmless (just extra API calls) -### - -get_cache_value() { - local key="$1" - # Use --arg + bracket lookup so keys containing dashes/dots/etc work - [ -f "$CACHE_FILE" ] && jq -r --arg k "$key" '.[$k] // empty' "$CACHE_FILE" 2>/dev/null || echo "" -} - -set_cache_value() { - local key="$1" - local value="$2" - local cache - cache=$([ -f "$CACHE_FILE" ] && cat "$CACHE_FILE" 2>/dev/null || echo '{}') - cache=$(echo "$cache" | jq --arg k "$key" --arg v "$value" '.[$k] = $v' 2>/dev/null) || return 0 - local tmp="$CACHE_FILE.tmp.$$" - echo "$cache" > "$tmp" && mv "$tmp" "$CACHE_FILE" -} - -# Resolve API URL via login endpoint (with caching) -resolve_api_url() { - # Check for explicit override first - if [ -n "${BRAINTRUST_API_URL:-}" ]; then - echo "$BRAINTRUST_API_URL" - return 0 - fi - - # Check cache - local cached_url - cached_url=$(get_cache_value "api_url") - if [ -n "$cached_url" ]; then - echo "$cached_url" - return 0 - fi - - # Login to discover API URL - if [ -z "$API_KEY" ]; then - echo "https://api.braintrust.dev" - return 0 - fi - - local resp http_code - resp=$(curl -s -w "\n%{http_code}" -X POST -H "Authorization: Bearer $API_KEY" "$APP_URL/api/apikey/login" 2>/dev/null) - http_code=$(echo "$resp" | tail -1) - resp=$(echo "$resp" | sed '$d') - - if [ "$http_code" = "401" ] || [ "$http_code" = "403" ]; then - log "ERROR" "Braintrust authentication failed (HTTP $http_code) at $APP_URL/api/apikey/login - BRAINTRUST_API_KEY appears to be invalid or expired. Check your API key at $APP_URL/app/settings?subroute=api-keys" - # Fall back to default API URL so callers can produce a definitive auth error too - echo "https://api.braintrust.dev" - return 0 - fi - - if [ "$http_code" != "200" ]; then - log "WARN" "Braintrust login endpoint returned HTTP $http_code at $APP_URL/api/apikey/login: $resp" - fi - - local api_url - local org_name="${BRAINTRUST_ORG_NAME:-}" - - if [ -n "$org_name" ]; then - # Filter by org name if specified - api_url=$(echo "$resp" | jq -r --arg name "$org_name" \ - '.org_info[] | select(.name == $name) | .api_url // empty' 2>/dev/null | head -1) - else - # Use first org - api_url=$(echo "$resp" | jq -r '.org_info[0].api_url // empty' 2>/dev/null) - fi - - if [ -n "$api_url" ]; then - set_cache_value "api_url" "$api_url" - echo "$api_url" - return 0 - fi - - # Fall back to default - echo "https://api.braintrust.dev" -} - -# Initialize API_URL (call resolve_api_url lazily when needed) -get_api_url() { - if [ -z "${_RESOLVED_API_URL:-}" ]; then - _RESOLVED_API_URL=$(resolve_api_url) - fi - echo "$_RESOLVED_API_URL" -} - -# Check if tracing is enabled -tracing_enabled() { - is_truthy "$TRACE_TO_BRAINTRUST" -} - -# Validate requirements -check_requirements() { - for cmd in jq curl uuidgen; do - command -v "$cmd" &>/dev/null || { log "ERROR" "$cmd not installed"; return 1; } - done - [ -z "$API_KEY" ] && { log "ERROR" "BRAINTRUST_API_KEY not set"; return 1; } - return 0 -} - -# Get or create project ID (cached per project name) -get_project_id() { - local name="$1" - local cache_key="project_id_$name" - - # Check cache first - local cached_id - cached_id=$(get_cache_value "$cache_key") - if [ -n "$cached_id" ]; then - echo "$cached_id" - return 0 - fi - - local encoded_name - encoded_name=$(printf '%s' "$name" | jq -sRr @uri) - - # Try to get existing project - local api_url - api_url=$(get_api_url) - local resp http_code - resp=$(curl -s -w "\n%{http_code}" -H "Authorization: Bearer $API_KEY" "$api_url/v1/project?project_name=$encoded_name" 2>/dev/null) - http_code=$(echo "$resp" | tail -1) - resp=$(echo "$resp" | sed '$d') - - if [ "$http_code" = "401" ] || [ "$http_code" = "403" ]; then - log "ERROR" "Braintrust authentication failed (HTTP $http_code) - BRAINTRUST_API_KEY is invalid, expired, or lacks permission. Get a new key at $APP_URL/app/settings?subroute=api-keys and set BRAINTRUST_API_KEY. Response: $resp" - return 1 - fi - - local pid - pid=$(echo "$resp" | jq -r '.id // empty' 2>/dev/null) - - if [ -n "$pid" ]; then - set_cache_value "$cache_key" "$pid" - echo "$pid" - return 0 - fi - - if [ "$http_code" != "200" ] && [ "$http_code" != "404" ]; then - log "WARN" "Project lookup returned HTTP $http_code at $api_url/v1/project: $resp" - fi - - # Create project. Build the JSON body with jq so any special chars - # (quotes, backslashes, control chars) in the project name are - # properly escaped rather than interpolated raw into a string literal. - debug "Creating project: $name" - local create_body - create_body=$(jq -nc --arg name "$name" '{name: $name}') - resp=$(curl -s -w "\n%{http_code}" -X POST -H "Authorization: Bearer $API_KEY" -H "Content-Type: application/json" \ - -d "$create_body" "$api_url/v1/project" 2>/dev/null) - http_code=$(echo "$resp" | tail -1) - resp=$(echo "$resp" | sed '$d') - - if [ "$http_code" = "401" ] || [ "$http_code" = "403" ]; then - log "ERROR" "Braintrust authentication failed (HTTP $http_code) while creating project '$name' - BRAINTRUST_API_KEY is invalid, expired, or lacks permission. Get a new key at $APP_URL/app/settings?subroute=api-keys. Response: $resp" - return 1 - fi - - pid=$(echo "$resp" | jq -r '.id // empty' 2>/dev/null) - - if [ -n "$pid" ]; then - set_cache_value "$cache_key" "$pid" - echo "$pid" - return 0 - fi - - log "ERROR" "Failed to create project '$name' (HTTP $http_code) at $api_url/v1/project: $resp" - return 1 -} - -# Check if we're in experiment mode -is_experiment_mode() { - [ -n "$CC_EXPERIMENT_ID" ] -} - -# Get the insert endpoint URL based on mode (experiment vs project_logs) -get_insert_endpoint() { - local object_id="$1" - local api_url - api_url=$(get_api_url) - - if is_experiment_mode; then - echo "$api_url/v1/experiment/$CC_EXPERIMENT_ID/insert" - else - echo "$api_url/v1/project_logs/$object_id/insert" - fi -} - -# Low-level HTTP insert: POSTs a single span event to the Braintrust insert -# endpoint and returns the inserted row id on stdout. Used by the queue -# worker; hooks should call enqueue_span() instead. -# -# In experiment mode, project_id is ignored and CC_EXPERIMENT_ID is used. -_http_insert_span() { - local project_id="$1" - local event_json="$2" - - event_json=$(add_span_origin_context "$event_json") || { - log "ERROR" "Insert aborted: failed to add span origin context" - return 1 - } - - debug "Inserting span: $(echo "$event_json" | jq -c '.')" - - if [ -z "$API_KEY" ]; then - log "ERROR" "API_KEY is empty - check BRAINTRUST_API_KEY env var" - return 1 - fi - - local endpoint - endpoint=$(get_insert_endpoint "$project_id") - debug "Insert endpoint: $endpoint" - - # Wrap the (already-jq-built) event in the insert envelope via jq. - # This validates the event is well-formed JSON before we POST it and - # avoids hand-crafted string concatenation around the body. - local body - body=$(jq -nc --argjson event "$event_json" '{events: [$event]}') || { - log "ERROR" "Insert aborted: event JSON failed to parse" - return 1 - } - - local resp http_code - resp=$(curl -s -w "\n%{http_code}" -X POST \ - -H "Authorization: Bearer $API_KEY" \ - -H "Content-Type: application/json" \ - -d "$body" \ - "$endpoint" 2>&1) - - http_code=$(echo "$resp" | tail -1) - resp=$(echo "$resp" | sed '$d') - - if [ "$http_code" != "200" ]; then - log "ERROR" "Insert failed (HTTP $http_code) to $endpoint: $resp" - return 1 - fi - - local row_id - row_id=$(echo "$resp" | jq -r '.row_ids[0] // empty' 2>/dev/null) - - if [ -n "$row_id" ]; then - echo "$row_id" - return 0 - else - log "WARN" "Insert returned empty row_ids: $resp" - return 1 - fi -} - -detect_span_origin_environment_json() { - if [ -n "${BRAINTRUST_ENVIRONMENT_TYPE:-}" ] || [ -n "${BRAINTRUST_ENVIRONMENT_NAME:-}" ]; then - jq -nc \ - --arg type "$BRAINTRUST_ENVIRONMENT_TYPE" \ - --arg name "${BRAINTRUST_ENVIRONMENT_NAME:-}" \ - '$ARGS.named | with_entries(select(.value != ""))' - return 0 - fi - if [ -n "${GITHUB_ACTIONS:-}" ]; then jq -nc '{type:"ci", name:"github_actions"}'; return 0; fi - if [ -n "${GITLAB_CI:-}" ]; then jq -nc '{type:"ci", name:"gitlab_ci"}'; return 0; fi - if [ -n "${CIRCLECI:-}" ]; then jq -nc '{type:"ci", name:"circleci"}'; return 0; fi - if [ -n "${BUILDKITE:-}" ]; then jq -nc '{type:"ci", name:"buildkite"}'; return 0; fi - if [ -n "${CI:-}" ]; then jq -nc '{type:"ci", name:"ci"}'; return 0; fi - if [ -n "${VERCEL:-}" ]; then jq -nc '{type:"server", name:"vercel"}'; return 0; fi - if [ -n "${NETLIFY:-}" ]; then jq -nc '{type:"server", name:"netlify"}'; return 0; fi - if [ -n "${AWS_LAMBDA_FUNCTION_NAME:-}" ] || [ -n "${AWS_EXECUTION_ENV:-}" ]; then jq -nc '{type:"server", name:"aws_lambda"}'; return 0; fi - if [ "${NODE_ENV:-}" = "production" ] || [ "${NODE_ENV:-}" = "staging" ]; then - jq -nc --arg name "$NODE_ENV" '{type:"server", name:$name}' - return 0 - fi - if [ "${NODE_ENV:-}" = "development" ] || [ "${NODE_ENV:-}" = "local" ]; then - jq -nc --arg name "$NODE_ENV" '{type:"local", name:$name}' - return 0 - fi - jq -nc 'null' -} - -add_span_origin_context() { - local event_json="$1" - local version environment - version=$(get_plugin_version) - environment=$(detect_span_origin_environment_json) - jq -c \ - --arg version "$version" \ - --argjson environment "$environment" \ - '.context = ((.context // {}) + { - span_origin: ({ - name: "braintrust.plugin.claude-code", - version: $version, - instrumentation: {name: "claude-code-hooks"} - } + (if $environment == null then {} else {environment: $environment} end)) - })' <<< "$event_json" -} - -### -# Queue layer (per-session) -# -# Hooks call enqueue_span() to schedule a span insert without blocking. -# Each Claude Code session gets its own queue subtree and its own -# background worker (hooks/worker.sh). This isolates sessions from one -# another: one session's slow inserts can't delay another's, and one -# session's worker crash doesn't strand another's spans. -# -# Filesystem layout: -# $QUEUE_DIR//pending/-.json -# $QUEUE_DIR//processing/-.json -# $QUEUE_DIR//worker.lock -# -# The worker.lock file holds the worker PID, and the worker refreshes its -# mtime on every loop iteration. A future session_start invocation can -# detect a crashed session by finding stale lock files (mtime older than -# $BRAINTRUST_WORKER_STALE_SECS). -# -# Job file is one JSON object: {project_id, experiment_id, event} -### - -# Return the queue dir for a session (no trailing slash). -session_queue_dir() { - local session_id="$1" - echo "$QUEUE_DIR/$session_id" -} - -# Create the on-disk layout for a session's queue if it doesn't already exist. -_ensure_session_queue() { - local session_id="$1" - local dir - dir=$(session_queue_dir "$session_id") - mkdir -p "$dir/pending" "$dir/processing" -} - -# Generate a monotonic-ish job filename. Uses epoch-ns + uuid suffix so the -# directory listing sorts in roughly insertion order without needing a -# central counter (multiple processes can enqueue concurrently safely). -# -# FIFO caveats: -# - Across distinct hook invocations, timestamps differ enough that -# create-then-merge orderings (e.g. user_prompt_submit creating a -# Turn span and a later stop_hook enqueuing a merge update to it) -# are reliably ordered correctly. -# - Within a SINGLE process that enqueues multiple spans back-to-back, -# timestamps can collide. We tie-break on uuid suffix, which is -# effectively random - meaning two enqueues from the same process at -# the same timestamp are NOT guaranteed FIFO. Don't enqueue a span -# and its merge from the same hook script. -# - On macOS without python3, the fallback drops to second-level -# precision (appending `000000000`), widening the collision window -# dramatically. Prefer python3 (or coreutils `gdate +%s%N`) when -# available to keep nanosecond precision. -_queue_job_name() { - local ts uuid - # Linux: `date +%s%N` gives epoch-nanoseconds directly. - if date +%s%N 2>/dev/null | grep -qv 'N$'; then - ts=$(date +%s%N) - elif command -v python3 >/dev/null 2>&1; then - # macOS: prefer python's time_ns for true ns precision. - ts=$(python3 -c 'import time; print(time.time_ns())') - elif command -v gdate >/dev/null 2>&1; then - # macOS with GNU coreutils installed. - ts=$(gdate +%s%N) - else - # Last resort: second-level precision (FIFO collisions likely - # under high load). See caveat above. - ts=$(date +%s)000000000 - fi - uuid=$(generate_uuid 2>/dev/null || echo "$$-$RANDOM") - echo "${ts}-${uuid}.json" -} - -# Enqueue a span insert for a specific session. Returns immediately after -# writing the job file. If BRAINTRUST_SYNC_QUEUE is truthy, processes the -# job inline instead (used in tests and as a debug fallback). -# -# Args: session_id project_id event_json -enqueue_span() { - local session_id="$1" - local project_id="$2" - local event_json="$3" - - if [ -z "$session_id" ]; then - log "ERROR" "enqueue_span called without session_id" - return 1 - fi - - # Build the job JSON - local job - job=$(jq -nc \ - --arg pid "$project_id" \ - --arg exp "${CC_EXPERIMENT_ID:-}" \ - --argjson event "$event_json" \ - '{type: "insert_span", project_id: $pid, experiment_id: $exp, event: $event}') - - # Synchronous mode: process inline. This is what tests use, and it's - # also the fallback if a user wants the old blocking behavior. - if is_truthy "$BRAINTRUST_SYNC_QUEUE"; then - _process_job_inline "$job" - return $? - fi - - # Async mode: write to /pending/ and ensure a worker is running. - _ensure_session_queue "$session_id" - local sdir - sdir=$(session_queue_dir "$session_id") - local job_file="$sdir/pending/$(_queue_job_name)" - - # Write atomically: write to tmp, then rename. - local tmp="${job_file}.tmp.$$" - echo "$job" > "$tmp" || return 1 - mv "$tmp" "$job_file" || return 1 - - debug "Enqueued job for session $session_id: $(basename "$job_file")" - ensure_worker_running "$session_id" - return 0 -} - -# Process a job in the current process. Returns the same exit code as -# _http_insert_span. Used by both sync mode and the worker. -_process_job_inline() { - local job="$1" - local project_id experiment_id event_json - project_id=$(echo "$job" | jq -r '.project_id // ""') - experiment_id=$(echo "$job" | jq -r '.experiment_id // ""') - event_json=$(echo "$job" | jq -c '.event') - - # Temporarily set CC_EXPERIMENT_ID so _http_insert_span routes correctly. - local prev_exp="${CC_EXPERIMENT_ID:-}" - CC_EXPERIMENT_ID="$experiment_id" - _http_insert_span "$project_id" "$event_json" >/dev/null - local rc=$? - CC_EXPERIMENT_ID="$prev_exp" - return $rc -} - -# Ensure a background worker is running for the given session. If one is -# already alive (worker.lock exists with a live PID), do nothing. -# Otherwise fork a new worker. -# -# Args: session_id -ensure_worker_running() { - is_truthy "$BRAINTRUST_SYNC_QUEUE" && return 0 - - local session_id="$1" - if [ -z "$session_id" ]; then - log "ERROR" "ensure_worker_running called without session_id" - return 1 - fi - - local sdir lock_file - sdir=$(session_queue_dir "$session_id") - lock_file="$sdir/worker.lock" - - # If a live worker holds the lock, we're done. - if [ -f "$lock_file" ]; then - local pid - pid=$(cat "$lock_file" 2>/dev/null) - if [ -n "$pid" ] && kill -0 "$pid" 2>/dev/null; then - return 0 - fi - # Stale lock; clean it up before spawning. - rm -f "$lock_file" - fi - - # Fork a new worker, scoped to this session. - local script_dir worker_script - script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" - worker_script="$script_dir/worker.sh" - - if [ ! -f "$worker_script" ]; then - log "ERROR" "Worker script not found: $worker_script" - return 1 - fi - - _ensure_session_queue "$session_id" - - # Detach the worker so it persists after this hook exits. - nohup bash "$worker_script" "$session_id" >/dev/null 2>&1 & - disown 2>/dev/null || true - debug "Spawned worker for session $session_id (parent pid=$$)" - return 0 -} - -# Block until this session's queue is drained, or BRAINTRUST_DRAIN_TIMEOUT -# elapses. Used by session_end.sh to ensure all spans are flushed before -# Claude Code exits. -# -# Args: session_id [timeout_secs] -drain_queue() { - is_truthy "$BRAINTRUST_SYNC_QUEUE" && return 0 - - local session_id="$1" - local timeout="${2:-$BRAINTRUST_DRAIN_TIMEOUT}" - - if [ -z "$session_id" ]; then - log "ERROR" "drain_queue called without session_id" - return 1 - fi - - local deadline=$(( $(date +%s) + timeout )) - debug "Draining queue for session $session_id (timeout=${timeout}s)" - - while [ "$(date +%s)" -lt "$deadline" ]; do - if ! _queue_has_jobs "$session_id"; then - debug "Queue drained successfully for session $session_id" - return 0 - fi - # Make sure a worker is running on every iteration so a transient - # worker death does not stall the drain. - ensure_worker_running "$session_id" - sleep 0.2 - done - - local remaining - remaining=$(_queue_pending_count "$session_id") - log "WARN" "drain_queue timed out for session $session_id after ${timeout}s with $remaining job(s) still pending" - return 1 -} - -# True if any jobs are in this session's pending/ or processing/ dir. -# Args: session_id -_queue_has_jobs() { - local session_id="$1" - [ "$(_queue_pending_count "$session_id")" -gt 0 ] || \ - [ "$(_queue_processing_count "$session_id")" -gt 0 ] -} - -# Args: session_id -_queue_pending_count() { - local session_id="$1" - local sdir - sdir=$(session_queue_dir "$session_id") - local n - n=$(find "$sdir/pending" -maxdepth 1 -name '*.json' -type f 2>/dev/null | wc -l) - echo "${n// /}" -} - -# Args: session_id -_queue_processing_count() { - local session_id="$1" - local sdir - sdir=$(session_queue_dir "$session_id") - local n - n=$(find "$sdir/processing" -maxdepth 1 -name '*.json' -type f 2>/dev/null | wc -l) - echo "${n// /}" -} - -# Sweep $QUEUE_DIR for session dirs that look crashed (worker.lock mtime -# older than BRAINTRUST_WORKER_STALE_SECS, or lock missing while pending -# files exist). For each stale session: kill the lock-holder PID if still -# around, sweep processing/ back to pending/, and respawn a worker to -# drain anything that's left. Empty stale dirs are removed. -# -# Skips the session_id passed as an argument (the current session), if any. -# -# Args: [current_session_id] -sweep_dead_sessions() { - is_truthy "$BRAINTRUST_SYNC_QUEUE" && return 0 - - local current_session="${1:-}" - local stale_secs="$BRAINTRUST_WORKER_STALE_SECS" - local now - now=$(date +%s) - - [ -d "$QUEUE_DIR" ] || return 0 - - local entry sid sdir lock_file pid lock_mtime age pending processing - for entry in "$QUEUE_DIR"/*; do - [ -d "$entry" ] || continue - sid=$(basename "$entry") - [ "$sid" = "$current_session" ] && continue - - sdir="$entry" - lock_file="$sdir/worker.lock" - pending=$(_queue_pending_count "$sid") - processing=$(_queue_processing_count "$sid") - - if [ -f "$lock_file" ]; then - # _file_mtime returns 0 if it can't stat the file - lock_mtime=$(_file_mtime "$lock_file") - age=$(( now - lock_mtime )) - if [ "$age" -lt "$stale_secs" ]; then - # Looks alive (or recently was). Leave it alone. - continue - fi - # Stale lock - try to kill the holder (harmless if already gone). - pid=$(cat "$lock_file" 2>/dev/null) - if [ -n "$pid" ] && kill -0 "$pid" 2>/dev/null; then - log "WARN" "Sweeping dead session $sid: killing stale worker pid=$pid (lock age=${age}s)" - kill "$pid" 2>/dev/null || true - else - log "INFO" "Sweeping dead session $sid (lock age=${age}s, pid=$pid not running)" - fi - rm -f "$lock_file" - elif [ "$pending" -eq 0 ] && [ "$processing" -eq 0 ]; then - # No lock and no jobs - clean leftover empty dir. - rmdir "$sdir/pending" "$sdir/processing" 2>/dev/null || true - rmdir "$sdir" 2>/dev/null || true - continue - fi - - # Recover any in-flight jobs by moving them back to pending. - local f - for f in "$sdir"/processing/*.json; do - [ -e "$f" ] || continue - mv "$f" "$sdir/pending/$(basename "$f")" 2>/dev/null || true - done - - pending=$(_queue_pending_count "$sid") - if [ "$pending" -gt 0 ]; then - log "INFO" "Recovering $pending orphaned span(s) from crashed session $sid" - ensure_worker_running "$sid" - else - # Nothing left to do. Tidy up. - rmdir "$sdir/pending" "$sdir/processing" 2>/dev/null || true - rmdir "$sdir" 2>/dev/null || true - fi - done -} - -# Stat a file's mtime in epoch seconds, portable across macOS (BSD stat) -# and Linux (GNU stat). Returns 0 if the file can't be stat'd. -_file_mtime() { - local f="$1" - [ -f "$f" ] || { echo 0; return; } - stat -f '%m' "$f" 2>/dev/null || stat -c '%Y' "$f" 2>/dev/null || echo 0 -} - -### -# Per-session state management -# Each session has its own state file: $SESSION_STATE_DIR/{session_id}.json -# This eliminates race conditions between sessions entirely. -### - -# Get the state file path for a session -get_session_state_file() { - local session_id="$1" - echo "$SESSION_STATE_DIR/${session_id}.json" -} - -# Get a value from session state -get_session_state() { - local session_id="$1" - local key="$2" - local state_file - state_file=$(get_session_state_file "$session_id") - # Use --arg + bracket lookup so keys containing dashes/dots/etc work - [ -f "$state_file" ] && jq -r --arg k "$key" '.[$k] // empty' "$state_file" 2>/dev/null || echo "" -} - -# Set a value in session state -set_session_state() { - local session_id="$1" - local key="$2" - local value="$3" - local state_file state - state_file=$(get_session_state_file "$session_id") - state=$([ -f "$state_file" ] && cat "$state_file" || echo '{}') - state=$(echo "$state" | jq --arg k "$key" --arg v "$value" '.[$k] = $v') - echo "$state" > "$state_file" -} - -# Atomic check-and-set for session state - returns 0 if set, 1 if already exists -# Uses mkdir as an atomic lock for the specific session -check_and_set_session_state() { - local session_id="$1" - local key="$2" - local value="$3" - local state_file lock_dir - state_file=$(get_session_state_file "$session_id") - lock_dir="${state_file}.lock" - - # Try to acquire lock for this specific session - if ! mkdir "$lock_dir" 2>/dev/null; then - # Another process is initializing this session, wait briefly and check - sleep 0.1 - local existing - existing=$(get_session_state "$session_id" "$key") - if [ -n "$existing" ]; then - echo "$existing" - return 1 - fi - # Lock was released but key still not set - try again - rmdir "$lock_dir" 2>/dev/null || true - if ! mkdir "$lock_dir" 2>/dev/null; then - # Still can't get lock, just check and return - existing=$(get_session_state "$session_id" "$key") - if [ -n "$existing" ]; then - echo "$existing" - return 1 - fi - fi - fi - - # We have the lock - check if key already exists - local existing - existing=$(get_session_state "$session_id" "$key") - if [ -n "$existing" ]; then - rmdir "$lock_dir" 2>/dev/null || true - echo "$existing" - return 1 - fi - - # Set the value - set_session_state "$session_id" "$key" "$value" - rmdir "$lock_dir" 2>/dev/null || true - return 0 -} - -# Clean up old session state files (call periodically or from session_stop) -cleanup_old_sessions() { - local max_age_hours="${1:-24}" - local max_age_minutes=$((max_age_hours * 60)) - find "$SESSION_STATE_DIR" -name "*.json" -mmin "+$max_age_minutes" -delete 2>/dev/null || true - find "$SESSION_STATE_DIR" -name "*.lock" -mmin "+5" -delete 2>/dev/null || true -} - -# Generate a UUID -generate_uuid() { - uuidgen | tr '[:upper:]' '[:lower:]' -} - -# Get current ISO timestamp -get_timestamp() { - date -u +"%Y-%m-%dT%H:%M:%S.000Z" -} - -# Get system info for metadata -get_hostname() { - hostname 2>/dev/null || echo "unknown" -} - -get_username() { - whoami 2>/dev/null || echo "unknown" -} - -get_os() { - uname -s 2>/dev/null || echo "unknown" -} - -redact_git_remote_url() { - local remote="$1" - [ -z "$remote" ] && return 0 - - case "$remote" in - *://*@*) - local scheme="${remote%%://*}" - local rest="${remote#*://}" - echo "${scheme}://${rest#*@}" - ;; - *) - echo "$remote" - ;; - esac -} - -git_metadata_json() { - local cwd="$1" - if [ -z "$cwd" ]; then - echo '{}' - return 0 - fi - - local origin branch commit - origin=$(GIT_OPTIONAL_LOCKS=0 git -C "$cwd" remote get-url origin 2>/dev/null || true) - branch=$(GIT_OPTIONAL_LOCKS=0 git -C "$cwd" symbolic-ref --quiet --short HEAD 2>/dev/null || true) - commit=$(GIT_OPTIONAL_LOCKS=0 git -C "$cwd" rev-parse HEAD 2>/dev/null || true) - - origin=$(redact_git_remote_url "$origin") - - jq -cn \ - --arg origin "$origin" \ - --arg branch "$branch" \ - --arg commit "$commit" \ - '{ - git_origin_url: $origin, - git_branch: $branch, - git_commit_sha: $commit - } | with_entries(select(.value != ""))' -} - -# Version of this plugin, read from its plugin.json manifest. Cached after the -# first lookup. Returns "unknown" if it can't be read. -get_plugin_version() { - if [ -n "${_PLUGIN_VERSION:-}" ]; then - echo "$_PLUGIN_VERSION" - return - fi - local manifest="${SCRIPT_DIR:-$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)}/../.claude-plugin/plugin.json" - local v="" - [ -f "$manifest" ] && v=$(jq -r '.version // empty' "$manifest" 2>/dev/null) - _PLUGIN_VERSION="${v:-unknown}" - echo "$_PLUGIN_VERSION" -} - -# Version of the running Claude Code CLI. Prefers a version found in the -# session transcript (authoritative for the run that produced it); falls back -# to `claude --version`. Cached after the first lookup. Returns "unknown" if -# neither source is available. -# -# Args: [transcript_path] - optional transcript to read `.version` from. -get_claude_code_version() { - local transcript="${1:-}" - if [ -n "${_CC_VERSION:-}" ]; then - echo "$_CC_VERSION" - return - fi - local v="" - if [ -n "$transcript" ] && [ -f "$transcript" ]; then - v=$(jq -rc 'select(.version) | .version' "$transcript" 2>/dev/null | head -1) - fi - if [ -z "$v" ]; then - # e.g. "2.1.173 (Claude Code)" -> "2.1.173" - v=$(claude --version 2>/dev/null | awk '{print $1}') - fi - _CC_VERSION="${v:-unknown}" - echo "$_CC_VERSION" -} - -### -# Emit spans for a Claude Code transcript file (typically a sub-agent's own -# transcript), parented under a given span. This reproduces the same span -# structure the main conversation uses, so a sub-agent's Agent tool span -# subtree reads like a miniature conversation: -# -# Agent (tool, the parent) -# ├── (llm) - one model call (plan + tool_use) -# ├── (tool) - a tool the sub-agent invoked -# ├── (llm) - next model call (after the tool result) -# └── ... -# -# We walk the transcript chronologically, threading conversation history so -# each LLM span's `input` is the messages seen so far and its `output` is the -# assistant content plus OpenAI-style tool_calls. Each tool_result becomes a -# tool span. -# -# Token accounting mirrors stop_hook.sh: a single API response repeats across -# content-block lines sharing one requestId; input/cache are identical on each -# line (count once) while output_tokens streams cumulatively (take the max). -# -# Args: -# $1 transcript_file - path to the (sub-agent) transcript JSONL -# $2 session_id - session id (for enqueue) -# $3 project_id - project id (for enqueue) -# $4 root_span_id - root span id of the trace -# $5 parent_span_id - span the emitted spans should be children of -# -# Returns the number of LLM spans emitted on stdout (tool spans are not -# counted, to preserve the historical return-value contract). Best-effort: -# returns 0 and emits nothing if the file is missing or has no usable content. -emit_llm_spans_from_transcript() { - local transcript_file="$1" - local session_id="$2" - local project_id="$3" - local root_span_id="$4" - local parent_span_id="$5" - - [ -n "$transcript_file" ] && [ -f "$transcript_file" ] || { echo 0; return 0; } - - # Single jq pass: walk the transcript in order and emit an ordered NDJSON - # stream of "span directives", one per line. Each directive is either: - # {kind:"llm", ...metrics, input:, output:} - # {kind:"tool", name, input:, output:, ts} - # History is threaded so each llm directive carries the conversation as it - # stood when that call was made. Doing this in jq (rather than a bash loop) - # keeps multi-line text and tool JSON intact. - local directives - directives=$(jq -s -c ' - # Collapse assistant content-block lines of one response (same - # requestId) into a single logical message, taking max tokens and - # concatenating text / collecting tool_use blocks. - def assistant_calls: - [ .[] | select(.type=="assistant") | select(.message.usage != null) ] - | group_by(.requestId // .message.id) - | map({ - kind: "llm", - rid: (.[0].requestId // .[0].message.id), - ts: .[0].timestamp, - model: (.[0].message.model // "claude"), - input_tokens: ([ .[].message.usage.input_tokens // 0 ] | max), - output_tokens: ([ .[].message.usage.output_tokens // 0 ] | max), - cache_creation_tokens: ([ .[].message.usage.cache_creation_input_tokens // 0 ] | max), - cache_creation_5m_tokens: ([ .[].message.usage.cache_creation.ephemeral_5m_input_tokens // 0 ] | max), - cache_creation_1h_tokens: ([ .[].message.usage.cache_creation.ephemeral_1h_input_tokens // 0 ] | max), - cache_creation_has_split: any(.[]; (((.message.usage.cache_creation.ephemeral_5m_input_tokens // 0) + (.message.usage.cache_creation.ephemeral_1h_input_tokens // 0)) > 0)), - cache_read_tokens: ([ .[].message.usage.cache_read_input_tokens // 0 ] | max), - text: ( [ .[].message.content - | if type=="array" then [ .[]|select(.type=="text")|.text ]|join("\n") - elif type=="string" then . else "" end ] - | map(select(. != "")) | join("\n") ), - tool_calls: ( [ .[].message.content[]? - | select(.type=="tool_use") - | { id: .id, type: "function", - function: { name: .name, arguments: (.input|tojson) } } ] - | unique_by(.id) ) - }) - # group_by sorts by the grouping key (requestId / message.id), which - # is not guaranteed to match conversation order. Re-sort by timestamp - # so history threading and span emission follow chronological order. - | sort_by(.ts); - - # Tool results, keyed by the tool_use_id they answer. - def tool_results: - [ .[] - | select(.type=="user") - | (.message.content) as $c - | select(($c|type=="array") and ($c[0].type=="tool_result")) - | { kind: "tool", - tool_use_id: $c[0].tool_use_id, - ts: .timestamp, - output: ($c[0].content), - is_error: ($c[0].is_error // false) } ]; - - (assistant_calls) as $llms - | (tool_results) as $tools - # Index tool results by id so we can attach name/args from the matching - # tool_use and place them right after the llm call that issued them. - | ( reduce $tools[] as $t ({}; .[$t.tool_use_id] = $t) ) as $tool_by_id - # Build the ordered output: for each llm call, emit it (with input - # history threaded), then emit a tool span for each of its tool_calls - # that has a matching result. We thread { history, out } through a - # single reduce; the inner per-tool work is done with map/reduce - # expressions that update the accumulator directly (no nested `reduce - # (...) as $x`, which jq does not allow). - | reduce $llms[] as $call ( - { history: [], out: [] }; - # Assistant message object (content + optional tool_calls). - ( { role:"assistant", content:$call.text } - + ( if ($call.tool_calls|length)>0 then {tool_calls:$call.tool_calls} else {} end ) - ) as $assistant_msg - # The llm directive carries the current history as input. - | ( { - kind: "llm", - ts: $call.ts, - model: $call.model, - input_tokens: $call.input_tokens, - output_tokens: $call.output_tokens, - cache_creation_tokens: $call.cache_creation_tokens, - cache_creation_5m_tokens: $call.cache_creation_5m_tokens, - cache_creation_1h_tokens: $call.cache_creation_1h_tokens, - cache_creation_has_split: $call.cache_creation_has_split, - cache_read_tokens: $call.cache_read_tokens, - input: .history, - output: $assistant_msg - } ) as $llm_dir - # Resolved tool calls for this llm call (those with a matching - # result), in order. - | ( [ $call.tool_calls[] - | { tc: ., res: $tool_by_id[.id] } - | select(.res != null) ] ) as $resolved - # Tool directives to emit after the llm directive. - | ( [ $resolved[] | { - kind: "tool", - ts: .res.ts, - tool_use_id: .res.tool_use_id, - name: .tc.function.name, - input: (.tc.function.arguments), - output: (.res.output), - is_error: (.res.is_error // false) - } ] ) as $tool_dirs - # History additions: the assistant message, then each tool result. - | ( [ $assistant_msg ] - + [ $resolved[] | { role:"tool", tool_call_id:.tc.id, - content: (.res.output|tostring) } ] ) as $hist_add - | { - history: ( .history + $hist_add ), - out: ( .out + [ $llm_dir ] + $tool_dirs ) - } - ) - | .out[] - ' "$transcript_file" 2>/dev/null) - - [ -z "$directives" ] && { echo 0; return 0; } - - local emitted=0 - local dir - while IFS= read -r dir; do - [ -z "$dir" ] && continue - - local kind ts epoch span_id - kind=$(echo "$dir" | jq -r '.kind') - ts=$(echo "$dir" | jq -r '.ts // empty') - epoch=$(_iso_to_epoch "$ts") - span_id=$(generate_uuid) - - local event - if [ "$kind" = "llm" ]; then - # LLM span: input is the threaded conversation history, output is - # the assistant message (content + tool_calls). Build in one jq - # call so multi-line text survives. - event=$(echo "$dir" | jq -c \ - --arg id "$span_id" \ - --arg root_span_id "$root_span_id" \ - --arg parent "$parent_span_id" \ - --argjson epoch "$epoch" \ - '{ - id: $id, - span_id: $id, - root_span_id: $root_span_id, - span_parents: [$parent], - created: (.ts // (now|todate)), - input: .input, - output: .output, - metrics: ({ - start: $epoch, end: $epoch, - prompt_tokens: ( - .input_tokens - + .cache_read_tokens - + ( - if .cache_creation_has_split then - (.cache_creation_5m_tokens + .cache_creation_1h_tokens) - else - .cache_creation_tokens - end - ) - ), - completion_tokens: .output_tokens, - tokens: ( - .input_tokens - + .cache_read_tokens - + ( - if .cache_creation_has_split then - (.cache_creation_5m_tokens + .cache_creation_1h_tokens) - else - .cache_creation_tokens - end - ) - + .output_tokens - ), - prompt_cached_tokens: .cache_read_tokens - } + ( - if .cache_creation_has_split then - { - prompt_cache_creation_5m_tokens: .cache_creation_5m_tokens, - prompt_cache_creation_1h_tokens: .cache_creation_1h_tokens - } - else - {prompt_cache_creation_tokens: .cache_creation_tokens} - end - )), - metadata: { model: .model }, - span_attributes: { name: .model, type: "llm" } - }') - else - # Tool span: mirror post_tool_use.sh shape (name + tool metadata). - local tool_name - tool_name=$(echo "$dir" | jq -r '.name // "tool"') - local span_name - span_name=$(_subagent_tool_span_name "$tool_name" "$(echo "$dir" | jq -c '.input')") - event=$(echo "$dir" | jq -c \ - --arg id "$span_id" \ - --arg root_span_id "$root_span_id" \ - --arg parent "$parent_span_id" \ - --arg name "$span_name" \ - --arg tool "$tool_name" \ - --argjson epoch "$epoch" \ - '{ - id: $id, - span_id: $id, - root_span_id: $root_span_id, - span_parents: [$parent], - created: (.ts // (now|todate)), - input: (.input | (try fromjson catch .)), - output: .output, - metrics: { start: $epoch, end: $epoch }, - metadata: { - tool_name: $tool, - tool_call_id: .tool_use_id, - tool_approval: "approved" - }, - span_attributes: { name: $name, type: "tool" } - } - + (if .is_error then {error: (.output|tostring)} else {} end)') - fi - - if [ -n "$event" ] && enqueue_span "$session_id" "$project_id" "$event"; then - [ "$kind" = "llm" ] && emitted=$((emitted + 1)) - fi - done <<< "$directives" - - echo "$emitted" - return 0 -} - -# Derive a tool span display name the same way post_tool_use.sh does, so -# sub-agent tool spans read consistently with top-level tool spans. -# Args: tool_name, tool_input_json -_subagent_tool_span_name() { - local tool_name="$1" - local tool_input="$2" - case "$tool_name" in - Read|Write|Edit|MultiEdit) - local fp - fp=$(echo "$tool_input" | jq -r '(. | (try fromjson catch .)) | (.file_path // .path // empty)' 2>/dev/null) - [ -n "$fp" ] && echo "$tool_name: $(basename "$fp")" || echo "$tool_name" - ;; - Bash|Terminal) - local cmd - cmd=$(echo "$tool_input" | jq -r '(. | (try fromjson catch .)) | (.command // empty)' 2>/dev/null | head -c 50) - echo "Terminal: ${cmd:-command}" - ;; - mcp__*) - echo "$tool_name" | sed 's/mcp__/MCP: /' | sed 's/__/ - /' - ;; - *) - echo "$tool_name" - ;; - esac -} - -# Convert an ISO-8601 timestamp (UTC, e.g. 2026-06-11T03:01:33.000Z) to a -# Unix epoch. Falls back to the current time when parsing fails. Shared by -# transcript-parsing code that needs span start/end metrics. -_iso_to_epoch() { - local ts="$1" - [ -z "$ts" ] && { date +%s; return; } - local clean_ts="${ts%.*}" # strip .xxxZ - clean_ts="${clean_ts}+0000" # treat as UTC - date -j -f "%Y-%m-%dT%H:%M:%S%z" "$clean_ts" "+%s" 2>/dev/null || \ - date -d "$ts" "+%s" 2>/dev/null || \ - date +%s -} diff --git a/src/plugins/claude/content/plugins/trace-claude-code/hooks/hooks.json b/src/plugins/claude/content/plugins/trace-claude-code/hooks/hooks.json index a6a9e9b..ae7e201 100644 --- a/src/plugins/claude/content/plugins/trace-claude-code/hooks/hooks.json +++ b/src/plugins/claude/content/plugins/trace-claude-code/hooks/hooks.json @@ -5,7 +5,7 @@ "hooks": [ { "type": "command", - "command": "bash ${CLAUDE_PLUGIN_ROOT}/hooks/session_start.sh", + "command": "sh \"${CLAUDE_PLUGIN_ROOT}/bin/claude-hook.sh\"", "async": false } ] @@ -16,7 +16,7 @@ "hooks": [ { "type": "command", - "command": "bash ${CLAUDE_PLUGIN_ROOT}/hooks/user_prompt_submit.sh", + "command": "sh \"${CLAUDE_PLUGIN_ROOT}/bin/claude-hook.sh\"", "async": false } ] @@ -28,7 +28,7 @@ "hooks": [ { "type": "command", - "command": "bash ${CLAUDE_PLUGIN_ROOT}/hooks/post_tool_use.sh", + "command": "sh \"${CLAUDE_PLUGIN_ROOT}/bin/claude-hook.sh\"", "async": false } ] @@ -39,7 +39,7 @@ "hooks": [ { "type": "command", - "command": "bash ${CLAUDE_PLUGIN_ROOT}/hooks/stop_hook.sh", + "command": "sh \"${CLAUDE_PLUGIN_ROOT}/bin/claude-hook.sh\"", "async": false } ] @@ -50,7 +50,7 @@ "hooks": [ { "type": "command", - "command": "bash ${CLAUDE_PLUGIN_ROOT}/hooks/session_end.sh", + "command": "sh \"${CLAUDE_PLUGIN_ROOT}/bin/claude-hook.sh\"", "async": false } ] @@ -61,7 +61,7 @@ "hooks": [ { "type": "command", - "command": "bash ${CLAUDE_PLUGIN_ROOT}/hooks/record_event.sh Setup", + "command": "sh \"${CLAUDE_PLUGIN_ROOT}/bin/claude-hook.sh\"", "async": false } ] @@ -72,7 +72,7 @@ "hooks": [ { "type": "command", - "command": "bash ${CLAUDE_PLUGIN_ROOT}/hooks/user_prompt_expansion.sh", + "command": "sh \"${CLAUDE_PLUGIN_ROOT}/bin/claude-hook.sh\"", "async": false } ] @@ -84,7 +84,7 @@ "hooks": [ { "type": "command", - "command": "bash ${CLAUDE_PLUGIN_ROOT}/hooks/record_event.sh PreToolUse", + "command": "sh \"${CLAUDE_PLUGIN_ROOT}/bin/claude-hook.sh\"", "async": false } ] @@ -96,7 +96,7 @@ "hooks": [ { "type": "command", - "command": "bash ${CLAUDE_PLUGIN_ROOT}/hooks/record_event.sh PermissionRequest", + "command": "sh \"${CLAUDE_PLUGIN_ROOT}/bin/claude-hook.sh\"", "async": false } ] @@ -108,7 +108,7 @@ "hooks": [ { "type": "command", - "command": "bash ${CLAUDE_PLUGIN_ROOT}/hooks/permission_denied.sh", + "command": "sh \"${CLAUDE_PLUGIN_ROOT}/bin/claude-hook.sh\"", "async": false } ] @@ -120,7 +120,7 @@ "hooks": [ { "type": "command", - "command": "bash ${CLAUDE_PLUGIN_ROOT}/hooks/post_tool_use_failure.sh", + "command": "sh \"${CLAUDE_PLUGIN_ROOT}/bin/claude-hook.sh\"", "async": false } ] @@ -131,7 +131,7 @@ "hooks": [ { "type": "command", - "command": "bash ${CLAUDE_PLUGIN_ROOT}/hooks/record_event.sh PostToolBatch", + "command": "sh \"${CLAUDE_PLUGIN_ROOT}/bin/claude-hook.sh\"", "async": false } ] @@ -143,7 +143,7 @@ "hooks": [ { "type": "command", - "command": "bash ${CLAUDE_PLUGIN_ROOT}/hooks/record_event.sh Notification", + "command": "sh \"${CLAUDE_PLUGIN_ROOT}/bin/claude-hook.sh\"", "async": false } ] @@ -154,7 +154,7 @@ "hooks": [ { "type": "command", - "command": "bash ${CLAUDE_PLUGIN_ROOT}/hooks/record_event.sh MessageDisplay", + "command": "sh \"${CLAUDE_PLUGIN_ROOT}/bin/claude-hook.sh\"", "async": false } ] @@ -166,7 +166,7 @@ "hooks": [ { "type": "command", - "command": "bash ${CLAUDE_PLUGIN_ROOT}/hooks/record_event.sh SubagentStart", + "command": "sh \"${CLAUDE_PLUGIN_ROOT}/bin/claude-hook.sh\"", "async": false } ] @@ -178,7 +178,7 @@ "hooks": [ { "type": "command", - "command": "bash ${CLAUDE_PLUGIN_ROOT}/hooks/record_event.sh SubagentStop", + "command": "sh \"${CLAUDE_PLUGIN_ROOT}/bin/claude-hook.sh\"", "async": false } ] @@ -189,7 +189,7 @@ "hooks": [ { "type": "command", - "command": "bash ${CLAUDE_PLUGIN_ROOT}/hooks/record_event.sh TaskCreated", + "command": "sh \"${CLAUDE_PLUGIN_ROOT}/bin/claude-hook.sh\"", "async": false } ] @@ -200,7 +200,7 @@ "hooks": [ { "type": "command", - "command": "bash ${CLAUDE_PLUGIN_ROOT}/hooks/record_event.sh TaskCompleted", + "command": "sh \"${CLAUDE_PLUGIN_ROOT}/bin/claude-hook.sh\"", "async": false } ] @@ -212,7 +212,7 @@ "hooks": [ { "type": "command", - "command": "bash ${CLAUDE_PLUGIN_ROOT}/hooks/record_event.sh StopFailure", + "command": "sh \"${CLAUDE_PLUGIN_ROOT}/bin/claude-hook.sh\"", "async": false } ] @@ -223,7 +223,7 @@ "hooks": [ { "type": "command", - "command": "bash ${CLAUDE_PLUGIN_ROOT}/hooks/record_event.sh TeammateIdle", + "command": "sh \"${CLAUDE_PLUGIN_ROOT}/bin/claude-hook.sh\"", "async": false } ] @@ -235,7 +235,7 @@ "hooks": [ { "type": "command", - "command": "bash ${CLAUDE_PLUGIN_ROOT}/hooks/record_event.sh InstructionsLoaded", + "command": "sh \"${CLAUDE_PLUGIN_ROOT}/bin/claude-hook.sh\"", "async": false } ] @@ -247,7 +247,7 @@ "hooks": [ { "type": "command", - "command": "bash ${CLAUDE_PLUGIN_ROOT}/hooks/record_event.sh ConfigChange", + "command": "sh \"${CLAUDE_PLUGIN_ROOT}/bin/claude-hook.sh\"", "async": false } ] @@ -258,7 +258,7 @@ "hooks": [ { "type": "command", - "command": "bash ${CLAUDE_PLUGIN_ROOT}/hooks/record_event.sh CwdChanged", + "command": "sh \"${CLAUDE_PLUGIN_ROOT}/bin/claude-hook.sh\"", "async": false } ] @@ -269,7 +269,7 @@ "hooks": [ { "type": "command", - "command": "bash ${CLAUDE_PLUGIN_ROOT}/hooks/record_event.sh WorktreeCreate", + "command": "sh \"${CLAUDE_PLUGIN_ROOT}/bin/claude-hook.sh\"", "async": false } ] @@ -280,7 +280,7 @@ "hooks": [ { "type": "command", - "command": "bash ${CLAUDE_PLUGIN_ROOT}/hooks/record_event.sh WorktreeRemove", + "command": "sh \"${CLAUDE_PLUGIN_ROOT}/bin/claude-hook.sh\"", "async": false } ] @@ -292,7 +292,7 @@ "hooks": [ { "type": "command", - "command": "bash ${CLAUDE_PLUGIN_ROOT}/hooks/record_event.sh PreCompact", + "command": "sh \"${CLAUDE_PLUGIN_ROOT}/bin/claude-hook.sh\"", "async": false } ] @@ -304,7 +304,7 @@ "hooks": [ { "type": "command", - "command": "bash ${CLAUDE_PLUGIN_ROOT}/hooks/record_event.sh PostCompact", + "command": "sh \"${CLAUDE_PLUGIN_ROOT}/bin/claude-hook.sh\"", "async": false } ] @@ -316,7 +316,7 @@ "hooks": [ { "type": "command", - "command": "bash ${CLAUDE_PLUGIN_ROOT}/hooks/record_event.sh Elicitation", + "command": "sh \"${CLAUDE_PLUGIN_ROOT}/bin/claude-hook.sh\"", "async": false } ] @@ -328,7 +328,7 @@ "hooks": [ { "type": "command", - "command": "bash ${CLAUDE_PLUGIN_ROOT}/hooks/record_event.sh ElicitationResult", + "command": "sh \"${CLAUDE_PLUGIN_ROOT}/bin/claude-hook.sh\"", "async": false } ] @@ -340,7 +340,7 @@ "hooks": [ { "type": "command", - "command": "bash ${CLAUDE_PLUGIN_ROOT}/hooks/record_event.sh FileChanged", + "command": "sh \"${CLAUDE_PLUGIN_ROOT}/bin/claude-hook.sh\"", "async": false } ] diff --git a/src/plugins/claude/content/plugins/trace-claude-code/hooks/permission_denied.sh b/src/plugins/claude/content/plugins/trace-claude-code/hooks/permission_denied.sh deleted file mode 100644 index a9195e5..0000000 --- a/src/plugins/claude/content/plugins/trace-claude-code/hooks/permission_denied.sh +++ /dev/null @@ -1,95 +0,0 @@ -#!/bin/bash -### -# PermissionDenied Hook - Creates a denied tool span when tied to a tool request -### - -set -e - -SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -source "$SCRIPT_DIR/common.sh" - -debug "PermissionDenied hook triggered" - -tracing_enabled || { debug "Tracing disabled"; exit 0; } -check_requirements || exit 0 - -INPUT=$(cat) -record_hook_input "permission_denied" "$INPUT" -debug "PermissionDenied input: $(echo "$INPUT" | jq -c '.' 2>/dev/null | head -c 500)" - -TOOL_NAME=$(echo "$INPUT" | jq -r '.tool_name // .tool // empty' 2>/dev/null) -TOOL_INPUT=$(echo "$INPUT" | jq -c '.tool_input // .input // {}' 2>/dev/null) -SESSION_ID=$(echo "$INPUT" | jq -r '.session_id // empty' 2>/dev/null) -TOOL_CALL_ID=$(echo "$INPUT" | jq -r '.tool_use_id // empty' 2>/dev/null) -PERMISSION_ID=$(echo "$INPUT" | jq -r '.permission_id // .permission.id // empty' 2>/dev/null) -PERMISSION_TYPE=$(echo "$INPUT" | jq -r '.permission_type // .permission.type // empty' 2>/dev/null) -PERMISSION_TITLE=$(echo "$INPUT" | jq -r '.permission_title // .permission.title // empty' 2>/dev/null) - -[ -z "$TOOL_NAME" ] && { debug "No tool name, skipping"; exit 0; } -[ -z "$SESSION_ID" ] && { debug "No session ID, skipping"; exit 0; } - -ROOT_SPAN_ID=$(get_session_state "$SESSION_ID" "root_span_id") -PROJECT_ID=$(get_session_state "$SESSION_ID" "project_id") -TURN_SPAN_ID=$(get_session_state "$SESSION_ID" "current_turn_span_id") - -if [ -z "$CC_EXPERIMENT_ID" ]; then - CC_EXPERIMENT_ID=$(get_session_state "$SESSION_ID" "experiment_id") - export CC_EXPERIMENT_ID -fi - -if [ -z "$TURN_SPAN_ID" ] || [ -z "$PROJECT_ID" ]; then - debug "No current turn for session $SESSION_ID, skipping denied tool trace" - exit 0 -fi - -SPAN_ID=$(generate_uuid) -TIMESTAMP=$(get_timestamp) -TOOL_TIME=$(date +%s) - -case "$TOOL_NAME" in - Bash|Terminal) - CMD=$(echo "$TOOL_INPUT" | jq -r '.command // empty' 2>/dev/null | head -c 50) - SPAN_NAME="Terminal: ${CMD:-command}" - ;; - *) - SPAN_NAME="$TOOL_NAME" - ;; -esac - -EVENT=$(jq -n \ - --arg id "$SPAN_ID" \ - --arg root_span_id "$ROOT_SPAN_ID" \ - --arg parent "$TURN_SPAN_ID" \ - --arg created "$TIMESTAMP" \ - --argjson input "$TOOL_INPUT" \ - --arg name "$SPAN_NAME" \ - --arg tool "$TOOL_NAME" \ - --arg tool_call_id "$TOOL_CALL_ID" \ - --arg permission_id "$PERMISSION_ID" \ - --arg permission_type "$PERMISSION_TYPE" \ - --arg permission_title "$PERMISSION_TITLE" \ - --argjson start_time "$TOOL_TIME" \ - --argjson end_time "$TOOL_TIME" \ - '{ - id: $id, - span_id: $id, - root_span_id: $root_span_id, - span_parents: [$parent], - created: $created, - input: $input, - metrics: {start: $start_time, end: $end_time}, - metadata: ({ - tool_name: $tool, - tool_approval: "denied" - } - + (if $tool_call_id != "" then {tool_call_id: $tool_call_id} else {} end) - + (if $permission_id != "" then {permission_id: $permission_id} else {} end) - + (if $permission_type != "" then {permission_type: $permission_type} else {} end) - + (if $permission_title != "" then {permission_title: $permission_title} else {} end)), - span_attributes: {name: $name, type: "tool"} - }') - -enqueue_span "$SESSION_ID" "$PROJECT_ID" "$EVENT" || { log "ERROR" "Failed to enqueue denied tool span"; exit 0; } - -log "INFO" "Denied tool: $SPAN_NAME (turn=$TURN_SPAN_ID)" -exit 0 diff --git a/src/plugins/claude/content/plugins/trace-claude-code/hooks/post_tool_use.sh b/src/plugins/claude/content/plugins/trace-claude-code/hooks/post_tool_use.sh deleted file mode 100755 index 911ce44..0000000 --- a/src/plugins/claude/content/plugins/trace-claude-code/hooks/post_tool_use.sh +++ /dev/null @@ -1,217 +0,0 @@ -#!/bin/bash -### -# PostToolUse Hook - Creates a tool span as child of current Turn -### - -set -e - -SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -source "$SCRIPT_DIR/common.sh" - -debug "PostToolUse hook triggered" - -tracing_enabled || { debug "Tracing disabled"; exit 0; } -check_requirements || exit 0 - -# Read input from stdin -INPUT=$(cat) -record_hook_input "post_tool_use" "$INPUT" -debug "PostToolUse input: $(echo "$INPUT" | jq -c '.' 2>/dev/null | head -c 500)" - -# Extract tool info -TOOL_NAME=$(echo "$INPUT" | jq -r '.tool_name // empty' 2>/dev/null) -TOOL_INPUT=$(echo "$INPUT" | jq -c '.tool_input // {}' 2>/dev/null) -TOOL_OUTPUT=$(echo "$INPUT" | jq -c '.tool_response // .output // {}' 2>/dev/null) -SESSION_ID=$(echo "$INPUT" | jq -r '.session_id // empty' 2>/dev/null) -TOOL_CALL_ID=$(echo "$INPUT" | jq -r '.tool_use_id // empty' 2>/dev/null) -TOOL_FAILED=$(echo "$INPUT" | jq -r ' - if ((.tool_response.interrupted // false) == true - or (.tool_response.is_error // false) == true - or (.tool_response.isError // false) == true - or (.tool_response.status // "") == "error" - or (.tool_response.status // "") == "failed" - or (.tool_response.error != null)) then - true - else - false - end -' 2>/dev/null) -if [ "$TOOL_FAILED" != "true" ]; then - TOOL_FAILED=false -fi -TOOL_ERROR=$(echo "$INPUT" | jq -r ' - .tool_response.error - // .tool_response.stderr - // .tool_response.message - // .tool_response.output - // "Tool execution failed" -' 2>/dev/null | head -n 1) - -# Skip if no tool name -[ -z "$TOOL_NAME" ] && { debug "No tool name, skipping"; exit 0; } -[ -z "$SESSION_ID" ] && { debug "No session ID, skipping"; exit 0; } - -# Get session info -ROOT_SPAN_ID=$(get_session_state "$SESSION_ID" "root_span_id") -PROJECT_ID=$(get_session_state "$SESSION_ID" "project_id") -TURN_SPAN_ID=$(get_session_state "$SESSION_ID" "current_turn_span_id") - -# Load experiment_id from session state if not already set -if [ -z "$CC_EXPERIMENT_ID" ]; then - CC_EXPERIMENT_ID=$(get_session_state "$SESSION_ID" "experiment_id") - export CC_EXPERIMENT_ID -fi - -# If no turn span exists, tools are orphaned - skip -if [ -z "$TURN_SPAN_ID" ] || [ -z "$PROJECT_ID" ]; then - debug "No current turn for session $SESSION_ID, skipping tool trace" - exit 0 -fi - -# Increment tool count for this turn -TOOL_COUNT=$(get_session_state "$SESSION_ID" "current_turn_tool_count") -TOOL_COUNT=${TOOL_COUNT:-0} -TOOL_COUNT=$((TOOL_COUNT + 1)) -set_session_state "$SESSION_ID" "current_turn_tool_count" "$TOOL_COUNT" - -# Generate span ID -SPAN_ID=$(generate_uuid) -TIMESTAMP=$(get_timestamp) -TOOL_TIME=$(date +%s) - -# Determine span name based on tool -METADATA_TOOL_NAME="$TOOL_NAME" -IS_SKILL_TOOL=false -SKILL_NAME="" -SKILL_LOAD_TRIGGER="" -case "$TOOL_NAME" in - Skill) - IS_SKILL_TOOL=true - SKILL_NAME=$(echo "$TOOL_INPUT" | jq -r '.name // .skill // .skill_name // .skillName // empty' 2>/dev/null) - EXPLICIT_SKILL_NAMES=$(get_session_state "$SESSION_ID" "current_turn_explicit_skill_names") - if [ -n "$SKILL_NAME" ] && [ -n "$EXPLICIT_SKILL_NAMES" ] && \ - echo "$EXPLICIT_SKILL_NAMES" | jq -e --arg name "$SKILL_NAME" 'index($name) != null' >/dev/null 2>&1; then - SKILL_LOAD_TRIGGER="explicit" - fi - if [ -n "$SKILL_NAME" ]; then - SPAN_NAME="skill: $SKILL_NAME" - else - SPAN_NAME="skill" - fi - ;; - Read|Write|Edit|MultiEdit) - FILE_PATH=$(echo "$TOOL_INPUT" | jq -r '.file_path // .path // empty' 2>/dev/null) - if [ -n "$FILE_PATH" ]; then - SPAN_NAME="$TOOL_NAME: $(basename "$FILE_PATH")" - else - SPAN_NAME="$TOOL_NAME" - fi - ;; - Bash|Terminal) - CMD=$(echo "$TOOL_INPUT" | jq -r '.command // empty' 2>/dev/null | head -c 50) - SPAN_NAME="Terminal: ${CMD:-command}" - ;; - mcp__*) - SPAN_NAME=$(echo "$TOOL_NAME" | sed 's/mcp__/MCP: /' | sed 's/__/ - /') - ;; - *) - SPAN_NAME="$TOOL_NAME" - ;; -esac - -# Build the event - tool is child of Turn -EVENT=$(jq -n \ - --arg id "$SPAN_ID" \ - --arg span_id "$SPAN_ID" \ - --arg root_span_id "$ROOT_SPAN_ID" \ - --arg parent "$TURN_SPAN_ID" \ - --arg created "$TIMESTAMP" \ - --arg tool "$TOOL_NAME" \ - --argjson input "$TOOL_INPUT" \ - --argjson output "$TOOL_OUTPUT" \ - --arg name "$SPAN_NAME" \ - --arg metadata_tool "$METADATA_TOOL_NAME" \ - --arg tool_call_id "$TOOL_CALL_ID" \ - --arg tool_error "$TOOL_ERROR" \ - --argjson tool_failed "$TOOL_FAILED" \ - --arg skill_name "$SKILL_NAME" \ - --arg skill_load_trigger "$SKILL_LOAD_TRIGGER" \ - --argjson is_skill_tool "$IS_SKILL_TOOL" \ - --argjson start_time "$TOOL_TIME" \ - --argjson end_time "$TOOL_TIME" \ - '{ - id: $id, - span_id: $span_id, - root_span_id: $root_span_id, - span_parents: [$parent], - created: $created, - input: $input, - output: $output, - metrics: { - start: $start_time, - end: $end_time - }, - metadata: ({ - tool_name: $metadata_tool, - tool_approval: "approved" - } - + (if $tool_call_id != "" then {tool_call_id: $tool_call_id} else {} end) - + (if $is_skill_tool then { - tool_kind: "skill", - skill_name: (if $skill_name != "" then $skill_name else null end) - } else {} end) - + (if $skill_load_trigger != "" then {skill_load_trigger: $skill_load_trigger} else {} end)), - span_attributes: { - name: $name, - type: "tool" - } - } - + (if $tool_failed then {error: $tool_error} else {} end)') - -enqueue_span "$SESSION_ID" "$PROJECT_ID" "$EVENT" || { log "ERROR" "Failed to enqueue tool span"; exit 0; } - -log "INFO" "Tool: $SPAN_NAME (turn=$TURN_SPAN_ID)" - -# For Agent (sub-agent) tool calls, surface the sub-agent's own model calls -# (e.g. claude-haiku-4-5) as LLM spans nested under this Agent tool span. -# Claude Code writes each sub-agent's conversation to its own transcript at -# //subagents/agent-.jsonl -# which we derive from the main transcript_path + the agentId in the tool -# response. We do this in PostToolUse (not SubagentStop) because SubagentStop -# fires *before* PostToolUse, so the Agent tool span does not exist yet then. -if [ "$TOOL_NAME" = "Agent" ]; then - AGENT_ID=$(echo "$TOOL_OUTPUT" | jq -r '.agentId // .agent_id // empty' 2>/dev/null) - MAIN_TRANSCRIPT=$(echo "$INPUT" | jq -r '.transcript_path // empty' 2>/dev/null) - - if [ -n "$AGENT_ID" ] && [ -n "$MAIN_TRANSCRIPT" ]; then - TRANSCRIPT_DIR=$(dirname "$MAIN_TRANSCRIPT") - SESSION_BASENAME=$(basename "$MAIN_TRANSCRIPT" .jsonl) - AGENT_FILE_NAME="agent-${AGENT_ID}.jsonl" - - # Candidate locations, in priority order: - # 1. Live layout: //subagents/agent-.jsonl - # 2. Replay/flat layout: /agent-.jsonl (record_hook_input - # snapshots agent transcripts flat next to the main one, and - # replay rewrites transcript_path into that flat transcripts/ dir) - AGENT_TRANSCRIPT="" - for candidate in \ - "$TRANSCRIPT_DIR/$SESSION_BASENAME/subagents/$AGENT_FILE_NAME" \ - "$TRANSCRIPT_DIR/$AGENT_FILE_NAME"; do - if [ -f "$candidate" ]; then - AGENT_TRANSCRIPT="$candidate" - break - fi - done - - if [ -n "$AGENT_TRANSCRIPT" ]; then - N_LLM=$(emit_llm_spans_from_transcript \ - "$AGENT_TRANSCRIPT" "$SESSION_ID" "$PROJECT_ID" \ - "$ROOT_SPAN_ID" "$SPAN_ID") - log "INFO" "Sub-agent $AGENT_ID: emitted ${N_LLM:-0} LLM spans under $SPAN_NAME" - else - debug "Agent transcript not found for agent_id=$AGENT_ID (looked under $TRANSCRIPT_DIR)" - fi - fi -fi - -exit 0 diff --git a/src/plugins/claude/content/plugins/trace-claude-code/hooks/post_tool_use_failure.sh b/src/plugins/claude/content/plugins/trace-claude-code/hooks/post_tool_use_failure.sh deleted file mode 100644 index 27df716..0000000 --- a/src/plugins/claude/content/plugins/trace-claude-code/hooks/post_tool_use_failure.sh +++ /dev/null @@ -1,114 +0,0 @@ -#!/bin/bash -### -# PostToolUseFailure Hook - Creates a failed tool span as child of current Turn -### - -set -e - -SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -source "$SCRIPT_DIR/common.sh" - -debug "PostToolUseFailure hook triggered" - -tracing_enabled || { debug "Tracing disabled"; exit 0; } -check_requirements || exit 0 - -INPUT=$(cat) -record_hook_input "post_tool_use_failure" "$INPUT" -debug "PostToolUseFailure input: $(echo "$INPUT" | jq -c '.' 2>/dev/null | head -c 500)" - -TOOL_NAME=$(echo "$INPUT" | jq -r '.tool_name // .tool // empty' 2>/dev/null) -TOOL_INPUT=$(echo "$INPUT" | jq -c '.tool_input // .input // {}' 2>/dev/null) -TOOL_OUTPUT=$(echo "$INPUT" | jq -c '.tool_response // .output // {}' 2>/dev/null) -SESSION_ID=$(echo "$INPUT" | jq -r '.session_id // empty' 2>/dev/null) -TOOL_CALL_ID=$(echo "$INPUT" | jq -r '.tool_use_id // empty' 2>/dev/null) -TOOL_ERROR=$(echo "$INPUT" | jq -r ' - .error - // .message - // .tool_response.error - // .tool_response.stderr - // .tool_response.message - // "Tool execution failed" -' 2>/dev/null | head -n 1) - -[ -z "$TOOL_NAME" ] && { debug "No tool name, skipping"; exit 0; } -[ -z "$SESSION_ID" ] && { debug "No session ID, skipping"; exit 0; } - -ROOT_SPAN_ID=$(get_session_state "$SESSION_ID" "root_span_id") -PROJECT_ID=$(get_session_state "$SESSION_ID" "project_id") -TURN_SPAN_ID=$(get_session_state "$SESSION_ID" "current_turn_span_id") - -if [ -z "$CC_EXPERIMENT_ID" ]; then - CC_EXPERIMENT_ID=$(get_session_state "$SESSION_ID" "experiment_id") - export CC_EXPERIMENT_ID -fi - -if [ -z "$TURN_SPAN_ID" ] || [ -z "$PROJECT_ID" ]; then - debug "No current turn for session $SESSION_ID, skipping failed tool trace" - exit 0 -fi - -TOOL_COUNT=$(get_session_state "$SESSION_ID" "current_turn_tool_count") -TOOL_COUNT=${TOOL_COUNT:-0} -TOOL_COUNT=$((TOOL_COUNT + 1)) -set_session_state "$SESSION_ID" "current_turn_tool_count" "$TOOL_COUNT" - -SPAN_ID=$(generate_uuid) -TIMESTAMP=$(get_timestamp) -TOOL_TIME=$(date +%s) - -case "$TOOL_NAME" in - Read|Write|Edit|MultiEdit) - FILE_PATH=$(echo "$TOOL_INPUT" | jq -r '.file_path // .path // empty' 2>/dev/null) - if [ -n "$FILE_PATH" ]; then - SPAN_NAME="$TOOL_NAME: $(basename "$FILE_PATH")" - else - SPAN_NAME="$TOOL_NAME" - fi - ;; - Bash|Terminal) - CMD=$(echo "$TOOL_INPUT" | jq -r '.command // empty' 2>/dev/null | head -c 50) - SPAN_NAME="Terminal: ${CMD:-command}" - ;; - mcp__*) - SPAN_NAME=$(echo "$TOOL_NAME" | sed 's/mcp__/MCP: /' | sed 's/__/ - /') - ;; - *) - SPAN_NAME="$TOOL_NAME" - ;; -esac - -EVENT=$(jq -n \ - --arg id "$SPAN_ID" \ - --arg root_span_id "$ROOT_SPAN_ID" \ - --arg parent "$TURN_SPAN_ID" \ - --arg created "$TIMESTAMP" \ - --argjson input "$TOOL_INPUT" \ - --argjson output "$TOOL_OUTPUT" \ - --arg name "$SPAN_NAME" \ - --arg tool "$TOOL_NAME" \ - --arg tool_call_id "$TOOL_CALL_ID" \ - --arg tool_error "$TOOL_ERROR" \ - --argjson start_time "$TOOL_TIME" \ - --argjson end_time "$TOOL_TIME" \ - '{ - id: $id, - span_id: $id, - root_span_id: $root_span_id, - span_parents: [$parent], - created: $created, - input: $input, - output: $output, - error: $tool_error, - metrics: {start: $start_time, end: $end_time}, - metadata: ({ - tool_name: $tool, - tool_approval: "approved" - } + (if $tool_call_id != "" then {tool_call_id: $tool_call_id} else {} end)), - span_attributes: {name: $name, type: "tool"} - }') - -enqueue_span "$SESSION_ID" "$PROJECT_ID" "$EVENT" || { log "ERROR" "Failed to enqueue failed tool span"; exit 0; } - -log "INFO" "Failed tool: $SPAN_NAME (turn=$TURN_SPAN_ID)" -exit 0 diff --git a/src/plugins/claude/content/plugins/trace-claude-code/hooks/record_event.sh b/src/plugins/claude/content/plugins/trace-claude-code/hooks/record_event.sh deleted file mode 100755 index 30b36d9..0000000 --- a/src/plugins/claude/content/plugins/trace-claude-code/hooks/record_event.sh +++ /dev/null @@ -1,41 +0,0 @@ -#!/bin/bash -### -# Record-only hook - captures a hook event's stdin payload for fixtures, -# and otherwise does nothing. -# -# This script is registered for every Claude Code hook event that the -# plugin does not otherwise act on (PreToolUse, SubagentStart/Stop, -# PreCompact/PostCompact, etc.). Its sole purpose is observability: when -# BRAINTRUST_RECORD_DIR is set it appends the event to the recording so we -# can see exactly what data Claude Code makes available at each lifecycle -# point. When recording is off it is a near-instant no-op. -# -# The event name is passed as the first argument because not every hook -# payload self-identifies the event, and we want the recording to label -# each event unambiguously. -# -# Usage (from hooks.json): -# "command": "bash ${CLAUDE_PLUGIN_ROOT}/hooks/record_event.sh PreToolUse" -# -# This hook NEVER blocks Claude Code and NEVER fails the event: it always -# exits 0, even on internal errors, so adding it everywhere is safe. -### - -# Note: intentionally no `set -e`. A record-only hook must never abort a -# Claude Code event, so we swallow all errors and always exit 0. - -# Fast path: if recording is off there is nothing to do. Avoid even -# reading stdin so the hook is as close to a no-op as possible. -[ -z "${BRAINTRUST_RECORD_DIR:-}" ] && exit 0 - -EVENT_NAME="${1:-unknown_event}" - -SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -# shellcheck source=common.sh -source "$SCRIPT_DIR/common.sh" 2>/dev/null || exit 0 - -# Read the event payload from stdin and record it under the event's name. -INPUT=$(cat 2>/dev/null) -record_hook_input "$EVENT_NAME" "$INPUT" 2>/dev/null || true - -exit 0 diff --git a/src/plugins/claude/content/plugins/trace-claude-code/hooks/session_end.sh b/src/plugins/claude/content/plugins/trace-claude-code/hooks/session_end.sh deleted file mode 100755 index 0a35b58..0000000 --- a/src/plugins/claude/content/plugins/trace-claude-code/hooks/session_end.sh +++ /dev/null @@ -1,83 +0,0 @@ -#!/bin/bash -### -# SessionEnd Hook - drains pending spans and shuts down this session's -# background worker. The only blocking step in the plugin's hook chain; -# Claude Code waits for this to return before it exits. -### - -set -e - -SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -source "$SCRIPT_DIR/common.sh" - -debug "SessionEnd hook triggered" - -tracing_enabled || { debug "Tracing disabled"; exit 0; } -check_requirements || exit 0 - -# Read input from stdin -INPUT=$(cat) -record_hook_input "session_end" "$INPUT" -debug "SessionEnd input: $(echo "$INPUT" | jq -c '.' 2>/dev/null | head -c 500)" - -# Extract session ID. Claude Code always sends one; if it doesn't, there's -# nothing we can drain (per-session queues are keyed by session id). -SESSION_ID=$(echo "$INPUT" | jq -r '.session_id // empty' 2>/dev/null) -[ -z "$SESSION_ID" ] && { debug "No session ID in payload, skipping"; exit 0; } - -# Log a one-line session summary for observability. State may be partial -# if the session never had a turn/tool, so default to 0. -TURN_COUNT=$(get_session_state "$SESSION_ID" "turn_count") -TOOL_COUNT=$(get_session_state "$SESSION_ID" "tool_count") -log "INFO" "Session ended: $SESSION_ID (turns=${TURN_COUNT:-0}, tools=${TOOL_COUNT:-0})" - -# Block until this session's worker has flushed all pending spans. This -# is the critical step that prevents spans from being lost when Claude -# Code exits. drain_queue is bounded by BRAINTRUST_DRAIN_TIMEOUT. -drain_queue "$SESSION_ID" || log "WARN" "Some spans may not have been flushed before session end" - -# Shut down this session's worker and clean up its queue dir. No-op in -# sync mode since there's no worker to stop. -if ! is_truthy "$BRAINTRUST_SYNC_QUEUE"; then - SDIR=$(session_queue_dir "$SESSION_ID") - LOCK_FILE="$SDIR/worker.lock" - - # Removing the lock file is the worker's exit signal: it checks - # ownership at the top of every loop and self-exits when the lock - # is gone. We avoid `kill` whenever possible because the PID we - # read from the lock could have been reused by an unrelated process - # between the read and the kill (the worker may have already exited - # after seeing the missing lock). - if [ -f "$LOCK_FILE" ]; then - WORKER_PID=$(cat "$LOCK_FILE" 2>/dev/null) - rm -f "$LOCK_FILE" - - if [ -n "$WORKER_PID" ]; then - # Poll up to 1s for the worker to self-exit. Loop period is - # ~200ms so one cycle is usually enough. - for _ in 1 2 3 4 5 6 7 8 9 10; do - kill -0 "$WORKER_PID" 2>/dev/null || break - sleep 0.1 - done - - # Still alive? Only escalate to SIGTERM if the process really - # is still our worker - verify by inspecting its command line. - if kill -0 "$WORKER_PID" 2>/dev/null; then - WORKER_CMD=$(ps -p "$WORKER_PID" -o command= 2>/dev/null || true) - if [[ "$WORKER_CMD" == *worker.sh* ]] && [[ "$WORKER_CMD" == *"$SESSION_ID"* ]]; then - kill "$WORKER_PID" 2>/dev/null || true - else - debug "Not killing pid $WORKER_PID; command line does not look like our worker: $WORKER_CMD" - fi - fi - fi - fi - - # Remove the now-empty session queue dir. rmdir is silent on - # non-empty dirs, which is the right behavior if a late job slipped - # in after the drain. - rmdir "$SDIR/pending" "$SDIR/processing" 2>/dev/null || true - rmdir "$SDIR" 2>/dev/null || true -fi - -exit 0 diff --git a/src/plugins/claude/content/plugins/trace-claude-code/hooks/session_start.sh b/src/plugins/claude/content/plugins/trace-claude-code/hooks/session_start.sh deleted file mode 100755 index a3eedac..0000000 --- a/src/plugins/claude/content/plugins/trace-claude-code/hooks/session_start.sh +++ /dev/null @@ -1,145 +0,0 @@ -#!/bin/bash -### -# SessionStart Hook - Creates the root trace span when a Claude Code session begins -### - -set -e - -SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -source "$SCRIPT_DIR/common.sh" - -debug "SessionStart hook triggered" -debug "TRACE_TO_BRAINTRUST=$TRACE_TO_BRAINTRUST" - -tracing_enabled || { debug "Tracing disabled"; exit 0; } -check_requirements || exit 0 - -# Read input from stdin -INPUT=$(cat) -record_hook_input "session_start" "$INPUT" -debug "SessionStart input: $INPUT" - -# Extract session ID from input -SESSION_ID=$(echo "$INPUT" | jq -r '.session_id // empty' 2>/dev/null) - -if [ -z "$SESSION_ID" ]; then - # Generate a session ID if not provided - SESSION_ID=$(generate_uuid) - debug "Generated session ID: $SESSION_ID" -fi - -# Clean up any queue dirs left behind by previous Claude Code sessions -# that crashed (no SessionEnd hook fired). Sweeps any dir whose worker -# lock is stale or missing-with-leftover-jobs, recovering or removing -# as appropriate. Skips our own session's dir. -sweep_dead_sessions "$SESSION_ID" - -# Determine mode and get appropriate IDs -if is_experiment_mode; then - debug "Experiment mode: CC_EXPERIMENT_ID=$CC_EXPERIMENT_ID" - # In experiment mode, we still get project_id for state management - # but spans are inserted to the experiment endpoint - PROJECT_ID=$(get_project_id "$PROJECT") || PROJECT_ID="experiment-mode" - log "INFO" "Tracing to experiment: $CC_EXPERIMENT_ID" -else - # Get project ID for project_logs mode - PROJECT_ID=$(get_project_id "$PROJECT") || { log "ERROR" "Aborting session_start: could not resolve project '$PROJECT' (see prior error)"; exit 0; } - debug "Using project: $PROJECT (id: $PROJECT_ID)" -fi - -# Create the session span -# If CC_PARENT_SPAN_ID is set, this session becomes a child of an existing trace -if [ -n "$CC_PARENT_SPAN_ID" ]; then - ROOT_SPAN_ID="$CC_ROOT_SPAN_ID" - debug "Attaching to parent span: $CC_PARENT_SPAN_ID (root: $ROOT_SPAN_ID)" -else - ROOT_SPAN_ID="$SESSION_ID" -fi -SPAN_ID="$SESSION_ID" -TIMESTAMP=$(get_timestamp) - -# Atomically check if we already have a root span for this session and set it if not -# This prevents race conditions when session_start is called multiple times -if ! check_and_set_session_state "$SESSION_ID" "root_span_id" "$ROOT_SPAN_ID"; then - EXISTING_ROOT=$(get_session_state "$SESSION_ID" "root_span_id") - debug "Session already has root span (race avoided): $EXISTING_ROOT" - exit 0 -fi -debug "Claimed session root span: $ROOT_SPAN_ID" - -# Extract workspace info if available -WORKSPACE=$(echo "$INPUT" | jq -r '.cwd // empty' 2>/dev/null) -WORKSPACE_NAME=$(basename "$WORKSPACE" 2>/dev/null || echo "Claude Code") - -# Get system info -HOSTNAME=$(get_hostname) -USERNAME=$(get_username) -OS=$(get_os) - -# Version info for observability: this plugin's version and the Claude Code -# CLI version that produced the session. -TRANSCRIPT_PATH=$(echo "$INPUT" | jq -r '.transcript_path // empty' 2>/dev/null) -PLUGIN_VERSION=$(get_plugin_version) -CLAUDE_CODE_VERSION=$(get_claude_code_version "$TRANSCRIPT_PATH") -GIT_METADATA=$(git_metadata_json "$WORKSPACE") - -EVENT=$(jq -n \ - --arg id "$SPAN_ID" \ - --arg span_id "$SPAN_ID" \ - --arg root_span_id "$ROOT_SPAN_ID" \ - --arg created "$TIMESTAMP" \ - --arg session "$SESSION_ID" \ - --arg workspace "$WORKSPACE_NAME" \ - --arg cwd "$WORKSPACE" \ - --arg hostname "$HOSTNAME" \ - --arg username "$USERNAME" \ - --arg os "$OS" \ - --arg plugin_version "$PLUGIN_VERSION" \ - --arg claude_code_version "$CLAUDE_CODE_VERSION" \ - --argjson git_metadata "$GIT_METADATA" \ - '{ - id: $id, - span_id: $span_id, - root_span_id: $root_span_id, - created: $created, - input: ("Session: " + $workspace), - metadata: ({ - session_id: $session, - workspace: $cwd, - hostname: $hostname, - username: $username, - os: $os, - source: "claude-code", - trace_claude_code_version: $plugin_version, - claude_code_version: $claude_code_version - } + $git_metadata), - span_attributes: { - name: ("Claude Code: " + $workspace), - type: "task" - } - }') - -# Add span_parents if attaching to an existing trace -if [ -n "$CC_PARENT_SPAN_ID" ]; then - debug "Setting span_parents to: $CC_PARENT_SPAN_ID" - EVENT=$(echo "$EVENT" | jq --arg parent "$CC_PARENT_SPAN_ID" '. + {span_parents: [$parent]}') -fi - -enqueue_span "$SESSION_ID" "$PROJECT_ID" "$EVENT" || { log "ERROR" "Failed to enqueue session root span"; exit 0; } - -# Save remaining session state (root_span_id was already set atomically above) -set_session_state "$SESSION_ID" "session_span_id" "$SPAN_ID" -set_session_state "$SESSION_ID" "project_id" "$PROJECT_ID" -set_session_state "$SESSION_ID" "turn_count" "0" -set_session_state "$SESSION_ID" "tool_count" "0" -set_session_state "$SESSION_ID" "started" "$TIMESTAMP" - -# Store experiment_id if in experiment mode -if is_experiment_mode; then - set_session_state "$SESSION_ID" "experiment_id" "$CC_EXPERIMENT_ID" - log "INFO" "Created session root: $SESSION_ID workspace=$WORKSPACE_NAME (experiment=$CC_EXPERIMENT_ID)" -else - log "INFO" "Created session root: $SESSION_ID workspace=$WORKSPACE_NAME (project=$PROJECT)" -fi - -exit 0 diff --git a/src/plugins/claude/content/plugins/trace-claude-code/hooks/stop_hook.sh b/src/plugins/claude/content/plugins/trace-claude-code/hooks/stop_hook.sh deleted file mode 100755 index 3493764..0000000 --- a/src/plugins/claude/content/plugins/trace-claude-code/hooks/stop_hook.sh +++ /dev/null @@ -1,529 +0,0 @@ -#!/bin/bash -### -# Stop Hook - Creates LLM spans for each model call within the Turn -# -# Structure: -# Session (task) -# ├── Turn 1 (task) - created by UserPromptSubmit -# │ ├── claude-sonnet... (llm) - first model call (plan + tool_use) -# │ ├── Tool 1 (tool) - created by PostToolUse -# │ ├── Tool 2 (tool) - created by PostToolUse -# │ └── claude-sonnet... (llm) - second model call (after tools) -# └── Turn 2 (task) -# └── ... -# -# Each assistant message block = one LLM call -# -# Token accounting note / known ceiling: -# Token counts are derived entirely from the transcript. For every request -# that the transcript records, our totals match Claude Code's /usage exactly. -# The exception is Claude Code's *internal background* model calls - chiefly -# automatic session-title generation (and conversation summarization). These -# are billed in /usage but the transcript stores only their result (e.g. an -# `ai-title` line) with NO requestId, model, or usage, and no hook payload -# carries their tokens. They are therefore unrecoverable, so an interactive -# session's traced totals can read slightly below /usage (a small amount of -# opus cache-read tokens). Non-interactive (-p) runs don't make these calls -# and reconcile exactly. See README "token accounting". -### - -set -e - -SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -source "$SCRIPT_DIR/common.sh" - -debug "Stop hook triggered" - -tracing_enabled || { debug "Tracing disabled"; exit 0; } -check_requirements || exit 0 - -# Read input from stdin -INPUT=$(cat) -record_hook_input "stop_hook" "$INPUT" -debug "Stop input: $(echo "$INPUT" | jq -c '.' 2>/dev/null | head -c 500)" - -# Get session ID -SESSION_ID=$(echo "$INPUT" | jq -r '.session_id // empty' 2>/dev/null) - -if [ -z "$SESSION_ID" ]; then - TRANSCRIPT_PATH=$(echo "$INPUT" | jq -r '.transcript_path // empty' 2>/dev/null) - if [ -n "$TRANSCRIPT_PATH" ]; then - SESSION_ID=$(basename "$TRANSCRIPT_PATH" .jsonl) - fi -fi - -[ -z "$SESSION_ID" ] && { debug "No session ID"; exit 0; } - -# The Stop event includes Claude's final assistant message directly in the -# payload, so we don't have to reconstruct it from the transcript. -# (See https://docs.claude.com/en/docs/claude-code/hooks -> Stop input) -LAST_ASSISTANT_MESSAGE=$(echo "$INPUT" | jq -r '.last_assistant_message // empty' 2>/dev/null) - -# Get session state -ROOT_SPAN_ID=$(get_session_state "$SESSION_ID" "root_span_id") -PROJECT_ID=$(get_session_state "$SESSION_ID" "project_id") -TURN_SPAN_ID=$(get_session_state "$SESSION_ID" "current_turn_span_id") -TURN_START=$(get_session_state "$SESSION_ID" "current_turn_start") - -# Load experiment_id from session state if not already set -if [ -z "$CC_EXPERIMENT_ID" ]; then - CC_EXPERIMENT_ID=$(get_session_state "$SESSION_ID" "experiment_id") - export CC_EXPERIMENT_ID -fi - -if [ -z "$TURN_SPAN_ID" ] || [ -z "$PROJECT_ID" ]; then - debug "No current turn to finalize" - exit 0 -fi - -# Find the conversation file -CONV_FILE=$(echo "$INPUT" | jq -r '.transcript_path // empty' 2>/dev/null) -if [ -z "$CONV_FILE" ] || [ ! -f "$CONV_FILE" ]; then - SESSIONS_DIR="$HOME/.claude/projects" - CONV_FILE=$(find "$SESSIONS_DIR" -name "${SESSION_ID}.jsonl" -type f 2>/dev/null | head -1) -fi - -[ -z "$CONV_FILE" ] || [ ! -f "$CONV_FILE" ] && { debug "No conversation file"; exit 0; } - -debug "Processing transcript: $CONV_FILE" - -# Get last processed line for this turn -TURN_LAST_LINE=$(get_session_state "$SESSION_ID" "turn_last_line") -TURN_LAST_LINE=${TURN_LAST_LINE:-0} - -TOTAL_LINES=$(wc -l < "$CONV_FILE" | tr -d ' ') - -# Process the transcript to find LLM calls -# An LLM call = assistant message(s) that follow a user message or tool_result -LLM_CALLS_CREATED=0 -CURRENT_OUTPUT_TEXT="" -CURRENT_TOOL_CALLS="[]" -CURRENT_MODEL="" -CURRENT_PROMPT_TOKENS=0 -CURRENT_COMPLETION_TOKENS=0 -CURRENT_CACHE_CREATION_TOKENS=0 -CURRENT_CACHE_CREATION_5M_TOKENS=0 -CURRENT_CACHE_CREATION_1H_TOKENS=0 -CURRENT_CACHE_CREATION_MISSING_SPLIT=false -CURRENT_CACHE_READ_TOKENS=0 -CURRENT_START_TIMESTAMP="" # ISO timestamp when this LLM call started -CURRENT_END_TIMESTAMP="" # ISO timestamp when this LLM call ended -LINE_NUM=0 - -# Claude Code writes one transcript line per content block (thinking, text, -# each tool_use) tagged with the same `requestId` for a single API response. -# Within a response, input/cache usage is repeated identically on every line, -# but `output_tokens` is reported CUMULATIVELY as the response streams (early -# lines hold partials, the final line holds the true total). A response can -# also straddle a tool_result boundary (the same requestId reappears after -# tool output). -# -# To count each response correctly we therefore: -# - add input/cache exactly once per requestId (first sighting), and -# - track the running MAX output per requestId, adding only the delta when a -# larger value appears, so the total reflects the final (max) output. -# -# SEEN_REQUEST_IDS holds requestIds whose input/cache have been counted. -# REQUEST_OUTPUT_MAX maps "=" so we can add deltas. -SEEN_REQUEST_IDS=" " -REQUEST_OUTPUT_MAX=" " - -# Note: we deliberately do NOT write aggregate token metrics onto the Turn -# span. Token metrics live only on the leaf LLM spans (main-conversation and -# sub-agent), and Braintrust rolls those up to parent spans for display. -# Writing our own Turn-level sums here would be both redundant and incomplete -# (it would miss sub-agent tokens, which are emitted outside this loop). - -# Accumulated conversation history (JSON array of messages) -CONVERSATION_HISTORY="[]" - -# Add message to conversation history -add_to_history() { - local role="$1" - local content="$2" - local tool_call_id="$3" - local tool_calls="$4" - - if [ "$role" = "tool" ]; then - CONVERSATION_HISTORY=$(echo "$CONVERSATION_HISTORY" | jq --arg role "$role" --arg content "$content" --arg id "$tool_call_id" \ - '. += [{role: $role, tool_call_id: $id, content: $content}]') - elif [ -n "$tool_calls" ] && [ "$tool_calls" != "[]" ]; then - CONVERSATION_HISTORY=$(echo "$CONVERSATION_HISTORY" | jq --arg role "$role" --arg content "$content" --argjson tc "$tool_calls" \ - '. += [{role: $role, content: $content, tool_calls: $tc}]') - else - CONVERSATION_HISTORY=$(echo "$CONVERSATION_HISTORY" | jq --arg role "$role" --arg content "$content" \ - '. += [{role: $role, content: $content}]') - fi -} - -create_llm_span() { - local output_text="$1" - local model="$2" - local prompt_tokens="$3" - local completion_tokens="$4" - local start_ts="$5" # ISO timestamp - local end_ts="$6" # ISO timestamp - local tool_calls_json="${7:-[]}" - local input_history="$8" # JSON array of conversation history - local cache_creation_tokens="${9:-0}" - local cache_read_tokens="${10:-0}" - local cache_creation_5m_tokens="${11:-0}" - local cache_creation_1h_tokens="${12:-0}" - local cache_creation_missing_split="${13:-false}" - - # Need either text or tool_calls - [ -z "$output_text" ] && [ "$tool_calls_json" = "[]" ] && return - - local span_id=$(generate_uuid) - local use_cache_creation_split=false - if [ "$cache_creation_missing_split" != "true" ]; then - if [ "$cache_creation_5m_tokens" -gt 0 ] 2>/dev/null \ - || [ "$cache_creation_1h_tokens" -gt 0 ] 2>/dev/null; then - use_cache_creation_split=true - fi - fi - - local effective_cache_creation_tokens="$cache_creation_tokens" - if [ "$use_cache_creation_split" = "true" ]; then - effective_cache_creation_tokens=$((cache_creation_5m_tokens + cache_creation_1h_tokens)) - fi - local bt_prompt_tokens=$((prompt_tokens + cache_read_tokens + effective_cache_creation_tokens)) - local total_tokens=$((bt_prompt_tokens + completion_tokens)) - local start_time=$(_iso_to_epoch "$start_ts") - local end_time=$(_iso_to_epoch "$end_ts") - - # Input is the conversation history up to this point - local input_json="$input_history" - - # Format output - include tool_calls if present - local output_json - local has_tool_calls=$(echo "$tool_calls_json" | jq 'length > 0' 2>/dev/null) - if [ "$has_tool_calls" = "true" ]; then - output_json=$(jq -n \ - --arg content "${output_text:-}" \ - --argjson tool_calls "$tool_calls_json" \ - '{role: "assistant", content: $content, tool_calls: $tool_calls}') - else - output_json=$(jq -n --arg content "$output_text" '{role: "assistant", content: $content}') - fi - - local event=$(jq -n \ - --arg id "$span_id" \ - --arg span_id "$span_id" \ - --arg root_span_id "$ROOT_SPAN_ID" \ - --arg parent "$TURN_SPAN_ID" \ - --arg created "${start_ts:-$(get_timestamp)}" \ - --argjson input "$input_json" \ - --argjson output "$output_json" \ - --arg model "${model:-claude}" \ - --argjson prompt_tokens "$prompt_tokens" \ - --argjson bt_prompt_tokens "$bt_prompt_tokens" \ - --argjson completion_tokens "$completion_tokens" \ - --argjson tokens "$total_tokens" \ - --argjson cache_creation_tokens "$cache_creation_tokens" \ - --argjson cache_read_tokens "$cache_read_tokens" \ - --argjson cache_creation_5m_tokens "$cache_creation_5m_tokens" \ - --argjson cache_creation_1h_tokens "$cache_creation_1h_tokens" \ - --argjson use_cache_creation_split "$use_cache_creation_split" \ - --argjson start_time "$start_time" \ - --argjson end_time "$end_time" \ - '{ - id: $id, - span_id: $span_id, - root_span_id: $root_span_id, - span_parents: [$parent], - created: $created, - input: $input, - output: $output, - metrics: ({ - start: $start_time, - end: $end_time, - prompt_tokens: $bt_prompt_tokens, - completion_tokens: $completion_tokens, - tokens: $tokens, - prompt_cached_tokens: $cache_read_tokens - } + ( - if $use_cache_creation_split then - { - prompt_cache_creation_5m_tokens: $cache_creation_5m_tokens, - prompt_cache_creation_1h_tokens: $cache_creation_1h_tokens - } - else - {prompt_cache_creation_tokens: $cache_creation_tokens} - end - )), - metadata: { - model: $model - }, - span_attributes: { - name: $model, - type: "llm" - } - }') - - if enqueue_span "$SESSION_ID" "$PROJECT_ID" "$event"; then - LLM_CALLS_CREATED=$((LLM_CALLS_CREATED + 1)) - log "INFO" "LLM span: $model tokens=$total_tokens (turn=$TURN_SPAN_ID)" - fi -} - -# Flush the pending assistant segment at a boundary (tool_result, real user -# message, or end of transcript). -# -# A single API response (one requestId) can span multiple tool_result -# boundaries: it emits a tool_use, gets a tool_result, then emits MORE -# tool_use blocks under the SAME requestId. Input/cache for that requestId -# are counted once and output is tracked as a running max, so every segment -# AFTER the first carries zero new tokens. Emitting a span for those -# continuation segments would create misleading all-zero-token LLM spans and -# split one logical response across several spans. -# -# To match the per-requestId grouping the sub-agent path uses, we only emit -# an LLM span when the pending segment actually accrued token metrics (its -# first sighting). Continuation segments are still recorded into the -# conversation history (so tool calls keep their context) but do not produce -# their own span. -flush_pending_llm() { - [ -n "$CURRENT_OUTPUT_TEXT" ] || [ "$CURRENT_TOOL_CALLS" != "[]" ] || return 0 - - if [ "$CURRENT_PROMPT_TOKENS" -gt 0 ] 2>/dev/null \ - || [ "$CURRENT_COMPLETION_TOKENS" -gt 0 ] 2>/dev/null \ - || [ "$CURRENT_CACHE_CREATION_TOKENS" -gt 0 ] 2>/dev/null \ - || [ "$CURRENT_CACHE_CREATION_5M_TOKENS" -gt 0 ] 2>/dev/null \ - || [ "$CURRENT_CACHE_CREATION_1H_TOKENS" -gt 0 ] 2>/dev/null \ - || [ "$CURRENT_CACHE_READ_TOKENS" -gt 0 ] 2>/dev/null; then - create_llm_span "$CURRENT_OUTPUT_TEXT" "$CURRENT_MODEL" "$CURRENT_PROMPT_TOKENS" "$CURRENT_COMPLETION_TOKENS" "$CURRENT_START_TIMESTAMP" "$CURRENT_END_TIMESTAMP" "$CURRENT_TOOL_CALLS" "$CONVERSATION_HISTORY" "$CURRENT_CACHE_CREATION_TOKENS" "$CURRENT_CACHE_READ_TOKENS" "$CURRENT_CACHE_CREATION_5M_TOKENS" "$CURRENT_CACHE_CREATION_1H_TOKENS" "$CURRENT_CACHE_CREATION_MISSING_SPLIT" - fi - - # Always thread the assistant turn into history, whether or not a span - # was emitted, so subsequent tool results / messages keep their context. - add_to_history "assistant" "$CURRENT_OUTPUT_TEXT" "" "$CURRENT_TOOL_CALLS" -} - -while IFS= read -r line; do - LINE_NUM=$((LINE_NUM + 1)) - [ "$LINE_NUM" -le "$TURN_LAST_LINE" ] && continue - [ -z "$line" ] && continue - - MSG_TYPE=$(echo "$line" | jq -r '.type // empty' 2>/dev/null) - MSG_TIMESTAMP=$(echo "$line" | jq -r '.timestamp // empty' 2>/dev/null) - - if [ "$MSG_TYPE" = "user" ]; then - # Check if tool_result or real user message - CONTENT=$(echo "$line" | jq -r '.message.content // empty' 2>/dev/null) - IS_TOOL_RESULT=$(echo "$CONTENT" | jq -e '.[0].type == "tool_result"' >/dev/null 2>&1 && echo "true" || echo "false") - - if [ "$IS_TOOL_RESULT" = "true" ]; then - # Tool result - flush any pending assistant segment first. This - # emits an LLM span only if the segment accrued tokens (a fresh - # requestId); continuation segments of an already-counted - # requestId are folded into history without a separate span. - flush_pending_llm - - # Extract tool result content and tool_use_id - TOOL_RESULT_CONTENT=$(echo "$CONTENT" | jq -r '.[0].content // "tool result"' 2>/dev/null) - TOOL_USE_ID=$(echo "$CONTENT" | jq -r '.[0].tool_use_id // ""' 2>/dev/null) - - # Add tool result to conversation history - add_to_history "tool" "$TOOL_RESULT_CONTENT" "$TOOL_USE_ID" "" - - # Reset for next LLM call - DON'T set start timestamp yet - # The next assistant message timestamp will be the actual LLM start - CURRENT_OUTPUT_TEXT="" - CURRENT_TOOL_CALLS="[]" - CURRENT_MODEL="" - CURRENT_PROMPT_TOKENS=0 - CURRENT_COMPLETION_TOKENS=0 - CURRENT_CACHE_CREATION_TOKENS=0 - CURRENT_CACHE_CREATION_5M_TOKENS=0 - CURRENT_CACHE_CREATION_1H_TOKENS=0 - CURRENT_CACHE_CREATION_MISSING_SPLIT=false - CURRENT_CACHE_READ_TOKENS=0 - CURRENT_START_TIMESTAMP="" # Will be set from first assistant message - CURRENT_END_TIMESTAMP="" - else - # Real user message - flush any pending assistant segment first. - flush_pending_llm - - # Add user message to conversation history - add_to_history "user" "$CONTENT" "" "" - - # Reset for next LLM call - CURRENT_OUTPUT_TEXT="" - CURRENT_TOOL_CALLS="[]" - CURRENT_MODEL="" - CURRENT_PROMPT_TOKENS=0 - CURRENT_COMPLETION_TOKENS=0 - CURRENT_CACHE_CREATION_TOKENS=0 - CURRENT_CACHE_CREATION_5M_TOKENS=0 - CURRENT_CACHE_CREATION_1H_TOKENS=0 - CURRENT_CACHE_CREATION_MISSING_SPLIT=false - CURRENT_CACHE_READ_TOKENS=0 - CURRENT_START_TIMESTAMP="$MSG_TIMESTAMP" - CURRENT_END_TIMESTAMP="" - fi - - elif [ "$MSG_TYPE" = "assistant" ]; then - # Extract text content - TEXT=$(echo "$line" | jq -r ' - .message.content - | if type == "array" then - [.[] | select(.type == "text") | .text] | join("\n") - elif type == "string" then - . - else - empty - end - ' 2>/dev/null) - - # Extract full tool_use objects for tool_calls - TOOL_CALLS_JSON=$(echo "$line" | jq -c ' - .message.content - | if type == "array" then - [.[] | select(.type == "tool_use") | { - id: .id, - type: "function", - function: { - name: .name, - arguments: (.input | tojson) - } - }] - else - [] - end - ' 2>/dev/null) - - # Check if we have tool calls - HAS_TOOL_CALLS=$(echo "$TOOL_CALLS_JSON" | jq 'length > 0' 2>/dev/null) - - # Set start timestamp from first assistant message of this LLM call - [ -z "$CURRENT_START_TIMESTAMP" ] && CURRENT_START_TIMESTAMP="$MSG_TIMESTAMP" - - if [ -n "$TEXT" ]; then - if [ -n "$CURRENT_OUTPUT_TEXT" ]; then - CURRENT_OUTPUT_TEXT="$CURRENT_OUTPUT_TEXT"$'\n'"$TEXT" - else - CURRENT_OUTPUT_TEXT="$TEXT" - fi - CURRENT_END_TIMESTAMP="$MSG_TIMESTAMP" - fi - - if [ "$HAS_TOOL_CALLS" = "true" ]; then - CURRENT_TOOL_CALLS="$TOOL_CALLS_JSON" - CURRENT_END_TIMESTAMP="$MSG_TIMESTAMP" - fi - - # Extract model - MODEL=$(echo "$line" | jq -r '.message.model // empty' 2>/dev/null) - [ -n "$MODEL" ] && CURRENT_MODEL="$MODEL" - - # Extract tokens. A single API response repeats across multiple - # content-block lines sharing one requestId. Input/cache are identical - # on every line (count once); output_tokens streams cumulatively - # (track the running max and add only deltas). Lines without a - # requestId (rare) fall back to message id; if neither is present we - # treat each line as its own request. - REQUEST_ID=$(echo "$line" | jq -r '.requestId // .message.id // empty' 2>/dev/null) - - USAGE=$(echo "$line" | jq -c '.message.usage // {}' 2>/dev/null) - if [ "$USAGE" != "{}" ] && [ -n "$USAGE" ]; then - INPUT_TOKENS=$(echo "$USAGE" | jq -r '.input_tokens // 0' 2>/dev/null) - OUTPUT_TOKENS=$(echo "$USAGE" | jq -r '.output_tokens // 0' 2>/dev/null) - CACHE_CREATION=$(echo "$USAGE" | jq -r '.cache_creation_input_tokens // 0' 2>/dev/null) - CACHE_READ=$(echo "$USAGE" | jq -r '.cache_read_input_tokens // 0' 2>/dev/null) - CACHE_CREATION_5M=$(echo "$USAGE" | jq -r '.cache_creation.ephemeral_5m_input_tokens // 0' 2>/dev/null) - CACHE_CREATION_1H=$(echo "$USAGE" | jq -r '.cache_creation.ephemeral_1h_input_tokens // 0' 2>/dev/null) - HAS_CACHE_CREATION_SPLIT=$(echo "$USAGE" | jq -r 'if ((.cache_creation? // null) | type) == "object" then "true" else "false" end' 2>/dev/null) - [ "$INPUT_TOKENS" = "null" ] && INPUT_TOKENS=0 - [ "$OUTPUT_TOKENS" = "null" ] && OUTPUT_TOKENS=0 - [ "$CACHE_CREATION" = "null" ] && CACHE_CREATION=0 - [ "$CACHE_READ" = "null" ] && CACHE_READ=0 - [ "$CACHE_CREATION_5M" = "null" ] && CACHE_CREATION_5M=0 - [ "$CACHE_CREATION_1H" = "null" ] && CACHE_CREATION_1H=0 - [ "$HAS_CACHE_CREATION_SPLIT" = "null" ] && HAS_CACHE_CREATION_SPLIT=false - - # Determine whether input/cache for this requestId were already - # counted, and fetch the prior max output for delta accounting. - local_first_sighting=true - prior_output=0 - if [ -n "$REQUEST_ID" ]; then - case "$SEEN_REQUEST_IDS" in - *" $REQUEST_ID "*) local_first_sighting=false ;; - *) SEEN_REQUEST_IDS="${SEEN_REQUEST_IDS}${REQUEST_ID} " ;; - esac - # Look up the prior max output recorded for this requestId. - case "$REQUEST_OUTPUT_MAX" in - *" ${REQUEST_ID}="*) - prior_output=${REQUEST_OUTPUT_MAX#*" ${REQUEST_ID}="} - prior_output=${prior_output%% *} - ;; - esac - fi - - # Input + cache: count once per requestId (constant across lines). - if [ "$local_first_sighting" = "true" ]; then - [ "$INPUT_TOKENS" -gt 0 ] 2>/dev/null && CURRENT_PROMPT_TOKENS=$((CURRENT_PROMPT_TOKENS + INPUT_TOKENS)) - [ "$CACHE_CREATION" -gt 0 ] 2>/dev/null && CURRENT_CACHE_CREATION_TOKENS=$((CURRENT_CACHE_CREATION_TOKENS + CACHE_CREATION)) - [ "$CACHE_READ" -gt 0 ] 2>/dev/null && CURRENT_CACHE_READ_TOKENS=$((CURRENT_CACHE_READ_TOKENS + CACHE_READ)) - [ "$CACHE_CREATION_5M" -gt 0 ] 2>/dev/null && CURRENT_CACHE_CREATION_5M_TOKENS=$((CURRENT_CACHE_CREATION_5M_TOKENS + CACHE_CREATION_5M)) - [ "$CACHE_CREATION_1H" -gt 0 ] 2>/dev/null && CURRENT_CACHE_CREATION_1H_TOKENS=$((CURRENT_CACHE_CREATION_1H_TOKENS + CACHE_CREATION_1H)) - if [ "$CACHE_CREATION" -gt 0 ] 2>/dev/null && [ "$HAS_CACHE_CREATION_SPLIT" != "true" ]; then - CURRENT_CACHE_CREATION_MISSING_SPLIT=true - fi - fi - - # Output: add only the increase over this requestId's prior max, - # so the running total converges on the final (largest) value. - if [ -z "$REQUEST_ID" ]; then - # No requestId: treat as a standalone response. - [ "$OUTPUT_TOKENS" -gt 0 ] 2>/dev/null && CURRENT_COMPLETION_TOKENS=$((CURRENT_COMPLETION_TOKENS + OUTPUT_TOKENS)) - elif [ "$OUTPUT_TOKENS" -gt "$prior_output" ] 2>/dev/null; then - CURRENT_COMPLETION_TOKENS=$((CURRENT_COMPLETION_TOKENS + OUTPUT_TOKENS - prior_output)) - # Record the new max for this requestId. Use an intermediate - # variable for the pattern: nesting double-quotes inside the - # ${var//pat/repl} expansion does NOT produce the intended - # pattern and instead corrupts REQUEST_OUTPUT_MAX, so on the - # next sighting the prior max can't be found and the full - # output gets re-added as a bogus delta. - _rom_pat=" ${REQUEST_ID}=${prior_output} " - REQUEST_OUTPUT_MAX="${REQUEST_OUTPUT_MAX//$_rom_pat/ }" - REQUEST_OUTPUT_MAX="${REQUEST_OUTPUT_MAX}${REQUEST_ID}=${OUTPUT_TOKENS} " - fi - fi - fi -done < "$CONV_FILE" - -# Save final LLM call (same zero-token suppression as the boundary flushes). -flush_pending_llm - -# Update the Turn span with its end time and Claude's final response via a -# merge write. The merge keeps the existing fields (input, name, type) set -# when user_prompt_submit.sh created the Turn span. We intentionally do NOT -# write token metrics here: token metrics live only on the leaf LLM spans, -# and Braintrust aggregates them onto parents (Turn, session) for display. -END_TIME=$(date +%s) - -TURN_UPDATE=$(jq -n \ - --arg id "$TURN_SPAN_ID" \ - --arg output "$LAST_ASSISTANT_MESSAGE" \ - --argjson end_time "$END_TIME" \ - '{ - id: $id, - _is_merge: true, - output: $output, - metrics: { - end: $end_time - } - }') - -enqueue_span "$SESSION_ID" "$PROJECT_ID" "$TURN_UPDATE" || true - -# Update state -set_session_state "$SESSION_ID" "turn_last_line" "$TOTAL_LINES" -set_session_state "$SESSION_ID" "current_turn_span_id" "" -set_session_state "$SESSION_ID" "current_turn_explicit_skill_names" "[]" - -[ "$LLM_CALLS_CREATED" -gt 0 ] && log "INFO" "Created $LLM_CALLS_CREATED LLM spans for turn" -log "INFO" "Turn finalized (end=$END_TIME)" - -exit 0 diff --git a/src/plugins/claude/content/plugins/trace-claude-code/hooks/user_prompt_expansion.sh b/src/plugins/claude/content/plugins/trace-claude-code/hooks/user_prompt_expansion.sh deleted file mode 100644 index 8e9ed75..0000000 --- a/src/plugins/claude/content/plugins/trace-claude-code/hooks/user_prompt_expansion.sh +++ /dev/null @@ -1,92 +0,0 @@ -#!/bin/bash -### -# UserPromptExpansion Hook - Captures explicit Claude slash skill requests. -### - -set -e - -SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -source "$SCRIPT_DIR/common.sh" - -tracing_enabled || exit 0 -check_requirements || exit 0 - -INPUT=$(cat) -record_hook_input "UserPromptExpansion" "$INPUT" - -SESSION_ID=$(echo "$INPUT" | jq -r '.session_id // empty' 2>/dev/null) -[ -z "$SESSION_ID" ] && exit 0 - -EXPANSION_TYPE=$(echo "$INPUT" | jq -r '.expansion_type // .type // empty' 2>/dev/null) -if [ -n "$EXPANSION_TYPE" ] && [ "$EXPANSION_TYPE" != "slash_command" ]; then - exit 0 -fi - -_normalize_skill_name() { - echo "$1" | sed 's#^/##' | sed 's/^[[:space:]]*//' | sed 's/[[:space:]]*$//' | sed 's/[),.;:]*$//' -} - -_skill_listing_contains() { - local transcript="$1" - local name="$2" - [ -n "$transcript" ] && [ -f "$transcript" ] || return 1 - jq -e --arg name "$name" ' - select(.attachment.type == "skill_listing") - | .attachment.names[]? - | select(. == $name) - ' "$transcript" >/dev/null 2>&1 -} - -SKILL_NAME=$(echo "$INPUT" | jq -r ' - .skill_name - // .skillName - // .skill.name - // .skill - // empty -' 2>/dev/null) - -if [ -z "$SKILL_NAME" ]; then - COMMAND_NAME=$(echo "$INPUT" | jq -r '.command_name // .command // .slash_command // .name // empty' 2>/dev/null) - COMMAND_NAME=$(_normalize_skill_name "$COMMAND_NAME") - TRANSCRIPT_PATH=$(echo "$INPUT" | jq -r '.transcript_path // empty' 2>/dev/null) - if [ -n "$COMMAND_NAME" ] && _skill_listing_contains "$TRANSCRIPT_PATH" "$COMMAND_NAME"; then - SKILL_NAME="$COMMAND_NAME" - fi -fi - -SKILL_NAME=$(_normalize_skill_name "$SKILL_NAME") -[ -z "$SKILL_NAME" ] && exit 0 - -EXISTING=$(get_session_state "$SESSION_ID" "current_turn_explicit_skill_names") -[ -z "$EXISTING" ] && EXISTING="[]" - -NAMES_JSON=$(jq -nc --argjson existing "$EXISTING" --arg name "$SKILL_NAME" ' - ($existing + [$name]) - | reduce .[] as $name ([]; if index($name) then . else . + [$name] end) -' 2>/dev/null || jq -nc --arg name "$SKILL_NAME" '[$name]') - -set_session_state "$SESSION_ID" "current_turn_explicit_skill_names" "$NAMES_JSON" - -TURN_SPAN_ID=$(get_session_state "$SESSION_ID" "current_turn_span_id") -PROJECT_ID=$(get_session_state "$SESSION_ID" "project_id") -[ -n "$TURN_SPAN_ID" ] && [ -n "$PROJECT_ID" ] || exit 0 - -ROOT_SPAN_ID=$(get_session_state "$SESSION_ID" "root_span_id") - -EVENT=$(jq -n \ - --arg id "$TURN_SPAN_ID" \ - --argjson names "$NAMES_JSON" \ - '{ - id: $id, - _is_merge: true, - metadata: { - loaded_skill_names: $names, - loaded_skills: ($names | map({name: .})) - } - }') - -enqueue_span "$SESSION_ID" "$PROJECT_ID" "$EVENT" || true - -log "INFO" "Explicit skill request: $SKILL_NAME (turn=$TURN_SPAN_ID root=$ROOT_SPAN_ID)" - -exit 0 diff --git a/src/plugins/claude/content/plugins/trace-claude-code/hooks/user_prompt_submit.sh b/src/plugins/claude/content/plugins/trace-claude-code/hooks/user_prompt_submit.sh deleted file mode 100755 index 75060b3..0000000 --- a/src/plugins/claude/content/plugins/trace-claude-code/hooks/user_prompt_submit.sh +++ /dev/null @@ -1,158 +0,0 @@ -#!/bin/bash -### -# UserPromptSubmit Hook - Creates a Turn container span when user submits a prompt -### - -set -e - -SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -source "$SCRIPT_DIR/common.sh" - -debug "UserPromptSubmit hook triggered" - -tracing_enabled || { debug "Tracing disabled"; exit 0; } -check_requirements || exit 0 - -# Read input from stdin -INPUT=$(cat) -record_hook_input "user_prompt_submit" "$INPUT" -debug "UserPromptSubmit input: $(echo "$INPUT" | jq -c '.' 2>/dev/null | head -c 500)" - -# Extract session ID and prompt -SESSION_ID=$(echo "$INPUT" | jq -r '.session_id // empty' 2>/dev/null) -PROMPT=$(echo "$INPUT" | jq -r '.prompt // empty' 2>/dev/null) - -[ -z "$SESSION_ID" ] && { debug "No session ID"; exit 0; } - -# Get session info -ROOT_SPAN_ID=$(get_session_state "$SESSION_ID" "root_span_id") -SESSION_SPAN_ID=$(get_session_state "$SESSION_ID" "session_span_id") -PROJECT_ID=$(get_session_state "$SESSION_ID" "project_id") - -# Load experiment_id from session state if not already set -if [ -z "$CC_EXPERIMENT_ID" ]; then - CC_EXPERIMENT_ID=$(get_session_state "$SESSION_ID" "experiment_id") - export CC_EXPERIMENT_ID -fi - -# If no session root exists yet, we'll create it -if [ -z "$ROOT_SPAN_ID" ] || [ -z "$PROJECT_ID" ]; then - PROJECT_ID=$(get_project_id "$PROJECT") || { log "ERROR" "Aborting user_prompt_submit: could not resolve project '$PROJECT' (see prior error)"; exit 0; } - ROOT_SPAN_ID="$SESSION_ID" - - # Get workspace name from cwd - CWD=$(echo "$INPUT" | jq -r '.cwd // empty' 2>/dev/null) - WORKSPACE_NAME=$(basename "$CWD" 2>/dev/null || echo "workspace") - - TIMESTAMP=$(get_timestamp) - HOSTNAME=$(get_hostname) - USERNAME=$(get_username) - OS=$(get_os) - - # Version info for observability (mirrors session_start.sh). - TRANSCRIPT_PATH=$(echo "$INPUT" | jq -r '.transcript_path // empty' 2>/dev/null) - PLUGIN_VERSION=$(get_plugin_version) - CLAUDE_CODE_VERSION=$(get_claude_code_version "$TRANSCRIPT_PATH") - GIT_METADATA=$(git_metadata_json "$CWD") - - EVENT=$(jq -n \ - --arg id "$ROOT_SPAN_ID" \ - --arg span_id "$ROOT_SPAN_ID" \ - --arg root_span_id "$ROOT_SPAN_ID" \ - --arg created "$TIMESTAMP" \ - --arg session "$SESSION_ID" \ - --arg workspace "$WORKSPACE_NAME" \ - --arg hostname "$HOSTNAME" \ - --arg username "$USERNAME" \ - --arg os "$OS" \ - --arg plugin_version "$PLUGIN_VERSION" \ - --arg claude_code_version "$CLAUDE_CODE_VERSION" \ - --argjson git_metadata "$GIT_METADATA" \ - '{ - id: $id, - span_id: $span_id, - root_span_id: $root_span_id, - created: $created, - input: ("Session: " + $workspace), - metadata: ({ - session_id: $session, - workspace: $workspace, - hostname: $hostname, - username: $username, - os: $os, - source: "claude-code", - trace_claude_code_version: $plugin_version, - claude_code_version: $claude_code_version - } + $git_metadata), - span_attributes: { - name: ("Claude Code: " + $workspace), - type: "task" - } - }') - - enqueue_span "$SESSION_ID" "$PROJECT_ID" "$EVENT" || true - set_session_state "$SESSION_ID" "root_span_id" "$ROOT_SPAN_ID" - set_session_state "$SESSION_ID" "session_span_id" "$ROOT_SPAN_ID" - set_session_state "$SESSION_ID" "project_id" "$PROJECT_ID" - SESSION_SPAN_ID="$ROOT_SPAN_ID" - log "INFO" "Created session root: $SESSION_ID" -fi - -# Increment turn count and create Turn span -TURN_COUNT=$(get_session_state "$SESSION_ID" "turn_count") -TURN_COUNT=${TURN_COUNT:-0} -TURN_COUNT=$((TURN_COUNT + 1)) - -TURN_SPAN_ID=$(generate_uuid) -TIMESTAMP=$(get_timestamp) -START_TIME=$(date +%s) - -EXPLICIT_SKILL_NAMES=$(get_session_state "$SESSION_ID" "current_turn_explicit_skill_names") -[ -z "$EXPLICIT_SKILL_NAMES" ] && EXPLICIT_SKILL_NAMES="[]" - -# Truncate prompt for display (first 100 chars) -PROMPT_PREVIEW="${PROMPT:0:100}" -[ ${#PROMPT} -gt 100 ] && PROMPT_PREVIEW="${PROMPT_PREVIEW}..." - -# Create Turn container span (parent is the session span, not the root) -EVENT=$(jq -n \ - --arg id "$TURN_SPAN_ID" \ - --arg span_id "$TURN_SPAN_ID" \ - --arg root_span_id "$ROOT_SPAN_ID" \ - --arg session_span_id "$SESSION_SPAN_ID" \ - --arg created "$TIMESTAMP" \ - --arg prompt "$PROMPT" \ - --argjson explicit_skill_names "$EXPLICIT_SKILL_NAMES" \ - --argjson turn "$TURN_COUNT" \ - --argjson start_time "$START_TIME" \ - '{ - id: $id, - span_id: $span_id, - root_span_id: $root_span_id, - span_parents: [$session_span_id], - created: $created, - input: $prompt, - metrics: { - start: $start_time - }, - metadata: (if ($explicit_skill_names | length) > 0 then { - loaded_skill_names: $explicit_skill_names, - loaded_skills: ($explicit_skill_names | map({name: .})) - } else {} end), - span_attributes: { - name: ("Turn " + ($turn | tostring)), - type: "task" - } - }') - -enqueue_span "$SESSION_ID" "$PROJECT_ID" "$EVENT" || { log "ERROR" "Failed to enqueue turn span"; exit 0; } - -# Save turn state -set_session_state "$SESSION_ID" "turn_count" "$TURN_COUNT" -set_session_state "$SESSION_ID" "current_turn_span_id" "$TURN_SPAN_ID" -set_session_state "$SESSION_ID" "current_turn_start" "$START_TIME" -set_session_state "$SESSION_ID" "current_turn_tool_count" "0" - -log "INFO" "Turn $TURN_COUNT started: $TURN_SPAN_ID" - -exit 0 diff --git a/src/plugins/claude/content/plugins/trace-claude-code/hooks/worker.sh b/src/plugins/claude/content/plugins/trace-claude-code/hooks/worker.sh deleted file mode 100644 index 175df43..0000000 --- a/src/plugins/claude/content/plugins/trace-claude-code/hooks/worker.sh +++ /dev/null @@ -1,138 +0,0 @@ -#!/bin/bash -### -# Background queue worker for a single Claude Code session. -# -# Spawned by ensure_worker_running() in common.sh. Drains the session's -# pending/ dir in FIFO order. The worker: -# 1. Acquires an exclusive lock by writing its PID to worker.lock. -# 2. Refreshes the lock file mtime on every loop iteration so a future -# sweep can distinguish "alive" from "crashed". -# 3. Picks the oldest pending/*.json file. -# 4. Atomically renames it into processing/. -# 5. Runs _http_insert_span() on it. -# 6. Deletes the file on success, or logs and drops on failure. -# -# Lifecycle: the worker runs until its lock file is removed (clean -# shutdown by session_end.sh) or it loses the lock (some other entity -# took over, e.g. crash recovery). There is no idle timeout: the worker -# lives as long as its Claude Code session is alive. -# -# Args: session_id (positional, required) -### - -set -u - -SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -# shellcheck source=common.sh -source "$SCRIPT_DIR/common.sh" - -SESSION_ID="${1:-}" -if [ -z "$SESSION_ID" ]; then - log "ERROR" "worker.sh started without a session_id argument" - exit 2 -fi - -SDIR=$(session_queue_dir "$SESSION_ID") -LOCK_FILE="$SDIR/worker.lock" - -# Ensure the session subtree exists. ensure_worker_running normally does -# this for us, but it's cheap and makes the worker robust if invoked -# manually (e.g. during crash recovery). -mkdir -p "$SDIR/pending" "$SDIR/processing" - -# If another worker holds the lock, exit. (We trust ensure_worker_running -# to have checked, but races happen.) -if [ -f "$LOCK_FILE" ]; then - other_pid=$(cat "$LOCK_FILE" 2>/dev/null) - if [ -n "$other_pid" ] && [ "$other_pid" != "$$" ] && kill -0 "$other_pid" 2>/dev/null; then - debug "Worker for session $SESSION_ID: another worker (pid=$other_pid) holds the lock; exiting" - exit 0 - fi -fi - -# Claim the lock. We use noclobber to make this somewhat atomic against -# other workers racing to claim the same session. -if ! { set -C; echo "$$" > "$LOCK_FILE"; } 2>/dev/null; then - # Lost the race - someone else just took the lock. - debug "Worker for session $SESSION_ID: lost lock race; exiting" - exit 0 -fi - -cleanup_worker() { - # Only remove the lock if it's still ours. Crash recovery may have - # already removed and replaced it. - if [ -f "$LOCK_FILE" ]; then - local owner - owner=$(cat "$LOCK_FILE" 2>/dev/null) - if [ "$owner" = "$$" ]; then - rm -f "$LOCK_FILE" - fi - fi - debug "Worker $$ for session $SESSION_ID exiting" -} -trap cleanup_worker EXIT - -# Sweep any in-flight files that a previous worker left behind. We're now -# the owner of this session's queue, so it's safe to recover them. -for f in "$SDIR"/processing/*.json; do - [ -e "$f" ] || continue - mv "$f" "$SDIR/pending/$(basename "$f")" 2>/dev/null || true -done - -debug "Worker $$ started for session $SESSION_ID" - -# Main loop. Runs until the lock file disappears (clean shutdown signal -# from session_end.sh). -while true; do - # Heartbeat: keep the lock file's mtime fresh so the sweep doesn't - # mistake us for a crashed worker. - touch "$LOCK_FILE" 2>/dev/null || { - debug "Worker $$ for session $SESSION_ID: lock file disappeared; exiting" - break - } - - # Verify we still own the lock. If something else claimed it (e.g. a - # crash-recovery sweep killed us and respawned), exit so the new - # worker can take over without contention. - owner=$(cat "$LOCK_FILE" 2>/dev/null) - if [ "$owner" != "$$" ]; then - debug "Worker $$ for session $SESSION_ID: lock now owned by pid=$owner; exiting" - # Don't remove the lock - it's not ours anymore. - trap - EXIT - exit 0 - fi - - # Pick the oldest pending job (sorted lexically; filenames sort by - # epoch-ns timestamp so this is effectively FIFO). - job_file=$(find "$SDIR/pending" -maxdepth 1 -name '*.json' -type f 2>/dev/null \ - | sort | head -n1) - - if [ -z "$job_file" ]; then - sleep 0.2 - continue - fi - - # Atomically claim the job by moving it to processing/. - processing_file="$SDIR/processing/$(basename "$job_file")" - if ! mv "$job_file" "$processing_file" 2>/dev/null; then - # Another mover beat us to it (shouldn't happen with a single - # worker per session, but harmless). Retry. - continue - fi - - job=$(cat "$processing_file" 2>/dev/null) - if [ -z "$job" ]; then - log "WARN" "Empty job file in session $SESSION_ID: $(basename "$processing_file") - discarding" - rm -f "$processing_file" - continue - fi - - if _process_job_inline "$job"; then - rm -f "$processing_file" - else - log "ERROR" "Worker for session $SESSION_ID dropping failed job: $(basename "$processing_file")" - rm -f "$processing_file" - fi -done - -exit 0 diff --git a/src/plugins/claude/content/plugins/trace-claude-code/setup.sh b/src/plugins/claude/content/plugins/trace-claude-code/setup.sh index da5c4f3..bece85d 100755 --- a/src/plugins/claude/content/plugins/trace-claude-code/setup.sh +++ b/src/plugins/claude/content/plugins/trace-claude-code/setup.sh @@ -1,214 +1,70 @@ #!/bin/bash -### -# Setup script for Braintrust Claude Code tracing -# Run this in any project directory to enable comprehensive tracing -### +# Configure Claude Code to use the shared Braintrust tracing daemon. -set -e +set -euo pipefail -echo "🧠 Braintrust Claude Code Tracing Setup" -echo "========================================" -echo "" -echo "This script will configure Claude Code to trace conversations to Braintrust." -echo "Traces include: sessions, conversation turns, and tool calls." -echo "" +echo "Braintrust Claude Code tracing setup" +echo -# Get the directory where this script lives SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -HOOKS_DIR="$SCRIPT_DIR/hooks" - -# Verify hooks exist -for hook in common.sh session_start.sh post_tool_use.sh stop_hook.sh session_end.sh; do - if [ ! -f "$HOOKS_DIR/$hook" ]; then - echo "❌ Error: Missing hook script: $HOOKS_DIR/$hook" - exit 1 - fi -done - -# Check for required tools -for cmd in jq curl uuidgen; do - if ! command -v "$cmd" &> /dev/null; then - echo "❌ Error: $cmd is required but not installed" - if [[ "$OSTYPE" == "darwin"* ]]; then - if [ "$cmd" = "uuidgen" ]; then - echo " uuidgen should be pre-installed on macOS" - else - echo " Install with: brew install $cmd" - fi - else - if [ "$cmd" = "uuidgen" ]; then - echo " Install with: sudo apt-get install uuid-runtime" - else - echo " Install with: sudo apt-get install $cmd" - fi - fi - exit 1 - fi -done - -# Load API key from .env files (check current dir and parents) -load_env() { - local dir="$PWD" - while [ "$dir" != "/" ]; do - if [ -f "$dir/.env" ]; then - # Source the .env file safely (only export lines) - while IFS= read -r line || [ -n "$line" ]; do - # Skip comments and empty lines - [[ "$line" =~ ^#.*$ ]] && continue - [[ -z "$line" ]] && continue - # Export valid variable assignments - if [[ "$line" =~ ^[A-Za-z_][A-Za-z0-9_]*= ]]; then - export "${line?}" - fi - done < "$dir/.env" - echo " Found .env at: $dir/.env" - return 0 - fi - dir="$(dirname "$dir")" - done - return 1 +[ -f "$SCRIPT_DIR/bin/claude-hook.sh" ] || { + echo "Missing bin/claude-hook.sh" >&2 + exit 1 +} +command -v jq >/dev/null 2>&1 || { + echo "jq is required to update .claude/settings.local.json" >&2 + exit 1 } -# Try to load from .env -EXISTING_KEY="" -if load_env 2>/dev/null; then - EXISTING_KEY="${BRAINTRUST_API_KEY:-}" -fi - -# Prompt for API key (with default from .env if available) -if [ -n "$EXISTING_KEY" ]; then - echo "Found BRAINTRUST_API_KEY in .env" - echo "Press Enter to use it, or enter a different key:" - read -r -p "> " INPUT_KEY - BRAINTRUST_API_KEY="${INPUT_KEY:-$EXISTING_KEY}" -else - echo "Enter your Braintrust API key (starts with 'sk-'):" - echo " Get one at: https://www.braintrust.dev/app/settings/api-keys" - read -r -p "> " BRAINTRUST_API_KEY +BT_BIN="$(command -v bt 2>/dev/null || true)" +LOCAL_BT="${XDG_BIN_HOME:-$HOME/.local/bin}/bt" +if ! [ -n "$BT_BIN" ] || ! "$BT_BIN" daemon hook --help >/dev/null 2>&1; then + if [ -x "$LOCAL_BT" ] && "$LOCAL_BT" daemon hook --help >/dev/null 2>&1; then + BT_BIN="$LOCAL_BT" + else + command -v curl >/dev/null 2>&1 || { + echo "curl is required to install or upgrade bt" >&2 + exit 1 + } + echo "Installing the daemon-capable bt CLI..." + curl -fsSL https://bt.dev/cli/install.sh | bash + BT_BIN="$LOCAL_BT" + fi fi -if [ -z "$BRAINTRUST_API_KEY" ]; then - echo "❌ API key is required" +"$BT_BIN" daemon hook --help >/dev/null 2>&1 || { + echo "The installed bt CLI does not support 'bt daemon hook'." >&2 exit 1 -fi +} -# Validate API key format -if [[ ! "$BRAINTRUST_API_KEY" =~ ^sk- ]]; then - echo "⚠️ Warning: API key doesn't start with 'sk-'. Continuing anyway..." +if [ -z "${BRAINTRUST_API_KEY:-}" ] && ! "$BT_BIN" status --json >/dev/null 2>&1; then + echo "Authenticate with Braintrust:" + "$BT_BIN" auth login fi -# Prompt for project name -echo "" -echo "Enter the Braintrust project name for traces (default: claude-code):" -read -r -p "> " PROJECT_NAME +read -r -p "Braintrust project for traces [claude-code]: " PROJECT_NAME PROJECT_NAME="${PROJECT_NAME:-claude-code}" -# Prompt for debug mode -echo "" -echo "Enable debug logging? (y/N):" -read -r -p "> " ENABLE_DEBUG -if [[ "$ENABLE_DEBUG" =~ ^[Yy] ]]; then - DEBUG_VALUE="true" -else - DEBUG_VALUE="false" -fi - -# Create .claude directory if needed mkdir -p .claude - -# Build environment config -ENV_CONFIG=$(jq -n \ - --arg key "$BRAINTRUST_API_KEY" \ - --arg proj "$PROJECT_NAME" \ - --arg debug "$DEBUG_VALUE" \ - '{ - "TRACE_TO_BRAINTRUST": "true", - "BRAINTRUST_API_KEY": $key, - "BRAINTRUST_CC_PROJECT": $proj, - "BRAINTRUST_CC_DEBUG": $debug - }') - -# Check if settings.local.json exists SETTINGS_FILE=".claude/settings.local.json" if [ -f "$SETTINGS_FILE" ]; then - echo "" - echo "Found existing $SETTINGS_FILE" - - # Read existing settings and merge - EXISTING=$(cat "$SETTINGS_FILE") - - UPDATED=$(echo "$EXISTING" | jq \ - --argjson env "$ENV_CONFIG" \ - '.env = (.env // {}) + $env') - - echo "$UPDATED" > "$SETTINGS_FILE" + jq --arg project "$PROJECT_NAME" ' + .env = (.env // {}) + { + TRACE_TO_BRAINTRUST: "true", + BRAINTRUST_CC_PROJECT: $project + } + ' "$SETTINGS_FILE" >"$SETTINGS_FILE.tmp" else - # Create new settings file - jq -n \ - --argjson env "$ENV_CONFIG" \ - '{env: $env}' > "$SETTINGS_FILE" + jq -n --arg project "$PROJECT_NAME" '{ + env: { + TRACE_TO_BRAINTRUST: "true", + BRAINTRUST_CC_PROJECT: $project + } + }' >"$SETTINGS_FILE.tmp" fi +mv "$SETTINGS_FILE.tmp" "$SETTINGS_FILE" -echo "" -echo "✅ Setup complete!" -echo "" -echo "Configuration saved to: $SETTINGS_FILE" -echo "" -echo "Hooks configured:" -echo " • SessionStart - Creates trace root when session begins" -echo " • UserPromptSubmit - Creates Turn container for each user message" -echo " • PostToolUse - Captures tool calls as children of Turn" -echo " • Stop - Creates LLM span and finalizes Turn" -echo " • SessionEnd - Finalizes trace when session ends" -echo "" -echo "Settings:" -echo " • Project: $PROJECT_NAME" -echo " • Debug: $DEBUG_VALUE" -echo "" -echo "Next steps:" -echo " 1. Start Claude Code in this directory: claude" -echo " 2. Have a conversation" -echo " 3. View traces at: https://www.braintrust.dev/app/$PROJECT_NAME/logs" -echo "" -echo "To view hook logs:" -echo " tail -f ~/.claude/state/braintrust_hook.log" -echo "" - -# Test API connection and discover API URL -echo "Testing API connection..." - -# Discover API URL via login endpoint -APP_URL="${BRAINTRUST_APP_URL:-https://www.braintrust.dev}" -LOGIN_RESPONSE=$(curl -sf -X POST -H "Authorization: Bearer $BRAINTRUST_API_KEY" "$APP_URL/api/apikey/login" 2>/dev/null) || true - -ORG_NAME="${BRAINTRUST_ORG_NAME:-}" -if [ -n "$ORG_NAME" ]; then - # Filter by org name if specified - API_URL=$(echo "$LOGIN_RESPONSE" | jq -r --arg name "$ORG_NAME" \ - '.org_info[] | select(.name == $name) | .api_url // empty' 2>/dev/null | head -1) -else - # Use first org - API_URL=$(echo "$LOGIN_RESPONSE" | jq -r '.org_info[0].api_url // empty' 2>/dev/null) -fi - -if [ -z "$API_URL" ]; then - # Fall back to default if login didn't return an API URL - API_URL="https://api.braintrust.dev" -fi - -echo " Using API URL: $API_URL" - -RESPONSE=$(curl -s -w "\n%{http_code}" -X GET \ - -H "Authorization: Bearer $BRAINTRUST_API_KEY" \ - "$API_URL/v1/project?project_name=$(echo "$PROJECT_NAME" | jq -sRr @uri)" 2>&1) - -HTTP_CODE=$(echo "$RESPONSE" | tail -n1) - -if [ "$HTTP_CODE" = "200" ]; then - echo "✅ API connection successful - project exists" -elif [ "$HTTP_CODE" = "404" ]; then - echo "✅ API connection successful - project will be created on first trace" -else - echo "⚠️ API connection issue (HTTP $HTTP_CODE)" - echo " Check your API key and try again" -fi +echo +echo "Tracing enabled in $SETTINGS_FILE" +echo "Project: $PROJECT_NAME" +echo "Daemon status: $BT_BIN daemon status" diff --git a/src/plugins/claude/content/plugins/trace-claude-code/test/helpers/assert.sh b/src/plugins/claude/content/plugins/trace-claude-code/test/helpers/assert.sh deleted file mode 100644 index 410ceec..0000000 --- a/src/plugins/claude/content/plugins/trace-claude-code/test/helpers/assert.sh +++ /dev/null @@ -1,207 +0,0 @@ -#!/bin/bash -### -# Assertion utilities for trace-claude-code tests. -# -# Test structure: -# describe "thing under test" -# it "behaves like X" -# -# assert_eq "$got" "$want" -# end_it -# -# Each test file should source this and helpers/harness.sh, then declare -# its tests. The runner aggregates pass/fail counts in environment vars -# so multiple test files share a single tally. -### - -# Color codes (disable if NO_COLOR or non-tty stdout) -if [ -t 1 ] && [ -z "${NO_COLOR:-}" ]; then - C_RED=$'\033[31m' - C_GREEN=$'\033[32m' - C_YELLOW=$'\033[33m' - C_BOLD=$'\033[1m' - C_DIM=$'\033[2m' - C_RESET=$'\033[0m' -else - C_RED="" - C_GREEN="" - C_YELLOW="" - C_BOLD="" - C_DIM="" - C_RESET="" -fi - -# Test state - exported so subshells inherit, but counters are only meaningful -# at the top-level shell (we manage them via files for cross-process aggregation). -export TESTS_RUN_FILE="${TESTS_RUN_FILE:-/tmp/braintrust_test_run_$$}" -export TESTS_FAIL_FILE="${TESTS_FAIL_FILE:-/tmp/braintrust_test_fail_$$}" - -# Per-test ephemeral state (not exported across processes) -CURRENT_DESCRIBE="" -CURRENT_IT="" -CURRENT_TEST_FAILED=0 -CURRENT_TEST_FAILURES=() - -_init_counters() { - : > "$TESTS_RUN_FILE" - : > "$TESTS_FAIL_FILE" -} - -_incr_run() { - echo "x" >> "$TESTS_RUN_FILE" -} - -_incr_fail() { - echo "x" >> "$TESTS_FAIL_FILE" -} - -tests_total() { - [ -f "$TESTS_RUN_FILE" ] && wc -l < "$TESTS_RUN_FILE" | tr -d ' ' || echo 0 -} - -tests_failed() { - [ -f "$TESTS_FAIL_FILE" ] && wc -l < "$TESTS_FAIL_FILE" | tr -d ' ' || echo 0 -} - -describe() { - CURRENT_DESCRIBE="$1" - printf '\n%s%s%s\n' "$C_BOLD" "$CURRENT_DESCRIBE" "$C_RESET" -} - -# Run a single test case. -# -# Usage: -# it "does the thing" test_body_function_name -# -# The body function is invoked between setup_test_env and teardown_test_env -# (when those functions are defined). Inside the body you may use `local` -# variables freely. Use assert_* helpers to record failures; the test only -# fails if at least one assertion failed. -it() { - CURRENT_IT="$1" - local body_fn="$2" - CURRENT_TEST_FAILED=0 - CURRENT_TEST_FAILURES=() - _incr_run - - if declare -F setup_test_env >/dev/null; then - setup_test_env - fi - - # Run the test body in this shell so failures from assert_* propagate - # into our CURRENT_TEST_FAILURES array. Suppress `set -e` style aborts - # by using `|| true` if the body returns non-zero - we don't want the - # whole test file to exit just because one assertion failed. - "$body_fn" || true - - if [ "$CURRENT_TEST_FAILED" -eq 0 ]; then - printf ' %s✓%s %s\n' "$C_GREEN" "$C_RESET" "$CURRENT_IT" - else - _incr_fail - printf ' %s✗%s %s\n' "$C_RED" "$C_RESET" "$CURRENT_IT" - for msg in "${CURRENT_TEST_FAILURES[@]}"; do - printf ' %s%s%s\n' "$C_RED" "$msg" "$C_RESET" - done - fi - - if declare -F teardown_test_env >/dev/null; then - teardown_test_env - fi - - CURRENT_IT="" - CURRENT_TEST_FAILED=0 - CURRENT_TEST_FAILURES=() -} - -# Mark current test as failed and append a message -_fail() { - CURRENT_TEST_FAILED=1 - CURRENT_TEST_FAILURES+=("$1") -} - -assert_eq() { - local got="$1" want="$2" msg="${3:-}" - if [ "$got" != "$want" ]; then - _fail "${msg:-assert_eq}: expected '$want', got '$got'" - return 1 - fi - return 0 -} - -assert_ne() { - local got="$1" not_want="$2" msg="${3:-}" - if [ "$got" = "$not_want" ]; then - _fail "${msg:-assert_ne}: expected NOT '$not_want', got '$got'" - return 1 - fi - return 0 -} - -assert_match() { - local got="$1" pattern="$2" msg="${3:-}" - if ! [[ "$got" =~ $pattern ]]; then - _fail "${msg:-assert_match}: '$got' does not match /$pattern/" - return 1 - fi - return 0 -} - -assert_contains() { - local haystack="$1" needle="$2" msg="${3:-}" - if [[ "$haystack" != *"$needle"* ]]; then - _fail "${msg:-assert_contains}: '$haystack' does not contain '$needle'" - return 1 - fi - return 0 -} - -assert_not_contains() { - local haystack="$1" needle="$2" msg="${3:-}" - if [[ "$haystack" == *"$needle"* ]]; then - _fail "${msg:-assert_not_contains}: '$haystack' unexpectedly contains '$needle'" - return 1 - fi - return 0 -} - -assert_success() { - local status="$1" msg="${2:-}" - if [ "$status" -ne 0 ]; then - _fail "${msg:-assert_success}: expected exit 0, got $status" - return 1 - fi - return 0 -} - -assert_failure() { - local status="$1" msg="${2:-}" - if [ "$status" -eq 0 ]; then - _fail "${msg:-assert_failure}: expected non-zero exit, got 0" - return 1 - fi - return 0 -} - -assert_file_exists() { - local path="$1" msg="${2:-}" - if [ ! -f "$path" ]; then - _fail "${msg:-assert_file_exists}: file '$path' does not exist" - return 1 - fi - return 0 -} - -# Explicit failure -fail() { - _fail "${1:-explicit fail}" - return 1 -} - -# Skip the current test without failing it. Prints a note; the test body -# should `return 0` immediately after calling this. The test still counts as -# run/passed (it makes no assertions), which keeps the suite green when an -# optional fixture is absent. -skip() { - printf ' %s(skipped) %s%s\n' "$C_YELLOW" "${1:-no reason given}" "$C_RESET" - return 0 -} diff --git a/src/plugins/claude/content/plugins/trace-claude-code/test/helpers/curl_stub.sh b/src/plugins/claude/content/plugins/trace-claude-code/test/helpers/curl_stub.sh deleted file mode 100644 index 185b5bc..0000000 --- a/src/plugins/claude/content/plugins/trace-claude-code/test/helpers/curl_stub.sh +++ /dev/null @@ -1,224 +0,0 @@ -#!/bin/bash -### -# curl stub for tests. -# -# Defines a shell function `curl` that overrides the binary in any code -# sourced/exec'd after this file is loaded. The stub: -# 1. Parses curl args to extract METHOD, URL, and the -d/--data body. -# 2. Appends a single NDJSON line to $CAPTURED_REQUESTS describing the call. -# 3. Looks up a canned response based on URL pattern matching. -# 4. Emits the response body to stdout, followed by a newline + status code -# if the call included -w "%{http_code}" (matching real curl behavior). -# -# Configuring responses in a test: -# -# stub_response_for "*/api/apikey/login" 200 '{"org_info":[{"api_url":"https://api.test.invalid"}]}' -# stub_response_for "*/v1/project*" 200 '{"id":"proj_abc"}' -# stub_response_for "*/insert" 200 '{"row_ids":["row_xyz"]}' -# -# Patterns are bash glob patterns (case-style matching). The first matching -# pattern wins. If no pattern matches, the stub returns 200 with an empty -# JSON object; the failing test will surface that as a missing expectation. -# -# To simulate auth failure: -# stub_response_for "*" 401 "Invalid API Key" -### - -# Arrays storing (pattern, status, body) triplets in parallel. -# Bash 3 (default on macOS) lacks associative arrays for this, so we use -# parallel indexed arrays. -_CURL_STUB_PATTERNS=() -_CURL_STUB_STATUSES=() -_CURL_STUB_BODIES=() - -_curl_stub_reset() { - _CURL_STUB_PATTERNS=() - _CURL_STUB_STATUSES=() - _CURL_STUB_BODIES=() - # Re-export so subprocesses see the cleared state (they get a copy at exec). - _curl_stub_export -} - -stub_response_for() { - local pattern="$1" - local status="$2" - local body="$3" - _CURL_STUB_PATTERNS+=("$pattern") - _CURL_STUB_STATUSES+=("$status") - _CURL_STUB_BODIES+=("$body") - _curl_stub_export -} - -# Encode the stub config into a single env var so child processes can rebuild -# the lookup. Format: NDJSON, one line per stub. -_curl_stub_export() { - local i out="" - for i in "${!_CURL_STUB_PATTERNS[@]}"; do - out+=$(printf '%s\t%s\t%s\n' "${_CURL_STUB_PATTERNS[i]}" "${_CURL_STUB_STATUSES[i]}" "${_CURL_STUB_BODIES[i]}") - out+=$'\n' - done - export _CURL_STUB_CONFIG="$out" -} - -# Rebuild local arrays from the env var (used in subprocesses that inherit -# the env but not the bash arrays). -_curl_stub_import() { - _CURL_STUB_PATTERNS=() - _CURL_STUB_STATUSES=() - _CURL_STUB_BODIES=() - [ -z "${_CURL_STUB_CONFIG:-}" ] && return 0 - local line pattern status body - while IFS=$'\t' read -r pattern status body; do - [ -z "$pattern" ] && continue - _CURL_STUB_PATTERNS+=("$pattern") - _CURL_STUB_STATUSES+=("$status") - _CURL_STUB_BODIES+=("$body") - done <<< "$_CURL_STUB_CONFIG" -} - -# The curl stub itself. Designed to handle the curl invocation patterns -# used in common.sh (see top of file for the catalogue). -curl() { - # Rebuild stub config in case we're in a subprocess that inherited only env. - _curl_stub_import - - local method="GET" - local url="" - local data="" - local want_http_code=0 - local arg - - # Parse args. We handle the flags actually used by common.sh: - # -s silent (ignore) - # -f fail on error (ignore - we control status) - # -X METHOD method - # -H HEADER header (ignore - we don't assert on headers) - # -d DATA request body - # -w FORMAT write-out format; we check for %{http_code} - # URL positional - while [ $# -gt 0 ]; do - arg="$1" - case "$arg" in - -s|--silent) shift ;; - -f|--fail) shift ;; - -X|--request) method="$2"; shift 2 ;; - -H|--header) shift 2 ;; - -d|--data|--data-raw|--data-binary) data="$2"; shift 2 ;; - -w|--write-out) - if [[ "$2" == *"%{http_code}"* ]]; then - want_http_code=1 - fi - shift 2 - ;; - -o|--output) shift 2 ;; - -L|--location) shift ;; - --) shift; break ;; - -*) - # Unknown flag — try to skip it without consuming the next arg. - # This is best-effort; if it requires a value we may misparse. - shift - ;; - *) - # Treat as URL if we don't have one yet - if [ -z "$url" ]; then - url="$arg" - fi - shift - ;; - esac - done - - # Default to POST if -d was provided but -X wasn't (matches curl behavior) - if [ -n "$data" ] && [ "$method" = "GET" ]; then - method="POST" - fi - - # Record the request to the capture file as NDJSON. - if [ -n "${CAPTURED_REQUESTS:-}" ]; then - # Try to record data as parsed JSON when possible, otherwise as a string. - local data_field - if [ -z "$data" ]; then - data_field='null' - elif echo "$data" | jq -e . >/dev/null 2>&1; then - data_field=$(echo "$data" | jq -c .) - else - data_field=$(jq -nc --arg d "$data" '$d') - fi - jq -nc \ - --arg method "$method" \ - --arg url "$url" \ - --argjson data "$data_field" \ - '{method: $method, url: $url, body: $data}' >> "$CAPTURED_REQUESTS" - fi - - # Find a matching response - local status=200 - local body='{}' - local i matched=0 - for i in "${!_CURL_STUB_PATTERNS[@]}"; do - # shellcheck disable=SC2053 - case "$url" in - ${_CURL_STUB_PATTERNS[i]}) - status="${_CURL_STUB_STATUSES[i]}" - body="${_CURL_STUB_BODIES[i]}" - matched=1 - break - ;; - esac - done - - # Default behavior on no match: 200 with empty object body. - # Tests that care should configure stubs. - - # Emit response. - printf '%s' "$body" - if [ "$want_http_code" -eq 1 ]; then - printf '\n%s' "$status" - fi - - # Exit status: real curl returns non-zero on connection errors etc. - # We always return 0 - the HTTP status code communicates HTTP errors. - return 0 -} - -# Inspection helpers for tests. - -# Print all captured requests as pretty JSON (one object per request, joined). -captured_requests() { - [ -f "${CAPTURED_REQUESTS:-/dev/null}" ] || return 0 - jq -s . "$CAPTURED_REQUESTS" -} - -# Print all captured requests that match a URL glob, as a JSON array. -captured_requests_matching() { - local pattern="$1" - [ -f "${CAPTURED_REQUESTS:-/dev/null}" ] || { echo '[]'; return 0; } - jq -s --arg p "$pattern" ' - [ .[] | select(.url | test($p)) ] - ' "$CAPTURED_REQUESTS" -} - -# Count captured requests matching a URL glob. -captured_request_count() { - local pattern="${1:-.*}" - [ -f "${CAPTURED_REQUESTS:-/dev/null}" ] || { echo 0; return 0; } - jq -s --arg p "$pattern" ' - [ .[] | select(.url | test($p)) ] | length - ' "$CAPTURED_REQUESTS" -} - -# Extract all spans from /insert calls as a flat JSON array. -captured_spans() { - [ -f "${CAPTURED_REQUESTS:-/dev/null}" ] || { echo '[]'; return 0; } - jq -s ' - [ .[] | select(.url | test("/insert$")) | .body.events[]? ] - ' "$CAPTURED_REQUESTS" -} - -# Export helper functions so subprocesses (hook scripts run via run_hook) -# inherit them - the stubbed curl() function calls _curl_stub_import on -# every invocation to rebuild its lookup table from $_CURL_STUB_CONFIG. -export -f _curl_stub_reset -export -f _curl_stub_export -export -f _curl_stub_import -export -f curl diff --git a/src/plugins/claude/content/plugins/trace-claude-code/test/helpers/fixtures.sh b/src/plugins/claude/content/plugins/trace-claude-code/test/helpers/fixtures.sh deleted file mode 100644 index cd1e442..0000000 --- a/src/plugins/claude/content/plugins/trace-claude-code/test/helpers/fixtures.sh +++ /dev/null @@ -1,127 +0,0 @@ -#!/bin/bash -### -# Fixture builders for Claude Code hook payloads. -# -# Each builder returns a JSON object on stdout that matches the shape -# Claude Code passes to the corresponding hook script over stdin. -# -# Usage in tests: -# payload=$(fixture_session_start "sess-1" "/tmp/proj") -# run_hook session_start.sh "$payload" -### - -# SessionStart payload -# -# Args: [session_id] [cwd] -fixture_session_start() { - local session_id="${1:-test-session}" - local cwd="${2:-/tmp/test-workspace}" - jq -nc \ - --arg s "$session_id" \ - --arg c "$cwd" \ - '{session_id: $s, cwd: $c}' -} - -# UserPromptSubmit payload -# -# Args: session_id prompt [cwd] -fixture_user_prompt() { - local session_id="$1" - local prompt="$2" - local cwd="${3:-/tmp/test-workspace}" - jq -nc \ - --arg s "$session_id" \ - --arg p "$prompt" \ - --arg c "$cwd" \ - '{session_id: $s, prompt: $p, cwd: $c}' -} - -# PostToolUse payload -# -# Args: session_id tool_name tool_input_json tool_response_json -fixture_post_tool_use() { - local session_id="$1" - local tool_name="$2" - local tool_input="$3" # JSON object - local tool_response="$4" # JSON object - jq -nc \ - --arg s "$session_id" \ - --arg t "$tool_name" \ - --argjson i "$tool_input" \ - --argjson r "$tool_response" \ - '{session_id: $s, tool_name: $t, tool_input: $i, tool_response: $r}' -} - -# PostToolUseFailure payload -# -# Args: session_id tool_name tool_input_json error [tool_response_json] -fixture_post_tool_use_failure() { - local session_id="$1" - local tool_name="$2" - local tool_input="$3" # JSON object - local error="$4" - local tool_response="${5:-}" - [ -z "$tool_response" ] && tool_response="{}" - jq -nc \ - --arg s "$session_id" \ - --arg t "$tool_name" \ - --arg e "$error" \ - --argjson i "$tool_input" \ - --argjson r "$tool_response" \ - '{session_id: $s, tool_name: $t, tool_input: $i, tool_response: $r, error: $e}' -} - -# PermissionDenied payload -# -# Args: session_id tool_name tool_input_json [tool_use_id] -fixture_permission_denied() { - local session_id="$1" - local tool_name="$2" - local tool_input="$3" # JSON object - local tool_use_id="${4:-}" - jq -nc \ - --arg s "$session_id" \ - --arg t "$tool_name" \ - --arg tuid "$tool_use_id" \ - --argjson i "$tool_input" \ - '{session_id: $s, tool_name: $t, tool_input: $i} + (if $tuid != "" then {tool_use_id: $tuid} else {} end)' -} - -# Stop payload - includes the transcript path and optionally the -# last assistant message that Claude Code provides in real Stop events. -# -# Args: session_id transcript_path [last_assistant_message] -fixture_stop() { - local session_id="$1" - local transcript_path="$2" - local last_msg="${3:-}" - jq -nc \ - --arg s "$session_id" \ - --arg t "$transcript_path" \ - --arg m "$last_msg" \ - '{session_id: $s, transcript_path: $t, last_assistant_message: $m}' -} - -# SessionEnd payload -# -# Args: session_id -fixture_session_end() { - local session_id="$1" - jq -nc --arg s "$session_id" '{session_id: $s}' -} - -# Convenience: tool input/output JSON builders -fixture_tool_input_bash() { - local command="$1" - jq -nc --arg c "$command" '{command: $c}' -} - -fixture_tool_input_read() { - local file_path="$1" - jq -nc --arg p "$file_path" '{file_path: $p}' -} - -fixture_tool_response_text() { - local output="$1" - jq -nc --arg o "$output" '{output: $o}' -} diff --git a/src/plugins/claude/content/plugins/trace-claude-code/test/helpers/harness.sh b/src/plugins/claude/content/plugins/trace-claude-code/test/helpers/harness.sh deleted file mode 100644 index eef6359..0000000 --- a/src/plugins/claude/content/plugins/trace-claude-code/test/helpers/harness.sh +++ /dev/null @@ -1,121 +0,0 @@ -#!/bin/bash -### -# Test harness: provides a clean, isolated environment for each test. -# -# Each test gets: -# - A fresh temp directory used as $HOME so common.sh writes to it -# - Env vars set to defaults safe for testing (e.g. BRAINTRUST_API_KEY) -# - common.sh sourced so its functions are available -# - The stubbed `curl` function loaded so no real network calls are made -# - A capture file ($CAPTURED_REQUESTS) recording every HTTP request -# -# Usage in a test file: -# source helpers/assert.sh -# source helpers/harness.sh -# -# describe "my function" -# it "does the thing" -# setup_test_env -# ... call functions ... -# assert_eq "$got" "$want" -# end_it -### - -# Locate the plugin root and hooks directory based on this file's path -TEST_HELPERS_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -TEST_DIR="$(dirname "$TEST_HELPERS_DIR")" -PLUGIN_DIR="$(dirname "$TEST_DIR")" -HOOKS_DIR="$PLUGIN_DIR/hooks" - -export TEST_HELPERS_DIR TEST_DIR PLUGIN_DIR HOOKS_DIR - -# Source the curl stub - defines curl() as a shell function that overrides -# the binary. Tests can configure responses via stub_response_for. -source "$TEST_HELPERS_DIR/curl_stub.sh" - -# Source fixture builders and span-tree helpers so tests don't have to -# source them individually. These are pure helpers; no state. -source "$TEST_HELPERS_DIR/fixtures.sh" -source "$TEST_HELPERS_DIR/span_tree.sh" -source "$TEST_HELPERS_DIR/replay.sh" - -# Per-test temp dir; reset on every setup_test_env call. -TEST_TMP="" - -setup_test_env() { - # Fresh isolated home for this test - TEST_TMP=$(mktemp -d "${TMPDIR:-/tmp}/braintrust-test.XXXXXX") - export HOME="$TEST_TMP" - - # Defaults that make common.sh happy - export BRAINTRUST_API_KEY="test-api-key" - export BRAINTRUST_APP_URL="https://app.test.invalid" - export BRAINTRUST_API_URL="https://api.test.invalid" - export TRACE_TO_BRAINTRUST=true - export DEBUG=false - # Run the span queue inline so tests are deterministic - we don't - # want to spawn background workers or wait on file watchers. - export BRAINTRUST_SYNC_QUEUE=true - # If a test opts into async mode and a worker is left behind, make - # sure drain_queue calls don't block tests for the full default. - export BRAINTRUST_DRAIN_TIMEOUT=5 - - # Capture file for HTTP requests - export CAPTURED_REQUESTS="$TEST_TMP/captured_requests.ndjson" - : > "$CAPTURED_REQUESTS" - - # Reset stub response configuration - _curl_stub_reset - - # Clear cached state from a previous test in the same shell. - # common.sh caches API URL in _RESOLVED_API_URL; clearing avoids - # cross-test pollution. - unset _RESOLVED_API_URL - - # Source common.sh so its functions are available in this shell. - # common.sh uses $HOME, so this must happen AFTER setting HOME. - # shellcheck source=/dev/null - source "$HOOKS_DIR/common.sh" - - # After sourcing common.sh, re-export the stub curl so it shadows the - # binary for any subprocesses spawned by hook scripts. - export -f curl -} - -teardown_test_env() { - if [ -n "$TEST_TMP" ] && [ -d "$TEST_TMP" ]; then - rm -rf "$TEST_TMP" - fi - TEST_TMP="" -} - -# Run a hook script as a subprocess with the given stdin payload. -# Returns the exit code; stdout/stderr are captured into the named variables. -# -# Usage: -# run_hook session_start.sh "$payload" -# echo "$HOOK_STDOUT" "$HOOK_STDERR" "$HOOK_STATUS" -run_hook() { - local hook="$1" - local payload="$2" - local out err - local tmpout tmperr - tmpout=$(mktemp) - tmperr=$(mktemp) - - # Pass the env explicitly so the subprocess sees our stub curl - # (export -f propagates over `env` invocations in bash). - echo "$payload" | bash "$HOOKS_DIR/$hook" >"$tmpout" 2>"$tmperr" - HOOK_STATUS=$? - HOOK_STDOUT=$(cat "$tmpout") - HOOK_STDERR=$(cat "$tmperr") - rm -f "$tmpout" "$tmperr" - return $HOOK_STATUS -} - -# Convenience: print the contents of the hook log file (common.sh writes -# to $HOME/.claude/state/braintrust_hook.log when sourced). -hook_log() { - local f="$HOME/.claude/state/braintrust_hook.log" - [ -f "$f" ] && cat "$f" || true -} diff --git a/src/plugins/claude/content/plugins/trace-claude-code/test/helpers/replay.sh b/src/plugins/claude/content/plugins/trace-claude-code/test/helpers/replay.sh deleted file mode 100644 index d2e7381..0000000 --- a/src/plugins/claude/content/plugins/trace-claude-code/test/helpers/replay.sh +++ /dev/null @@ -1,134 +0,0 @@ -#!/bin/bash -### -# Replay helper: drive hooks from a recorded session fixture. -# -# A session fixture is a directory produced by setting BRAINTRUST_RECORD_DIR -# during a real Claude Code session. It contains: -# -# events.ndjson - one JSON record per hook invocation, in order: -# {ts, hook, payload}. The `hook` field holds -# Claude Code's event name (e.g. "PostToolUse"). -# transcripts/.jsonl - any transcript files referenced by a Stop event -# -# Usage in a test: -# -# replay_session "$TEST_DIR/fixtures/sessions/my-session" -# assert_eq "$(span_count_by_type tool)" "5" -### - -# Replay every event in a session fixture exactly the way Claude Code -# would: for each recorded event, look up its handlers in hooks.json and -# run each registered command with the payload piped to stdin. Replay is -# intentionally dumb - it does not know or care which hooks "do something". -# Whatever is registered for an event runs; whatever isn't, doesn't. This -# means record-only events (PreToolUse, PreCompact, ...) replay through -# record_event.sh and simply no-op (recording is off during replay), while -# the acting hooks create their spans. -# -# Stop payloads have their transcript_path rewritten to point at the -# fixture's transcripts/ directory so the hook can find the file. -# -# Returns the number of events that matched at least one handler on stdout; -# returns non-zero if the fixture is missing or any hook returns non-zero. -# -# Requires $HOOKS_DIR and $PLUGIN_DIR (exported by helpers/harness.sh). -replay_session() { - local fixture_dir="$1" - local events_file="$fixture_dir/events.ndjson" - local hooks_json="${HOOKS_DIR:-}/hooks.json" - - if [ ! -f "$events_file" ]; then - echo "replay_session: fixture not found: $events_file" >&2 - return 1 - fi - if [ ! -f "$hooks_json" ]; then - echo "replay_session: hooks.json not found: $hooks_json" >&2 - return 1 - fi - - local replayed=0 - local line event payload - - # Read line-by-line. NDJSON, one event per line. - while IFS= read -r line; do - [ -z "$line" ] && continue - - event=$(echo "$line" | jq -r '.hook // empty') - payload=$(echo "$line" | jq -c '.payload // {}') - - [ -z "$event" ] && continue - - # Rewrite transcript paths so the replayed hook reads the bundled - # copies instead of the absolute paths from the original machine. - # Stop references the main transcript via `transcript_path`; - # SubagentStop references the sub-agent transcript via - # `agent_transcript_path`. Both are snapshotted flat into - # transcripts/ by record_hook_input and resolved here by basename. - local _field - for _field in transcript_path agent_transcript_path; do - local original_path basename_t replay_path - original_path=$(echo "$payload" | jq -r --arg f "$_field" '.[$f] // empty') - [ -z "$original_path" ] && continue - basename_t=$(basename "$original_path") - replay_path="$fixture_dir/transcripts/$basename_t" - if [ -f "$replay_path" ]; then - payload=$(echo "$payload" | jq -c \ - --arg f "$_field" --arg p "$replay_path" '.[$f] = $p') - fi - done - - # Look up every command registered for this event in hooks.json and - # run them in order, mirroring how Claude Code dispatches an event. - # We read the raw command strings (which embed ${CLAUDE_PLUGIN_ROOT}) - # and substitute the plugin root before executing. - local commands - commands=$(jq -r --arg e "$event" ' - .hooks[$e][]?.hooks[]? - | select(.type == "command") - | .command - ' "$hooks_json" 2>/dev/null) - - [ -z "$commands" ] && continue # no handler registered: nothing to do - - local ran_any=0 - local cmd - while IFS= read -r cmd; do - [ -z "$cmd" ] && continue - # Substitute the plugin-root placeholder with the real path. - cmd=${cmd//\$\{CLAUDE_PLUGIN_ROOT\}/$PLUGIN_DIR} - - # Run the command with the payload on stdin, exactly as Claude - # Code would. Errors fail the replay so tests catch them. - echo "$payload" | bash -c "$cmd" - local rc=$? - if [ "$rc" -ne 0 ]; then - echo "replay_session: '$event' handler exited $rc on event $((replayed + 1)): $cmd" >&2 - return "$rc" - fi - ran_any=1 - done <<< "$commands" - - [ "$ran_any" -eq 1 ] && replayed=$((replayed + 1)) - done < "$events_file" - - echo "$replayed" - return 0 -} - -# Print a summary of what's in a fixture (count of each hook type, etc.) -# Useful for debugging. -describe_fixture() { - local fixture_dir="$1" - local events_file="$fixture_dir/events.ndjson" - [ -f "$events_file" ] || { echo "(no events)"; return 1; } - - echo "Fixture: $fixture_dir" - echo " Events: $(wc -l < "$events_file" | tr -d ' ')" - echo " Hook counts:" - jq -r '.hook' "$events_file" | sort | uniq -c | awk '{printf " %s: %s\n", $2, $1}' - local n_transcripts=0 - if [ -d "$fixture_dir/transcripts" ]; then - n_transcripts=$(find "$fixture_dir/transcripts" -maxdepth 1 -name '*.jsonl' -type f 2>/dev/null | wc -l | tr -d ' ') - fi - echo " Transcripts: $n_transcripts" -} diff --git a/src/plugins/claude/content/plugins/trace-claude-code/test/helpers/span_tree.sh b/src/plugins/claude/content/plugins/trace-claude-code/test/helpers/span_tree.sh deleted file mode 100644 index 13142a4..0000000 --- a/src/plugins/claude/content/plugins/trace-claude-code/test/helpers/span_tree.sh +++ /dev/null @@ -1,88 +0,0 @@ -#!/bin/bash -### -# Span query helpers - the bash equivalent of opencode-plugin's spansToTree(). -# -# These read the captured POST requests from $CAPTURED_REQUESTS, extract -# spans from /insert calls, and provide flat + relational queries for tests. -# -# All functions print JSON to stdout. Tests use `jq` to drill into results. -### - -# Flat array of all spans sent to any /insert endpoint, in arrival order. -all_spans() { - [ -f "${CAPTURED_REQUESTS:-/dev/null}" ] || { echo '[]'; return 0; } - jq -s ' - [ .[] - | select(.url | test("/insert$")) - | .body.events[]? - ] - ' "$CAPTURED_REQUESTS" -} - -# Total count of spans inserted. -span_count() { - all_spans | jq 'length' -} - -# Count of spans by .span_attributes.type (e.g. "task", "tool", "llm"). -span_count_by_type() { - local type="$1" - all_spans | jq --arg t "$type" ' - [ .[] | select(.span_attributes.type == $t) ] | length - ' -} - -# Return spans whose span_attributes.name matches the given regex. -# Spans without a name (e.g. merge updates that only touch metrics) are -# excluded. -spans_named() { - local pattern="$1" - all_spans | jq --arg p "$pattern" ' - [ .[] - | select(.span_attributes.name != null) - | select(.span_attributes.name | test($p)) - ] - ' -} - -# Return the first span matching a name regex (or `null`). -span_by_name() { - local pattern="$1" - spans_named "$pattern" | jq '.[0] // null' -} - -# Return the first span with a given .span_attributes.type (or `null`). -span_by_type() { - local type="$1" - all_spans | jq --arg t "$type" ' - [.[] | select(.span_attributes.type == $t)][0] // null - ' -} - -# Return the span with the given span_id (or `null`). -span_by_id() { - local id="$1" - all_spans | jq --arg i "$id" ' - [.[] | select(.span_id == $i)][0] // null - ' -} - -# Return all spans whose first parent matches the given span_id. -children_of() { - local parent_id="$1" - all_spans | jq --arg p "$parent_id" ' - [ .[] - | select(.span_parents and (.span_parents | length > 0) - and (.span_parents[0] == $p)) - ] - ' -} - -# True/false: does a span_id have a span_parents[0] equal to the given id? -is_child_of() { - local child_id="$1" - local parent_id="$2" - all_spans | jq --arg c "$child_id" --arg p "$parent_id" -e ' - any(.[]; .span_id == $c and (.span_parents // [])[0] == $p) - ' >/dev/null -} diff --git a/src/plugins/claude/content/plugins/trace-claude-code/test/reconcile_usage.sh b/src/plugins/claude/content/plugins/trace-claude-code/test/reconcile_usage.sh deleted file mode 100755 index f6dd593..0000000 --- a/src/plugins/claude/content/plugins/trace-claude-code/test/reconcile_usage.sh +++ /dev/null @@ -1,166 +0,0 @@ -#!/bin/bash -### -# reconcile_usage.sh - drive a real Claude Code session headlessly, then -# compare Claude Code's own reported token usage against what the plugin's -# transcript parsing extracts. This is a developer tool for closing token -# reconciliation gaps (e.g. getting opus totals to match). -# -# It runs `claude -p` with the dev plugin dir and BRAINTRUST_RECORD_DIR set, -# captures CC's authoritative per-model usage from `--output-format json` -# (the same numbers /usage shows), then re-derives per-model totals from the -# recorded transcripts (main + sub-agents) using the same rules the plugin -# uses: dedupe by requestId, take MAX output_tokens per request (it streams), -# count input/cache once per request. -# -# Usage: -# ./reconcile_usage.sh "your prompt here" -# ./reconcile_usage.sh # uses a default sub-agent prompt -# -# Env: -# CC_PROJECT Braintrust project (default: trace-cc-debug) -# KEEP_REC=1 keep the recording dir (otherwise printed but not deleted) -### - -set -u - -SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -PLUGIN_DIR="$(dirname "$SCRIPT_DIR")" -CC_PROJECT="${CC_PROJECT:-trace-cc-debug}" - -PROMPT="${1:-Launch two Explore subagents to look at different parts of /tmp, then summarize.}" - -SID=$(uuidgen) -REC="${TMPDIR:-/tmp}/cc-reconcile-$SID" -mkdir -p "$REC" - -echo "==> Running Claude Code headlessly" -echo " session: $SID" -echo " record: $REC" -echo " prompt: $PROMPT" -echo - -SETTINGS=$(jq -nc \ - --arg p "$CC_PROJECT" \ - --arg r "$REC" \ - '{env:{TRACE_TO_BRAINTRUST:"true", BRAINTRUST_CC_PROJECT:$p, BRAINTRUST_RECORD_DIR:$r}}') - -# Capture CC's JSON result (includes modelUsage + session_id). -CC_JSON=$(cd "${TMPDIR:-/tmp}" && claude -p "$PROMPT" \ - --output-format json \ - --settings "$SETTINGS" \ - --plugin-dir "$PLUGIN_DIR" \ - --session-id "$SID" 2>/dev/null) - -if [ -z "$CC_JSON" ]; then - echo "ERROR: no output from claude -p" >&2 - exit 1 -fi - -# Resolve the session's transcript from the LIVE Claude projects dir rather -# than the recording's snapshot. In -p mode the plugin's Stop hook copies the -# transcript before the assistant reply is flushed to disk, so the recorded -# copy can be incomplete. The live file under ~/.claude/projects is complete by -# the time `claude -p` has returned. We pull its path (and any sub-agent -# transcripts) from the Stop event payload the plugin recorded. -SESSION_LC=$(echo "$CC_JSON" | jq -r '.session_id' | tr 'A-Z' 'a-z') -LIVE_MAIN=$(jq -rc 'select(.hook=="Stop")|.payload.transcript_path' "$REC/events.ndjson" 2>/dev/null | head -1) -LIVE_DIR="" -[ -n "$LIVE_MAIN" ] && LIVE_DIR=$(dirname "$LIVE_MAIN") - -echo "==> Claude Code reported usage (authoritative):" -echo "$CC_JSON" | jq -r ' - .modelUsage // {} - | to_entries[] - | " \(.key): input=\(.value.inputTokens) output=\(.value.outputTokens) cache_read=\(.value.cacheReadInputTokens) cache_write=\(.value.cacheCreationInputTokens) cost=$\(.value.costUSD)" -' -echo " total_cost=$(echo "$CC_JSON" | jq -r '.total_cost_usd // "?"')" -echo - -# ---- Derive plugin-side totals from the recorded transcripts ---- -# Normalize model names: CC's modelUsage uses e.g. "claude-opus-4-8[1m]", -# transcripts use "claude-opus-4-8". Strip the "[...]" suffix for comparison. -_strip_model() { sed -E 's/\[[^]]*\]$//'; } - -echo "==> Plugin-derived usage from the session transcripts:" -# Prefer the live transcript(s): the main file plus any sub-agent transcripts -# under /subagents/. Fall back to the recorded snapshot. -TRANSCRIPTS="" -if [ -n "$LIVE_MAIN" ] && [ -f "$LIVE_MAIN" ]; then - SESSION_BASE=$(basename "$LIVE_MAIN" .jsonl) - TRANSCRIPTS="$LIVE_MAIN" - if [ -d "$LIVE_DIR/$SESSION_BASE/subagents" ]; then - TRANSCRIPTS="$TRANSCRIPTS -$(find "$LIVE_DIR/$SESSION_BASE/subagents" -name 'agent-*.jsonl' -type f 2>/dev/null)" - fi -fi -[ -z "${TRANSCRIPTS//[[:space:]]/}" ] && TRANSCRIPTS=$(find "$REC/transcripts" -name '*.jsonl' -type f 2>/dev/null) -if [ -z "${TRANSCRIPTS//[[:space:]]/}" ]; then - echo " (no transcripts found)" -else - # Per model: dedupe assistant lines by requestId; max each token field. - # shellcheck disable=SC2086 - jq -rs ' - [ .[] - | select(.type=="assistant") - | select(.message.usage != null) - | { model:(.message.model // "claude"), - rid:(.requestId // .message.id), - inp:(.message.usage.input_tokens // 0), - out:(.message.usage.output_tokens // 0), - cr:(.message.usage.cache_read_input_tokens // 0), - cc:(.message.usage.cache_creation_input_tokens // 0) } - ] - | group_by(.model) - | map( - .[0].model as $m - | (group_by(.rid) - | { model:$m, - requests:length, - input:(map([.[].inp]|max)|add), - output:(map([.[].out]|max)|add), - cache_read:(map([.[].cr]|max)|add), - cache_write:(map([.[].cc]|max)|add) }) - ) - | .[] - | " \(.model): input=\(.input) output=\(.output) cache_read=\(.cache_read) cache_write=\(.cache_write) (\(.requests) requests)" - ' $TRANSCRIPTS -fi -echo - -# ---- Print a focused diff for each model CC reported ---- -echo "==> Diff (CC - plugin), per model:" -echo "$CC_JSON" | jq -r '.modelUsage // {} | to_entries[] | "\(.key)\t\(.value.inputTokens)\t\(.value.outputTokens)\t\(.value.cacheReadInputTokens)\t\(.value.cacheCreationInputTokens)"' \ -| while IFS=$'\t' read -r ccmodel ci co ccr ccw; do - model=$(echo "$ccmodel" | _strip_model) - # plugin totals for this model - ptot=$(jq -s --arg m "$model" ' - [ .[] | select(.type=="assistant") | select(.message.usage != null) - | select((.message.model // "") == $m) - | { rid:(.requestId // .message.id), - inp:(.message.usage.input_tokens // 0), - out:(.message.usage.output_tokens // 0), - cr:(.message.usage.cache_read_input_tokens // 0), - cc:(.message.usage.cache_creation_input_tokens // 0) } ] - | group_by(.rid) - | { input:(map([.[].inp]|max)|add // 0), - output:(map([.[].out]|max)|add // 0), - cache_read:(map([.[].cr]|max)|add // 0), - cache_write:(map([.[].cc]|max)|add // 0) } - ' $TRANSCRIPTS 2>/dev/null) - pi=$(echo "$ptot" | jq -r '.input // 0') - po=$(echo "$ptot" | jq -r '.output // 0') - pcr=$(echo "$ptot" | jq -r '.cache_read // 0') - pcw=$(echo "$ptot" | jq -r '.cache_write // 0') - printf ' %s\n' "$model" - printf ' input: cc=%-8s plugin=%-8s diff=%s\n' "$ci" "$pi" "$((ci - pi))" - printf ' output: cc=%-8s plugin=%-8s diff=%s\n' "$co" "$po" "$((co - po))" - printf ' cache_read: cc=%-8s plugin=%-8s diff=%s\n' "$ccr" "$pcr" "$((ccr - pcr))" - printf ' cache_write: cc=%-8s plugin=%-8s diff=%s\n' "$ccw" "$pcw" "$((ccw - pcw))" -done - -echo -echo "==> session_id: $(echo "$CC_JSON" | jq -r '.session_id')" -echo "==> recording: $REC" -if [ "${KEEP_REC:-0}" != "1" ]; then - echo " (set KEEP_REC=1 to preserve; leaving in place for inspection)" -fi diff --git a/src/plugins/claude/content/plugins/trace-claude-code/test/record_session.sh b/src/plugins/claude/content/plugins/trace-claude-code/test/record_session.sh deleted file mode 100755 index 02aa0d7..0000000 --- a/src/plugins/claude/content/plugins/trace-claude-code/test/record_session.sh +++ /dev/null @@ -1,100 +0,0 @@ -#!/bin/bash -### -# record_session.sh - convenience wrapper for capturing a Claude Code -# session as a test fixture. -# -# Usage: -# ./record_session.sh -# -# This prints the env vars you need to set in your shell before running -# Claude Code. After the session, the fixture will be under -# test/fixtures/sessions// ready to be used by replay_session. -# -# Example: -# $ ./record_session.sh my-session -# # then in another terminal: -# $ export BRAINTRUST_RECORD_DIR=/path/printed/above -# $ claude -# # ... use claude normally ... -# # When done: -# $ ./record_session.sh --describe my-session -### - -set -e - -SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -FIXTURES_DIR="$SCRIPT_DIR/fixtures/sessions" - -usage() { - cat < Prepare a new fixture and print instructions. - $0 --describe Show a summary of an existing fixture. - $0 --list List all existing fixtures. - -When recording, all you need to do is set BRAINTRUST_RECORD_DIR (printed by -this script) in your shell before running 'claude'. The hooks themselves -do the recording automatically. -USAGE - exit 1 -} - -case "${1:-}" in - "" | "-h" | "--help") usage ;; - "--list") - if [ ! -d "$FIXTURES_DIR" ]; then - echo "(no fixtures yet)" - exit 0 - fi - for d in "$FIXTURES_DIR"/*/; do - [ -d "$d" ] || continue - name=$(basename "$d") - events_file="$d/events.ndjson" - n_events=0 - if [ -f "$events_file" ]; then - n_events=$(wc -l < "$events_file" | tr -d ' ') - fi - printf ' %-30s %d events\n' "$name" "$n_events" - done - ;; - "--describe") - name="${2:-}" - [ -z "$name" ] && usage - # Load helpers and dispatch - # shellcheck source=helpers/replay.sh - source "$SCRIPT_DIR/helpers/replay.sh" - describe_fixture "$FIXTURES_DIR/$name" - ;; - *) - name="$1" - case "$name" in - -*) usage ;; - esac - dest="$FIXTURES_DIR/$name" - if [ -e "$dest" ]; then - echo "Fixture '$name' already exists at $dest" >&2 - echo "Remove it first if you want to re-record." >&2 - exit 1 - fi - mkdir -p "$dest" - cat < "$TESTS_RUN_FILE" -: > "$TESTS_FAIL_FILE" - -cleanup() { - rm -f "$TESTS_RUN_FILE" "$TESTS_FAIL_FILE" -} -trap cleanup EXIT - -# Resolve which test files to run. -declare -a TEST_FILES=() -if [ $# -gt 0 ]; then - for arg in "$@"; do - # Allow specifying with or without .sh, with or without test_ prefix - local_name="$arg" - case "$local_name" in - *.sh) ;; - *) local_name="${local_name}.sh" ;; - esac - case "$local_name" in - test_*) ;; - *) local_name="test_${local_name}" ;; - esac - if [ -f "$SCRIPT_DIR/$local_name" ]; then - TEST_FILES+=("$local_name") - else - echo "${C_RED}Test file not found: $local_name${C_RESET}" >&2 - exit 2 - fi - done -else - while IFS= read -r f; do - TEST_FILES+=("$f") - done < <(find "$SCRIPT_DIR" -maxdepth 1 -name 'test_*.sh' -type f | sort | xargs -n1 basename) -fi - -if [ ${#TEST_FILES[@]} -eq 0 ]; then - echo "${C_YELLOW}No test files found.${C_RESET}" - exit 0 -fi - -printf '%sRunning tests in %s%s\n' "$C_BOLD" "$SCRIPT_DIR" "$C_RESET" -printf '%s%d test file(s)%s\n' "$C_DIM" "${#TEST_FILES[@]}" "$C_RESET" - -START_TIME=$(date +%s) -FILES_WITH_FAILURES=0 - -for file in "${TEST_FILES[@]}"; do - printf '\n%s──── %s ────%s\n' "$C_BOLD" "$file" "$C_RESET" - # Run each test file in a fresh subshell so its globals don't leak. - if ! bash "$SCRIPT_DIR/$file"; then - FILES_WITH_FAILURES=$((FILES_WITH_FAILURES + 1)) - fi -done - -END_TIME=$(date +%s) -ELAPSED=$((END_TIME - START_TIME)) - -TOTAL=$(wc -l < "$TESTS_RUN_FILE" | tr -d ' ') -FAILED=$(wc -l < "$TESTS_FAIL_FILE" | tr -d ' ') -PASSED=$((TOTAL - FAILED)) - -printf '\n%s──── Summary ────%s\n' "$C_BOLD" "$C_RESET" -printf ' %sPassed:%s %d\n' "$C_GREEN" "$C_RESET" "$PASSED" -if [ "$FAILED" -gt 0 ]; then - printf ' %sFailed:%s %d\n' "$C_RED" "$C_RESET" "$FAILED" -else - printf ' Failed: 0\n' -fi -printf ' Total: %d\n' "$TOTAL" -printf ' Time: %ds\n' "$ELAPSED" - -if [ "$FAILED" -gt 0 ]; then - printf '\n%s✗ Tests failed%s\n' "$C_RED" "$C_RESET" - exit 1 -fi - -printf '\n%s✓ All tests passed%s\n' "$C_GREEN" "$C_RESET" -exit 0 diff --git a/src/plugins/claude/content/plugins/trace-claude-code/test/test_common.sh b/src/plugins/claude/content/plugins/trace-claude-code/test/test_common.sh deleted file mode 100755 index 03c317b..0000000 --- a/src/plugins/claude/content/plugins/trace-claude-code/test/test_common.sh +++ /dev/null @@ -1,460 +0,0 @@ -#!/bin/bash -### -# Unit tests for utility functions in hooks/common.sh -### - -SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -# shellcheck source=helpers/assert.sh -source "$SCRIPT_DIR/helpers/assert.sh" -# shellcheck source=helpers/harness.sh -source "$SCRIPT_DIR/helpers/harness.sh" - -# --------------------------------------------------------------------------- -describe "is_truthy" -# --------------------------------------------------------------------------- - -t_truthy_true() { is_truthy "true"; assert_eq "$?" "0"; } -t_truthy_TRUE() { is_truthy "TRUE"; assert_eq "$?" "0"; } -t_truthy_mixed() { is_truthy "tRuE"; assert_eq "$?" "0"; } -t_truthy_one() { is_truthy "1"; assert_eq "$?" "0"; } -t_truthy_yes() { is_truthy "yes"; assert_eq "$?" "0"; } -t_truthy_on() { is_truthy "on"; assert_eq "$?" "0"; } -t_truthy_false() { is_truthy "false"; assert_failure "$?"; } -t_truthy_zero() { is_truthy "0"; assert_failure "$?"; } -t_truthy_empty() { is_truthy ""; assert_failure "$?"; } -t_truthy_arbitrary() { is_truthy "maybe"; assert_failure "$?"; } - -it "returns 0 for 'true'" t_truthy_true -it "returns 0 for 'TRUE' (uppercase)" t_truthy_TRUE -it "returns 0 for 'tRuE' (mixed case)" t_truthy_mixed -it "returns 0 for '1'" t_truthy_one -it "returns 0 for 'yes'" t_truthy_yes -it "returns 0 for 'on'" t_truthy_on -it "returns non-zero for 'false'" t_truthy_false -it "returns non-zero for '0'" t_truthy_zero -it "returns non-zero for empty string" t_truthy_empty -it "returns non-zero for arbitrary string" t_truthy_arbitrary - -# --------------------------------------------------------------------------- -describe "tracing_enabled" -# --------------------------------------------------------------------------- - -t_tracing_true() { - TRACE_TO_BRAINTRUST=true - tracing_enabled - assert_eq "$?" "0" -} -t_tracing_false() { - TRACE_TO_BRAINTRUST=false - tracing_enabled - assert_failure "$?" -} -t_tracing_unset() { - unset TRACE_TO_BRAINTRUST - tracing_enabled - assert_failure "$?" -} - -it "follows TRACE_TO_BRAINTRUST=true" t_tracing_true -it "follows TRACE_TO_BRAINTRUST=false" t_tracing_false -it "returns non-zero when TRACE_TO_BRAINTRUST is unset" t_tracing_unset - -# --------------------------------------------------------------------------- -describe "check_requirements" -# --------------------------------------------------------------------------- - -t_check_req_ok() { - API_KEY="some-key" - check_requirements - assert_eq "$?" "0" -} -t_check_req_missing_key() { - API_KEY="" - check_requirements - assert_failure "$?" - local log - log=$(hook_log) - assert_contains "$log" "BRAINTRUST_API_KEY not set" -} - -it "passes when all binaries exist and API_KEY is set" t_check_req_ok -it "fails when API_KEY is empty" t_check_req_missing_key - -# --------------------------------------------------------------------------- -describe "get_cache_value / set_cache_value" -# --------------------------------------------------------------------------- - -t_cache_roundtrip() { - set_cache_value "my_key" "my_value" - local got - got=$(get_cache_value "my_key") - assert_eq "$got" "my_value" -} -t_cache_missing() { - local got - got=$(get_cache_value "never_set_key") - assert_eq "$got" "" -} -t_cache_overwrite() { - set_cache_value "k" "v1" - set_cache_value "k" "v2" - local got - got=$(get_cache_value "k") - assert_eq "$got" "v2" -} - -it "round-trips a value" t_cache_roundtrip -it "returns empty string when key is unset" t_cache_missing -it "overwrites an existing value" t_cache_overwrite - -# --------------------------------------------------------------------------- -describe "set_session_state / get_session_state" -# --------------------------------------------------------------------------- - -t_state_roundtrip() { - set_session_state "sess1" "name" "value-A" - local got - got=$(get_session_state "sess1" "name") - assert_eq "$got" "value-A" -} -t_state_isolation() { - set_session_state "sessA" "k" "valueA" - set_session_state "sessB" "k" "valueB" - local got_a got_b - got_a=$(get_session_state "sessA" "k") - got_b=$(get_session_state "sessB" "k") - assert_eq "$got_a" "valueA" - assert_eq "$got_b" "valueB" -} -t_state_missing() { - local got - got=$(get_session_state "sess_missing" "missing_key") - assert_eq "$got" "" -} -t_state_overwrite() { - set_session_state "sess" "k" "old" - set_session_state "sess" "k" "new" - local got - got=$(get_session_state "sess" "k") - assert_eq "$got" "new" -} - -it "round-trips a value within a single session" t_state_roundtrip -it "isolates state across distinct sessions" t_state_isolation -it "returns empty string for an unknown key" t_state_missing -it "supports overwriting an existing key" t_state_overwrite - -# --------------------------------------------------------------------------- -describe "check_and_set_session_state" -# --------------------------------------------------------------------------- - -t_check_set_new() { - check_and_set_session_state "sess" "first" "v" - local rc=$? - assert_eq "$rc" "0" - local got - got=$(get_session_state "sess" "first") - assert_eq "$got" "v" -} -t_check_set_existing() { - set_session_state "sess" "claimed" "original" - local out - out=$(check_and_set_session_state "sess" "claimed" "new-value") - local rc=$? - assert_eq "$rc" "1" - assert_eq "$out" "original" - local got - got=$(get_session_state "sess" "claimed") - assert_eq "$got" "original" -} - -it "sets and returns 0 when key is new" t_check_set_new -it "preserves existing value and returns 1 when key is already set" t_check_set_existing - -# --------------------------------------------------------------------------- -describe "is_experiment_mode" -# --------------------------------------------------------------------------- - -t_exp_set() { - CC_EXPERIMENT_ID="exp_abc" - is_experiment_mode - assert_eq "$?" "0" -} -t_exp_empty() { - CC_EXPERIMENT_ID="" - is_experiment_mode - assert_failure "$?" -} - -it "is true when CC_EXPERIMENT_ID is set" t_exp_set -it "is false when CC_EXPERIMENT_ID is empty" t_exp_empty - -# --------------------------------------------------------------------------- -describe "generate_uuid" -# --------------------------------------------------------------------------- - -t_uuid_format() { - local uuid - uuid=$(generate_uuid) - assert_match "$uuid" "^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$" -} -t_uuid_unique() { - local a b - a=$(generate_uuid) - b=$(generate_uuid) - assert_ne "$a" "$b" -} - -it "returns a non-empty lowercase UUID" t_uuid_format -it "returns unique values on subsequent calls" t_uuid_unique - -# --------------------------------------------------------------------------- -describe "git metadata helpers" -# --------------------------------------------------------------------------- - -_make_git_repo() { - local dir - dir=$(mktemp -d) - git -C "$dir" init >/dev/null 2>&1 - git -C "$dir" config user.email test@example.com - git -C "$dir" config user.name "Test User" - printf "hello\n" > "$dir/README.md" - git -C "$dir" add README.md - git -C "$dir" commit -m init >/dev/null 2>&1 - git -C "$dir" branch -M main - git -C "$dir" remote add origin "https://token@github.com/acme/app.git" - echo "$dir" -} - -t_git_remote_redaction() { - local redacted - redacted=$(redact_git_remote_url "https://token:secret@github.com/acme/app.git") - assert_eq "$redacted" "https://github.com/acme/app.git" - - local ssh - ssh=$(redact_git_remote_url "git@github.com:acme/app.git") - assert_eq "$ssh" "git@github.com:acme/app.git" -} - -t_git_metadata_json() { - local repo commit metadata - repo=$(_make_git_repo) - commit=$(git -C "$repo" rev-parse HEAD) - metadata=$(git_metadata_json "$repo") - - assert_eq "$(echo "$metadata" | jq -r '.git_origin_url')" "https://github.com/acme/app.git" - assert_eq "$(echo "$metadata" | jq -r '.git_branch')" "main" - assert_eq "$(echo "$metadata" | jq -r '.git_commit_sha')" "$commit" - - rm -rf "$repo" -} - -t_git_metadata_json_not_git() { - local dir metadata - dir=$(mktemp -d) - metadata=$(git_metadata_json "$dir") - assert_eq "$metadata" "{}" - rm -rf "$dir" -} - -it "redacts URL-style git remote credentials" t_git_remote_redaction -it "captures origin, branch, and commit" t_git_metadata_json -it "omits fields outside a git repo" t_git_metadata_json_not_git - -# --------------------------------------------------------------------------- -describe "record_hook_input: event labeling and transcript snapshots" -# --------------------------------------------------------------------------- - -# Helper: point recording at a fresh dir under the test's isolated HOME. -_rec_dir() { - echo "$HOME/recording" -} - -t_record_labels_by_event_name() { - # The recorded event should be labeled with the payload's - # hook_event_name (CamelCase), regardless of the name passed in. - export BRAINTRUST_RECORD_DIR="$(_rec_dir)" - local payload='{"session_id":"s1","hook_event_name":"PreToolUse","tool_name":"Bash"}' - record_hook_input "ignored_arg" "$payload" - - local label - label=$(jq -r '.hook' "$BRAINTRUST_RECORD_DIR/events.ndjson") - assert_eq "$label" "PreToolUse" "event labeled by hook_event_name" -} - -t_record_falls_back_to_arg_name() { - # When the payload has no hook_event_name, fall back to the passed name. - export BRAINTRUST_RECORD_DIR="$(_rec_dir)" - record_hook_input "CwdChanged" '{"session_id":"s1"}' - - local label - label=$(jq -r '.hook' "$BRAINTRUST_RECORD_DIR/events.ndjson") - assert_eq "$label" "CwdChanged" "falls back to caller-supplied name" -} - -t_record_copies_main_transcript_on_stop() { - # Regression guard: a Stop event must snapshot the main transcript into - # transcripts/. (This broke once when the copy guard still checked the - # old snake_case name after we switched to CamelCase labels.) - export BRAINTRUST_RECORD_DIR="$(_rec_dir)" - local transcript="$HOME/main.jsonl" - echo '{"type":"assistant"}' > "$transcript" - - local payload - payload=$(jq -nc --arg t "$transcript" \ - '{session_id:"s1", hook_event_name:"Stop", transcript_path:$t}') - record_hook_input "stop_hook" "$payload" - - assert_file_exists "$BRAINTRUST_RECORD_DIR/transcripts/main.jsonl" \ - "Stop should copy the main transcript" -} - -t_record_copies_agent_transcript_on_subagent_stop() { - # SubagentStop must snapshot the sub-agent's own transcript (which holds - # its model calls) before Claude Code can clean it up. - export BRAINTRUST_RECORD_DIR="$(_rec_dir)" - local agent_t="$HOME/agent-abc123.jsonl" - echo '{"type":"assistant"}' > "$agent_t" - - local payload - payload=$(jq -nc --arg t "$agent_t" \ - '{session_id:"s1", hook_event_name:"SubagentStop", agent_id:"abc123", agent_transcript_path:$t}') - record_hook_input "ignored" "$payload" - - assert_file_exists "$BRAINTRUST_RECORD_DIR/transcripts/agent-abc123.jsonl" \ - "SubagentStop should copy the agent transcript" -} - -t_record_off_is_noop() { - # With no BRAINTRUST_RECORD_DIR, recording must write nothing. - unset BRAINTRUST_RECORD_DIR - record_hook_input "Stop" '{"session_id":"s1","hook_event_name":"Stop"}' - # Nothing to assert beyond "no crash"; the absence of a recording dir - # means there is no file to inspect. Exit status should be success. - assert_success "$?" "record_hook_input is a no-op when recording is off" -} - -it "labels recorded events by hook_event_name" t_record_labels_by_event_name -it "falls back to the caller-supplied name" t_record_falls_back_to_arg_name -it "copies the main transcript on Stop" t_record_copies_main_transcript_on_stop -it "copies the agent transcript on SubagentStop" t_record_copies_agent_transcript_on_subagent_stop -it "is a no-op when recording is disabled" t_record_off_is_noop - -# --------------------------------------------------------------------------- -describe "emit_llm_spans_from_transcript: per-request LLM spans" -# --------------------------------------------------------------------------- - -# Write a tiny NDJSON transcript with two API requests. Request A spans two -# content-block lines sharing a requestId where output_tokens STREAMS (5 then -# 30) while input/cache stay constant - this exercises both the dedupe and the -# "take max output per request" logic. -_write_agent_transcript() { - local path="$1" - { - # Request A, line 1: partial output (5). - echo '{"type":"assistant","requestId":"reqA","timestamp":"2026-06-11T03:00:00.000Z","message":{"model":"claude-haiku-4-5","content":[{"type":"text","text":"hello"}],"usage":{"input_tokens":5,"output_tokens":5,"cache_creation_input_tokens":100,"cache_read_input_tokens":2000}}}' - # Request A, line 2: final cumulative output (30); input/cache identical. - echo '{"type":"assistant","requestId":"reqA","timestamp":"2026-06-11T03:00:00.000Z","message":{"model":"claude-haiku-4-5","content":[{"type":"tool_use","id":"t1","name":"Bash","input":{}}],"usage":{"input_tokens":5,"output_tokens":30,"cache_creation_input_tokens":100,"cache_read_input_tokens":2000}}}' - # A user/tool_result line in between (must be ignored). - echo '{"type":"user","message":{"content":[{"type":"tool_result","tool_use_id":"t1","content":"ok"}]}}' - # Request B: single line. - echo '{"type":"assistant","requestId":"reqB","timestamp":"2026-06-11T03:00:05.000Z","message":{"model":"claude-haiku-4-5","content":[{"type":"text","text":"done"}],"usage":{"input_tokens":2,"output_tokens":20,"cache_creation_input_tokens":3,"cache_read_input_tokens":2500}}}' - } > "$path" -} - -t_emit_dedupes_by_request_id() { - local transcript="$HOME/agent.jsonl" - _write_agent_transcript "$transcript" - - # Capture emitted spans instead of enqueuing them. Compact each span to - # one line so the file is valid NDJSON regardless of jq pretty-printing. - local out="$HOME/emitted.ndjson" - : > "$out" - enqueue_span() { echo "$3" | jq -c '.' >> "$out"; return 0; } - - local n - n=$(emit_llm_spans_from_transcript "$transcript" "sess" "proj" "ROOT" "PARENT") - - # Return value counts LLM spans only: 2 unique requestIds -> 2 LLM spans. - assert_eq "$n" "2" "return value counts LLM spans" - - # Structure: 2 LLM spans + 1 tool span (request A's Bash tool_use, which - # has a matching tool_result). The third raw line is a tool_result, not a - # separate LLM call. - assert_eq "$(jq -s '[.[]|select(.span_attributes.type=="llm")]|length' "$out")" "2" "two LLM spans" - assert_eq "$(jq -s '[.[]|select(.span_attributes.type=="tool")]|length' "$out")" "1" "one tool span" - - # Output is the MAX per request: A=30 (not 5), B=20 -> 50. - # Braintrust prompt_tokens are inclusive for Anthropic: - # input 7 + cache_read 4500 + cache_creation 103 = 4610. - assert_eq "$(jq -s '[.[]|select(.span_attributes.type=="llm")|.metrics.completion_tokens]|add' "$out")" "50" "completion uses max per request" - assert_eq "$(jq -s '[.[]|select(.span_attributes.type=="llm")|.metrics.prompt_tokens]|add' "$out")" "4610" "prompt includes input and cache tokens" - assert_eq "$(jq -s '[.[]|select(.span_attributes.type=="llm")|.metrics.tokens]|add' "$out")" "4660" "total tokens includes inclusive prompt and completion" - assert_eq "$(jq -s '[.[]|select(.span_attributes.type=="llm")|.metrics.prompt_cached_tokens]|add' "$out")" "4500" "cache_read deduped" - assert_eq "$(jq -s '[.[]|select(.span_attributes.type=="llm")|.metrics.prompt_cache_creation_tokens]|add' "$out")" "103" "cache_creation deduped" - assert_eq "$(jq -s '[.[]|select(.span_attributes.type=="llm" and (.metrics | (has("cache_read_input_tokens") or has("cache_creation_input_tokens"))))]|length' "$out")" "0" "raw Anthropic cache metrics are not emitted" - - # All spans parented under PARENT. - assert_eq "$(jq -s 'all(.[]; .span_parents[0] == "PARENT")' "$out")" "true" "all parented under PARENT" - # LLM spans tagged with the model; tool span named after the tool. - assert_eq "$(jq -s '[.[]|select(.span_attributes.type=="llm")]|all(.span_attributes.name == "claude-haiku-4-5")' "$out")" "true" "model name on LLM spans" - assert_eq "$(jq -rs '[.[]|select(.span_attributes.type=="tool")][0].span_attributes.name' "$out")" "Terminal: command" "tool span named after the tool" - - # The second LLM span's input includes the prior assistant + tool history. - local hist_roles - hist_roles=$(jq -s '[.[]|select(.span_attributes.type=="llm")][1].input|map(.role)|join(",")' "$out") - assert_eq "$hist_roles" '"assistant,tool"' "second LLM span input carries conversation history" -} - -t_emit_missing_file_is_zero() { - enqueue_span() { return 0; } - local n - n=$(emit_llm_spans_from_transcript "$HOME/does-not-exist.jsonl" "s" "p" "R" "P") - assert_eq "$n" "0" "missing transcript emits nothing" -} - -# Write a transcript whose chronological order is the REVERSE of the -# requestId sort order: the first request ("reqZ") happens at t=00, the -# second ("reqA") at t=05. group_by sorts by id, so without an explicit -# re-sort the directives come out reqA-then-reqZ, scrambling the threaded -# conversation history. -_write_out_of_id_order_transcript() { - local path="$1" - { - # Chronologically FIRST (t=00) but sorts LAST by id. - echo '{"type":"assistant","requestId":"reqZ","timestamp":"2026-06-11T03:00:00.000Z","message":{"model":"claude-haiku-4-5","content":[{"type":"text","text":"first turn"}],"usage":{"input_tokens":5,"output_tokens":10,"cache_creation_input_tokens":0,"cache_read_input_tokens":0}}}' - # Chronologically SECOND (t=05) but sorts FIRST by id. - echo '{"type":"assistant","requestId":"reqA","timestamp":"2026-06-11T03:00:05.000Z","message":{"model":"claude-haiku-4-5","content":[{"type":"text","text":"second turn"}],"usage":{"input_tokens":8,"output_tokens":20,"cache_creation_input_tokens":0,"cache_read_input_tokens":0}}}' - } > "$path" -} - -t_emit_preserves_chronological_order() { - local transcript="$HOME/agent_order.jsonl" - _write_out_of_id_order_transcript "$transcript" - - local out="$HOME/emitted_order.ndjson" - : > "$out" - enqueue_span() { echo "$3" | jq -c '.' >> "$out"; return 0; } - - emit_llm_spans_from_transcript "$transcript" "sess" "proj" "ROOT" "PARENT" >/dev/null - - # Spans must be emitted in chronological order: "first turn" then - # "second turn", regardless of requestId sort order. - local first_text second_text - first_text=$(jq -rs '[.[]|select(.span_attributes.type=="llm")][0].output.content' "$out") - second_text=$(jq -rs '[.[]|select(.span_attributes.type=="llm")][1].output.content' "$out") - assert_eq "$first_text" "first turn" "first emitted LLM span is the chronologically-first turn" - assert_eq "$second_text" "second turn" "second emitted LLM span is the chronologically-second turn" - - # History threading must follow chronological order: the first turn has - # empty input history; the second turn carries the first turn's assistant - # message as input. - assert_eq "$(jq -s '[.[]|select(.span_attributes.type=="llm")][0].input|length' "$out")" "0" "first turn has empty input history" - local second_input_text - second_input_text=$(jq -rs '[.[]|select(.span_attributes.type=="llm")][1].input[0].content' "$out") - assert_eq "$second_input_text" "first turn" "second turn input carries the first turn as history" -} - -it "emits one LLM span per requestId (dedupes repeated usage)" t_emit_dedupes_by_request_id -it "emits nothing when the transcript file is missing" t_emit_missing_file_is_zero -it "preserves chronological order when requestIds don't sort chronologically" t_emit_preserves_chronological_order diff --git a/src/plugins/claude/content/plugins/trace-claude-code/test/test_fixture_replay.sh b/src/plugins/claude/content/plugins/trace-claude-code/test/test_fixture_replay.sh deleted file mode 100755 index 746ef5c..0000000 --- a/src/plugins/claude/content/plugins/trace-claude-code/test/test_fixture_replay.sh +++ /dev/null @@ -1,361 +0,0 @@ -#!/bin/bash -### -# Regression tests against the `test-fixture` session - a real Claude Code -# session captured via BRAINTRUST_RECORD_DIR and verified to look correct -# in the Braintrust UI. -# -# The fixture contains: -# - 1 session_start -# - 4 user_prompt_submit (4 turns) -# - 13 post_tool_use (tool calls across the 4 turns) -# - 4 stop_hook -# - 1 session_end -# - 1 transcript with 17 assistant messages (claude-opus-4-7) -# -# Tool breakdown (from the recorded payloads): -# Agent: 4, Bash: 5, TaskCreate: 1, ToolSearch: 1, WebFetch: 1, WebSearch: 1 -# -# Re-record with: -# ./record_session.sh test-fixture -# -# Each `it` test below replays the full fixture once. To keep wall time -# reasonable (~3s per replay), related assertions are bundled into a -# single test rather than split across many `it` blocks. -### - -SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -# shellcheck source=helpers/assert.sh -source "$SCRIPT_DIR/helpers/assert.sh" -# shellcheck source=helpers/harness.sh -source "$SCRIPT_DIR/helpers/harness.sh" - -FIXTURE_DIR="$SCRIPT_DIR/fixtures/sessions/test-fixture" -SESSION_ID="4381b0d7-d67e-4187-bb2d-86a101d3b955" - -_setup_default_stubs() { - stub_response_for "*/v1/project?project_name=*" 200 '{"id":"proj_fixture"}' - stub_response_for "*/v1/project_logs/*/insert" 200 '{"row_ids":["row_1"]}' -} - -# Count tool spans whose metadata.tool_name matches a given value. -_count_tool_spans_by_name() { - local name="$1" - all_spans | jq --arg n "$name" ' - [ .[] - | select(.span_attributes.type == "tool") - | select(.metadata.tool_name == $n) - ] | length - ' -} - -# --------------------------------------------------------------------------- -describe "test-fixture: replay end-to-end" -# --------------------------------------------------------------------------- - -t_replay_all_events() { - _setup_default_stubs - - local n - n=$(replay_session "$FIXTURE_DIR") - assert_success "$?" "every hook in the fixture should exit cleanly" - assert_eq "$n" "23" "expected 23 events replayed" -} - -it "replays all 23 hook events without errors" t_replay_all_events - -# --------------------------------------------------------------------------- -describe "test-fixture: top-level span counts" -# --------------------------------------------------------------------------- - -t_top_level_counts() { - _setup_default_stubs - replay_session "$FIXTURE_DIR" >/dev/null - - # Exactly one root session span - assert_eq "$(spans_named '^Claude Code: ' | jq 'length')" "1" \ - "expected exactly one root session span" - - # 4 user_prompt_submit events -> 4 Turn spans - assert_eq "$(spans_named '^Turn ' | jq 'length')" "4" \ - "expected 4 Turn spans (one per user_prompt_submit)" - - # The critical regression assertion: 13 post_tool_use -> 13 tool spans - # (no drops from async/race issues). - assert_eq "$(span_count_by_type tool)" "13" \ - "every post_tool_use must produce a tool span" - - # The transcript has 17 assistant messages; the stop_hook collapses - # them into one LLM span per LLM call. Should be at least one per - # turn (4) and at most 17. - local llms - llms=$(span_count_by_type llm) - if [ "$llms" -lt 4 ] || [ "$llms" -gt 17 ]; then - fail "expected 4-17 LLM spans; got $llms" - fi - - # The stop_hook writes a merge update for each Turn at end-of-turn, - # carrying the final output text and metrics. 4 turns -> 4 merges. - local merges - merges=$(all_spans | jq '[.[] | select(._is_merge == true)] | length') - assert_eq "$merges" "4" "expected 4 merge updates (one per Turn end)" -} - -it "session/turn/tool/llm span counts match the fixture" t_top_level_counts - -# --------------------------------------------------------------------------- -describe "test-fixture: tool span breakdown" -# --------------------------------------------------------------------------- - -t_tool_breakdown() { - _setup_default_stubs - replay_session "$FIXTURE_DIR" >/dev/null - - assert_eq "$(_count_tool_spans_by_name 'Agent')" "4" "Agent count" - assert_eq "$(_count_tool_spans_by_name 'Bash')" "5" "Bash count" - assert_eq "$(_count_tool_spans_by_name 'TaskCreate')" "1" "TaskCreate count" - assert_eq "$(_count_tool_spans_by_name 'ToolSearch')" "1" "ToolSearch count" - assert_eq "$(_count_tool_spans_by_name 'WebFetch')" "1" "WebFetch count" - assert_eq "$(_count_tool_spans_by_name 'WebSearch')" "1" "WebSearch count" -} - -it "tool spans match: 4 Agent / 5 Bash / 1 TaskCreate / 1 ToolSearch / 1 WebFetch / 1 WebSearch" \ - t_tool_breakdown - -# --------------------------------------------------------------------------- -describe "test-fixture: tree structure" -# --------------------------------------------------------------------------- - -t_tree_structure() { - _setup_default_stubs - replay_session "$FIXTURE_DIR" >/dev/null - - # 1. The root span's span_id should equal the recorded session id - local session_span - session_span=$(span_by_id "$SESSION_ID") - assert_ne "$session_span" "null" "session span with id=$SESSION_ID should exist" - - # 2. Every non-merge span shares the session root_span_id. - # (Merge updates omit root_span_id since they target an existing span - # by id - they only carry the fields being updated, like final output - # and metrics.) - local off_root - off_root=$(all_spans | jq --arg r "$SESSION_ID" ' - [ .[] - | select(._is_merge != true) - | select(.root_span_id != $r) - ] | length - ') - assert_eq "$off_root" "0" "all non-merge spans should share the session root_span_id" - - # 3. Every Turn span's first parent is the session id - local off_turn - off_turn=$(spans_named "^Turn " | jq --arg s "$SESSION_ID" ' - [ .[] | select((.span_parents // [])[0] != $s) ] | length - ') - assert_eq "$off_turn" "0" "all Turn spans should be parented to the session" - - # 4. Collect all turn ids; tools and llms must be children of some turn - local turn_ids - turn_ids=$(spans_named "^Turn " | jq -c '[.[].span_id]') - - local orphan_tools - orphan_tools=$(all_spans | jq --argjson ids "$turn_ids" ' - [ .[] - | select(.span_attributes.type == "tool") - | select(((.span_parents // [])[0]) as $p | ($ids | index($p)) == null) - ] | length - ') - assert_eq "$orphan_tools" "0" "every tool span should be a child of some Turn" - - local orphan_llms - orphan_llms=$(all_spans | jq --argjson ids "$turn_ids" ' - [ .[] - | select(.span_attributes.type == "llm") - | select(((.span_parents // [])[0]) as $p | ($ids | index($p)) == null) - ] | length - ') - assert_eq "$orphan_llms" "0" "every LLM span should be a child of some Turn" -} - -it "session > turn > tool/llm hierarchy is correct" t_tree_structure - -# --------------------------------------------------------------------------- -describe "test-fixture: LLM span content" -# --------------------------------------------------------------------------- - -t_llm_content() { - _setup_default_stubs - replay_session "$FIXTURE_DIR" >/dev/null - - # All LLM spans use the model the recorded transcript shows - local off_model - off_model=$(all_spans | jq ' - [ .[] - | select(.span_attributes.type == "llm") - | select(.metadata.model != "claude-opus-4-7") - ] | length - ') - assert_eq "$off_model" "0" "all LLM spans should be tagged with model=claude-opus-4-7" - - # All LLM spans have non-negative token counts - local bad_metrics - bad_metrics=$(all_spans | jq ' - [ .[] - | select(.span_attributes.type == "llm") - | select((.metrics.prompt_tokens // 0) < 0 - or (.metrics.completion_tokens // 0) < 0) - ] | length - ') - assert_eq "$bad_metrics" "0" "LLM spans should have non-negative token metrics" - - # At least one LLM span should report > 0 completion tokens - local with_output - with_output=$(all_spans | jq ' - [ .[] - | select(.span_attributes.type == "llm") - | select((.metrics.completion_tokens // 0) > 0) - ] | length - ') - if [ "$with_output" -lt 1 ]; then - fail "expected at least one LLM span with completion_tokens > 0; got $with_output" - fi -} - -it "LLM spans use claude-opus-4-7 and have sensible token metrics" t_llm_content - -# --------------------------------------------------------------------------- -describe "test-fixture: token totals dedupe by requestId" -# --------------------------------------------------------------------------- - -# Regression test for the double-counting bug: Claude Code writes one -# transcript line per content block (thinking, text, each tool_use) and -# every line for the same API response repeats the identical `usage` block, -# tagged with the same `requestId`. The stop_hook must count each response's -# usage exactly once, not once per content-block line. -# -# The fixture transcript has 30 assistant lines but only 7 unique -# requestIds. We assert the dedup by summing metrics across the LLM spans -# (token metrics live only on LLM spans now - the Turn merge carries no -# token totals; Braintrust aggregates them for display). Summing all LLM -# spans gives the whole-session totals, which must match the deduped -# expectation. Before the fix, output alone would be 425*4 + ... + 745*8 + -# ... = thousands too high. -t_token_dedupe_totals() { - _setup_default_stubs - replay_session "$FIXTURE_DIR" >/dev/null - - # Sum metrics across all LLM spans (this fixture has no sub-agents, so - # all LLM spans are the main-conversation calls). - local llms - llms=$(all_spans | jq '[.[] | select(.span_attributes.type == "llm")]') - - local total_prompt total_completion total_cache_creation total_cache_read total_tokens raw_metric_count - total_prompt=$(echo "$llms" | jq '[.[].metrics.prompt_tokens // 0] | add') - total_completion=$(echo "$llms" | jq '[.[].metrics.completion_tokens // 0] | add') - total_cache_creation=$(echo "$llms" | jq '[.[].metrics.prompt_cache_creation_tokens // 0, .[].metrics.prompt_cache_creation_5m_tokens // 0, .[].metrics.prompt_cache_creation_1h_tokens // 0] | add') - total_cache_read=$(echo "$llms" | jq '[.[].metrics.prompt_cached_tokens // 0] | add') - total_tokens=$(echo "$llms" | jq '[.[].metrics.tokens // 0] | add') - raw_metric_count=$(echo "$llms" | jq '[.[] | select(.metrics | (has("cache_creation_input_tokens") or has("cache_read_input_tokens")))] | length') - - # Expected = sum of usage over the 7 unique requestIds in the transcript. - assert_eq "$total_prompt" "187216" "prompt_tokens should include input and cache tokens deduped by requestId" - assert_eq "$total_completion" "1867" "completion_tokens should dedupe by requestId" - assert_eq "$total_cache_creation" "21064" "prompt cache creation tokens should dedupe by requestId" - assert_eq "$total_cache_read" "165784" "prompt_cached_tokens should dedupe by requestId" - assert_eq "$total_tokens" "189083" "tokens should include inclusive prompt and completion" - assert_eq "$raw_metric_count" "0" "raw Anthropic cache metrics should not be emitted" - - # Turn merge spans must NOT carry token metrics anymore. - local merge_tokens - merge_tokens=$(all_spans | jq '[.[] | select(._is_merge == true) | .metrics.prompt_tokens // empty] | length') - assert_eq "$merge_tokens" "0" "Turn merges carry no token metrics" -} - -it "LLM span token totals count each requestId once (no per-content-block double-count)" \ - t_token_dedupe_totals - -# --------------------------------------------------------------------------- -describe "subagent-compact: sub-agent LLM spans" -# --------------------------------------------------------------------------- - -# Real recorded session that launched Explore sub-agents. Each sub-agent made -# its own model calls and wrote its own transcript, which the recorder -# snapshotted into transcripts/agent-.jsonl. On replay, post_tool_use.sh -# (for the Agent tool) must parse those transcripts and emit one LLM span per -# sub-agent API request, nested under the corresponding Agent tool span. -# -# Rather than hard-coding token goldens (which would break every time the -# fixture is re-recorded), we DERIVE the expected span count and token totals -# directly from the fixture's own sub-agent transcripts, applying the same -# rules the production code does: dedupe by requestId, count output_tokens as -# the MAX per request (it streams cumulatively), and count input/cache once. -# The test then asserts the replayed spans match that derived expectation. -SUBAGENT_FIXTURE="$SCRIPT_DIR/fixtures/sessions/subagent-compact" - -# Compute expected {spans,input,output,cache_read,cache_creation} from the fixture's -# agent-*.jsonl transcripts. Prints a compact JSON object. -_expected_subagent_totals() { - jq -s ' - [ .[] - | select(.type=="assistant") - | select(.message.usage != null) - | { rid:(.requestId // .message.id), - in:(.message.usage.input_tokens // 0), - out:(.message.usage.output_tokens // 0), - cr:(.message.usage.cache_read_input_tokens // 0), - cc:(.message.usage.cache_creation_input_tokens // 0) } - ] - | group_by(.rid) - | { spans: length, - input: (map(.[0].in) | add), - output: (map([.[].out]|max) | add), - cache_read: (map(.[0].cr) | add), - cache_creation: (map(.[0].cc) | add) } - ' "$SUBAGENT_FIXTURE"/transcripts/agent-*.jsonl -} - -t_subagent_llm_spans() { - # Skip cleanly if the fixture hasn't been (re-)recorded yet. - if ! ls "$SUBAGENT_FIXTURE"/transcripts/agent-*.jsonl >/dev/null 2>&1; then - skip "subagent-compact fixture not present; record one to enable this test" - return 0 - fi - - _setup_default_stubs - replay_session "$SUBAGENT_FIXTURE" >/dev/null - - local expected - expected=$(_expected_subagent_totals) - local exp_spans exp_in exp_out exp_cr exp_cc exp_prompt exp_tokens - exp_spans=$(echo "$expected" | jq '.spans') - exp_in=$(echo "$expected" | jq '.input') - exp_out=$(echo "$expected" | jq '.output') - exp_cr=$(echo "$expected" | jq '.cache_read') - exp_cc=$(echo "$expected" | jq '.cache_creation') - exp_prompt=$((exp_in + exp_cr + exp_cc)) - exp_tokens=$((exp_prompt + exp_out)) - - # Collect the sub-agent LLM spans: llm spans whose parent is an Agent - # tool span (model-agnostic, since the sub-agent model may differ between - # recordings). - local agent_ids subagent_spans - agent_ids=$(all_spans | jq -c '[.[] | select(.span_attributes.type=="tool" and .span_attributes.name=="Agent") | .span_id]') - subagent_spans=$(all_spans | jq --argjson a "$agent_ids" \ - '[.[] | select(.span_attributes.type=="llm") | select(.span_parents[0] as $p | ($a | index($p)) != null)]') - - # Span count and deduped token totals match what the transcripts imply. - assert_eq "$(echo "$subagent_spans" | jq 'length')" "$exp_spans" "sub-agent span count matches transcripts" - assert_eq "$(echo "$subagent_spans" | jq '[.[].metrics.completion_tokens]|add')" "$exp_out" "sub-agent completion (max per request)" - assert_eq "$(echo "$subagent_spans" | jq '[.[].metrics.prompt_tokens]|add')" "$exp_prompt" "sub-agent prompt includes input and cache tokens" - assert_eq "$(echo "$subagent_spans" | jq '[.[].metrics.tokens]|add')" "$exp_tokens" "sub-agent total tokens" - assert_eq "$(echo "$subagent_spans" | jq '[.[].metrics.prompt_cached_tokens]|add')" "$exp_cr" "sub-agent cache_read total" - assert_eq "$(echo "$subagent_spans" | jq '[.[].metrics.prompt_cache_creation_tokens // 0, .[].metrics.prompt_cache_creation_5m_tokens // 0, .[].metrics.prompt_cache_creation_1h_tokens // 0] | add')" "$exp_cc" "sub-agent cache_creation total" - assert_eq "$(echo "$subagent_spans" | jq '[.[] | select(.metrics | (has("cache_creation_input_tokens") or has("cache_read_input_tokens")))] | length')" "0" "sub-agent raw Anthropic cache metrics absent" - - # Sanity: at least one sub-agent span was actually produced. - if [ "$exp_spans" -gt 0 ]; then - assert_eq "$(echo "$subagent_spans" | jq 'length > 0')" "true" "expected some sub-agent spans" - fi -} - -it "emits sub-agent spans under Agent tool spans matching the transcripts" t_subagent_llm_spans diff --git a/src/plugins/claude/content/plugins/trace-claude-code/test/test_full_pipeline.sh b/src/plugins/claude/content/plugins/trace-claude-code/test/test_full_pipeline.sh deleted file mode 100755 index c1c62c8..0000000 --- a/src/plugins/claude/content/plugins/trace-claude-code/test/test_full_pipeline.sh +++ /dev/null @@ -1,174 +0,0 @@ -#!/bin/bash -### -# Full pipeline end-to-end tests. -# -# These tests chain together SessionStart → UserPromptSubmit → PostToolUse... -# to assert that the resulting span tree has the expected structure. They -# are the bash equivalent of opencode-plugin's `assertEventsProduceTree` -# integration tests. -# -# These are the tests most likely to catch regressions like the missing- -# spans bug (where async hooks dropped spans due to state-file races or -# killed curl processes). -### - -SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -# shellcheck source=helpers/assert.sh -source "$SCRIPT_DIR/helpers/assert.sh" -# shellcheck source=helpers/harness.sh -source "$SCRIPT_DIR/helpers/harness.sh" - -_setup_default_stubs() { - stub_response_for "*/v1/project?project_name=*" 200 '{"id":"proj_test"}' - stub_response_for "*/v1/project_logs/*/insert" 200 '{"row_ids":["row_1"]}' -} - -# --------------------------------------------------------------------------- -describe "full pipeline: simple session with one turn and two tools" -# --------------------------------------------------------------------------- - -t_e2e_simple_session() { - _setup_default_stubs - local sid="e2e-sess-1" - - run_hook session_start.sh "$(fixture_session_start "$sid" "/tmp/proj-x")" - run_hook user_prompt_submit.sh "$(fixture_user_prompt "$sid" "List files")" - - run_hook post_tool_use.sh "$(fixture_post_tool_use "$sid" "Bash" \ - "$(fixture_tool_input_bash 'ls')" \ - "$(fixture_tool_response_text 'a.txt')")" - - run_hook post_tool_use.sh "$(fixture_post_tool_use "$sid" "Read" \ - "$(fixture_tool_input_read /tmp/a.txt)" \ - "$(fixture_tool_response_text 'hello')")" - - # Expected total: 1 session + 1 turn + 2 tools = 4 spans - local total - total=$(span_count) - assert_eq "$total" "4" - - # Count by type - local task_count tool_count - task_count=$(span_count_by_type "task") - tool_count=$(span_count_by_type "tool") - assert_eq "$task_count" "2" "expected 2 task spans (session + turn)" - assert_eq "$tool_count" "2" "expected 2 tool spans" -} - -t_e2e_span_hierarchy() { - _setup_default_stubs - local sid="e2e-hier-1" - - run_hook session_start.sh "$(fixture_session_start "$sid" "/tmp/proj")" - run_hook user_prompt_submit.sh "$(fixture_user_prompt "$sid" "go")" - run_hook post_tool_use.sh "$(fixture_post_tool_use "$sid" "Bash" \ - "$(fixture_tool_input_bash 'echo hi')" \ - "$(fixture_tool_response_text 'hi')")" - - # Session span: span_id == session_id, no parents - local session_span - session_span=$(span_by_name "^Claude Code: ") - local session_id_value - session_id_value=$(echo "$session_span" | jq -r '.span_id') - assert_eq "$session_id_value" "$sid" - - # Turn span: parent is session - local turn_span turn_id turn_parent - turn_span=$(span_by_name "^Turn 1$") - turn_id=$(echo "$turn_span" | jq -r '.span_id') - turn_parent=$(echo "$turn_span" | jq -r '.span_parents[0]') - assert_eq "$turn_parent" "$sid" - - # Tool span: parent is turn - local tool_span tool_parent - tool_span=$(span_by_type "tool") - tool_parent=$(echo "$tool_span" | jq -r '.span_parents[0]') - assert_eq "$tool_parent" "$turn_id" - - # All three share the same root_span_id (the session id) - local turn_root tool_root - turn_root=$(echo "$turn_span" | jq -r '.root_span_id') - tool_root=$(echo "$tool_span" | jq -r '.root_span_id') - assert_eq "$turn_root" "$sid" - assert_eq "$tool_root" "$sid" -} - -t_e2e_multi_turn() { - _setup_default_stubs - local sid="e2e-multi-1" - - run_hook session_start.sh "$(fixture_session_start "$sid" "/tmp/proj")" - - # Turn 1: one tool - run_hook user_prompt_submit.sh "$(fixture_user_prompt "$sid" "turn 1")" - run_hook post_tool_use.sh "$(fixture_post_tool_use "$sid" "Bash" \ - "$(fixture_tool_input_bash 'echo 1')" \ - "$(fixture_tool_response_text '1')")" - - # Turn 2: two tools - run_hook user_prompt_submit.sh "$(fixture_user_prompt "$sid" "turn 2")" - run_hook post_tool_use.sh "$(fixture_post_tool_use "$sid" "Read" \ - "$(fixture_tool_input_read /tmp/a)" \ - "$(fixture_tool_response_text 'a')")" - run_hook post_tool_use.sh "$(fixture_post_tool_use "$sid" "Read" \ - "$(fixture_tool_input_read /tmp/b)" \ - "$(fixture_tool_response_text 'b')")" - - # Total: 1 session + 2 turns + 3 tools = 6 spans - local total - total=$(span_count) - assert_eq "$total" "6" - - # Two turn spans - local turns - turns=$(spans_named "^Turn ") - local turn_count - turn_count=$(echo "$turns" | jq 'length') - assert_eq "$turn_count" "2" - - # Tool spans are split across turns - local turn1_id turn2_id - turn1_id=$(echo "$turns" | jq -r '.[0].span_id') - turn2_id=$(echo "$turns" | jq -r '.[1].span_id') - - local children_t1 children_t2 - children_t1=$(children_of "$turn1_id" | jq 'length') - children_t2=$(children_of "$turn2_id" | jq 'length') - - assert_eq "$children_t1" "1" "turn 1 should have 1 tool child" - assert_eq "$children_t2" "2" "turn 2 should have 2 tool children" -} - -it "produces session + turn + 2 tool spans (4 total)" t_e2e_simple_session -it "spans form correct session > turn > tool hierarchy" t_e2e_span_hierarchy -it "multiple turns produce correctly-parented spans" t_e2e_multi_turn - -# --------------------------------------------------------------------------- -describe "full pipeline: no tool spans dropped under sequential PostToolUse" -# --------------------------------------------------------------------------- - -# This is the regression test for the missing-spans class of bugs. We fire -# many PostToolUse hooks in rapid succession and assert that every one -# produced a tool span. With the previous async config, some of these -# would be silently dropped; with sync hooks they should all land. -t_e2e_no_drops_sequential() { - _setup_default_stubs - local sid="e2e-no-drops" - local N=20 - - run_hook session_start.sh "$(fixture_session_start "$sid" "/tmp/proj")" - run_hook user_prompt_submit.sh "$(fixture_user_prompt "$sid" "many tools")" - - local i - for i in $(seq 1 $N); do - run_hook post_tool_use.sh "$(fixture_post_tool_use "$sid" "Bash" \ - "$(fixture_tool_input_bash "echo $i")" \ - "$(fixture_tool_response_text "$i")")" - done - - local tool_count - tool_count=$(span_count_by_type "tool") - assert_eq "$tool_count" "$N" "expected $N tool spans, none dropped" -} - -it "20 sequential PostToolUse hooks produce 20 tool spans" t_e2e_no_drops_sequential diff --git a/src/plugins/claude/content/plugins/trace-claude-code/test/test_insert_span.sh b/src/plugins/claude/content/plugins/trace-claude-code/test/test_insert_span.sh deleted file mode 100755 index 1f615fe..0000000 --- a/src/plugins/claude/content/plugins/trace-claude-code/test/test_insert_span.sh +++ /dev/null @@ -1,262 +0,0 @@ -#!/bin/bash -### -# Tests for _http_insert_span() and get_project_id() HTTP status handling. -# -# Exercises the curl_stub by configuring canned responses and asserting on -# the captured requests and the function's stdout/exit codes. -### - -SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -# shellcheck source=helpers/assert.sh -source "$SCRIPT_DIR/helpers/assert.sh" -# shellcheck source=helpers/harness.sh -source "$SCRIPT_DIR/helpers/harness.sh" - -# A minimal valid span event JSON (matching the shape common.sh builds). -_test_event() { - jq -nc \ - --arg id "span-test-1" \ - --arg root "root-1" \ - '{ - id: $id, - span_id: $id, - root_span_id: $root, - input: "test input", - span_attributes: { name: "test span", type: "task" } - }' -} - -# --------------------------------------------------------------------------- -describe "_http_insert_span: success" -# --------------------------------------------------------------------------- - -t_insert_success_returns_row_id() { - stub_response_for "*/v1/project_logs/*/insert" 200 '{"row_ids":["row_abc"]}' - - local event row_id rc - event=$(_test_event) - row_id=$(_http_insert_span "proj_123" "$event") - rc=$? - - assert_success "$rc" - assert_eq "$row_id" "row_abc" - - local count - count=$(captured_request_count '/insert$') - assert_eq "$count" "1" -} - -t_insert_request_body_shape() { - stub_response_for "*/insert" 200 '{"row_ids":["row_xyz"]}' - - local event - event=$(_test_event) - _http_insert_span "proj_456" "$event" >/dev/null - - local body_events_len - body_events_len=$(jq -s '.[0].body.events | length' "$CAPTURED_REQUESTS") - assert_eq "$body_events_len" "1" - - local body_span_id - body_span_id=$(jq -s -r '.[0].body.events[0].span_id' "$CAPTURED_REQUESTS") - assert_eq "$body_span_id" "span-test-1" -} - -it "POSTs to the project_logs insert endpoint and returns the row id" t_insert_success_returns_row_id -it "includes the event in the request body wrapped under .events" t_insert_request_body_shape - -# --------------------------------------------------------------------------- -describe "_http_insert_span: failure modes" -# --------------------------------------------------------------------------- - -t_insert_401() { - stub_response_for "*/insert" 401 "Invalid API Key" - - local event rc - event=$(_test_event) - _http_insert_span "proj_123" "$event" >/dev/null - rc=$? - - assert_failure "$rc" - local log - log=$(hook_log) - assert_contains "$log" "Insert failed (HTTP 401)" -} - -t_insert_500() { - stub_response_for "*/insert" 500 "Internal Server Error" - - local event rc - event=$(_test_event) - _http_insert_span "proj_123" "$event" >/dev/null - rc=$? - - assert_failure "$rc" - local log - log=$(hook_log) - assert_contains "$log" "Insert failed (HTTP 500)" -} - -t_insert_no_api_key() { - API_KEY="" - - local event rc - event=$(_test_event) - _http_insert_span "proj_123" "$event" >/dev/null - rc=$? - - assert_failure "$rc" - local log - log=$(hook_log) - assert_contains "$log" "API_KEY is empty" -} - -t_insert_empty_row_ids() { - stub_response_for "*/insert" 200 '{"row_ids":[]}' - - local event rc - event=$(_test_event) - _http_insert_span "proj_123" "$event" >/dev/null - rc=$? - - assert_failure "$rc" -} - -it "returns non-zero on HTTP 401 and logs an error" t_insert_401 -it "returns non-zero on HTTP 500" t_insert_500 -it "returns non-zero when API_KEY is empty" t_insert_no_api_key -it "returns non-zero when the response has no row_ids" t_insert_empty_row_ids - -# --------------------------------------------------------------------------- -describe "_http_insert_span: experiment mode" -# --------------------------------------------------------------------------- - -t_insert_experiment_mode() { - stub_response_for "*/v1/experiment/exp_42/insert" 200 '{"row_ids":["row_e1"]}' - - CC_EXPERIMENT_ID="exp_42" - - local event row_id rc - event=$(_test_event) - row_id=$(_http_insert_span "proj_irrelevant" "$event") - rc=$? - - assert_success "$rc" - assert_eq "$row_id" "row_e1" - - local pl_count - pl_count=$(captured_request_count '/project_logs/') - assert_eq "$pl_count" "0" - - local exp_count - exp_count=$(captured_request_count '/v1/experiment/exp_42/insert') - assert_eq "$exp_count" "1" -} - -it "POSTs to /v1/experiment//insert when CC_EXPERIMENT_ID is set" t_insert_experiment_mode - -# --------------------------------------------------------------------------- -describe "get_project_id" -# --------------------------------------------------------------------------- - -t_get_project_existing() { - stub_response_for "*/v1/project?project_name=*" 200 '{"id":"proj_existing"}' - - local pid rc - pid=$(get_project_id "my-project") - rc=$? - - assert_success "$rc" - assert_eq "$pid" "proj_existing" -} - -t_get_project_create() { - stub_response_for "*/v1/project?project_name=*" 200 '{}' - stub_response_for "*/v1/project" 200 '{"id":"proj_created"}' - - local pid rc - pid=$(get_project_id "brand-new-project") - rc=$? - - assert_success "$rc" - assert_eq "$pid" "proj_created" -} - -t_get_project_cached() { - stub_response_for "*/v1/project?project_name=*" 200 '{"id":"proj_cached"}' - - get_project_id "cached-project" >/dev/null - local before - before=$(captured_request_count '.') - - local pid - pid=$(get_project_id "cached-project") - local after - after=$(captured_request_count '.') - - assert_eq "$pid" "proj_cached" - assert_eq "$before" "$after" -} - -t_get_project_401() { - stub_response_for "*/v1/project*" 401 "Invalid API Key" - - local pid rc - pid=$(get_project_id "doomed-project") - rc=$? - - assert_failure "$rc" - assert_eq "$pid" "" - - local log - log=$(hook_log) - assert_contains "$log" "authentication failed" - assert_contains "$log" "BRAINTRUST_API_KEY" -} - -t_get_project_403() { - stub_response_for "*/v1/project*" 403 "Forbidden" - - local pid rc - pid=$(get_project_id "forbidden-project") - rc=$? - - assert_failure "$rc" - assert_eq "$pid" "" - - local log - log=$(hook_log) - assert_contains "$log" "authentication failed" -} - -t_get_project_create_escapes_special_chars() { - # Project names can contain characters that would break a hand-rolled - # JSON literal: double quotes, backslashes, newlines. The create body - # must escape these correctly via jq. - stub_response_for "*/v1/project?project_name=*" 200 '{}' - stub_response_for "*/v1/project" 200 '{"id":"proj_created"}' - - local weird_name='my "quoted" \backslash project' - local pid rc - pid=$(get_project_id "$weird_name") - rc=$? - - assert_success "$rc" - assert_eq "$pid" "proj_created" - - # The POST body must be parseable JSON and the name field must round - # trip cleanly (including the embedded quotes and backslash). - local sent_name - sent_name=$(jq -s --arg url "/v1/project" -r ' - [.[] | select(.method == "POST") | select(.url | endswith($url))] - | .[-1].body.name - ' "$CAPTURED_REQUESTS") - assert_eq "$sent_name" "$weird_name" -} - -it "returns the existing project id on successful lookup" t_get_project_existing -it "creates a new project when lookup returns no id" t_get_project_create -it "escapes special characters in the project create body" t_get_project_create_escapes_special_chars -it "caches the project id so the second call makes no HTTP request" t_get_project_cached -it "returns non-zero and logs an auth error on HTTP 401" t_get_project_401 -it "returns non-zero and logs an auth error on HTTP 403" t_get_project_403 diff --git a/src/plugins/claude/content/plugins/trace-claude-code/test/test_post_tool_use.sh b/src/plugins/claude/content/plugins/trace-claude-code/test/test_post_tool_use.sh deleted file mode 100755 index 50ac952..0000000 --- a/src/plugins/claude/content/plugins/trace-claude-code/test/test_post_tool_use.sh +++ /dev/null @@ -1,322 +0,0 @@ -#!/bin/bash -### -# End-to-end tests for the PostToolUse hook. -# -# PostToolUse fires after each tool invocation by the assistant. It creates -# a "tool" span as a child of the current Turn span. If no current Turn is -# active (no UserPromptSubmit since last Stop), the tool span is skipped. -### - -SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -# shellcheck source=helpers/assert.sh -source "$SCRIPT_DIR/helpers/assert.sh" -# shellcheck source=helpers/harness.sh -source "$SCRIPT_DIR/helpers/harness.sh" - -_setup_default_stubs() { - stub_response_for "*/v1/project?project_name=*" 200 '{"id":"proj_test"}' - stub_response_for "*/v1/project_logs/*/insert" 200 '{"row_ids":["row_1"]}' -} - -# Setup helper: run session_start + user_prompt_submit, then clear capture. -_with_turn_started() { - local session_id="$1" - _setup_default_stubs - run_hook session_start.sh "$(fixture_session_start "$session_id" "/tmp/x")" - run_hook user_prompt_submit.sh "$(fixture_user_prompt "$session_id" "do something")" - : > "$CAPTURED_REQUESTS" -} - -# --------------------------------------------------------------------------- -describe "post_tool_use.sh: with active turn" -# --------------------------------------------------------------------------- - -t_post_tool_creates_tool_span() { - _with_turn_started "sess-pt-1" - - local payload - payload=$(fixture_post_tool_use "sess-pt-1" "Bash" \ - "$(fixture_tool_input_bash 'ls -la')" \ - "$(fixture_tool_response_text 'a.txt\nb.txt')") - payload=$(echo "$payload" | jq -c '. + {tool_use_id: "toolu_success"}') - - run_hook post_tool_use.sh "$payload" - assert_success "$HOOK_STATUS" - - local count - count=$(span_count) - assert_eq "$count" "1" - - local tool_span - tool_span=$(span_by_type "tool") - assert_ne "$tool_span" "null" - - local name - name=$(echo "$tool_span" | jq -r '.span_attributes.name') - # Bash spans become "Terminal: " - assert_contains "$name" "Terminal" - assert_contains "$name" "ls -la" - assert_eq "$(echo "$tool_span" | jq -r '.metadata.tool_approval')" "approved" - assert_eq "$(echo "$tool_span" | jq -r '.metadata.tool_call_id')" "toolu_success" -} - -t_post_tool_failure_creates_error_span() { - _with_turn_started "sess-pt-fail" - - run_hook post_tool_use_failure.sh "$(fixture_post_tool_use_failure "sess-pt-fail" "Bash" \ - "$(fixture_tool_input_bash 'exit 1')" \ - "Exit code 1" \ - "$(fixture_tool_response_text 'failed')")" - assert_success "$HOOK_STATUS" - - local tool_span - tool_span=$(span_by_type "tool") - assert_eq "$(echo "$tool_span" | jq -r '.metadata.tool_approval')" "approved" - assert_eq "$(echo "$tool_span" | jq -r '.error')" "Exit code 1" -} - -t_permission_denied_creates_denied_span() { - _with_turn_started "sess-pt-denied" - - run_hook permission_denied.sh "$(fixture_permission_denied "sess-pt-denied" "Bash" \ - "$(fixture_tool_input_bash 'rm -rf /tmp/nope')" \ - "toolu_denied")" - assert_success "$HOOK_STATUS" - - local tool_span - tool_span=$(span_by_type "tool") - assert_eq "$(echo "$tool_span" | jq -r '.metadata.tool_approval')" "denied" - assert_eq "$(echo "$tool_span" | jq -r '.metadata.tool_call_id')" "toolu_denied" - assert_eq "$(echo "$tool_span" | jq 'has("output")')" "false" - assert_eq "$(echo "$tool_span" | jq 'has("error")')" "false" -} - -t_tool_span_is_child_of_turn() { - _with_turn_started "sess-pt-child" - - # Capture the current turn id from state - local turn_id - turn_id=$(get_session_state "sess-pt-child" "current_turn_span_id") - assert_ne "$turn_id" "" "expected a current turn span" - - run_hook post_tool_use.sh "$(fixture_post_tool_use "sess-pt-child" "Read" \ - "$(fixture_tool_input_read /tmp/a.txt)" \ - "$(fixture_tool_response_text 'hello')")" - - local tool_span - tool_span=$(span_by_type "tool") - local parent - parent=$(echo "$tool_span" | jq -r '.span_parents[0]') - - assert_eq "$parent" "$turn_id" -} - -t_read_tool_span_name_includes_basename() { - _with_turn_started "sess-pt-read" - - run_hook post_tool_use.sh "$(fixture_post_tool_use "sess-pt-read" "Read" \ - "$(fixture_tool_input_read /tmp/some/long/path/file.txt)" \ - "$(fixture_tool_response_text 'content')")" - - local tool_span name - tool_span=$(span_by_type "tool") - name=$(echo "$tool_span" | jq -r '.span_attributes.name') - assert_eq "$name" "Read: file.txt" -} - -t_skill_tool_span_marks_tool_kind() { - _with_turn_started "sess-pt-skill" - - run_hook post_tool_use.sh "$(fixture_post_tool_use "sess-pt-skill" "Skill" \ - "$(jq -nc '{name: "review"}')" \ - "$(fixture_tool_response_text 'loaded')")" - - local tool_span name - tool_span=$(span_by_type "tool") - name=$(echo "$tool_span" | jq -r '.span_attributes.name') - assert_eq "$name" "skill: review" - assert_eq "$(echo "$tool_span" | jq -r '.metadata.tool_name')" "Skill" - assert_eq "$(echo "$tool_span" | jq -r '.metadata.tool_kind')" "skill" - assert_eq "$(echo "$tool_span" | jq -r '.metadata.skill_name')" "review" -} - -t_multiple_tools_in_turn() { - _with_turn_started "sess-pt-multi" - - run_hook post_tool_use.sh "$(fixture_post_tool_use "sess-pt-multi" "Bash" \ - "$(fixture_tool_input_bash 'echo 1')" \ - "$(fixture_tool_response_text '1')")" - - run_hook post_tool_use.sh "$(fixture_post_tool_use "sess-pt-multi" "Bash" \ - "$(fixture_tool_input_bash 'echo 2')" \ - "$(fixture_tool_response_text '2')")" - - run_hook post_tool_use.sh "$(fixture_post_tool_use "sess-pt-multi" "Read" \ - "$(fixture_tool_input_read /tmp/x)" \ - "$(fixture_tool_response_text 'x')")" - - local tool_count - tool_count=$(span_count_by_type "tool") - assert_eq "$tool_count" "3" -} - -it "creates a tool span on PostToolUse" t_post_tool_creates_tool_span -it "creates an error tool span on PostToolUseFailure" t_post_tool_failure_creates_error_span -it "creates a denied tool span on PermissionDenied" t_permission_denied_creates_denied_span -it "tool span is a child of the current Turn span" t_tool_span_is_child_of_turn -it "Read tool span name includes file basename" t_read_tool_span_name_includes_basename -it "Skill tool span marks tool kind" t_skill_tool_span_marks_tool_kind -it "all tools in a turn produce distinct spans" t_multiple_tools_in_turn - -# --------------------------------------------------------------------------- -describe "post_tool_use.sh: without active turn" -# --------------------------------------------------------------------------- - -t_post_tool_skipped_without_turn() { - # No session_start or user_prompt_submit ran. The hook should bail out - # without creating any span (and without erroring). - _setup_default_stubs - - run_hook post_tool_use.sh "$(fixture_post_tool_use "sess-no-turn" "Bash" \ - "$(fixture_tool_input_bash 'ls')" \ - "$(fixture_tool_response_text 'output')")" - - assert_success "$HOOK_STATUS" - local count - count=$(span_count) - assert_eq "$count" "0" -} - -it "skips silently when no current turn is active" t_post_tool_skipped_without_turn - -# --------------------------------------------------------------------------- -describe "post_tool_use.sh: input validation" -# --------------------------------------------------------------------------- - -t_post_tool_no_tool_name() { - _with_turn_started "sess-no-name" - - # Payload without tool_name - local payload - payload=$(jq -nc --arg s "sess-no-name" '{session_id: $s}') - run_hook post_tool_use.sh "$payload" - - assert_success "$HOOK_STATUS" - local count - count=$(span_count) - assert_eq "$count" "0" -} - -t_post_tool_no_session_id() { - _setup_default_stubs - - local payload - payload=$(jq -nc --arg t "Bash" '{tool_name: $t, tool_input: {}, tool_response: {}}') - run_hook post_tool_use.sh "$payload" - - assert_success "$HOOK_STATUS" - local count - count=$(span_count) - assert_eq "$count" "0" -} - -it "skips silently when payload has no tool_name" t_post_tool_no_tool_name -it "skips silently when payload has no session_id" t_post_tool_no_session_id - -# --------------------------------------------------------------------------- -describe "post_tool_use.sh: Agent sub-agent LLM spans" -# --------------------------------------------------------------------------- - -# When the tool is an Agent (sub-agent), the hook should locate the -# sub-agent's transcript and emit its model calls as LLM spans nested under -# the Agent tool span. -t_agent_emits_subagent_llm_spans() { - _with_turn_started "sess-agent" - - # The Agent payload references the main transcript_path. The hook derives - # the sub-agent transcript from dirname(transcript_path) + agent-; - # we place a synthetic one in that flat fallback location. - local main_transcript="$TEST_TMP/88a535be.jsonl" - : > "$main_transcript" - local agent_id="aea5test" - local agent_transcript="$TEST_TMP/agent-${agent_id}.jsonl" - { - # r1: text + a tool_use (Bash) -> emits an LLM span and a tool span. - echo '{"type":"assistant","requestId":"r1","timestamp":"2026-06-11T03:00:00.000Z","message":{"model":"claude-haiku-4-5","content":[{"type":"text","text":"hi"},{"type":"tool_use","id":"tu1","name":"Bash","input":{"command":"ls -la"}}],"usage":{"input_tokens":5,"output_tokens":40,"cache_creation_input_tokens":10,"cache_read_input_tokens":1000}}}' - echo '{"type":"user","message":{"content":[{"type":"tool_result","tool_use_id":"tu1","content":"Exit code 128","is_error":true}]}}' - # r2: final text answer -> emits a second LLM span (with history). - echo '{"type":"assistant","requestId":"r2","timestamp":"2026-06-11T03:00:02.000Z","message":{"model":"claude-haiku-4-5","content":[{"type":"text","text":"bye"}],"usage":{"input_tokens":3,"output_tokens":20,"cache_creation_input_tokens":5,"cache_read_input_tokens":1500}}}' - } > "$agent_transcript" - - # Build an Agent PostToolUse payload with transcript_path + tool_response.agentId. - local payload - payload=$(jq -nc \ - --arg s "sess-agent" \ - --arg tp "$main_transcript" \ - --arg aid "$agent_id" \ - '{ - session_id: $s, - transcript_path: $tp, - tool_name: "Agent", - tool_input: {description: "explore", subagent_type: "Explore"}, - tool_response: {agentId: $aid, content: "result"} - }') - - run_hook post_tool_use.sh "$payload" - assert_success "$HOOK_STATUS" - - # Tool spans: the Agent tool span itself + the sub-agent's Bash tool span. - assert_eq "$(span_count_by_type tool)" "2" "Agent span + sub-agent Bash tool span" - # Two sub-agent LLM spans (r1, r2). - assert_eq "$(span_count_by_type llm)" "2" "two sub-agent LLM spans" - - # The Agent tool span is the one named "Agent"; the sub-agent spans nest - # under it. - local agent_span_id - agent_span_id=$(all_spans | jq -r '[.[]|select(.span_attributes.type=="tool" and .span_attributes.name=="Agent")][0].span_id') - - # Every LLM span is a child of the Agent tool span. - local llm_parents_ok - llm_parents_ok=$(all_spans | jq --arg p "$agent_span_id" \ - 'all(.[]|select(.span_attributes.type=="llm"); .span_parents[0] == $p)') - assert_eq "$llm_parents_ok" "true" "LLM spans nested under the Agent tool span" - - # The sub-agent's Bash tool span also nests under the Agent tool span. - local subagent_tool - subagent_tool=$(all_spans | jq --arg p "$agent_span_id" \ - '[.[]|select(.span_attributes.type=="tool" and .span_parents[0]==$p)][0]') - assert_eq "$(echo "$subagent_tool" | jq -r '.span_attributes.name')" "Terminal: ls -la" "sub-agent tool span named after the Bash command" - assert_eq "$(echo "$subagent_tool" | jq -r '.metadata.tool_approval')" "approved" "sub-agent tool approval state" - assert_eq "$(echo "$subagent_tool" | jq -r '.metadata.tool_call_id')" "tu1" "sub-agent tool call id" - assert_eq "$(echo "$subagent_tool" | jq -r '.error')" "Exit code 128" "sub-agent tool error text" - - # Token totals match the deduped transcript. Braintrust prompt_tokens are - # inclusive for Anthropic: input 8 + cache_read 2500 + cache_creation 15 = 2523. - assert_eq "$(all_spans | jq '[.[]|select(.span_attributes.type=="llm")|.metrics.completion_tokens]|add')" "60" "completion summed" - assert_eq "$(all_spans | jq '[.[]|select(.span_attributes.type=="llm")|.metrics.prompt_tokens]|add')" "2523" "prompt includes input and cache tokens" - assert_eq "$(all_spans | jq '[.[]|select(.span_attributes.type=="llm")|.metrics.tokens]|add')" "2583" "total tokens includes inclusive prompt and completion" - assert_eq "$(all_spans | jq '[.[]|select(.span_attributes.type=="llm")|.metrics.prompt_cached_tokens]|add')" "2500" "cache_read summed" - assert_eq "$(all_spans | jq '[.[]|select(.span_attributes.type=="llm")|.metrics.prompt_cache_creation_tokens]|add')" "15" "cache_creation summed" - assert_eq "$(all_spans | jq '[.[]|select(.span_attributes.type=="llm" and (.metrics | (has("cache_read_input_tokens") or has("cache_creation_input_tokens"))))]|length')" "0" "raw Anthropic cache metrics are not emitted" - - # The LLM spans are tagged with the sub-agent's model. - assert_eq "$(all_spans | jq '[.[]|select(.span_attributes.type=="llm")]|all(.span_attributes.name=="claude-haiku-4-5")')" "true" "sub-agent model tagged" - - # The second LLM span's input carries the prior assistant + tool history. - assert_eq "$(all_spans | jq -r '[.[]|select(.span_attributes.type=="llm")][1].input|map(.role)|join(",")')" "assistant,tool" "sub-agent LLM input includes history" -} - -t_non_agent_tool_emits_no_llm_spans() { - # A normal (non-Agent) tool must not emit any LLM spans. - _with_turn_started "sess-noagent" - local payload - payload=$(fixture_post_tool_use "sess-noagent" "Bash" \ - "$(fixture_tool_input_bash 'ls')" \ - "$(fixture_tool_response_text 'out')") - run_hook post_tool_use.sh "$payload" - - assert_eq "$(span_count_by_type llm)" "0" "no LLM spans for non-Agent tools" -} - -it "emits sub-agent LLM spans under the Agent tool span" t_agent_emits_subagent_llm_spans -it "does not emit LLM spans for non-Agent tools" t_non_agent_tool_emits_no_llm_spans diff --git a/src/plugins/claude/content/plugins/trace-claude-code/test/test_queue.sh b/src/plugins/claude/content/plugins/trace-claude-code/test/test_queue.sh deleted file mode 100755 index 2edb98b..0000000 --- a/src/plugins/claude/content/plugins/trace-claude-code/test/test_queue.sh +++ /dev/null @@ -1,592 +0,0 @@ -#!/bin/bash -### -# Tests for the per-session span queue + background worker. -# -# Covers: -# - Sync mode: enqueue_span processes inline (default for tests) -# - Async mode: each session has its own pending/ and its own worker -# - drain_queue blocks until that session's queue is empty -# - sweep_dead_sessions cleans up crashed sessions -### - -SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -# shellcheck source=helpers/assert.sh -source "$SCRIPT_DIR/helpers/assert.sh" -# shellcheck source=helpers/harness.sh -source "$SCRIPT_DIR/helpers/harness.sh" - -_test_event() { - local id="${1:-test-span-1}" - jq -nc \ - --arg id "$id" \ - '{ - id: $id, - span_id: $id, - root_span_id: $id, - input: "test", - span_attributes: { name: "test", type: "task" } - }' -} - -_setup_default_stubs() { - stub_response_for "*/v1/project_logs/*/insert" 200 '{"row_ids":["row_x"]}' - stub_response_for "*/v1/experiment/*/insert" 200 '{"row_ids":["row_x"]}' -} - -# Stop the worker for a session by removing its lock file (the in-loop -# heartbeat will fail to touch and the worker exits). Then poll until -# the PID is gone or we give up. -_stop_worker() { - local session_id="$1" - local lock_file - lock_file="$(session_queue_dir "$session_id")/worker.lock" - if [ -f "$lock_file" ]; then - local pid - pid=$(cat "$lock_file" 2>/dev/null) - rm -f "$lock_file" - if [ -n "$pid" ]; then - local i - for i in $(seq 1 50); do - kill -0 "$pid" 2>/dev/null || return 0 - sleep 0.1 - done - kill "$pid" 2>/dev/null || true - fi - fi -} - -# --------------------------------------------------------------------------- -describe "enqueue_span: sync mode (default in tests)" -# --------------------------------------------------------------------------- - -t_sync_enqueue_inserts_immediately() { - _setup_default_stubs - # Harness already sets BRAINTRUST_SYNC_QUEUE=true - - local event - event=$(_test_event) - enqueue_span "sess-sync" "proj_sync" "$event" - assert_success "$?" - - local count - count=$(span_count) - assert_eq "$count" "1" "expected the span to be inserted immediately" - - # No job files should be left in the queue. - # In sync mode the session dir is never created at all. - assert_eq "$(_queue_pending_count "sess-sync")" "0" - assert_eq "$(_queue_processing_count "sess-sync")" "0" -} - -t_sync_enqueue_does_not_spawn_worker() { - _setup_default_stubs - - enqueue_span "sess-sync2" "proj_sync" "$(_test_event)" >/dev/null - - # No worker lock should exist - local lock="$(session_queue_dir "sess-sync2")/worker.lock" - if [ -f "$lock" ]; then - fail "expected no worker lock in sync mode, got $lock" - fi -} - -t_sync_enqueue_uses_experiment_endpoint() { - _setup_default_stubs - CC_EXPERIMENT_ID="exp_sync" - - enqueue_span "sess-exp" "proj_irrelevant" "$(_test_event)" >/dev/null - - local pl_count exp_count - pl_count=$(captured_request_count '/project_logs/') - exp_count=$(captured_request_count '/v1/experiment/exp_sync/insert') - assert_eq "$pl_count" "0" - assert_eq "$exp_count" "1" -} - -t_sync_enqueue_requires_session_id() { - _setup_default_stubs - # Calling without a session_id should fail and log an error. - enqueue_span "" "proj_x" "$(_test_event)" >/dev/null 2>&1 - assert_failure "$?" - local log - log=$(hook_log) - assert_contains "$log" "without session_id" -} - -it "inserts the span immediately when BRAINTRUST_SYNC_QUEUE=true" t_sync_enqueue_inserts_immediately -it "does not spawn a background worker in sync mode" t_sync_enqueue_does_not_spawn_worker -it "honors CC_EXPERIMENT_ID and routes to experiment endpoint" t_sync_enqueue_uses_experiment_endpoint -it "returns an error if session_id is missing" t_sync_enqueue_requires_session_id - -# --------------------------------------------------------------------------- -describe "enqueue_span: async mode" -# --------------------------------------------------------------------------- - -t_async_enqueue_writes_job_file() { - _setup_default_stubs - export BRAINTRUST_SYNC_QUEUE=false - - # Disable worker spawn by hiding worker.sh so we can inspect pending/ - # before any draining can happen. - mv "$HOOKS_DIR/worker.sh" "$HOOKS_DIR/worker.sh.disabled" - - enqueue_span "sess-async" "proj_async" "$(_test_event)" - assert_success "$?" - - assert_eq "$(_queue_pending_count "sess-async")" "1" - - # The file should contain valid JSON with the right fields - local sdir job_file - sdir=$(session_queue_dir "sess-async") - job_file=$(find "$sdir/pending" -maxdepth 1 -name '*.json' -type f | head -1) - local proj_id event_id - proj_id=$(jq -r '.project_id' "$job_file") - event_id=$(jq -r '.event.id' "$job_file") - assert_eq "$proj_id" "proj_async" - assert_eq "$event_id" "test-span-1" - - mv "$HOOKS_DIR/worker.sh.disabled" "$HOOKS_DIR/worker.sh" -} - -t_async_drain_processes_pending_jobs() { - _setup_default_stubs - export BRAINTRUST_SYNC_QUEUE=false - - local i - for i in 1 2 3; do - enqueue_span "sess-drain" "proj_drain" "$(_test_event "span-$i")" - done - - drain_queue "sess-drain" 10 - assert_success "$?" - - assert_eq "$(_queue_pending_count "sess-drain")" "0" - assert_eq "$(_queue_processing_count "sess-drain")" "0" - assert_eq "$(span_count)" "3" - - _stop_worker "sess-drain" -} - -t_async_drain_timeout_returns_failure() { - _setup_default_stubs - export BRAINTRUST_SYNC_QUEUE=false - - # Disable worker spawn so the queue can never drain - mv "$HOOKS_DIR/worker.sh" "$HOOKS_DIR/worker.sh.disabled" - - enqueue_span "sess-stuck" "proj_stuck" "$(_test_event)" >/dev/null - - local start_time end_time elapsed - start_time=$(date +%s) - drain_queue "sess-stuck" 1 - local rc=$? - end_time=$(date +%s) - elapsed=$(( end_time - start_time )) - - assert_failure "$rc" "drain_queue should return non-zero on timeout" - if [ "$elapsed" -gt 3 ]; then - fail "drain_queue took ${elapsed}s; expected ~1s" - fi - - local log - log=$(hook_log) - assert_contains "$log" "drain_queue timed out" - - mv "$HOOKS_DIR/worker.sh.disabled" "$HOOKS_DIR/worker.sh" -} - -t_async_sessions_are_isolated() { - # Two sessions enqueueing concurrently should each get their own - # queue dir and their own worker. - _setup_default_stubs - export BRAINTRUST_SYNC_QUEUE=false - - enqueue_span "sess-A" "proj_a" "$(_test_event "a-1")" - enqueue_span "sess-B" "proj_b" "$(_test_event "b-1")" - - drain_queue "sess-A" 10 - drain_queue "sess-B" 10 - - assert_eq "$(_queue_pending_count "sess-A")" "0" - assert_eq "$(_queue_pending_count "sess-B")" "0" - assert_eq "$(span_count)" "2" - - # Two separate POSTs should have been made - local pl_count - pl_count=$(captured_request_count '/insert$') - assert_eq "$pl_count" "2" - - _stop_worker "sess-A" - _stop_worker "sess-B" -} - -it "writes a job file to pending/ when worker is offline" t_async_enqueue_writes_job_file -it "drain_queue processes all pending jobs" t_async_drain_processes_pending_jobs -it "drain_queue returns non-zero on timeout" t_async_drain_timeout_returns_failure -it "different sessions have isolated queues and workers" t_async_sessions_are_isolated - -# --------------------------------------------------------------------------- -describe "worker.sh" -# --------------------------------------------------------------------------- - -t_worker_drains_existing_jobs() { - _setup_default_stubs - export BRAINTRUST_SYNC_QUEUE=false - - # Pre-seed the queue with two job files (bypassing enqueue_span so we - # don't spawn a worker yet). - _ensure_session_queue "sess-seed" - local sdir - sdir=$(session_queue_dir "sess-seed") - local job1 job2 - job1=$(jq -nc --argjson e "$(_test_event "pre-1")" \ - '{type:"insert_span", project_id:"proj_w", experiment_id:"", event:$e}') - job2=$(jq -nc --argjson e "$(_test_event "pre-2")" \ - '{type:"insert_span", project_id:"proj_w", experiment_id:"", event:$e}') - echo "$job1" > "$sdir/pending/01-aaa.json" - echo "$job2" > "$sdir/pending/02-bbb.json" - - # Start the worker, give it time to drain, then remove its lock to - # signal shutdown. - bash "$HOOKS_DIR/worker.sh" sess-seed >/dev/null 2>&1 & - local worker_pid=$! - - # Poll for completion (up to 5s) - local i - for i in $(seq 1 50); do - if [ "$(_queue_pending_count "sess-seed")" = "0" ] && \ - [ "$(_queue_processing_count "sess-seed")" = "0" ]; then - break - fi - sleep 0.1 - done - - assert_eq "$(span_count)" "2" - assert_eq "$(_queue_pending_count "sess-seed")" "0" - - # Signal worker to exit by removing the lock, then wait briefly. - _stop_worker "sess-seed" - # Belt-and-suspenders: ensure background process is gone - kill "$worker_pid" 2>/dev/null || true -} - -t_worker_only_one_at_a_time_per_session() { - _setup_default_stubs - export BRAINTRUST_SYNC_QUEUE=false - - # Start the first worker for sess-W. - bash "$HOOKS_DIR/worker.sh" sess-W >/dev/null 2>&1 & - local first_pid=$! - - # Give it time to claim the lock - sleep 0.5 - - # Try to start a second worker for the same session - it should exit - # immediately (rc=0) and the first worker should still be alive. - bash "$HOOKS_DIR/worker.sh" sess-W - local rc=$? - assert_eq "$rc" "0" - - if ! kill -0 "$first_pid" 2>/dev/null; then - fail "first worker should still be alive" - fi - - _stop_worker "sess-W" -} - -t_worker_two_sessions_concurrent() { - _setup_default_stubs - export BRAINTRUST_SYNC_QUEUE=false - - bash "$HOOKS_DIR/worker.sh" sess-1 >/dev/null 2>&1 & - local pid1=$! - bash "$HOOKS_DIR/worker.sh" sess-2 >/dev/null 2>&1 & - local pid2=$! - - sleep 0.5 - - # Both workers should be alive (different sessions = different locks) - if ! kill -0 "$pid1" 2>/dev/null; then - fail "session 1 worker should be alive" - fi - if ! kill -0 "$pid2" 2>/dev/null; then - fail "session 2 worker should be alive" - fi - - # And each should hold its own lock - assert_file_exists "$(session_queue_dir sess-1)/worker.lock" - assert_file_exists "$(session_queue_dir sess-2)/worker.lock" - - _stop_worker "sess-1" - _stop_worker "sess-2" -} - -t_worker_heartbeat_refreshes_lock_mtime() { - _setup_default_stubs - export BRAINTRUST_SYNC_QUEUE=false - - bash "$HOOKS_DIR/worker.sh" sess-hb >/dev/null 2>&1 & - local pid=$! - - # Wait for the lock file to appear - local lock_file="$(session_queue_dir sess-hb)/worker.lock" - local i - for i in $(seq 1 50); do - [ -f "$lock_file" ] && break - sleep 0.1 - done - assert_file_exists "$lock_file" - - # Capture the initial mtime - local mtime1 - mtime1=$(_file_mtime "$lock_file") - - # Wait a bit longer than one heartbeat (0.2s loop) and re-check. - # Use a generous 1.5s to absorb scheduling jitter on slow CI. - sleep 1.5 - - local mtime2 - mtime2=$(_file_mtime "$lock_file") - - # mtime should have advanced - if [ "$mtime2" -le "$mtime1" ]; then - fail "expected lock mtime to advance: was=$mtime1 now=$mtime2" - fi - - _stop_worker "sess-hb" - kill "$pid" 2>/dev/null || true -} - -t_worker_survives_parent_exit() { - # Regression test for the core bug this whole refactor addresses: - # when a hook script enqueues a span and then exits, the background - # worker it spawned must keep running and drain the queue. Pre-refactor, - # `async: true` hooks were killed mid-curl and dropped spans. - _setup_default_stubs - export BRAINTRUST_SYNC_QUEUE=false - - local sid="sess-survives" - - # Run a child shell that enqueues a span and then exits. The child - # inherits our $HOME (which is the test's isolated tmp dir) and our - # stubbed curl + stub config. From the child's perspective this looks - # exactly like a hook script run by Claude Code: - # - sources common.sh - # - calls enqueue_span (which spawns a worker via nohup ... & disown) - # - exits. - # - # The worker has no idle-timeout mechanism; it lives until its lock - # file is removed (which only happens when session_end runs or when - # _stop_worker is invoked at the end of this test). - bash -c " - source '$HOOKS_DIR/common.sh' - event='$(_test_event 'survive-1')' - enqueue_span '$sid' 'proj_s' \"\$event\" - " - assert_success "$?" "child shell that enqueued should exit cleanly" - - # The child shell is gone. The worker should still be alive. - # Read its PID from the lock and assert. - local lock_file pid - lock_file="$(session_queue_dir "$sid")/worker.lock" - - # Worker may take a brief moment to claim its lock; wait up to 2s. - local i - for i in $(seq 1 20); do - [ -f "$lock_file" ] && break - sleep 0.1 - done - assert_file_exists "$lock_file" "worker should have claimed its lock" - - pid=$(cat "$lock_file" 2>/dev/null) - if [ -z "$pid" ]; then - fail "worker lock has no pid" - return 1 - fi - - # The PID must NOT be the long-gone child shell. It should be a - # detached process whose parent is init (pid 1) on Linux or launchd - # on macOS. Either way, the key assertion is: it's alive. - if ! kill -0 "$pid" 2>/dev/null; then - fail "worker pid $pid should still be alive after parent exit" - return 1 - fi - - # And it should have drained the job we enqueued. - local deadline=$(( $(date +%s) + 5 )) - while [ "$(date +%s)" -lt "$deadline" ]; do - [ "$(span_count)" -gt 0 ] && break - sleep 0.1 - done - assert_eq "$(span_count)" "1" "worker should have drained the enqueued job" - - _stop_worker "$sid" -} - -t_worker_survives_parent_exit_and_drains_more() { - # Stronger version: after the parent shell exits, we keep enqueuing - # jobs from the test process. The surviving worker should pick them up. - _setup_default_stubs - export BRAINTRUST_SYNC_QUEUE=false - - local sid="sess-survives-2" - - # Child enqueues one job and exits, spawning the worker. - bash -c " - source '$HOOKS_DIR/common.sh' - event='$(_test_event 'first')' - enqueue_span '$sid' 'proj_s' \"\$event\" - " - - # Wait for the worker to claim its lock so subsequent enqueues don't - # try to spawn a second worker. - local lock_file="$(session_queue_dir "$sid")/worker.lock" - local i - for i in $(seq 1 20); do - [ -f "$lock_file" ] && break - sleep 0.1 - done - - # From the test process, enqueue more jobs. Same session, so the - # already-running worker picks them up. - local n - for n in 2 3 4 5; do - enqueue_span "$sid" "proj_s" "$(_test_event "later-$n")" - done - - # Wait for the worker to drain all 5 jobs. - local deadline=$(( $(date +%s) + 5 )) - while [ "$(date +%s)" -lt "$deadline" ]; do - [ "$(span_count)" -ge 5 ] && break - sleep 0.1 - done - - assert_eq "$(span_count)" "5" "worker should have drained all 5 jobs across parent exit" - - _stop_worker "$sid" -} - -it "drains pre-existing jobs from pending/" t_worker_drains_existing_jobs -it "only one worker per session can hold the lock" t_worker_only_one_at_a_time_per_session -it "two sessions can have concurrent workers" t_worker_two_sessions_concurrent -it "worker refreshes its lock mtime on every iteration" t_worker_heartbeat_refreshes_lock_mtime -it "worker survives the spawning shell's exit" t_worker_survives_parent_exit -it "surviving worker drains jobs enqueued after parent exit" t_worker_survives_parent_exit_and_drains_more - -# --------------------------------------------------------------------------- -describe "drain_queue: edge cases" -# --------------------------------------------------------------------------- - -t_drain_empty_queue() { - export BRAINTRUST_SYNC_QUEUE=false - drain_queue "sess-empty" 1 - assert_success "$?" "draining an empty queue should succeed immediately" -} - -t_drain_sync_mode_noop() { - export BRAINTRUST_SYNC_QUEUE=true - drain_queue "sess-noop" 1 - assert_success "$?" "drain_queue is a no-op in sync mode" -} - -t_drain_requires_session_id() { - export BRAINTRUST_SYNC_QUEUE=false - drain_queue "" 1 - assert_failure "$?" "drain_queue without session_id should fail" -} - -it "succeeds immediately when the queue is empty" t_drain_empty_queue -it "is a no-op when BRAINTRUST_SYNC_QUEUE=true" t_drain_sync_mode_noop -it "returns an error if session_id is missing" t_drain_requires_session_id - -# --------------------------------------------------------------------------- -describe "sweep_dead_sessions" -# --------------------------------------------------------------------------- - -t_sweep_removes_empty_dead_session() { - export BRAINTRUST_SYNC_QUEUE=false - - # Create an empty session dir with no lock at all. - _ensure_session_queue "sess-empty-dead" - [ -d "$(session_queue_dir sess-empty-dead)/pending" ] || \ - fail "setup precondition: pending dir should exist" - - sweep_dead_sessions "current-session" - - # Empty dead dirs should be removed. - if [ -d "$(session_queue_dir sess-empty-dead)" ]; then - fail "expected empty dead session dir to be removed" - fi -} - -t_sweep_recovers_orphaned_jobs() { - _setup_default_stubs - export BRAINTRUST_SYNC_QUEUE=false - - # Create a session with leftover jobs but no live worker. - _ensure_session_queue "sess-crashed" - local sdir - sdir=$(session_queue_dir "sess-crashed") - local job - job=$(jq -nc --argjson e "$(_test_event "orphan-1")" \ - '{type:"insert_span", project_id:"proj_x", experiment_id:"", event:$e}') - echo "$job" > "$sdir/pending/01-orphan.json" - - # Sweep should recover the orphaned job by spawning a worker. - sweep_dead_sessions "current-session" - - # Wait for the recovery worker to drain - local i - for i in $(seq 1 50); do - [ "$(_queue_pending_count "sess-crashed")" = "0" ] && \ - [ "$(_queue_processing_count "sess-crashed")" = "0" ] && break - sleep 0.1 - done - - # The orphaned span should now have been POSTed. - assert_eq "$(span_count)" "1" - - _stop_worker "sess-crashed" -} - -t_sweep_skips_current_session() { - export BRAINTRUST_SYNC_QUEUE=false - - # Create what looks like a dead session for "active-sess" - empty dir - # with no lock. If sweep didn't skip the current session, it would - # rmdir this dir. - _ensure_session_queue "active-sess" - [ -d "$(session_queue_dir active-sess)/pending" ] || \ - fail "setup precondition: pending dir should exist" - - sweep_dead_sessions "active-sess" - - # The current session's dir should still exist. - if [ ! -d "$(session_queue_dir active-sess)" ]; then - fail "sweep should not have removed the current session's dir" - fi -} - -t_sweep_leaves_fresh_sessions_alone() { - _setup_default_stubs - export BRAINTRUST_SYNC_QUEUE=false - - # Spawn a worker for a "live" session - its heartbeat keeps the lock - # fresh, so sweep should leave it alone. - bash "$HOOKS_DIR/worker.sh" sess-live >/dev/null 2>&1 & - sleep 0.3 - - assert_file_exists "$(session_queue_dir sess-live)/worker.lock" - - sweep_dead_sessions "current-session" - - # The live session's lock should still exist. - assert_file_exists "$(session_queue_dir sess-live)/worker.lock" - - _stop_worker "sess-live" -} - -it "removes empty dead session dirs" t_sweep_removes_empty_dead_session -it "recovers orphaned jobs by spawning a recovery worker" t_sweep_recovers_orphaned_jobs -it "skips the current session" t_sweep_skips_current_session -it "leaves sessions with fresh heartbeats alone" t_sweep_leaves_fresh_sessions_alone diff --git a/src/plugins/claude/content/plugins/trace-claude-code/test/test_replay.sh b/src/plugins/claude/content/plugins/trace-claude-code/test/test_replay.sh deleted file mode 100755 index 7a0a0db..0000000 --- a/src/plugins/claude/content/plugins/trace-claude-code/test/test_replay.sh +++ /dev/null @@ -1,150 +0,0 @@ -#!/bin/bash -### -# Tests for the replay helper. -# -# Replay-based tests are how you turn a real Claude Code session into a -# regression test: -# -# 1. Capture: set BRAINTRUST_RECORD_DIR to a fresh directory, run claude, -# let the session play out. Every hook invocation gets appended to -# $BRAINTRUST_RECORD_DIR/events.ndjson and any stop_hook transcripts -# are copied to $BRAINTRUST_RECORD_DIR/transcripts/. -# -# 2. Move: drop the captured directory under -# test/fixtures/sessions// -# -# 3. Replay: in a test, call replay_session "$FIXTURE_DIR" then assert -# on the resulting span tree using span_count_by_type, span_by_name, -# etc. -### - -SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -# shellcheck source=helpers/assert.sh -source "$SCRIPT_DIR/helpers/assert.sh" -# shellcheck source=helpers/harness.sh -source "$SCRIPT_DIR/helpers/harness.sh" - -_setup_default_stubs() { - stub_response_for "*/v1/project?project_name=*" 200 '{"id":"proj_test"}' - stub_response_for "*/v1/project_logs/*/insert" 200 '{"row_ids":["row_1"]}' -} - -# --------------------------------------------------------------------------- -describe "replay_session: example-simple fixture" -# --------------------------------------------------------------------------- - -t_replay_runs_all_events() { - _setup_default_stubs - - local n - n=$(replay_session "$SCRIPT_DIR/fixtures/sessions/example-simple") - assert_success "$?" "replay_session should succeed" - assert_eq "$n" "5" "expected 5 events replayed" -} - -t_replay_produces_expected_spans() { - _setup_default_stubs - - replay_session "$SCRIPT_DIR/fixtures/sessions/example-simple" >/dev/null - assert_success "$?" - - # The fixture has: session_start, user_prompt_submit, 2x post_tool_use, - # session_end. That should result in: - # - 1 session span (type=task, "Claude Code: ...") - # - 1 turn span (type=task, "Turn 1") - # - 2 tool spans (type=tool, "Terminal: ...", "Read: a.txt") - local task_count tool_count llm_count total - task_count=$(span_count_by_type "task") - tool_count=$(span_count_by_type "tool") - llm_count=$(span_count_by_type "llm") - total=$(span_count) - - assert_eq "$task_count" "2" "expected 2 task spans (session + turn)" - assert_eq "$tool_count" "2" "expected 2 tool spans" - assert_eq "$llm_count" "0" "no llm spans without a stop_hook" - assert_eq "$total" "4" -} - -t_replay_preserves_hierarchy() { - _setup_default_stubs - - replay_session "$SCRIPT_DIR/fixtures/sessions/example-simple" >/dev/null - - # Session id from the fixture - local sid="example-sess-001" - - # The session span's id is the session id - local session_span - session_span=$(span_by_id "$sid") - assert_ne "$session_span" "null" "expected session span to exist" - - # The turn span's parent is the session - local turn_span turn_parent - turn_span=$(span_by_name "^Turn 1$") - turn_parent=$(echo "$turn_span" | jq -r '.span_parents[0]') - assert_eq "$turn_parent" "$sid" - - # Each tool span's parent is the turn - local turn_id - turn_id=$(echo "$turn_span" | jq -r '.span_id') - local tool_children - tool_children=$(children_of "$turn_id" | jq 'length') - assert_eq "$tool_children" "2" "turn should have 2 tool children" -} - -it "replays all 5 hook events" t_replay_runs_all_events -it "produces session + turn + 2 tool spans" t_replay_produces_expected_spans -it "preserves session > turn > tool hierarchy" t_replay_preserves_hierarchy - -# --------------------------------------------------------------------------- -describe "replay_session: error handling" -# --------------------------------------------------------------------------- - -t_replay_missing_fixture() { - _setup_default_stubs - replay_session "/nonexistent/fixture/path" >/dev/null 2>&1 - assert_failure "$?" "replay_session should fail when fixture is missing" -} - -it "returns non-zero when the fixture directory does not exist" t_replay_missing_fixture - -# --------------------------------------------------------------------------- -describe "replay_session: record-only events run their no-op handler" -# --------------------------------------------------------------------------- - -# Replay is hooks.json-driven: every event runs whatever is registered for -# it. Record-only events (PreToolUse, PreCompact, InstructionsLoaded, ...) -# are registered to record_event.sh, which no-ops when recording is off. -# So they replay successfully and produce no spans, while the acting hooks -# create their spans. Replay must never fail just because an event is -# observability-only. -t_replay_runs_record_only_handlers() { - _setup_default_stubs - - # Build a fixture that interleaves record-only events among the acting - # hooks. All names are Claude Code's CamelCase event names. - local dir="$TEST_TMP/mixed-fixture" - mkdir -p "$dir/transcripts" - { - echo '{"ts":"t0","hook":"SessionStart","payload":{"session_id":"mix-1","cwd":"/tmp","hook_event_name":"SessionStart"}}' - echo '{"ts":"t1","hook":"InstructionsLoaded","payload":{"session_id":"mix-1","hook_event_name":"InstructionsLoaded"}}' - echo '{"ts":"t2","hook":"UserPromptSubmit","payload":{"session_id":"mix-1","prompt":"hi","cwd":"/tmp","hook_event_name":"UserPromptSubmit"}}' - echo '{"ts":"t3","hook":"PreToolUse","payload":{"session_id":"mix-1","tool_name":"Bash","hook_event_name":"PreToolUse"}}' - echo '{"ts":"t4","hook":"PostToolUse","payload":{"session_id":"mix-1","tool_name":"Bash","tool_input":{"command":"ls"},"tool_response":{"output":"x"},"hook_event_name":"PostToolUse"}}' - echo '{"ts":"t5","hook":"PreCompact","payload":{"session_id":"mix-1","trigger":"auto","hook_event_name":"PreCompact"}}' - echo '{"ts":"t6","hook":"SessionEnd","payload":{"session_id":"mix-1","hook_event_name":"SessionEnd"}}' - } > "$dir/events.ndjson" - - # Every event has a registered handler in hooks.json, so all 7 run. - local n - n=$(replay_session "$dir") - assert_success "$?" "replay should not fail on record-only events" - assert_eq "$n" "7" "all 7 events have a handler and should replay" - - # Only the acting hooks produce spans (session + turn + tool). The - # record-only events no-op, contributing nothing. - assert_eq "$(span_count_by_type task)" "2" "session + turn task spans" - assert_eq "$(span_count_by_type tool)" "1" "one tool span from PostToolUse" -} - -it "runs record-only event handlers as no-ops without failing" t_replay_runs_record_only_handlers diff --git a/src/plugins/claude/content/plugins/trace-claude-code/test/test_session_start.sh b/src/plugins/claude/content/plugins/trace-claude-code/test/test_session_start.sh deleted file mode 100755 index 635e257..0000000 --- a/src/plugins/claude/content/plugins/trace-claude-code/test/test_session_start.sh +++ /dev/null @@ -1,244 +0,0 @@ -#!/bin/bash -### -# End-to-end tests for the SessionStart hook. -# -# Each test: -# 1. Configures canned curl responses -# 2. Builds a hook payload using fixture_session_start() -# 3. Invokes the hook via run_hook -# 4. Asserts on the captured POST requests / resulting span data -# -# These tests are the bash equivalent of opencode-plugin's -# `assertEventsProduceTree` pattern. -### - -SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -# shellcheck source=helpers/assert.sh -source "$SCRIPT_DIR/helpers/assert.sh" -# shellcheck source=helpers/harness.sh -source "$SCRIPT_DIR/helpers/harness.sh" - -# Canned response setup used by most tests. -_setup_default_stubs() { - stub_response_for "*/v1/project?project_name=*" 200 '{"id":"proj_test"}' - stub_response_for "*/v1/project_logs/*/insert" 200 '{"row_ids":["row_1"]}' -} - -# --------------------------------------------------------------------------- -describe "session_start.sh: happy path" -# --------------------------------------------------------------------------- - -t_session_start_creates_one_span() { - _setup_default_stubs - - local payload - payload=$(fixture_session_start "sess-001" "/tmp/my-workspace") - - run_hook session_start.sh "$payload" - assert_success "$HOOK_STATUS" "hook should exit 0" - - local count - count=$(span_count) - assert_eq "$count" "1" "expected exactly one span inserted" -} - -t_session_start_span_shape() { - _setup_default_stubs - - local payload - payload=$(fixture_session_start "sess-002" "/tmp/cool-project") - run_hook session_start.sh "$payload" - - local span - span=$(span_by_type "task") - assert_ne "$span" "null" "expected a task span to exist" - - local name - name=$(echo "$span" | jq -r '.span_attributes.name') - assert_eq "$name" "Claude Code: cool-project" - - # span_id should equal the session_id; root_span_id should too - local span_id root_span_id - span_id=$(echo "$span" | jq -r '.span_id') - root_span_id=$(echo "$span" | jq -r '.root_span_id') - assert_eq "$span_id" "sess-002" - assert_eq "$root_span_id" "sess-002" -} - -t_session_start_metadata() { - _setup_default_stubs - - run_hook session_start.sh "$(fixture_session_start "sess-meta" "/tmp/x")" - - local span - span=$(span_by_type "task") - - local source - source=$(echo "$span" | jq -r '.metadata.source') - assert_eq "$source" "claude-code" - - local session_id - session_id=$(echo "$span" | jq -r '.metadata.session_id') - assert_eq "$session_id" "sess-meta" - - # Version attributes are present and non-empty. trace_claude_code_version - # comes from plugin.json; claude_code_version from the transcript or - # `claude --version` (falls back to "unknown" but must always be set). - local trace_version cc_version - trace_version=$(echo "$span" | jq -r '.metadata.trace_claude_code_version') - cc_version=$(echo "$span" | jq -r '.metadata.claude_code_version') - assert_ne "$trace_version" "null" "trace_claude_code_version should be set" - assert_ne "$trace_version" "" "trace_claude_code_version should be non-empty" - # It should match the manifest (e.g. a semver-ish string). - assert_match "$trace_version" "^[0-9]+\.[0-9]+\.[0-9]+" "trace_claude_code_version looks like a version" - assert_ne "$cc_version" "null" "claude_code_version should be set" - assert_ne "$cc_version" "" "claude_code_version should be non-empty" -} - -_make_git_repo() { - local dir - dir=$(mktemp -d) - git -C "$dir" init >/dev/null 2>&1 - git -C "$dir" config user.email test@example.com - git -C "$dir" config user.name "Test User" - printf "hello\n" > "$dir/README.md" - git -C "$dir" add README.md - git -C "$dir" commit -m init >/dev/null 2>&1 - git -C "$dir" branch -M main - git -C "$dir" remote add origin "https://token@github.com/acme/app.git" - echo "$dir" -} - -t_session_start_git_metadata() { - _setup_default_stubs - - local repo commit - repo=$(_make_git_repo) - commit=$(git -C "$repo" rev-parse HEAD) - - run_hook session_start.sh "$(fixture_session_start "sess-git" "$repo")" - - local span - span=$(span_by_type "task") - - assert_eq "$(echo "$span" | jq -r '.metadata.git_origin_url')" "https://github.com/acme/app.git" - assert_eq "$(echo "$span" | jq -r '.metadata.git_branch')" "main" - assert_eq "$(echo "$span" | jq -r '.metadata.git_commit_sha')" "$commit" - - rm -rf "$repo" -} - -t_session_start_writes_state() { - _setup_default_stubs - - run_hook session_start.sh "$(fixture_session_start "sess-state" "/tmp/x")" - - # The hook should persist root_span_id, session_span_id, project_id, etc. - # These are written by the parent shell of session_start via - # set_session_state. Reading them back is best done via the same helper - # so paths agree. - local root_id project_id turn_count - root_id=$(get_session_state "sess-state" "root_span_id") - project_id=$(get_session_state "sess-state" "project_id") - turn_count=$(get_session_state "sess-state" "turn_count") - - assert_eq "$root_id" "sess-state" - assert_eq "$project_id" "proj_test" - assert_eq "$turn_count" "0" -} - -it "creates exactly one root session span" t_session_start_creates_one_span -it "span has correct name, id, and type" t_session_start_span_shape -it "span includes claude-code metadata" t_session_start_metadata -it "span includes minimal git metadata" t_session_start_git_metadata -it "persists session state for later hooks" t_session_start_writes_state - -# --------------------------------------------------------------------------- -describe "session_start.sh: tracing disabled" -# --------------------------------------------------------------------------- - -t_session_start_disabled() { - _setup_default_stubs - export TRACE_TO_BRAINTRUST=false - - run_hook session_start.sh "$(fixture_session_start "sess-off" "/tmp/x")" - assert_success "$HOOK_STATUS" - - # No spans should have been inserted - local count - count=$(span_count) - assert_eq "$count" "0" -} - -it "is a no-op when TRACE_TO_BRAINTRUST is false" t_session_start_disabled - -# --------------------------------------------------------------------------- -describe "session_start.sh: missing API key" -# --------------------------------------------------------------------------- - -t_session_start_no_api_key() { - _setup_default_stubs - export BRAINTRUST_API_KEY="" - - run_hook session_start.sh "$(fixture_session_start "sess-noauth" "/tmp/x")" - # Hook exits 0 (graceful failure) - it should never block Claude Code. - assert_success "$HOOK_STATUS" - - local count - count=$(span_count) - assert_eq "$count" "0" - - local log - log=$(hook_log) - assert_contains "$log" "BRAINTRUST_API_KEY not set" -} - -it "exits gracefully and logs when BRAINTRUST_API_KEY is unset" t_session_start_no_api_key - -# --------------------------------------------------------------------------- -describe "session_start.sh: invalid API key" -# --------------------------------------------------------------------------- - -t_session_start_invalid_key() { - stub_response_for "*/v1/project?project_name=*" 401 "Invalid API Key" - - run_hook session_start.sh "$(fixture_session_start "sess-bad" "/tmp/x")" - # Hook exits 0 so Claude Code keeps running - assert_success "$HOOK_STATUS" - - local count - count=$(span_count) - assert_eq "$count" "0" "no spans should be sent when project lookup fails" - - local log - log=$(hook_log) - assert_contains "$log" "authentication failed" -} - -it "exits gracefully and logs an auth error on HTTP 401" t_session_start_invalid_key - -# --------------------------------------------------------------------------- -describe "session_start.sh: race protection" -# --------------------------------------------------------------------------- - -t_session_start_idempotent() { - _setup_default_stubs - - # Two SessionStart hooks for the same session should produce one span. - # The second invocation should detect the existing root_span_id via - # check_and_set_session_state and exit without inserting. - local payload - payload=$(fixture_session_start "sess-dup" "/tmp/x") - - run_hook session_start.sh "$payload" - assert_success "$HOOK_STATUS" - - run_hook session_start.sh "$payload" - assert_success "$HOOK_STATUS" - - local count - count=$(span_count) - assert_eq "$count" "1" "duplicate session_start should not double-insert" -} - -it "does not double-insert when called twice for the same session" t_session_start_idempotent diff --git a/src/plugins/claude/content/plugins/trace-claude-code/test/test_stop_hook.sh b/src/plugins/claude/content/plugins/trace-claude-code/test/test_stop_hook.sh deleted file mode 100755 index 0fdae54..0000000 --- a/src/plugins/claude/content/plugins/trace-claude-code/test/test_stop_hook.sh +++ /dev/null @@ -1,375 +0,0 @@ -#!/bin/bash -### -# End-to-end tests for the Stop hook. -# -# Stop fires when Claude finishes responding to a user turn. It: -# - Reads $LAST_ASSISTANT_MESSAGE from the hook input (Claude's final -# response text) and uses it as the Turn span's `output` field -# - Parses the conversation transcript to emit per-LLM-call spans and -# to aggregate turn-level token totals -# - Emits a TURN_UPDATE merge to populate the Turn span's output and -# metrics fields, finalizing the turn -### - -SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -# shellcheck source=helpers/assert.sh -source "$SCRIPT_DIR/helpers/assert.sh" -# shellcheck source=helpers/harness.sh -source "$SCRIPT_DIR/helpers/harness.sh" - -_setup_default_stubs() { - stub_response_for "*/v1/project?project_name=*" 200 '{"id":"proj_test"}' - stub_response_for "*/v1/project_logs/*/insert" 200 '{"row_ids":["row_1"]}' -} - -# Set up session + turn so stop_hook has a current turn to finalize. -_with_turn_started() { - local session_id="$1" - _setup_default_stubs - run_hook session_start.sh "$(fixture_session_start "$session_id" "/tmp/x")" - run_hook user_prompt_submit.sh "$(fixture_user_prompt "$session_id" "do something")" - : > "$CAPTURED_REQUESTS" -} - -# Write a minimal empty transcript file. The hook will read it, find no -# assistant messages, and skip straight to emitting the TURN_UPDATE merge. -_empty_transcript() { - local path="$1" - : > "$path" - echo "$path" -} - -# --------------------------------------------------------------------------- -describe "stop_hook.sh: populates Turn output from last_assistant_message" -# --------------------------------------------------------------------------- - -t_stop_sets_turn_output() { - _with_turn_started "stop-out-1" - local transcript - transcript=$(_empty_transcript "$TEST_TMP/transcript.jsonl") - - local payload - payload=$(fixture_stop "stop-out-1" "$transcript" "Here is my answer.") - run_hook stop_hook.sh "$payload" - assert_success "$HOOK_STATUS" - - # The hook should have emitted one TURN_UPDATE merge span. Find it. - local span - span=$(all_spans | jq '.[] | select(._is_merge == true)' | jq -s '.[0]') - assert_ne "$span" "null" "expected a merge span to be emitted" - - local output - output=$(echo "$span" | jq -r '.output') - assert_eq "$output" "Here is my answer." -} - -t_stop_output_empty_when_message_missing() { - # If Claude Code doesn't supply last_assistant_message, the output - # field should be empty (rather than e.g. "null" or undefined). - _with_turn_started "stop-out-2" - local transcript - transcript=$(_empty_transcript "$TEST_TMP/transcript.jsonl") - - # Build a payload without last_assistant_message - local payload - payload=$(jq -nc --arg s "stop-out-2" --arg t "$transcript" \ - '{session_id: $s, transcript_path: $t}') - run_hook stop_hook.sh "$payload" - assert_success "$HOOK_STATUS" - - local span - span=$(all_spans | jq '.[] | select(._is_merge == true)' | jq -s '.[0]') - local output - output=$(echo "$span" | jq -r '.output') - assert_eq "$output" "" -} - -t_stop_turn_update_has_correct_id() { - # The merge should target the current_turn_span_id stored at - # user_prompt_submit time, not a freshly-generated id. - _with_turn_started "stop-id-1" - local turn_id - turn_id=$(get_session_state "stop-id-1" "current_turn_span_id") - assert_ne "$turn_id" "" - - local transcript - transcript=$(_empty_transcript "$TEST_TMP/transcript.jsonl") - run_hook stop_hook.sh "$(fixture_stop "stop-id-1" "$transcript" "msg")" - - local span - span=$(all_spans | jq '.[] | select(._is_merge == true)' | jq -s '.[0]') - local span_id - span_id=$(echo "$span" | jq -r '.id') - assert_eq "$span_id" "$turn_id" -} - -t_stop_merge_flag_is_set() { - # Sanity check that we send `_is_merge: true` so Braintrust doesn't - # try to create a brand-new span (which would orphan the original - # Turn span's children). - _with_turn_started "stop-merge-1" - local transcript - transcript=$(_empty_transcript "$TEST_TMP/transcript.jsonl") - run_hook stop_hook.sh "$(fixture_stop "stop-merge-1" "$transcript" "msg")" - - local merges - merges=$(all_spans | jq '[.[] | select(._is_merge == true)] | length') - assert_eq "$merges" "1" -} - -t_stop_merge_has_end_time_no_tokens() { - # The Turn merge should carry an end time but NO token metrics: token - # metrics live only on the leaf LLM spans, and Braintrust aggregates - # them onto parent spans for display. Writing Turn-level token sums here - # would be redundant and would miss sub-agent tokens. - _with_turn_started "stop-metrics-1" - local transcript - transcript=$(_empty_transcript "$TEST_TMP/transcript.jsonl") - run_hook stop_hook.sh "$(fixture_stop "stop-metrics-1" "$transcript" "ok")" - - local span - span=$(all_spans | jq '.[] | select(._is_merge == true)' | jq -s '.[0]') - - # end is a unix timestamp - should be a positive integer - local end_time - end_time=$(echo "$span" | jq -r '.metrics.end') - if [ "$end_time" -le 0 ] 2>/dev/null; then - fail "expected positive end time, got $end_time" - fi - - # No token metrics should be present on the merge. - assert_eq "$(echo "$span" | jq -r '.metrics.prompt_tokens // "absent"')" "absent" "no prompt_tokens on Turn merge" - assert_eq "$(echo "$span" | jq -r '.metrics.completion_tokens // "absent"')" "absent" "no completion_tokens on Turn merge" - assert_eq "$(echo "$span" | jq -r '.metrics.tokens // "absent"')" "absent" "no tokens on Turn merge" - assert_eq "$(echo "$span" | jq -r '.metrics.prompt_cached_tokens // "absent"')" "absent" "no cache_read on Turn merge" - assert_eq "$(echo "$span" | jq -r '.metrics.prompt_cache_creation_tokens // "absent"')" "absent" "no cache_creation on Turn merge" - assert_eq "$(echo "$span" | jq -r '.metrics.prompt_cache_creation_5m_tokens // "absent"')" "absent" "no cache_creation_5m on Turn merge" - assert_eq "$(echo "$span" | jq -r '.metrics.prompt_cache_creation_1h_tokens // "absent"')" "absent" "no cache_creation_1h on Turn merge" -} - -# --------------------------------------------------------------------------- -describe "stop_hook.sh: streaming output_tokens are not double-counted" -# --------------------------------------------------------------------------- - -# Build a single assistant transcript line for one requestId, carrying the -# given cumulative output_tokens in message.usage. input/cache are held -# constant across lines (as Claude Code reports them) so only the output -# delta logic is exercised. -_assistant_line() { - local request_id="$1" - local output_tokens="$2" - local text="$3" - jq -nc \ - --arg rid "$request_id" \ - --arg text "$text" \ - --argjson out "$output_tokens" \ - '{ - type: "assistant", - requestId: $rid, - timestamp: "2024-01-01T00:00:00.000Z", - message: { - model: "claude-test", - content: [{type: "text", text: $text}], - usage: { - input_tokens: 10, - cache_creation_input_tokens: 0, - cache_read_input_tokens: 0, - output_tokens: $out - } - } - }' -} - -t_stop_streaming_output_not_double_counted() { - # A single API response streams output_tokens cumulatively across its - # transcript lines (e.g. 5 -> 30 -> 40). The hook tracks the running max - # per requestId and should add only deltas, so the emitted LLM span's - # completion_tokens must equal the FINAL value (40), not the sum of all - # reported values (5 + 30 + 40 = 75) nor a partial double-count (70). - # - # This requires THREE OR MORE increasing lines on one requestId: the - # two-line case happens to be correct, which is why earlier fixtures - # (constant output_tokens per requestId) did not catch this. - _with_turn_started "stop-stream-1" - - local transcript="$TEST_TMP/transcript.jsonl" - { - _assistant_line "req_stream" 5 "partial one" - _assistant_line "req_stream" 30 "partial two" - _assistant_line "req_stream" 40 "final answer" - } > "$transcript" - - run_hook stop_hook.sh "$(fixture_stop "stop-stream-1" "$transcript" "final answer")" - assert_success "$HOOK_STATUS" - - # Exactly one LLM span should be emitted for this single response. - local llm_spans - llm_spans=$(all_spans | jq '[.[] | select(.span_attributes.type == "llm")]') - assert_eq "$(echo "$llm_spans" | jq 'length')" "1" "expected exactly one LLM span" - - local completion - completion=$(echo "$llm_spans" | jq -r '.[0].metrics.completion_tokens') - assert_eq "$completion" "40" "completion_tokens should be the final max, not a re-added full output" - - # Input is counted once per requestId, so prompt_tokens stays at 10. - local prompt - prompt=$(echo "$llm_spans" | jq -r '.[0].metrics.prompt_tokens') - assert_eq "$prompt" "10" "prompt_tokens should be counted once per requestId" -} - -# --------------------------------------------------------------------------- -describe "stop_hook.sh: one LLM span per requestId across tool_result boundaries" -# --------------------------------------------------------------------------- - -# Assistant line carrying a single tool_use block plus usage, for the given -# requestId. Output/input tokens are held constant across the requestId's -# lines (as Claude Code reports them for non-streamed usage). -_assistant_tool_use_line() { - local request_id="$1" - local tool_use_id="$2" - local tool_name="$3" - local output_tokens="$4" - jq -nc \ - --arg rid "$request_id" \ - --arg tuid "$tool_use_id" \ - --arg name "$tool_name" \ - --argjson out "$output_tokens" \ - '{ - type: "assistant", - requestId: $rid, - timestamp: "2024-01-01T00:00:00.000Z", - message: { - model: "claude-test", - content: [{type: "tool_use", id: $tuid, name: $name, input: {}}], - usage: { - input_tokens: 10, - cache_creation_input_tokens: 0, - cache_read_input_tokens: 0, - output_tokens: $out - } - } - }' -} - -# A user/tool_result line answering the given tool_use_id. -_tool_result_line() { - local tool_use_id="$1" - local result="$2" - jq -nc \ - --arg tuid "$tool_use_id" \ - --arg result "$result" \ - '{ - type: "user", - timestamp: "2024-01-01T00:00:00.000Z", - message: { - content: [{type: "tool_result", tool_use_id: $tuid, content: $result}] - } - }' -} - -t_stop_same_request_not_split_into_zero_token_spans() { - # A single API response (one requestId) can emit a tool_use, receive a - # tool_result, then emit MORE tool_use blocks under the SAME requestId - # before the next tool_result. The per-boundary span logic would emit a - # second LLM span for the continuation segment, but its input/cache were - # already counted and its output delta is zero - so that span carries - # all-zero token metrics and misattributes the response. - # - # Expectation: exactly one LLM span per requestId, and no LLM span with - # entirely zero token metrics. - _with_turn_started "stop-split-1" - - local transcript="$TEST_TMP/transcript.jsonl" - { - # First segment of req_A: tool_use #1 - _assistant_tool_use_line "req_A" "tool_1" "Bash" 20 - _tool_result_line "tool_1" "ok" - # Continuation of req_A after the tool_result: more tool_use blocks, - # same requestId, same (constant) usage. - _assistant_tool_use_line "req_A" "tool_2" "Bash" 20 - _tool_result_line "tool_2" "ok" - # A distinct follow-up response. - _assistant_line "req_B" 15 "all done" - } > "$transcript" - - run_hook stop_hook.sh "$(fixture_stop "stop-split-1" "$transcript" "all done")" - assert_success "$HOOK_STATUS" - - local llm_spans - llm_spans=$(all_spans | jq '[.[] | select(.span_attributes.type == "llm")]') - - # No LLM span should have all-zero token metrics. - local zero_token_spans - zero_token_spans=$(echo "$llm_spans" | jq '[ - .[] | select( - (.metrics.prompt_tokens // 0) == 0 - and (.metrics.completion_tokens // 0) == 0 - and (.metrics.prompt_cache_creation_tokens // 0) == 0 - and (.metrics.prompt_cache_creation_5m_tokens // 0) == 0 - and (.metrics.prompt_cache_creation_1h_tokens // 0) == 0 - and (.metrics.prompt_cached_tokens // 0) == 0 - ) - ] | length') - assert_eq "$zero_token_spans" "0" "no LLM span should carry all-zero token metrics" - - # Exactly two distinct LLM responses (req_A and req_B) -> two LLM spans. - assert_eq "$(echo "$llm_spans" | jq 'length')" "2" \ - "expected one LLM span per requestId (req_A, req_B)" - - # Session-level token totals must be preserved: req_A=10 in / 20 out, - # req_B=10 in / 15 out -> prompt 20, completion 35. - local total_prompt total_completion - total_prompt=$(echo "$llm_spans" | jq '[.[].metrics.prompt_tokens // 0] | add') - total_completion=$(echo "$llm_spans" | jq '[.[].metrics.completion_tokens // 0] | add') - assert_eq "$total_prompt" "20" "total prompt_tokens preserved across spans" - assert_eq "$total_completion" "35" "total completion_tokens preserved across spans" -} - -t_stop_emits_split_cache_metrics() { - _with_turn_started "stop-cache-split-1" - - local transcript="$TEST_TMP/transcript.jsonl" - jq -nc '{ - type: "assistant", - requestId: "req_cache", - timestamp: "2024-01-01T00:00:00.000Z", - message: { - model: "claude-test", - content: [{type: "text", text: "cached answer"}], - usage: { - input_tokens: 7, - cache_creation_input_tokens: 30, - cache_read_input_tokens: 100, - cache_creation: { - ephemeral_5m_input_tokens: 10, - ephemeral_1h_input_tokens: 20 - }, - output_tokens: 5 - } - } - }' > "$transcript" - - run_hook stop_hook.sh "$(fixture_stop "stop-cache-split-1" "$transcript" "cached answer")" - assert_success "$HOOK_STATUS" - - local llm - llm=$(all_spans | jq '[.[] | select(.span_attributes.type == "llm")][0]') - - assert_eq "$(echo "$llm" | jq -r '.metrics.prompt_tokens')" "137" "prompt includes input, cache read, and cache write" - assert_eq "$(echo "$llm" | jq -r '.metrics.tokens')" "142" "tokens includes inclusive prompt and completion" - assert_eq "$(echo "$llm" | jq -r '.metrics.prompt_cached_tokens')" "100" "cache read uses canonical metric" - assert_eq "$(echo "$llm" | jq -r '.metrics.prompt_cache_creation_5m_tokens')" "10" "5m cache write uses canonical metric" - assert_eq "$(echo "$llm" | jq -r '.metrics.prompt_cache_creation_1h_tokens')" "20" "1h cache write uses canonical metric" - assert_eq "$(echo "$llm" | jq -r '.metrics.prompt_cache_creation_tokens // "absent"')" "absent" "aggregate cache write omitted when split is present" - assert_eq "$(echo "$llm" | jq -r '.metrics.cache_creation_input_tokens // "absent"')" "absent" "raw cache creation omitted" - assert_eq "$(echo "$llm" | jq -r '.metrics.cache_read_input_tokens // "absent"')" "absent" "raw cache read omitted" -} - -it "writes last_assistant_message into the Turn span output" t_stop_sets_turn_output -it "leaves output empty when last_assistant_message missing" t_stop_output_empty_when_message_missing -it "targets the existing Turn span id via merge" t_stop_turn_update_has_correct_id -it "sets _is_merge=true on the update" t_stop_merge_flag_is_set -it "merge carries end time but no token metrics" t_stop_merge_has_end_time_no_tokens -it "does not double-count streaming output across 3+ lines" t_stop_streaming_output_not_double_counted -it "emits one LLM span per requestId across tool_result splits" t_stop_same_request_not_split_into_zero_token_spans -it "emits canonical split cache metrics" t_stop_emits_split_cache_metrics diff --git a/src/plugins/claude/content/plugins/trace-claude-code/test/test_user_prompt_expansion.sh b/src/plugins/claude/content/plugins/trace-claude-code/test/test_user_prompt_expansion.sh deleted file mode 100644 index fba7445..0000000 --- a/src/plugins/claude/content/plugins/trace-claude-code/test/test_user_prompt_expansion.sh +++ /dev/null @@ -1,120 +0,0 @@ -#!/bin/bash -### -# End-to-end tests for explicit skill capture from UserPromptExpansion. -### - -SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -# shellcheck source=helpers/assert.sh -source "$SCRIPT_DIR/helpers/assert.sh" -# shellcheck source=helpers/harness.sh -source "$SCRIPT_DIR/helpers/harness.sh" - -_setup_default_stubs() { - stub_response_for "*/v1/project?project_name=*" 200 '{"id":"proj_test"}' - stub_response_for "*/v1/project_logs/*/insert" 200 '{"row_ids":["row_1"]}' -} - -_skill_listing_transcript() { - local path="$1" - jq -nc '{attachment: {type: "skill_listing", names: ["review", "security-review"]}}' > "$path" -} - -_fixture_user_prompt_expansion() { - local session_id="$1" - local command_name="$2" - local transcript_path="$3" - jq -nc \ - --arg s "$session_id" \ - --arg c "$command_name" \ - --arg t "$transcript_path" \ - '{ - session_id: $s, - expansion_type: "slash_command", - command_name: $c, - transcript_path: $t - }' -} - -_with_started_session() { - local session_id="$1" - _setup_default_stubs - run_hook session_start.sh "$(fixture_session_start "$session_id" "/tmp/x")" -} - -_with_turn_started() { - local session_id="$1" - _with_started_session "$session_id" - run_hook user_prompt_submit.sh "$(fixture_user_prompt "$session_id" "do something")" - : > "$CAPTURED_REQUESTS" -} - -# --------------------------------------------------------------------------- -describe "user_prompt_expansion.sh" -# --------------------------------------------------------------------------- - -t_expansion_merges_current_turn_metadata() { - _with_turn_started "sess-upe-merge" - local transcript="$TEST_TMP/transcript.jsonl" - _skill_listing_transcript "$transcript" - - run_hook user_prompt_expansion.sh "$(_fixture_user_prompt_expansion "sess-upe-merge" "/review" "$transcript")" - assert_success "$HOOK_STATUS" - - local span - span=$(all_spans | jq -c '.[0]') - assert_eq "$(echo "$span" | jq -r '._is_merge')" "true" - assert_eq "$(echo "$span" | jq -r '.metadata.loaded_skill_names[0]')" "review" - assert_eq "$(echo "$span" | jq -r '.metadata.loaded_skills[0].name')" "review" -} - -t_pending_expansion_is_added_to_next_turn() { - _with_started_session "sess-upe-pending" - local transcript="$TEST_TMP/transcript.jsonl" - _skill_listing_transcript "$transcript" - - run_hook user_prompt_expansion.sh "$(_fixture_user_prompt_expansion "sess-upe-pending" "/review" "$transcript")" - assert_success "$HOOK_STATUS" - : > "$CAPTURED_REQUESTS" - - run_hook user_prompt_submit.sh "$(fixture_user_prompt "sess-upe-pending" "after expansion")" - assert_success "$HOOK_STATUS" - - local turn - turn=$(span_by_name "^Turn 1$") - assert_eq "$(echo "$turn" | jq -r '.metadata.loaded_skill_names[0]')" "review" - assert_eq "$(echo "$turn" | jq -r '.metadata.loaded_skills[0].name')" "review" -} - -t_matching_skill_tool_is_marked_explicit() { - _with_turn_started "sess-upe-tool" - local transcript="$TEST_TMP/transcript.jsonl" - _skill_listing_transcript "$transcript" - - run_hook user_prompt_expansion.sh "$(_fixture_user_prompt_expansion "sess-upe-tool" "/review" "$transcript")" - assert_success "$HOOK_STATUS" - : > "$CAPTURED_REQUESTS" - - run_hook post_tool_use.sh "$(fixture_post_tool_use "sess-upe-tool" "Skill" \ - "$(jq -nc '{name: "review"}')" \ - "$(fixture_tool_response_text 'loaded')")" - - local tool_span - tool_span=$(span_by_type "tool") - assert_eq "$(echo "$tool_span" | jq -r '.metadata.skill_load_trigger')" "explicit" -} - -t_non_skill_slash_command_is_ignored() { - _with_turn_started "sess-upe-ignore" - local transcript="$TEST_TMP/transcript.jsonl" - _skill_listing_transcript "$transcript" - - run_hook user_prompt_expansion.sh "$(_fixture_user_prompt_expansion "sess-upe-ignore" "/not-a-skill" "$transcript")" - assert_success "$HOOK_STATUS" - - assert_eq "$(span_count)" "0" -} - -it "merges explicit skill metadata onto the current turn" t_expansion_merges_current_turn_metadata -it "adds pending explicit skill metadata to the next turn" t_pending_expansion_is_added_to_next_turn -it "marks matching Skill tool spans as explicit" t_matching_skill_tool_is_marked_explicit -it "ignores slash commands not present in skill listings" t_non_skill_slash_command_is_ignored diff --git a/src/plugins/claude/content/plugins/trace-claude-code/test/test_user_prompt_submit.sh b/src/plugins/claude/content/plugins/trace-claude-code/test/test_user_prompt_submit.sh deleted file mode 100755 index 098300e..0000000 --- a/src/plugins/claude/content/plugins/trace-claude-code/test/test_user_prompt_submit.sh +++ /dev/null @@ -1,155 +0,0 @@ -#!/bin/bash -### -# End-to-end tests for the UserPromptSubmit hook. -# -# UserPromptSubmit is fired each time the user submits a prompt. It creates -# a "Turn N" child span under the session span. If the session root doesn't -# exist yet (e.g. session_start was missed), it back-fills one. -### - -SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -# shellcheck source=helpers/assert.sh -source "$SCRIPT_DIR/helpers/assert.sh" -# shellcheck source=helpers/harness.sh -source "$SCRIPT_DIR/helpers/harness.sh" - -_setup_default_stubs() { - stub_response_for "*/v1/project?project_name=*" 200 '{"id":"proj_test"}' - stub_response_for "*/v1/project_logs/*/insert" 200 '{"row_ids":["row_1"]}' -} - -# Helper: run session_start first, ignoring its inserts. -_with_started_session() { - local session_id="$1" - _setup_default_stubs - run_hook session_start.sh "$(fixture_session_start "$session_id" "/tmp/x")" - # Clear capture so subsequent tests see only the new hook's requests - : > "$CAPTURED_REQUESTS" -} - -# --------------------------------------------------------------------------- -describe "user_prompt_submit.sh: with existing session" -# --------------------------------------------------------------------------- - -t_prompt_creates_turn_span() { - _with_started_session "sess-turn-1" - - run_hook user_prompt_submit.sh "$(fixture_user_prompt "sess-turn-1" "Hello!")" - assert_success "$HOOK_STATUS" - - local count - count=$(span_count) - assert_eq "$count" "1" "expected exactly one new span (the turn)" - - local turn - turn=$(span_by_type "task") - assert_ne "$turn" "null" - - local name - name=$(echo "$turn" | jq -r '.span_attributes.name') - assert_eq "$name" "Turn 1" - - local input - input=$(echo "$turn" | jq -r '.input') - assert_eq "$input" "Hello!" -} - -t_turn_is_child_of_session() { - _with_started_session "sess-parent" - run_hook user_prompt_submit.sh "$(fixture_user_prompt "sess-parent" "Hi")" - - local turn - turn=$(span_by_name "^Turn 1$") - local parent root - parent=$(echo "$turn" | jq -r '.span_parents[0]') - root=$(echo "$turn" | jq -r '.root_span_id') - - # Turn's parent is the session span; its root is the same session id. - assert_eq "$parent" "sess-parent" - assert_eq "$root" "sess-parent" -} - -t_subsequent_prompts_increment_turn_number() { - _with_started_session "sess-multi" - - run_hook user_prompt_submit.sh "$(fixture_user_prompt "sess-multi" "first")" - run_hook user_prompt_submit.sh "$(fixture_user_prompt "sess-multi" "second")" - run_hook user_prompt_submit.sh "$(fixture_user_prompt "sess-multi" "third")" - - local count - count=$(span_count) - assert_eq "$count" "3" - - # Verify each turn got the right name - local t1 t2 t3 - t1=$(all_spans | jq -r '.[0].span_attributes.name') - t2=$(all_spans | jq -r '.[1].span_attributes.name') - t3=$(all_spans | jq -r '.[2].span_attributes.name') - assert_eq "$t1" "Turn 1" - assert_eq "$t2" "Turn 2" - assert_eq "$t3" "Turn 3" -} - -t_turn_state_stored() { - _with_started_session "sess-state-turn" - run_hook user_prompt_submit.sh "$(fixture_user_prompt "sess-state-turn" "X")" - - local turn_span_id - turn_span_id=$(get_session_state "sess-state-turn" "current_turn_span_id") - assert_ne "$turn_span_id" "" "current_turn_span_id should be persisted" - - local turn_count - turn_count=$(get_session_state "sess-state-turn" "turn_count") - assert_eq "$turn_count" "1" -} - -it "creates a Turn span on prompt submit" t_prompt_creates_turn_span -it "Turn span is a child of the session span" t_turn_is_child_of_session -it "subsequent prompts increment the turn number" t_subsequent_prompts_increment_turn_number -it "persists current_turn_span_id to state" t_turn_state_stored - -# --------------------------------------------------------------------------- -describe "user_prompt_submit.sh: without prior session_start" -# --------------------------------------------------------------------------- - -t_prompt_backfills_session_root() { - # No session_start ran first. The hook should create both the session - # root span AND the Turn span. - _setup_default_stubs - - run_hook user_prompt_submit.sh "$(fixture_user_prompt "sess-orphan" "Hello")" - assert_success "$HOOK_STATUS" - - local count - count=$(span_count) - assert_eq "$count" "2" "expected session root + turn (2 spans)" - - # We should have at least one span with type=task named "Claude Code: *" - local session_span - session_span=$(span_by_name "^Claude Code: ") - assert_ne "$session_span" "null" - - local turn_span - turn_span=$(span_by_name "^Turn 1$") - assert_ne "$turn_span" "null" -} - -it "back-fills a session root span if session_start was missed" t_prompt_backfills_session_root - -# --------------------------------------------------------------------------- -describe "user_prompt_submit.sh: tracing disabled" -# --------------------------------------------------------------------------- - -t_prompt_disabled() { - _setup_default_stubs - export TRACE_TO_BRAINTRUST=false - - run_hook user_prompt_submit.sh "$(fixture_user_prompt "sess-off" "x")" - assert_success "$HOOK_STATUS" - - local count - count=$(span_count) - assert_eq "$count" "0" -} - -it "is a no-op when TRACE_TO_BRAINTRUST is false" t_prompt_disabled diff --git a/src/plugins/claude/validate.sh b/src/plugins/claude/validate.sh index 7bb7940..3512264 100755 --- a/src/plugins/claude/validate.sh +++ b/src/plugins/claude/validate.sh @@ -35,12 +35,30 @@ required=( "plugins/braintrust/skills/troubleshoot-braintrust-mcp/SKILL.md" "plugins/trace-claude-code/.claude-plugin/plugin.json" "plugins/trace-claude-code/hooks/hooks.json" + "plugins/trace-claude-code/bin/claude-hook.sh" + "plugins/trace-claude-code/bin/claude-hook.cmd" ) for rel in "${required[@]}"; do [[ -f "$TARGET_DIR/$rel" ]] || fail "missing $rel" case "$rel" in *.json) check_json "$TARGET_DIR/$rel";; esac done +grep -q "'daemon','hook','--source','claude-code'" \ + "$TARGET_DIR/plugins/trace-claude-code/bin/claude-hook.cmd" \ + || fail "Claude Windows hook does not invoke bt daemon" + +# The Rust daemon is the only event processor. Shipping any of the legacy +# per-event shell processors would reintroduce two competing trace models. +legacy_hooks=( + common.sh session_start.sh user_prompt_submit.sh user_prompt_expansion.sh + post_tool_use.sh post_tool_use_failure.sh permission_denied.sh stop_hook.sh + session_end.sh worker.sh +) +for hook in "${legacy_hooks[@]}"; do + [[ ! -e "$TARGET_DIR/plugins/trace-claude-code/hooks/$hook" ]] \ + || fail "obsolete Claude processor was packaged: hooks/$hook" +done + # Every marketplace entry's source path must exist in the built tree. if command -v jq >/dev/null 2>&1; then while IFS= read -r p; do diff --git a/src/plugins/codex/build.sh b/src/plugins/codex/build.sh index b24f4c2..47cb0a9 100755 --- a/src/plugins/codex/build.sh +++ b/src/plugins/codex/build.sh @@ -5,23 +5,15 @@ # The Codex marketplace consumes a repo whose ROOT is the marketplace: # .agents/plugins/marketplace.json marketplace manifest # plugins/braintrust-codex-plugin/ skills plugin (MCP + skills) -# plugins/trace-codex/ tracing plugin (TS event server + hooks) +# plugins/trace-codex/ thin hooks over the shared bt daemon # -# trace-codex ships as SOURCE only. The compiled `codex-hook` binary is not -# committed; the launcher (bin/codex-hook.sh) downloads the matching binary from -# the dist repo's GitHub Releases at runtime, and local dev installs build it -# with `pnpm run build` (tsx/tsup + pkg). So this build is a content assembly, -# not a compile. +# The tracing launcher finds (or installs) `bt` and invokes +# `bt daemon hook --source codex`; there is no per-plugin compiled binary. # # Everything deployable lives under content/. Today the assembly is a straight # copy; the seam is here for when the generic event-server code is extracted to # a shared location and injected into trace-codex at build time. # -# Optional env: -# CODEX_DIST_REPO owner/name of the dist repo whose Releases host the -# codex-hook binaries. When set, rewrites the launcher's -# REPO= line so a fork/scratch repo resolves its own binaries. -# # Usage: build.sh (TARGET_DIR is created if missing) set -euo pipefail @@ -33,13 +25,4 @@ CONTENT_DIR="$SRC_DIR/content" mkdir -p "$TARGET_DIR" rsync -a --delete --exclude '.git' "$CONTENT_DIR/" "$TARGET_DIR/" -# Point the runtime binary launcher at the dist repo we're deploying to, if -# overridden (defaults to the braintrustdata/braintrust-codex-plugin baked into -# the launcher). -if [[ -n "${CODEX_DIST_REPO:-}" ]]; then - launcher="$TARGET_DIR/plugins/trace-codex/bin/codex-hook.sh" - sed -i.bak -E "s#^REPO=\"[^\"]*\"#REPO=\"${CODEX_DIST_REPO}\"#" "$launcher" - rm -f "$launcher.bak" -fi - echo "Built codex dist into $TARGET_DIR (content from $CONTENT_DIR)." diff --git a/src/plugins/codex/content/AGENTS.md b/src/plugins/codex/content/AGENTS.md index 2e4bf58..6cca1c5 100644 --- a/src/plugins/codex/content/AGENTS.md +++ b/src/plugins/codex/content/AGENTS.md @@ -22,7 +22,8 @@ Key files for the tracing plugin (see [`plugins/trace-codex/AGENTS.md`](plugins/ - `plugins/trace-codex/AGENTS.md` — architecture and contributor guide for this plugin - `plugins/trace-codex/.codex-plugin/plugin.json` — plugin manifest - `plugins/trace-codex/hooks/hooks.json` — lifecycle hook config -- `plugins/trace-codex/src/` — the hook client + event server (compiled to `bin/codex-hook`) +- `plugins/trace-codex/bin/codex-hook.*` — fail-open shims that invoke the + shared daemon through `bt daemon hook --source codex` ## Making changes diff --git a/src/plugins/codex/content/install.sh b/src/plugins/codex/content/install.sh index 402076d..84436e2 100755 --- a/src/plugins/codex/content/install.sh +++ b/src/plugins/codex/content/install.sh @@ -28,25 +28,6 @@ set -euo pipefail REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" MARKETPLACE="braintrust-codex-plugins" -# Default port the trace-codex event server listens on (see plugins/trace-codex -# config.ts DEFAULT_PORT). Overridable via the same env var the plugin reads. -EVENT_SERVER_PORT="${BRAINTRUST_EVENT_SERVER_PORT:-52734}" - -# Ask any running trace-codex event server to shut down. After a re-install the -# cache holds a new build, but a server spawned from the OLD build may still be -# running; it would refuse the new version (version mismatch) and block tracing -# until its idle timeout. Shutting it down lets the next session boot a fresh -# one. No server running is the normal case, so failures are ignored. -shutdown_event_server() { - if ! command -v curl >/dev/null 2>&1; then - return 0 - fi - if curl -fsS --max-time 2 -X POST \ - "http://127.0.0.1:$EVENT_SERVER_PORT/shutdown" >/dev/null 2>&1; then - echo " shut down running event server on port $EVENT_SERVER_PORT." - fi -} - # Plugin folders to install. Default: every folder under plugins/. if [ "$#" -gt 0 ]; then PLUGINS=("$@") @@ -61,6 +42,10 @@ if ! command -v codex >/dev/null 2>&1; then echo "Error: 'codex' CLI not found. Install Codex CLI first." >&2 exit 1 fi +if ! command -v bt >/dev/null 2>&1 && [ ! -x "${XDG_BIN_HOME:-$HOME/.local/bin}/bt" ]; then + echo "Installing the bt CLI required by trace-codex..." + curl -fsSL https://bt.dev/cli/install.sh | bash +fi echo "Installing Braintrust Codex plugins from: $REPO_ROOT" echo "" @@ -78,24 +63,6 @@ read_json_field() { grep -o "\"$2\"[[:space:]]*:[[:space:]]*\"[^\"]*\"" "$1" | head -1 | sed 's/.*"\([^"]*\)"$/\1/' } -# Build a plugin's compiled assets if it has a build (build-on-install). -build_plugin() { - plugin_src="$1" - if [ ! -f "$plugin_src/package.json" ]; then - return 0 - fi - if ! grep -q '"build"' "$plugin_src/package.json"; then - return 0 - fi - if ! command -v pnpm >/dev/null 2>&1; then - echo "Error: '$folder' needs pnpm to build, but 'pnpm' was not found." >&2 - echo " Install pnpm from https://pnpm.io/installation and re-run ./install.sh" >&2 - exit 1 - fi - echo " building (pnpm)..." - ( cd "$plugin_src" && pnpm install --reporter=silent && BUILD_HOST_ONLY=1 pnpm run build ) -} - for folder in "${PLUGINS[@]}"; do plugin_src="$REPO_ROOT/plugins/$folder" manifest="$plugin_src/.codex-plugin/plugin.json" @@ -115,18 +82,10 @@ for folder in "${PLUGINS[@]}"; do fi echo "Installing '$name' (v$version) from plugins/$folder..." - # Build compiled assets first so the marketplace copy includes them. - build_plugin "$plugin_src" # Remove any prior install so the copy is re-synced from the current files. codex plugin remove "$name@$MARKETPLACE" >/dev/null 2>&1 || true codex plugin add "$name" --marketplace "$MARKETPLACE" >/dev/null echo " installed." - - # trace-codex runs a long-lived background event server. Stop any stale one - # left over from a previous build so it doesn't linger with the old version. - if [ "$folder" = "trace-codex" ]; then - shutdown_event_server - fi done echo "" diff --git a/src/plugins/codex/content/plugins/trace-codex/.codex-plugin/plugin.json b/src/plugins/codex/content/plugins/trace-codex/.codex-plugin/plugin.json index 04d1ce1..0657016 100644 --- a/src/plugins/codex/content/plugins/trace-codex/.codex-plugin/plugin.json +++ b/src/plugins/codex/content/plugins/trace-codex/.codex-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "trace-codex", - "version": "0.1.0", + "version": "0.2.0", "description": "Trace Codex sessions to Braintrust (session, turn, and tool spans).", "author": { "name": "Braintrust", diff --git a/src/plugins/codex/content/plugins/trace-codex/.gitignore b/src/plugins/codex/content/plugins/trace-codex/.gitignore deleted file mode 100644 index 17e411d..0000000 --- a/src/plugins/codex/content/plugins/trace-codex/.gitignore +++ /dev/null @@ -1,10 +0,0 @@ -node_modules/ -# tsup's intermediate bundle (input to pkg); regenerated by `pnpm run build`. -dist/ -# Built binaries are downloaded at runtime / built locally, never committed. -# The launcher scripts (bin/codex-hook.sh, bin/codex-hook.cmd) are tracked. -bin/codex-hook -bin/codex-hook-* -*.log -# Local user settings; the tracked template is config.json.example. -config.json diff --git a/src/plugins/codex/content/plugins/trace-codex/AGENTS.md b/src/plugins/codex/content/plugins/trace-codex/AGENTS.md index 1d07d3f..637b82e 100644 --- a/src/plugins/codex/content/plugins/trace-codex/AGENTS.md +++ b/src/plugins/codex/content/plugins/trace-codex/AGENTS.md @@ -1,208 +1,29 @@ # AGENTS.md — trace-codex -Guidelines for AI agents working in the `trace-codex` plugin. This is the -opt-in plugin that traces Codex sessions to Braintrust. It is **independent** -from the MCP/skills plugin (`braintrust-codex-plugin`) — do not merge behavior -between them. See the [repo AGENTS.md](../../AGENTS.md) for the monorepo -overview. - -## What this plugin is - -A long-lived, local **event server** plus a short-lived **hook client**, both -compiled into a single `codex-hook` binary. Codex fires lifecycle hooks; each -hook invocation runs the client, which forwards the event to the server, which -turns the stream of events into Braintrust spans (session → turn → tool). - -## Architecture - -Request/data flow: +This opt-in plugin is a thin hook shim over the shared Rust tracing daemon in +the `bt` CLI: +```text +Codex hook → bin/codex-hook.sh|cmd → bt daemon hook --source codex + → local socket/pipe → per-session Rust translator → Braintrust ``` -Codex hook → bin/codex-hook.sh → codex-hook (client mode) - → POST /enqueue → in-memory FIFO queue - → ProcessorRegistry (one EventProcessor per session) - → Braintrust spans -``` - -Key pieces: - -- `src/index.ts` — entry point. One binary, three modes: `serve` (the - background server), `hook` (default; read one event from stdin, enqueue it), - and `replay` (re-POST a recorded NDJSON session). -- `src/client/` — the hook client. Ensures a server is up (booting a detached - one if needed), POSTs events, and on a terminal event (`Stop`) calls `/flush` - so final spans are delivered before the process tree is torn down. -- `src/server/` — the HTTP server, the FIFO `EventQueue`, routes - (`/enqueue`, `/flush`, `/health`, `/shutdown`), and the idle watchdog. -- `src/processor/` — the generic `ProcessorRegistry` (LRU map of per-session - processors) and the `EventProcessor` interface. -- `src/agents/codex/` — Codex-specific: translate hook payloads into - `EnqueueEvent`s (`event-builder.ts`), build spans (`event-processor.ts`), and - read user settings (`settings.ts`). -- `src/braintrust/` — thin wrapper over the Braintrust SDK (span factory). - -The server is **single-version**: a client whose version doesn't match a running -server bails rather than talking to it. It shuts itself down after an idle -window (default 5 min) or on `/shutdown`. - -The design has three deliberate properties worth understanding before changing -anything: - -### 1. Agent-agnostic "event server" - -The core (`src/server/`, `src/processor/`, `src/braintrust/`) knows nothing -about Codex. It speaks a generic `EnqueueEvent` shape and routes events to a -per-session processor. All Codex-specific knowledge lives in `src/agents/codex/` -and is registered into the generic core at startup. Adding another agent (Claude -Code, opencode, etc.) means adding a sibling `src/agents//` module that -exports the same `Agent` shape — the server, queue, and registry should not need -to change. - -Keep this boundary clean: nothing under `src/server/`, `src/processor/`, or -`src/braintrust/` should reference Codex by name. The generic event server may -eventually be extracted into its own package, so treat any Codex-specific leak -into the generic layers as a bug, not a shortcut. - -### 2. Node-compiled, cross-platform binaries - -The hook command in `hooks.json` is a fixed, platform-agnostic string that -invokes `bin/codex-hook.sh` (or `.cmd` on Windows), **not** the binary directly. -That launcher resolves and runs the real, platform-specific `codex-hook` binary. - -- `scripts/build.ts` runs a two-step build: `tsup` bundles `src/index.ts` into a - single self-contained CommonJS file (`dist/codex-hook.cjs`, inlining the - `braintrust` dependency), then `@yao-pkg/pkg` wraps that bundle plus a Node - runtime into a standalone executable per target (`darwin-arm64/x64`, - `linux-x64/arm64`; Windows slots in but isn't built yet). `BUILD_HOST_ONLY=1` - builds just the host target (used by the repo-root `install.sh` for local dev - installs). -- **Build the release on macOS.** pkg code-signs the darwin binaries via the - `codesign` utility (macOS-only), and Apple Silicon kills an unsigned arm64 - binary on launch. A macOS host cross-compiles the Linux targets too, so one - job produces all four signed/valid binaries (see `release.yaml`). -- **Re-exec caveat (pkg):** the hook spawns the server by re-executing - `process.execPath` with `serve`. pkg's child_process patch would otherwise make - the child act like `node` and treat `serve` as a script path, so - `spawn-server.ts` pre-sets `PKG_EXECPATH` to a non-exec-path sentinel to force - packaged-app mode. See the comment there. -- The compiled binaries are **large and not committed**. The launcher downloads - the matching binary from the plugin's GitHub release on first use and caches - it at `$PLUGIN_ROOT/bin/codex-hook`. Codex wipes `$PLUGIN_ROOT` on every - install/upgrade, so the cache self-invalidates and the next hook re-downloads - the right version. -- Plugin version is the single source of truth in - `.codex-plugin/plugin.json`; the launcher reads it to construct the release - tag (`trace-codex-v`). -**Hard rule:** the hook must never fail the Codex turn. The launcher and client -log to stderr and exit 0 on any error; the server swallows errors so a turn is -never blocked by tracing. +The launcher must always fail open: log errors to stderr and exit 0 so tracing +cannot fail or stall a Codex turn. Keep hook commands platform-neutral through +the `.sh`/`.cmd` launchers. -### 3. Hooks are non-blocking by default +Agent-specific state-machine logic belongs in +`bt-daemon/src/translate/codex.rs`, not in this plugin. -Tracing must not degrade the experience of using the coding agent. Every hook -fires synchronously in Codex's path, so blocking work on a hook directly adds -latency to the user's session. The design therefore makes the common path -fire-and-forget: the client POSTs to `/enqueue` and returns immediately, while -the background server does the slow work (Braintrust SDK calls, flushes) off the -critical path. +Run: -The terminal event is the one place where blocking is even an option, and it's -**opt-in**: - -- On a **terminal event** (`Stop`), the client calls `/flush` **only** when - `BRAINTRUST_FLUSH_ON_TURN_END` is set, blocking until the server confirms the - queue has drained and buffered spans reached Braintrust. The blocking mode - exists for short-lived hosts (e.g. a CI job that ends right after the last turn) - that would otherwise tear the process tree down before the final spans are - delivered. When enabled, the wait is bounded by a timeout (`/flush` gives up - rather than hanging the turn). By default the client does nothing on `Stop`: the - long-lived server flushes on its own when the queue goes idle, so in normal - interactive use no spans are lost and the turn isn't stalled by a flush request. - -When adding behavior, prefer enqueue-and-return. Reach for a blocking -request/await only when data would otherwise be lost, keep it off the -per-hook hot path where possible, and always bound it so a slow or stuck backend -can't stall the agent. - -### 4. Sessions can outlive the server (resume) - -The server is short-lived (idle shutdown after ~5 min, or the user closes and -later resumes). A Codex session, though, can span that gap. To avoid dropping -the tail of a trace — or worse, re-emitting it as duplicate/orphaned spans — the -Codex processor **persists its resumable state** and rehydrates it when a new -processor is created for the same session id. - -How it works: - -- The transcript (rollout JSONL) on disk is the durable source of truth for span - *content*. What a restart loses is the processor's in-memory bookkeeping: - transcript byte offsets, which spans are still open (and their identities), - the reconstructed conversation history, and the subagent/compaction side-maps. -- On every `flush()` (idle drain, eviction, terminal Stop, shutdown), the - processor writes a JSON snapshot of that bookkeeping to - `PLUGIN_DATA/state/.json` (`src/agents/codex/snapshot-store.ts`). - Span handles are stored as identities (`span_id`/`root_span_id`/`parents` plus - name/type), captured from the SDK's synchronous span getters. -- On its first event, a processor attempts a one-time restore for its session - id. If a compatible snapshot exists, it recreates each span handle bound to the - original id via `SpanFactory.rehydrateSpan` (Braintrust merges rows by - `span_id`, so further `log()`/`end()` calls continue the same trace). Restore - runs **before** the tracing-enabled master switch, because the snapshot also - carries the session's reporting config (a bare mid-session restart may have no - leading config event). -- The snapshot is **not** deleted when the root span ends. Codex's `Stop` hook is - per-**turn**, not per-session (there is no session-end hook), and we end the - root on the first `Stop` — but later turns still attach as children of that same - root, so a restart *between* turns must be able to resume. The snapshot - therefore persists past root-end; stale ones (sessions that never resume) are - reclaimed by a startup GC sweep that removes snapshots older than a TTL. - -Invariants to preserve when changing this: - -- **Persistence is Codex-owned**, not part of the generic event server: *where* a - plugin may persist state is the host agent's call (Codex gives us - `PLUGIN_DATA`). Keep the store and snapshot shape under `src/agents/codex/`. - The only generic addition is `SpanFactory.rehydrateSpan` (pure SDK behavior). -- **Never persist secrets.** The `apiKey` is stripped from the snapshot's - reporting config and re-resolved from env / the config event on resume (mirrors - the event recorder's redaction). -- **Never throw.** The store swallows and logs all I/O errors; a persistence or - restore failure must not break a turn — it just falls back to starting fresh. -- **Version-gate snapshots.** Each carries `pluginVersion` + a schema version; - a mismatch discards the snapshot. Bump `SNAPSHOT_SCHEMA_VERSION` in - `state-snapshot.ts` whenever the shape changes incompatibly. - -## Making changes - -- **Generic vs. agent code**: put agent-specific logic in `src/agents//`; - keep the server/processor/braintrust layers agent-agnostic. -- **Config**: two layers. The agent-specific layer (`src/agents/codex/settings.ts`) - reads the user's `config.json` from `PLUGIN_DATA`, maps its friendly camelCase - keys onto `BRAINTRUST_*` / `BRAINTRUST_EVENT_SERVER_*` env vars (env wins over - the file), and is run by the hook client before it boots the server — so the - spawned server inherits the resolved env. The generic layer (`src/config.ts`) - then reads **only** those env vars and never touches `config.json`. Keep it - that way: config-file parsing is deliberately agent-specific. User-facing - settings are documented in the [README](./README.md); update it (and the - `Settings` map in `settings.ts`) when adding one. -- **Hooks**: `hooks/hooks.json` lists the lifecycle events wired to the client. - Most are forwarded but not yet turned into spans (the processor no-ops the - ones it doesn't handle). -- **Build**: edit `scripts/build.ts` for targets/output; the launcher scripts - (`bin/codex-hook.sh`, `bin/codex-hook.cmd`) for download/exec behavior. - -## Commands - -Run from `plugins/trace-codex/`: +```bash +make test +``` -- `pnpm test` — run the test suite with vitest (tests live next to sources as - `*.test.ts`). -- `pnpm run typecheck` — `tsc --noEmit`. -- `pnpm run lint` / `pnpm run check` — Biome lint / lint+format. -- `pnpm run build` — build all target binaries via tsup + pkg - (`BUILD_HOST_ONLY=1` for host only). -- `pnpm run dev` — run the server in watch mode (tsx). +The shared daemon crate owns translator and pipeline tests: -Add or update tests alongside any behavior change; keep typecheck and lint -clean. +```bash +cd ../../../../../bt-daemon +cargo test +``` diff --git a/src/plugins/codex/content/plugins/trace-codex/Makefile b/src/plugins/codex/content/plugins/trace-codex/Makefile index b479a14..047b716 100644 --- a/src/plugins/codex/content/plugins/trace-codex/Makefile +++ b/src/plugins/codex/content/plugins/trace-codex/Makefile @@ -1,108 +1,8 @@ -# Makefile for the Braintrust Codex tracing plugin. -# -# Targets: -# make test Lint + typecheck + unit tests + a full build + live -# integration check. -# make lint Run the Biome linter/formatter check (no writes). -# make typecheck Run the TypeScript type checker. -# make unit Run only the unit tests (vitest). -# make build Compile the host binary (and per-platform binaries). -# make integration Build, boot the server via the hook binary, health-check, -# assert the version, then shut it down. -# make smoke End-to-end: run a real `codex exec` session with the -# installed plugin, traced to a local mock collector, and -# assert >=1 trace row. Requires codex, node, pnpm, and -# OPENAI_API_KEY; optionally SMOKE_VERSION to pin a release. -# make token-proxy Run the token-counting proxy (debugging aid) that sits -# between Codex and OpenAI and prints each call's token usage -# for comparison against the trace. Requires OPENAI_API_KEY. -# See scripts/token-proxy.ts for the config.toml snippet. -# make clean Remove build artifacts. - -SHELL := /bin/bash - -# Port used only by the integration test. Chosen from the IANA dynamic/private -# range (49152-65535) and deliberately different from the default (52734) so -# the test never collides with a real running server. -TEST_PORT ?= 54219 - -# Expected /health version, read from the plugin manifest (single source of truth). -EXPECTED_VERSION := $(shell sed -n 's/.*"version"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' .codex-plugin/plugin.json | head -1) - -BIN := bin/codex-hook -BASE_URL := http://127.0.0.1:$(TEST_PORT) - -.PHONY: test lint typecheck unit build integration smoke token-proxy clean - -test: lint typecheck unit integration - -lint: - @echo "==> Linting (biome)" - pnpm run check - -typecheck: - @echo "==> Typechecking (tsc)" - pnpm run typecheck - -unit: - @echo "==> Running unit tests" - pnpm test - -build: - @echo "==> Building binaries" - BUILD_HOST_ONLY=1 pnpm run build - -integration: build - @echo "==> Integration test (port $(TEST_PORT), expecting version $(EXPECTED_VERSION))" - @set -euo pipefail; \ - export BRAINTRUST_EVENT_SERVER_PORT=$(TEST_PORT); \ - export BRAINTRUST_EVENT_SERVER_LOG_DIR="$$(mktemp -d)"; \ - cleanup() { curl -s -o /dev/null -X POST $(BASE_URL)/shutdown || true; }; \ - trap cleanup EXIT; \ - echo " -> invoking hook dispatcher (boots server in background)"; \ - echo '{"hook_event_name":"SessionStart","session_id":"make-test"}' | "$(BIN)" hook; \ - echo " -> waiting for /health on $(BASE_URL)"; \ - version=""; \ - for i in $$(seq 1 50); do \ - body="$$(curl -s --max-time 1 $(BASE_URL)/health || true)"; \ - if [ -n "$$body" ]; then version="$$body"; break; fi; \ - sleep 0.1; \ - done; \ - if [ -z "$$version" ]; then echo "FAIL: server never became healthy on $(BASE_URL)"; exit 1; fi; \ - echo " -> /health returned: $$version"; \ - expected='{"version":"$(EXPECTED_VERSION)"}'; \ - if [ "$$version" != "$$expected" ]; then \ - echo "FAIL: version mismatch"; echo " expected: $$expected"; echo " actual: $$version"; exit 1; \ - fi; \ - echo " -> version matches ($(EXPECTED_VERSION))"; \ - echo " -> calling /shutdown"; \ - code="$$(curl -s -o /dev/null -w '%{http_code}' -X POST $(BASE_URL)/shutdown)"; \ - if [ "$$code" != "200" ]; then echo "FAIL: /shutdown returned $$code (expected 200)"; exit 1; fi; \ - echo " -> /shutdown returned 200"; \ - echo " -> verifying server stopped"; \ - stopped=0; \ - for i in $$(seq 1 30); do \ - if ! curl -s -o /dev/null --max-time 1 $(BASE_URL)/health; then stopped=1; break; fi; \ - sleep 0.1; \ - done; \ - if [ "$$stopped" != "1" ]; then echo "FAIL: server still responding after shutdown"; exit 1; fi; \ - echo " -> server stopped cleanly"; \ - echo "PASS: integration test" - -# End-to-end smoke test. Assumes the plugin is installed in Codex (run -# `../../install.sh trace-codex` first for a local dev install). Set -# SMOKE_VERSION to pin the release whose binary the launcher downloads; leave it -# unset to use the installed plugin manifest version / local dev binary. -smoke: - @echo "==> Smoke test" - SMOKE_RELEASE_VERSION="$(SMOKE_VERSION)" sh scripts/smoke-test.sh - -# Token-counting proxy for verifying trace-codex's token accounting. Run it, then -# point Codex at it via a custom model_provider (see scripts/token-proxy.ts). -token-proxy: - @echo "==> Token proxy (Ctrl-C for the session total)" - pnpm exec tsx scripts/token-proxy.ts - -clean: - @echo "==> Cleaning" - rm -rf bin +.PHONY: test + +# Runtime validation for the thin shared-daemon shim. +test: + @sh -n bin/codex-hook.sh + @grep -q "'daemon','hook','--source','codex'" bin/codex-hook.cmd + @jq empty hooks/hooks.json .codex-plugin/plugin.json + @echo "trace-codex shim OK" diff --git a/src/plugins/codex/content/plugins/trace-codex/README.md b/src/plugins/codex/content/plugins/trace-codex/README.md index cafbfee..ff54745 100644 --- a/src/plugins/codex/content/plugins/trace-codex/README.md +++ b/src/plugins/codex/content/plugins/trace-codex/README.md @@ -1,141 +1,65 @@ -# Braintrust Codex Tracing Plugin +# Braintrust Codex tracing plugin -A [Codex plugin](https://developers.openai.com/codex/plugins) that wires Codex lifecycle hooks as a foundation for sending Codex sessions to Braintrust as traces. +An opt-in Codex plugin that sends lifecycle events to the shared Braintrust +daemon built into the `bt` CLI. The daemon tails Codex rollout transcripts and +creates a session → turn → LLM/tool trace without doing parsing or network +delivery in the blocking hook process. -## Quickstart +## Setup -```bash -codex plugin marketplace add braintrustdata/braintrust-codex-plugin -codex plugin add trace-codex@braintrust-codex-plugins -``` - -Create an API key in Braintrust under **Settings > API keys**. The key must be available in the environment where Codex runs. Either export it in your current shell before starting Codex: +Install the current `bt` CLI: ```bash -export BRAINTRUST_API_KEY="" -# NOTE: tracing must be explicitly enabled -# upon first run, codex will prompt for plugin permissions -TRACE_TO_BRAINTRUST=true BRAINTRUST_PROJECT=my-coding-agent codex +curl -fsSL https://bt.dev/cli/install.sh | bash +bt auth login ``` -Or set it for only that Codex invocation: +Then install the plugin and enable tracing when starting Codex: ```bash -BRAINTRUST_API_KEY="" TRACE_TO_BRAINTRUST=true BRAINTRUST_PROJECT=my-coding-agent codex -``` - -To upgrade: - -```bash -codex plugin marketplace upgrade braintrust-codex-plugins -``` +codex plugin marketplace add braintrustdata/braintrust-codex-plugin +codex plugin add trace-codex@braintrust-codex-plugins -## Using the plugin in CI - -Tracing `codex exec` runs in CI works. A few notes: - -- **Block on flush.** Set `BRAINTRUST_FLUSH_ON_TURN_END=true` so the final spans are delivered before the job exits (a short-lived CI job can otherwise tear down before the background server's idle-drain flush fires). -- **Trust the hooks non-interactively.** Pass `codex exec --dangerously-bypass-hook-trust` to skip the one-time interactive hook-trust prompt. (Only for plugins you trust.) - -GitHub Actions example: - -```yaml -name: codex-traced -on: [workflow_dispatch] - -jobs: - run: - runs-on: ubuntu-24.04 - steps: - - uses: actions/checkout@v4 - - - name: Install Codex CLI - run: npm install -g @openai/codex # or your preferred install method - - - name: Install the trace-codex plugin (pinned to a release tag) - # TODO: replace with desired version ---------------------------------vvvvvvvvvvvvvvvvvv - run: | - codex plugin marketplace add braintrustdata/braintrust-codex-plugin@trace-codex-v0.0.X - codex plugin add trace-codex@braintrust-codex-plugins - - - name: Run a traced Codex session - env: - TRACE_TO_BRAINTRUST: "true" - BRAINTRUST_API_KEY: ${{ secrets.BRAINTRUST_API_KEY }} - BRAINTRUST_PROJECT: my-coding-agent # <--- TODO: replace with your project name - BRAINTRUST_FLUSH_ON_TURN_END: "true" - run: | - codex exec \ - --skip-git-repo-check \ - --dangerously-bypass-hook-trust \ - --sandbox read-only \ - "summarize the changes in this repo" +TRACE_TO_BRAINTRUST=true BRAINTRUST_PROJECT=my-coding-agent codex ``` -If your plugin release repo is private, also expose a token the launcher can use to download the binary (`GH_TOKEN` or `GITHUB_TOKEN`, or an authenticated `gh`). +`BRAINTRUST_API_KEY` and the normal Braintrust URL/org environment variables +also work; `bt` owns profile, OAuth, keychain, and token-refresh handling. ## Configuration -There are two ways to configure the plugin. **Environment variables always win over the config file** +| Environment variable | Default | Meaning | +|---|---:|---| +| `TRACE_TO_BRAINTRUST` | `false` | Master opt-in switch. | +| `BRAINTRUST_PROJECT` | `codex` | Destination project resolved by `bt`. | +| `BRAINTRUST_FLUSH_ON_TURN_END` | `false` | Flush after `Stop`; useful for short-lived CI jobs. | +| `CODEX_PARENT_SPAN_ID` | unset | Attach the Codex session below this span. | +| `CODEX_ROOT_SPAN_ID` | parent id | Existing trace root when attaching below a non-root span. | +| `BRAINTRUST_ADDITIONAL_METADATA` | unset | JSON object merged into root metadata. | -``` -cp ~/.codex/plugins/cache/braintrust-codex-plugins/trace-codex//config.json.example ~/.codex/plugins/data/trace-codex-braintrust-codex-plugins/config.json -# now edit config.json with your desired settings -``` - -Every setting can be provided as a `config.json` key or as an environment variable; **an environment variable always overrides config.json** +The hook is fail-open: tracing errors are written to stderr and never fail the +Codex turn. If `bt` is missing, the launcher securely queues the current event, +starts one best-effort background install with the official installer, and +replays the event after installation. If the event cannot be queued, the +installer still starts and only that event is skipped. -| `config.json` key | Environment variable | Default | Meaning | -|----------------------|---------------------------------------|--------------------|----------------------------------------------------------------------------------------------------------------------------------------------| -| `traceToBraintrust` | `TRACE_TO_BRAINTRUST` | `false` | Master switch. **When `false` or unset, no traces are reported** Set `true` to enable tracing. | -| `apiKey` | `BRAINTRUST_API_KEY` | _(unset)_ | Braintrust API key. | -| `project` | `BRAINTRUST_PROJECT` | _(unset)_ | Project to log traces into. | -| `apiUrl` | `BRAINTRUST_API_URL` | api.braintrust.dev | Braintrust API URL. | -| `additionalMetadata` | `BRAINTRUST_ADDITIONAL_METADATA` | _(unset)_ | JSON object of extra metadata merged into the root span. Standard keys (`session_id`, `model`, `project`, etc.) take precedence on conflict. | -| `parentSpanId` | `CODEX_PARENT_SPAN_ID` | _(unset)_ | Existing Braintrust span to attach the Codex session under. If `rootSpanId` is unset, this is also used as the trace root. | -| `rootSpanId` | `CODEX_ROOT_SPAN_ID` | _(unset)_ | Root span id for the existing trace. If `parentSpanId` is unset, this is also used as the parent. | -| `flushOnTurnEnd` | `BRAINTRUST_FLUSH_ON_TURN_END` | `false` | When `true`, the hook blocks at each turn's end (the `Stop` event) until the server confirms all spans are flushed. Use in programmatic/CI runs to guarantee traces are delivered before Codex exits. | -| `recordFile` | `BRAINTRUST_EVENT_SERVER_RECORD_FILE` | _(unset)_ | If set, record every event to this NDJSON file (for `replay`). | +Both `bin/codex-hook.sh` and `bin/codex-hook.cmd` forward the full hook +configuration to `bt`. On Windows, the daemon uses a local named pipe. -### Add a Codex trace to an existing trace +## CI -You can attach a Codex session to an existing Braintrust trace by passing `CODEX_PARENT_SPAN_ID`: +Set `BRAINTRUST_FLUSH_ON_TURN_END=true` so the terminal hook waits for the +session queue and SDK batch to drain before the job exits: ```bash -TRACE_TO_BRAINTRUST=true CODEX_PARENT_SPAN_ID=your-parent-span-id codex +BRAINTRUST_FLUSH_ON_TURN_END=true \ +TRACE_TO_BRAINTRUST=true \ +BRAINTRUST_PROJECT=ci-agents \ +codex exec --dangerously-bypass-hook-trust "summarize this repository" ``` -If the parent span is not the trace root, also pass `CODEX_ROOT_SPAN_ID`: +Inspect the local daemon and its sessions with: ```bash -TRACE_TO_BRAINTRUST=true CODEX_PARENT_SPAN_ID=parent-span-id CODEX_ROOT_SPAN_ID=root-span-id codex -``` - -The Codex session and all its turns/tools will appear as children of your parent span in Braintrust. - -### Resuming sessions - -Note that when resuming a session, the original session's options will remain in effect. - -For example: - -```sh -TRACE_TO_BRAINTRUST=true codex # first session enables braintrust -TRACE_TO_BRAINTRUST=false codex resume abcde # the resumed session will still be traced because the original session was +bt daemon status ``` - -The trace itself also survives the background server stopping. The server shuts down after the idle window (or on an explicit shutdown), but a session can outlive it — you might leave it open past the timeout, send another message after the server stopped, or `codex resume` later. The plugin snapshots each session's in-progress trace state under `$PLUGIN_DATA/state/` and restores it when the session continues, so later turns keep landing in the same trace instead of starting a new one. Stale snapshots age out automatically, and secrets (your API key) are never written to them. - -### Advanced Options - -Advanced plugin settings for debugging or developing the plugin - - -| `config.json` key | Environment variable | Default | Meaning | -|-----------------------|--------------------------------------------------|----------------|-------------------------------------------------| -| `port` | `BRAINTRUST_EVENT_SERVER_PORT` | `52734` | Loopback port for the server. | -| `idleTimeoutMs` | `BRAINTRUST_EVENT_SERVER_IDLE_TIMEOUT_MS` | `300000` | Idle shutdown window. | -| `idleCheckIntervalMs` | `BRAINTRUST_EVENT_SERVER_IDLE_CHECK_INTERVAL_MS` | `30000` | Idle watchdog cadence. | -| _(none)_ | `BRAINTRUST_EVENT_SERVER_LOG_DIR` | `$PLUGIN_DATA` | Directory for logs, pidfile, and `config.json`. | - -The config file is read by the hook client only at the moment it boots the background server (the running server keeps the config it started with). To pick up config changes, stop the server (or wait for it to idle out) so the next event re-boots it. diff --git a/src/plugins/codex/content/plugins/trace-codex/bin/codex-hook.cmd b/src/plugins/codex/content/plugins/trace-codex/bin/codex-hook.cmd index 9c1525c..4b1bb76 100644 --- a/src/plugins/codex/content/plugins/trace-codex/bin/codex-hook.cmd +++ b/src/plugins/codex/content/plugins/trace-codex/bin/codex-hook.cmd @@ -1,9 +1,38 @@ @echo off -REM Windows launcher for the trace-codex hook binary. -REM -REM Windows is not supported yet. This stub exits 0 so it never fails a Codex -REM turn; tracing simply does nothing on Windows for now. When Windows support -REM lands, this will mirror codex-hook.sh: detect arch, download the matching -REM codex-hook.exe from the GitHub release into %PLUGIN_ROOT%\bin, and exec it. -echo trace-codex: Windows support coming soon; tracing disabled this session.>&2 +REM Thin, fail-open Codex hook shim for the shared Braintrust daemon. +REM Invokes: bt daemon hook --source codex +setlocal EnableExtensions DisableDelayedExpansion + +set "TRACE_ENABLED=" +for %%V in (1 true yes on) do if /I "%TRACE_TO_BRAINTRUST%"=="%%V" set "TRACE_ENABLED=1" +if not defined TRACE_ENABLED exit /b 0 + +set "BT_HOOK_BIN=" +for /f "delims=" %%B in ('where bt 2^>nul') do if not defined BT_HOOK_BIN set "BT_HOOK_BIN=%%B" +if not defined BT_HOOK_BIN if exist "%USERPROFILE%\.local\bin\bt.exe" set "BT_HOOK_BIN=%USERPROFILE%\.local\bin\bt.exe" +if not defined BT_HOOK_BIN ( + echo trace-codex: bt CLI is unavailable; tracing disabled for this event.>&2 + exit /b 0 +) + +"%BT_HOOK_BIN%" daemon hook --help >nul 2>&1 +if errorlevel 1 ( + echo trace-codex: a daemon-capable bt CLI is unavailable; tracing disabled for this event.>&2 + exit /b 0 +) + +if not defined BRAINTRUST_DEFAULT_PROJECT if defined BRAINTRUST_PROJECT set "BRAINTRUST_DEFAULT_PROJECT=%BRAINTRUST_PROJECT%" + +set "BT_PLUGIN_JSON=%~dp0..\.codex-plugin\plugin.json" +powershell.exe -NoLogo -NoProfile -NonInteractive -ExecutionPolicy Bypass -Command ^ + "$ErrorActionPreference='SilentlyContinue';" ^ + "$a=@('daemon','hook','--source','codex');" ^ + "if(Test-Path $env:BT_PLUGIN_JSON){$v=(Get-Content -Raw $env:BT_PLUGIN_JSON|ConvertFrom-Json).version;if($v){$a+=@('--source-version',[string]$v)}};" ^ + "if($env:BRAINTRUST_FLUSH_ON_TURN_END -match '^(?i:1|true|yes|on)$'){$a+='--flush-on-turn-end'};" ^ + "if($env:CODEX_PARENT_SPAN_ID){$a+=@('--parent-span-id',$env:CODEX_PARENT_SPAN_ID)};" ^ + "if($env:CODEX_ROOT_SPAN_ID){$a+=@('--root-span-id',$env:CODEX_ROOT_SPAN_ID)};" ^ + "if($env:BRAINTRUST_ADDITIONAL_METADATA){$a+=@('--additional-metadata',$env:BRAINTRUST_ADDITIONAL_METADATA)};" ^ + "& $env:BT_HOOK_BIN @a;" ^ + "if($LASTEXITCODE -ne 0){[Console]::Error.WriteLine('trace-codex: bt daemon hook failed non-fatally')};exit 0" + exit /b 0 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 index 102c3d9..1e2b6bb 100755 --- 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,164 +1,75 @@ #!/bin/sh -# Launcher for the trace-codex hook binary. -# -# hooks.json invokes this script (a fixed, platform-agnostic command). The real -# binary is platform-specific and far too large to commit, so it is downloaded -# on demand from the plugin's GitHub release and cached next to this script at -# $PLUGIN_ROOT/bin/codex-hook. -# -# Because Codex wipes the versioned plugin cache ($PLUGIN_ROOT) on every -# install/upgrade, a cached binary there is automatically invalidated on -# upgrade: the next hook finds it missing and re-downloads the matching version. -# This means the hot path is just "is the binary here? exec it" with no version -# parsing, and upgrades self-heal with no manual step. -# -# Hard rule: never fail the Codex turn. Any error here logs to stderr and exits -# 0 (Codex treats a 0 exit with no stdout as success). +# Thin, fail-open Codex hook shim for the shared Braintrust daemon. set -u -REPO="braintrustdata/braintrust-codex-plugin" - -# PLUGIN_ROOT is set by Codex to the installed plugin directory. Fall back to -# this script's own directory's parent so the launcher is runnable standalone. -SCRIPT_DIR=$(CDPATH= cd "$(dirname "$0")" && pwd) -ROOT="${PLUGIN_ROOT:-$(dirname "$SCRIPT_DIR")}" -BIN="$ROOT/bin/codex-hook" - -# Fast path: the binary is already cached for this plugin version. Run it. -if [ -x "$BIN" ]; then - exec "$BIN" "$@" -fi - -# --- Slow path: download the matching binary, then exec it. --- - -log() { printf 'trace-codex launcher: %s\n' "$1" >&2; } - -# Map uname output to our release asset suffix (-). -os=$(uname -s 2>/dev/null || echo unknown) -arch=$(uname -m 2>/dev/null || echo unknown) -case "$os" in - Darwin) os_name=darwin ;; - Linux) os_name=linux ;; - *) log "unsupported OS '$os'; tracing disabled this session"; exit 0 ;; -esac -case "$arch" in - arm64 | aarch64) arch_name=arm64 ;; - x86_64 | amd64) arch_name=x64 ;; - *) log "unsupported arch '$arch'; tracing disabled this session"; exit 0 ;; -esac -suffix="$os_name-$arch_name" - -# Read the plugin version (single source of truth) from the manifest. Only on -# this slow path, so no dependency (jq) and no parsing on the hot path. -# -# TRACE_CODEX_RELEASE_VERSION overrides the manifest version. This is intended -# for testing (e.g. the smoke test pointing at an arbitrary published release); -# normal installs leave it unset and use the manifest. -manifest="$ROOT/.codex-plugin/plugin.json" -version="${TRACE_CODEX_RELEASE_VERSION:-}" -if [ -z "$version" ]; then - version=$(sed -n 's/.*"version"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' "$manifest" 2>/dev/null | head -1) -fi -# Allow an optional leading 'v' on the override (v0.0.1 or 0.0.1). -version="${version#v}" -if [ -z "$version" ]; then - log "could not read plugin version from $manifest; tracing disabled this session" - exit 0 -fi - -tag="trace-codex-v$version" -asset="codex-hook-$suffix" -url="https://github.com/$REPO/releases/download/$tag/$asset" -tmp="$BIN.download.$$" - -mkdir -p "$ROOT/bin" 2>/dev/null || { - log "could not create $ROOT/bin; tracing disabled this session" - exit 0 +log() { printf 'trace-codex: %s\n' "$1" >&2; } +truthy() { + case "$(printf '%s' "${1:-}" | tr '[:upper:]' '[:lower:]')" in + 1 | true | yes | on) return 0 ;; + *) return 1 ;; + esac } -# The release repo may be private, so the plain browser download URL 404s for -# unauthenticated callers. Try authenticated paths first (gh CLI, then a token -# from the environment via the GitHub API), then fall back to an unauthenticated -# download for the public-repo case. The first method that produces a non-empty -# file wins. Every method is best-effort and never fails the turn. -ok=1 - -# 1) gh CLI: uses the same credentials Codex used to install the plugin, and -# transparently handles private repos. `gh release download` writes the asset -# to the path given by -O. -if [ "$ok" -ne 0 ] && command -v gh >/dev/null 2>&1; then - if gh release download "$tag" \ - --repo "$REPO" \ - --pattern "$asset" \ - --output "$tmp" \ - --clobber >/dev/null 2>&1 && [ -s "$tmp" ]; then - ok=0 - else - rm -f "$tmp" 2>/dev/null - fi -fi - -# 2) Token from the environment + GitHub API. The API asset endpoint with -# Accept: application/octet-stream returns the binary (or a redirect curl -# follows) for private repos when the token is authorized. -token="${GH_TOKEN:-${GITHUB_TOKEN:-}}" -if [ "$ok" -ne 0 ] && [ -n "$token" ] && command -v curl >/dev/null 2>&1; then - api="https://api.github.com/repos/$REPO/releases/tags/$tag" - # Resolve the asset id from the release JSON, then download it by id via the - # API octet-stream endpoint. The asset's "id" precedes its "name" in the JSON, - # so split on commas and track the most recent "id" seen, emitting it when the - # matching "name" line appears. Avoids a jq dependency on this path. - asset_id=$(curl -fsSL \ - -H "Authorization: Bearer $token" \ - -H "Accept: application/vnd.github+json" \ - "$api" 2>/dev/null \ - | tr ',' '\n' \ - | awk -v a="\"$asset\"" ' - /"id"[[:space:]]*:/ { match($0, /[0-9]+/); id = substr($0, RSTART, RLENGTH) } - index($0, "\"name\"") && index($0, a) { print id; exit } - ') - if [ -n "$asset_id" ]; then - curl -fsSL \ - -H "Authorization: Bearer $token" \ - -H "Accept: application/octet-stream" \ - "https://api.github.com/repos/$REPO/releases/assets/$asset_id" \ - -o "$tmp" 2>/dev/null - if [ $? -eq 0 ] && [ -s "$tmp" ]; then - ok=0 - else - rm -f "$tmp" 2>/dev/null - fi - fi +# Tracing remains opt-in. +truthy "${TRACE_TO_BRAINTRUST:-false}" || exit 0 + +# Build the eventual bt invocation before checking availability so a first-run +# event can be replayed after the background installer completes. +ROOT="${PLUGIN_ROOT:-$(CDPATH= cd "$(dirname "$0")/.." && pwd)}" +version=$(sed -n 's/.*"version"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' \ + "$ROOT/.codex-plugin/plugin.json" 2>/dev/null | head -1) + +set -- daemon hook --source codex +[ -z "$version" ] || set -- "$@" --source-version "$version" +truthy "${BRAINTRUST_FLUSH_ON_TURN_END:-false}" && set -- "$@" --flush-on-turn-end +[ -z "${CODEX_PARENT_SPAN_ID:-}" ] || set -- "$@" --parent-span-id "$CODEX_PARENT_SPAN_ID" +[ -z "${CODEX_ROOT_SPAN_ID:-}" ] || set -- "$@" --root-span-id "$CODEX_ROOT_SPAN_ID" +[ -z "${BRAINTRUST_ADDITIONAL_METADATA:-}" ] \ + || set -- "$@" --additional-metadata "$BRAINTRUST_ADDITIONAL_METADATA" + +BT_BIN=$(command -v bt 2>/dev/null || true) +if [ -z "$BT_BIN" ] && [ -x "${XDG_BIN_HOME:-$HOME/.local/bin}/bt" ]; then + BT_BIN="${XDG_BIN_HOME:-$HOME/.local/bin}/bt" fi - -# 3) Unauthenticated download (works when the repo/releases are public). -if [ "$ok" -ne 0 ]; then - if command -v curl >/dev/null 2>&1; then - curl -fsSL "$url" -o "$tmp" 2>/dev/null - ok=$? - elif command -v wget >/dev/null 2>&1; then - wget -q "$url" -O "$tmp" 2>/dev/null - ok=$? +if [ -z "$BT_BIN" ] && command -v curl >/dev/null 2>&1; then + # Never install synchronously in a blocking hook. Securely spool this event, + # then install and forward it in the detached child so a first SessionStart + # does not lose hook-only source/permission metadata. + umask 077 + pending=$(mktemp "${TMPDIR:-/tmp}/trace-codex-event.XXXXXX" 2>/dev/null || true) + if [ -n "$pending" ] && cat >"$pending"; then + nohup sh -c ' + pending=$1 + shift + trap '"'"'rm -f "$pending"'"'"' 0 + curl -fsSL --max-time 20 https://bt.dev/cli/install.sh | sh >/dev/null 2>&1 || exit 0 + bt_bin=$(command -v bt 2>/dev/null || true) + if [ -z "$bt_bin" ] && [ -x "${XDG_BIN_HOME:-$HOME/.local/bin}/bt" ]; then + bt_bin="${XDG_BIN_HOME:-$HOME/.local/bin}/bt" + fi + [ -z "$bt_bin" ] || "$bt_bin" "$@" <"$pending" >/dev/null 2>&1 + ' trace-codex-install "$pending" "$@" /dev/null 2>&1 & + log "bt CLI is unavailable; queued this event behind a background install" else - log "neither curl nor wget found; cannot download binary; tracing disabled this session" - exit 0 + [ -z "$pending" ] || rm -f "$pending" + nohup sh -c 'curl -fsSL --max-time 20 https://bt.dev/cli/install.sh | sh' \ + /dev/null 2>&1 & + log "bt CLI is unavailable; started a background install but could not queue this event" fi + exit 0 fi - -if [ "$ok" -ne 0 ] || [ ! -s "$tmp" ]; then - rm -f "$tmp" 2>/dev/null - log "failed to download $asset for $tag; tracing disabled this session" +if [ -z "$BT_BIN" ]; then + log "bt CLI is unavailable; tracing disabled for this event" exit 0 fi -chmod +x "$tmp" 2>/dev/null -# Atomic rename into place so a concurrent hook never sees a half-written file. -mv -f "$tmp" "$BIN" 2>/dev/null || { - rm -f "$tmp" 2>/dev/null - log "could not install binary at $BIN; tracing disabled this session" - exit 0 -} +# The standalone Codex plugin has historically exposed BRAINTRUST_PROJECT, +# while bt's global project option uses BRAINTRUST_DEFAULT_PROJECT. Preserve +# the documented plugin contract without overriding an explicit bt default. +if [ -z "${BRAINTRUST_DEFAULT_PROJECT:-}" ] && [ -n "${BRAINTRUST_PROJECT:-}" ]; then + export BRAINTRUST_DEFAULT_PROJECT="$BRAINTRUST_PROJECT" +fi -log "downloaded codex-hook $version ($suffix)" -exec "$BIN" "$@" +"$BT_BIN" "$@" || log "bt daemon hook failed non-fatally" +exit 0 diff --git a/src/plugins/codex/content/plugins/trace-codex/biome.json b/src/plugins/codex/content/plugins/trace-codex/biome.json deleted file mode 100644 index 4391881..0000000 --- a/src/plugins/codex/content/plugins/trace-codex/biome.json +++ /dev/null @@ -1,29 +0,0 @@ -{ - "$schema": "https://biomejs.dev/schemas/2.3.11/schema.json", - "vcs": { - "enabled": true, - "clientKind": "git", - "useIgnoreFile": true - }, - "files": { - "ignoreUnknown": true - }, - "formatter": { - "enabled": true, - "indentStyle": "space", - "indentWidth": 2, - "lineWidth": 100 - }, - "linter": { - "enabled": true, - "rules": { - "recommended": true - } - }, - "javascript": { - "formatter": { - "quoteStyle": "double", - "semicolons": "always" - } - } -} diff --git a/src/plugins/codex/content/plugins/trace-codex/config.json.example b/src/plugins/codex/content/plugins/trace-codex/config.json.example deleted file mode 100644 index 5fba6fb..0000000 --- a/src/plugins/codex/content/plugins/trace-codex/config.json.example +++ /dev/null @@ -1,16 +0,0 @@ -{ - "_comment": "Copy this file to your plugin data dir as config.json (e.g. ~/.codex/plugins/data/trace-codex-/config.json) and fill in your values. All keys are optional. Environment variables override these values. See README.md for details.", - "traceToBraintrust": true, - "apiKey": "sk-...", - "apiUrl": "https://api.braintrust.dev", - "appUrl": "https://www.braintrust.dev", - "project": "my-codex-project", - "additionalMetadata": { "team": "platform", "env": "dev" }, - "parentSpanId": "", - "rootSpanId": "", - "flushOnTurnEnd": false, - "recordFile": "", - "port": 52734, - "idleTimeoutMs": 300000, - "idleCheckIntervalMs": 30000 -} diff --git a/src/plugins/codex/content/plugins/trace-codex/package.json b/src/plugins/codex/content/plugins/trace-codex/package.json deleted file mode 100644 index 5f6d0ab..0000000 --- a/src/plugins/codex/content/plugins/trace-codex/package.json +++ /dev/null @@ -1,39 +0,0 @@ -{ - "name": "trace-codex", - "version": "0.1.0", - "private": true, - "description": "Background event server and hook client for tracing Codex sessions to Braintrust.", - "type": "module", - "scripts": { - "build": "tsx scripts/build.ts", - "dev": "tsx --watch src/index.ts serve", - "test": "vitest run", - "test:watch": "vitest", - "typecheck": "tsc --noEmit", - "lint": "biome lint src scripts", - "lint:fix": "biome lint --write src scripts", - "format": "biome format --write src scripts", - "check": "biome check src scripts" - }, - "keywords": [ - "braintrust", - "tracing", - "observability", - "codex", - "hooks" - ], - "author": "Braintrust", - "license": "MIT", - "devDependencies": { - "@biomejs/biome": "^2.3.11", - "@types/node": "^24.3.0", - "@yao-pkg/pkg": "^6.6.0", - "tsup": "^8.5.1", - "tsx": "^4.21.0", - "typescript": "^5.7.2", - "vitest": "^4.1.5" - }, - "dependencies": { - "braintrust": "^3.17.0" - } -} diff --git a/src/plugins/codex/content/plugins/trace-codex/pnpm-lock.yaml b/src/plugins/codex/content/plugins/trace-codex/pnpm-lock.yaml deleted file mode 100644 index f195faa..0000000 --- a/src/plugins/codex/content/plugins/trace-codex/pnpm-lock.yaml +++ /dev/null @@ -1,4059 +0,0 @@ -lockfileVersion: '9.0' - -settings: - autoInstallPeers: true - excludeLinksFromLockfile: false - -importers: - - .: - dependencies: - braintrust: - specifier: ^3.17.0 - version: 3.23.1(zod@4.4.3) - devDependencies: - '@biomejs/biome': - specifier: ^2.3.11 - version: 2.5.4 - '@types/node': - specifier: ^24.3.0 - version: 24.13.3 - '@yao-pkg/pkg': - specifier: ^6.6.0 - version: 6.21.0 - tsup: - specifier: ^8.5.1 - version: 8.5.1(postcss@8.5.20)(tsx@4.23.1)(typescript@5.9.3) - tsx: - specifier: ^4.21.0 - version: 4.23.1 - typescript: - specifier: ^5.7.2 - version: 5.9.3 - vitest: - specifier: ^4.1.5 - version: 4.1.10(@types/node@24.13.3)(vite@8.1.5(@types/node@24.13.3)(esbuild@0.27.7)(tsx@4.23.1)) - -packages: - - '@babel/code-frame@7.29.7': - resolution: {integrity: sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==} - engines: {node: '>=6.9.0'} - - '@babel/generator@7.29.7': - resolution: {integrity: sha512-DkXD5OJQaAQIdZ1bt3UZdEnHAn9Imd3IVBdX03UFe+ony9Ojw5pzr9YVKGDY1jt+Gcn/FnGkNf8r+Vj5NOJWtQ==} - engines: {node: '>=6.9.0'} - - '@babel/helper-globals@7.29.7': - resolution: {integrity: sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==} - engines: {node: '>=6.9.0'} - - '@babel/helper-string-parser@7.29.7': - resolution: {integrity: sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==} - engines: {node: '>=6.9.0'} - - '@babel/helper-validator-identifier@7.29.7': - resolution: {integrity: sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==} - engines: {node: '>=6.9.0'} - - '@babel/parser@7.29.7': - resolution: {integrity: sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==} - engines: {node: '>=6.0.0'} - hasBin: true - - '@babel/template@7.29.7': - resolution: {integrity: sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==} - engines: {node: '>=6.9.0'} - - '@babel/traverse@7.29.7': - resolution: {integrity: sha512-EhlfNQtZ+NK22w5BM61ciuiq1m58ed33Wr1Xan//ZRTy6hgjnwyCffRYwzsGXdASJSUJ1guZILsErh1eQcl+zw==} - engines: {node: '>=6.9.0'} - - '@babel/types@7.29.7': - resolution: {integrity: sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==} - engines: {node: '>=6.9.0'} - - '@biomejs/biome@2.5.4': - resolution: {integrity: sha512-xy5FNE5kQJKyK5MR1gJy6ztXYx4WBAbYGlK04lMEgmyPRWKybY9NFwiG9yo0XdzOU8Xvhj41u034J1ywfoWfMw==} - engines: {node: '>=14.21.3'} - hasBin: true - - '@biomejs/cli-darwin-arm64@2.5.4': - resolution: {integrity: sha512-4o3NFRobXHynkgcFVrlZsoDAFtF2ldlEGN8sORSws5ZQqyY4PXnPUIylu4ksfyHuwkfvDREuWh3JK+niRwGq3w==} - engines: {node: '>=14.21.3'} - cpu: [arm64] - os: [darwin] - - '@biomejs/cli-darwin-x64@2.5.4': - resolution: {integrity: sha512-D32P5HkU2Y6PySuC/WsVDTOgsDwVFmujzhhhOQjajtATpVWFDXuVd3oRbsWNSEA+aaFzyzZm22szsyydBYlSyQ==} - engines: {node: '>=14.21.3'} - cpu: [x64] - os: [darwin] - - '@biomejs/cli-linux-arm64-musl@2.5.4': - resolution: {integrity: sha512-Rpm5/AT1m+DlJmUoYvS4/vXc+0tXJPJ2NQz25TGPyHVF5JrWy75PE0GH6kVxsKtQDuCH4OgzquZq0R4kj/wCVg==} - engines: {node: '>=14.21.3'} - cpu: [arm64] - os: [linux] - libc: [musl] - - '@biomejs/cli-linux-arm64@2.5.4': - resolution: {integrity: sha512-pSEfW7B8kTsXUjUxC1xVVK+y85Ht3C5XxZ9gclmC7/3Ku9Vqz8jmI7k0p/BNIjQ6t4sFERI2sFeH73ybiZl6YQ==} - engines: {node: '>=14.21.3'} - cpu: [arm64] - os: [linux] - libc: [glibc] - - '@biomejs/cli-linux-x64-musl@2.5.4': - resolution: {integrity: sha512-aby/PohmmgbShcHqFsZVzG8H6D98+P+A6xRWRrQcLW1pCjabcov5UUlke4UqNQBYTkDQav+jB4zyyDDeKB2GaA==} - engines: {node: '>=14.21.3'} - cpu: [x64] - os: [linux] - libc: [musl] - - '@biomejs/cli-linux-x64@2.5.4': - resolution: {integrity: sha512-FNxojWJkL7EajAuzBgoLe0T2G0y112M4lBrDIFl/DomFTx8yqenYOIdsRLNXvOvBBofE8hJi85LjzLmBDpY7/Q==} - engines: {node: '>=14.21.3'} - cpu: [x64] - os: [linux] - libc: [glibc] - - '@biomejs/cli-win32-arm64@2.5.4': - resolution: {integrity: sha512-emoXexPZIPAZkz2RKmA95WJUqK3I5MJNYtwEbL5ESciRzhmFMMyekDhNG8hpeOaK+ZGRDxAU4wvGuA5IHQ0h0w==} - engines: {node: '>=14.21.3'} - cpu: [arm64] - os: [win32] - - '@biomejs/cli-win32-x64@2.5.4': - resolution: {integrity: sha512-U1jaluLw1qQc2Tx7/CeSoL9N5XcqIH+GWjpUAy1ouB5nVjSCMNO+NNHdY3RAs8zxNurLWAdj6pehQdCA2zyU+Q==} - engines: {node: '>=14.21.3'} - cpu: [x64] - os: [win32] - - '@braintrust/bt-darwin-arm64@0.12.0': - resolution: {integrity: sha512-mY6VW/3VwcOQOGN8sYHS6F0xzHTFwgZcNlj7zlQttI6OXOCGt/bhonGIqd03QBhxmj0M31ymSS7TqSeCK/RYIQ==} - cpu: [arm64] - os: [darwin] - - '@braintrust/bt-darwin-x64@0.12.0': - resolution: {integrity: sha512-woyRyDv2DfCF8+von+3X9f1cddAbFnKLSfCbEkrBQVc0ZsAM60as8nehYv7DkafJF/67yKFx/sUraV65sukesQ==} - cpu: [x64] - os: [darwin] - - '@braintrust/bt-linux-arm64@0.12.0': - resolution: {integrity: sha512-J9/7f3EIMKmFmSSQHQnAQLpUgB8YJENrOlkABjlTprBgsIYVgz7tSH7yg2P3ur+2Jem7PpGnrDxHGh/UjRfjsg==} - cpu: [arm64] - os: [linux] - libc: [glibc] - - '@braintrust/bt-linux-x64-musl@0.12.0': - resolution: {integrity: sha512-KnLgENOoztBXcH+mLFJe4bYzi67kSOuELOQrm4Hler35GjgaBMI1fFLIUjKRPAmyT9VIhBMhy1ICfGEFLueMEQ==} - cpu: [x64] - os: [linux] - libc: [musl] - - '@braintrust/bt-linux-x64@0.12.0': - resolution: {integrity: sha512-HJFbUl3HYYY1Ivw8OYBeIMcIsU9r7+fC9BzLWB5FdH7881ToKee2wbbLZssMCxFbMVeUxzEo+SzGD/1Z9Gk9CA==} - cpu: [x64] - os: [linux] - libc: [glibc] - - '@braintrust/bt-win32-arm64@0.12.0': - resolution: {integrity: sha512-+7PkdBAmiqEJsWteGHP/Zs62F75PkHPA8m/ej4TOz/mHGKulUU0bZibw91KRqIB7J7jGi5ItvCiqkiGaLKLnyw==} - cpu: [arm64] - os: [win32] - - '@braintrust/bt-win32-x64@0.12.0': - resolution: {integrity: sha512-Q0c+pVUGm82n/7N8QJ5s6j/G/Q0GFzqOaTipHjkmElLbAOOEcfRH/uljdtIPNBiEQcrzqirS0GmOgiFkeQ3Txg==} - cpu: [x64] - os: [win32] - - '@colors/colors@1.5.0': - resolution: {integrity: sha512-ooWCrlZP11i8GImSjTHYHLkvFDP48nS4+204nGb1RiX/WXYHmJA2III9/e2DWVabCESdW7hBAEzHRqUn9OUVvQ==} - engines: {node: '>=0.1.90'} - - '@emnapi/core@1.11.1': - resolution: {integrity: sha512-RSvbQmHzdKzNsLYa/wHrbc3KN4sYLKAdPZxqiM2HATqv/SBk2/ENSHpvXGaLOMcsAyz0poEGqkmmKYG3OWiJEQ==} - - '@emnapi/runtime@1.11.1': - resolution: {integrity: sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw==} - - '@emnapi/wasi-threads@1.2.2': - resolution: {integrity: sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA==} - - '@esbuild/aix-ppc64@0.27.7': - resolution: {integrity: sha512-EKX3Qwmhz1eMdEJokhALr0YiD0lhQNwDqkPYyPhiSwKrh7/4KRjQc04sZ8db+5DVVnZ1LmbNDI1uAMPEUBnQPg==} - engines: {node: '>=18'} - cpu: [ppc64] - os: [aix] - - '@esbuild/aix-ppc64@0.28.0': - resolution: {integrity: sha512-lhRUCeuOyJQURhTxl4WkpFTjIsbDayJHih5kZC1giwE+MhIzAb7mEsQMqMf18rHLsrb5qI1tafG20mLxEWcWlA==} - engines: {node: '>=18'} - cpu: [ppc64] - os: [aix] - - '@esbuild/aix-ppc64@0.28.1': - resolution: {integrity: sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==} - engines: {node: '>=18'} - cpu: [ppc64] - os: [aix] - - '@esbuild/android-arm64@0.27.7': - resolution: {integrity: sha512-62dPZHpIXzvChfvfLJow3q5dDtiNMkwiRzPylSCfriLvZeq0a1bWChrGx/BbUbPwOrsWKMn8idSllklzBy+dgQ==} - engines: {node: '>=18'} - cpu: [arm64] - os: [android] - - '@esbuild/android-arm64@0.28.0': - resolution: {integrity: sha512-+WzIXQOSaGs33tLEgYPYe/yQHf0WTU0X42Jca3y8NWMbUVhp7rUnw+vAsRC/QiDrdD31IszMrZy+qwPOPjd+rw==} - engines: {node: '>=18'} - cpu: [arm64] - os: [android] - - '@esbuild/android-arm64@0.28.1': - resolution: {integrity: sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==} - engines: {node: '>=18'} - cpu: [arm64] - os: [android] - - '@esbuild/android-arm@0.27.7': - resolution: {integrity: sha512-jbPXvB4Yj2yBV7HUfE2KHe4GJX51QplCN1pGbYjvsyCZbQmies29EoJbkEc+vYuU5o45AfQn37vZlyXy4YJ8RQ==} - engines: {node: '>=18'} - cpu: [arm] - os: [android] - - '@esbuild/android-arm@0.28.0': - resolution: {integrity: sha512-wqh0ByljabXLKHeWXYLqoJ5jKC4XBaw6Hk08OfMrCRd2nP2ZQ5eleDZC41XHyCNgktBGYMbqnrJKq/K/lzPMSQ==} - engines: {node: '>=18'} - cpu: [arm] - os: [android] - - '@esbuild/android-arm@0.28.1': - resolution: {integrity: sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==} - engines: {node: '>=18'} - cpu: [arm] - os: [android] - - '@esbuild/android-x64@0.27.7': - resolution: {integrity: sha512-x5VpMODneVDb70PYV2VQOmIUUiBtY3D3mPBG8NxVk5CogneYhkR7MmM3yR/uMdITLrC1ml/NV1rj4bMJuy9MCg==} - engines: {node: '>=18'} - cpu: [x64] - os: [android] - - '@esbuild/android-x64@0.28.0': - resolution: {integrity: sha512-+VJggoaKhk2VNNqVL7f6S189UzShHC/mR9EE8rDdSkdpN0KflSwWY/gWjDrNxxisg8Fp1ZCD9jLMo4m0OUfeUA==} - engines: {node: '>=18'} - cpu: [x64] - os: [android] - - '@esbuild/android-x64@0.28.1': - resolution: {integrity: sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==} - engines: {node: '>=18'} - cpu: [x64] - os: [android] - - '@esbuild/darwin-arm64@0.27.7': - resolution: {integrity: sha512-5lckdqeuBPlKUwvoCXIgI2D9/ABmPq3Rdp7IfL70393YgaASt7tbju3Ac+ePVi3KDH6N2RqePfHnXkaDtY9fkw==} - engines: {node: '>=18'} - cpu: [arm64] - os: [darwin] - - '@esbuild/darwin-arm64@0.28.0': - resolution: {integrity: sha512-0T+A9WZm+bZ84nZBtk1ckYsOvyA3x7e2Acj1KdVfV4/2tdG4fzUp91YHx+GArWLtwqp77pBXVCPn2We7Letr0Q==} - engines: {node: '>=18'} - cpu: [arm64] - os: [darwin] - - '@esbuild/darwin-arm64@0.28.1': - resolution: {integrity: sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==} - engines: {node: '>=18'} - cpu: [arm64] - os: [darwin] - - '@esbuild/darwin-x64@0.27.7': - resolution: {integrity: sha512-rYnXrKcXuT7Z+WL5K980jVFdvVKhCHhUwid+dDYQpH+qu+TefcomiMAJpIiC2EM3Rjtq0sO3StMV/+3w3MyyqQ==} - engines: {node: '>=18'} - cpu: [x64] - os: [darwin] - - '@esbuild/darwin-x64@0.28.0': - resolution: {integrity: sha512-fyzLm/DLDl/84OCfp2f/XQ4flmORsjU7VKt8HLjvIXChJoFFOIL6pLJPH4Yhd1n1gGFF9mPwtlN5Wf82DZs+LQ==} - engines: {node: '>=18'} - cpu: [x64] - os: [darwin] - - '@esbuild/darwin-x64@0.28.1': - resolution: {integrity: sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==} - engines: {node: '>=18'} - cpu: [x64] - os: [darwin] - - '@esbuild/freebsd-arm64@0.27.7': - resolution: {integrity: sha512-B48PqeCsEgOtzME2GbNM2roU29AMTuOIN91dsMO30t+Ydis3z/3Ngoj5hhnsOSSwNzS+6JppqWsuhTp6E82l2w==} - engines: {node: '>=18'} - cpu: [arm64] - os: [freebsd] - - '@esbuild/freebsd-arm64@0.28.0': - resolution: {integrity: sha512-l9GeW5UZBT9k9brBYI+0WDffcRxgHQD8ShN2Ur4xWq/NFzUKm3k5lsH4PdaRgb2w7mI9u61nr2gI2mLI27Nh3Q==} - engines: {node: '>=18'} - cpu: [arm64] - os: [freebsd] - - '@esbuild/freebsd-arm64@0.28.1': - resolution: {integrity: sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==} - engines: {node: '>=18'} - cpu: [arm64] - os: [freebsd] - - '@esbuild/freebsd-x64@0.27.7': - resolution: {integrity: sha512-jOBDK5XEjA4m5IJK3bpAQF9/Lelu/Z9ZcdhTRLf4cajlB+8VEhFFRjWgfy3M1O4rO2GQ/b2dLwCUGpiF/eATNQ==} - engines: {node: '>=18'} - cpu: [x64] - os: [freebsd] - - '@esbuild/freebsd-x64@0.28.0': - resolution: {integrity: sha512-BXoQai/A0wPO6Es3yFJ7APCiKGc1tdAEOgeTNy3SsB491S3aHn4S4r3e976eUnPdU+NbdtmBuLncYir2tMU9Nw==} - engines: {node: '>=18'} - cpu: [x64] - os: [freebsd] - - '@esbuild/freebsd-x64@0.28.1': - resolution: {integrity: sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==} - engines: {node: '>=18'} - cpu: [x64] - os: [freebsd] - - '@esbuild/linux-arm64@0.27.7': - resolution: {integrity: sha512-RZPHBoxXuNnPQO9rvjh5jdkRmVizktkT7TCDkDmQ0W2SwHInKCAV95GRuvdSvA7w4VMwfCjUiPwDi0ZO6Nfe9A==} - engines: {node: '>=18'} - cpu: [arm64] - os: [linux] - - '@esbuild/linux-arm64@0.28.0': - resolution: {integrity: sha512-RVyzfb3FWsGA55n6WY0MEIEPURL1FcbhFE6BffZEMEekfCzCIMtB5yyDcFnVbTnwk+CLAgTujmV/Lgvih56W+A==} - engines: {node: '>=18'} - cpu: [arm64] - os: [linux] - - '@esbuild/linux-arm64@0.28.1': - resolution: {integrity: sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==} - engines: {node: '>=18'} - cpu: [arm64] - os: [linux] - - '@esbuild/linux-arm@0.27.7': - resolution: {integrity: sha512-RkT/YXYBTSULo3+af8Ib0ykH8u2MBh57o7q/DAs3lTJlyVQkgQvlrPTnjIzzRPQyavxtPtfg0EopvDyIt0j1rA==} - engines: {node: '>=18'} - cpu: [arm] - os: [linux] - - '@esbuild/linux-arm@0.28.0': - resolution: {integrity: sha512-CjaaREJagqJp7iTaNQjjidaNbCKYcd4IDkzbwwxtSvjI7NZm79qiHc8HqciMddQ6CKvJT6aBd8lO9kN/ZudLlw==} - engines: {node: '>=18'} - cpu: [arm] - os: [linux] - - '@esbuild/linux-arm@0.28.1': - resolution: {integrity: sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==} - engines: {node: '>=18'} - cpu: [arm] - os: [linux] - - '@esbuild/linux-ia32@0.27.7': - resolution: {integrity: sha512-GA48aKNkyQDbd3KtkplYWT102C5sn/EZTY4XROkxONgruHPU72l+gW+FfF8tf2cFjeHaRbWpOYa/uRBz/Xq1Pg==} - engines: {node: '>=18'} - cpu: [ia32] - os: [linux] - - '@esbuild/linux-ia32@0.28.0': - resolution: {integrity: sha512-KBnSTt1kxl9x70q+ydterVdl+Cn0H18ngRMRCEQfrbqdUuntQQ0LoMZv47uB97NljZFzY6HcfqEZ2SAyIUTQBQ==} - engines: {node: '>=18'} - cpu: [ia32] - os: [linux] - - '@esbuild/linux-ia32@0.28.1': - resolution: {integrity: sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==} - engines: {node: '>=18'} - cpu: [ia32] - os: [linux] - - '@esbuild/linux-loong64@0.27.7': - resolution: {integrity: sha512-a4POruNM2oWsD4WKvBSEKGIiWQF8fZOAsycHOt6JBpZ+JN2n2JH9WAv56SOyu9X5IqAjqSIPTaJkqN8F7XOQ5Q==} - engines: {node: '>=18'} - cpu: [loong64] - os: [linux] - - '@esbuild/linux-loong64@0.28.0': - resolution: {integrity: sha512-zpSlUce1mnxzgBADvxKXX5sl8aYQHo2ezvMNI8I0lbblJtp8V4odlm3Yzlj7gPyt3T8ReksE6bK+pT3WD+aJRg==} - engines: {node: '>=18'} - cpu: [loong64] - os: [linux] - - '@esbuild/linux-loong64@0.28.1': - resolution: {integrity: sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==} - engines: {node: '>=18'} - cpu: [loong64] - os: [linux] - - '@esbuild/linux-mips64el@0.27.7': - resolution: {integrity: sha512-KabT5I6StirGfIz0FMgl1I+R1H73Gp0ofL9A3nG3i/cYFJzKHhouBV5VWK1CSgKvVaG4q1RNpCTR2LuTVB3fIw==} - engines: {node: '>=18'} - cpu: [mips64el] - os: [linux] - - '@esbuild/linux-mips64el@0.28.0': - resolution: {integrity: sha512-2jIfP6mmjkdmeTlsX/9vmdmhBmKADrWqN7zcdtHIeNSCH1SqIoNI63cYsjQR8J+wGa4Y5izRcSHSm8K3QWmk3w==} - engines: {node: '>=18'} - cpu: [mips64el] - os: [linux] - - '@esbuild/linux-mips64el@0.28.1': - resolution: {integrity: sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==} - engines: {node: '>=18'} - cpu: [mips64el] - os: [linux] - - '@esbuild/linux-ppc64@0.27.7': - resolution: {integrity: sha512-gRsL4x6wsGHGRqhtI+ifpN/vpOFTQtnbsupUF5R5YTAg+y/lKelYR1hXbnBdzDjGbMYjVJLJTd2OFmMewAgwlQ==} - engines: {node: '>=18'} - cpu: [ppc64] - os: [linux] - - '@esbuild/linux-ppc64@0.28.0': - resolution: {integrity: sha512-bc0FE9wWeC0WBm49IQMPSPILRocGTQt3j5KPCA8os6VprfuJ7KD+5PzESSrJ6GmPIPJK965ZJHTUlSA6GNYEhg==} - engines: {node: '>=18'} - cpu: [ppc64] - os: [linux] - - '@esbuild/linux-ppc64@0.28.1': - resolution: {integrity: sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==} - engines: {node: '>=18'} - cpu: [ppc64] - os: [linux] - - '@esbuild/linux-riscv64@0.27.7': - resolution: {integrity: sha512-hL25LbxO1QOngGzu2U5xeXtxXcW+/GvMN3ejANqXkxZ/opySAZMrc+9LY/WyjAan41unrR3YrmtTsUpwT66InQ==} - engines: {node: '>=18'} - cpu: [riscv64] - os: [linux] - - '@esbuild/linux-riscv64@0.28.0': - resolution: {integrity: sha512-SQPZOwoTTT/HXFXQJG/vBX8sOFagGqvZyXcgLA3NhIqcBv1BJU1d46c0rGcrij2B56Z2rNiSLaZOYW5cUk7yLQ==} - engines: {node: '>=18'} - cpu: [riscv64] - os: [linux] - - '@esbuild/linux-riscv64@0.28.1': - resolution: {integrity: sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==} - engines: {node: '>=18'} - cpu: [riscv64] - os: [linux] - - '@esbuild/linux-s390x@0.27.7': - resolution: {integrity: sha512-2k8go8Ycu1Kb46vEelhu1vqEP+UeRVj2zY1pSuPdgvbd5ykAw82Lrro28vXUrRmzEsUV0NzCf54yARIK8r0fdw==} - engines: {node: '>=18'} - cpu: [s390x] - os: [linux] - - '@esbuild/linux-s390x@0.28.0': - resolution: {integrity: sha512-SCfR0HN8CEEjnYnySJTd2cw0k9OHB/YFzt5zgJEwa+wL/T/raGWYMBqwDNAC6dqFKmJYZoQBRfHjgwLHGSrn3Q==} - engines: {node: '>=18'} - cpu: [s390x] - os: [linux] - - '@esbuild/linux-s390x@0.28.1': - resolution: {integrity: sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==} - engines: {node: '>=18'} - cpu: [s390x] - os: [linux] - - '@esbuild/linux-x64@0.27.7': - resolution: {integrity: sha512-hzznmADPt+OmsYzw1EE33ccA+HPdIqiCRq7cQeL1Jlq2gb1+OyWBkMCrYGBJ+sxVzve2ZJEVeePbLM2iEIZSxA==} - engines: {node: '>=18'} - cpu: [x64] - os: [linux] - - '@esbuild/linux-x64@0.28.0': - resolution: {integrity: sha512-us0dSb9iFxIi8srnpl931Nvs65it/Jd2a2K3qs7fz2WfGPHqzfzZTfec7oxZJRNPXPnNYZtanmRc4AL/JwVzHQ==} - engines: {node: '>=18'} - cpu: [x64] - os: [linux] - - '@esbuild/linux-x64@0.28.1': - resolution: {integrity: sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==} - engines: {node: '>=18'} - cpu: [x64] - os: [linux] - - '@esbuild/netbsd-arm64@0.27.7': - resolution: {integrity: sha512-b6pqtrQdigZBwZxAn1UpazEisvwaIDvdbMbmrly7cDTMFnw/+3lVxxCTGOrkPVnsYIosJJXAsILG9XcQS+Yu6w==} - engines: {node: '>=18'} - cpu: [arm64] - os: [netbsd] - - '@esbuild/netbsd-arm64@0.28.0': - resolution: {integrity: sha512-CR/RYotgtCKwtftMwJlUU7xCVNg3lMYZ0RzTmAHSfLCXw3NtZtNpswLEj/Kkf6kEL3Gw+BpOekRX0BYCtklhUw==} - engines: {node: '>=18'} - cpu: [arm64] - os: [netbsd] - - '@esbuild/netbsd-arm64@0.28.1': - resolution: {integrity: sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==} - engines: {node: '>=18'} - cpu: [arm64] - os: [netbsd] - - '@esbuild/netbsd-x64@0.27.7': - resolution: {integrity: sha512-OfatkLojr6U+WN5EDYuoQhtM+1xco+/6FSzJJnuWiUw5eVcicbyK3dq5EeV/QHT1uy6GoDhGbFpprUiHUYggrw==} - engines: {node: '>=18'} - cpu: [x64] - os: [netbsd] - - '@esbuild/netbsd-x64@0.28.0': - resolution: {integrity: sha512-nU1yhmYutL+fQ71Kxnhg8uEOdC0pwEW9entHykTgEbna2pw2dkbFSMeqjjyHZoCmt8SBkOSvV+yNmm94aUrrqw==} - engines: {node: '>=18'} - cpu: [x64] - os: [netbsd] - - '@esbuild/netbsd-x64@0.28.1': - resolution: {integrity: sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==} - engines: {node: '>=18'} - cpu: [x64] - os: [netbsd] - - '@esbuild/openbsd-arm64@0.27.7': - resolution: {integrity: sha512-AFuojMQTxAz75Fo8idVcqoQWEHIXFRbOc1TrVcFSgCZtQfSdc1RXgB3tjOn/krRHENUB4j00bfGjyl2mJrU37A==} - engines: {node: '>=18'} - cpu: [arm64] - os: [openbsd] - - '@esbuild/openbsd-arm64@0.28.0': - resolution: {integrity: sha512-cXb5vApOsRsxsEl4mcZ1XY3D4DzcoMxR/nnc4IyqYs0rTI8ZKmW6kyyg+11Z8yvgMfAEldKzP7AdP64HnSC/6g==} - engines: {node: '>=18'} - cpu: [arm64] - os: [openbsd] - - '@esbuild/openbsd-arm64@0.28.1': - resolution: {integrity: sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==} - engines: {node: '>=18'} - cpu: [arm64] - os: [openbsd] - - '@esbuild/openbsd-x64@0.27.7': - resolution: {integrity: sha512-+A1NJmfM8WNDv5CLVQYJ5PshuRm/4cI6WMZRg1by1GwPIQPCTs1GLEUHwiiQGT5zDdyLiRM/l1G0Pv54gvtKIg==} - engines: {node: '>=18'} - cpu: [x64] - os: [openbsd] - - '@esbuild/openbsd-x64@0.28.0': - resolution: {integrity: sha512-8wZM2qqtv9UP3mzy7HiGYNH/zjTA355mpeuA+859TyR+e+Tc08IHYpLJuMsfpDJwoLo1ikIJI8jC3GFjnRClzA==} - engines: {node: '>=18'} - cpu: [x64] - os: [openbsd] - - '@esbuild/openbsd-x64@0.28.1': - resolution: {integrity: sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==} - engines: {node: '>=18'} - cpu: [x64] - os: [openbsd] - - '@esbuild/openharmony-arm64@0.27.7': - resolution: {integrity: sha512-+KrvYb/C8zA9CU/g0sR6w2RBw7IGc5J2BPnc3dYc5VJxHCSF1yNMxTV5LQ7GuKteQXZtspjFbiuW5/dOj7H4Yw==} - engines: {node: '>=18'} - cpu: [arm64] - os: [openharmony] - - '@esbuild/openharmony-arm64@0.28.0': - resolution: {integrity: sha512-FLGfyizszcef5C3YtoyQDACyg95+dndv79i2EekILBofh5wpCa1KuBqOWKrEHZg3zrL3t5ouE5jgr94vA+Wb2w==} - engines: {node: '>=18'} - cpu: [arm64] - os: [openharmony] - - '@esbuild/openharmony-arm64@0.28.1': - resolution: {integrity: sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==} - engines: {node: '>=18'} - cpu: [arm64] - os: [openharmony] - - '@esbuild/sunos-x64@0.27.7': - resolution: {integrity: sha512-ikktIhFBzQNt/QDyOL580ti9+5mL/YZeUPKU2ivGtGjdTYoqz6jObj6nOMfhASpS4GU4Q/Clh1QtxWAvcYKamA==} - engines: {node: '>=18'} - cpu: [x64] - os: [sunos] - - '@esbuild/sunos-x64@0.28.0': - resolution: {integrity: sha512-1ZgjUoEdHZZl/YlV76TSCz9Hqj9h9YmMGAgAPYd+q4SicWNX3G5GCyx9uhQWSLcbvPW8Ni7lj4gDa1T40akdlw==} - engines: {node: '>=18'} - cpu: [x64] - os: [sunos] - - '@esbuild/sunos-x64@0.28.1': - resolution: {integrity: sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==} - engines: {node: '>=18'} - cpu: [x64] - os: [sunos] - - '@esbuild/win32-arm64@0.27.7': - resolution: {integrity: sha512-7yRhbHvPqSpRUV7Q20VuDwbjW5kIMwTHpptuUzV+AA46kiPze5Z7qgt6CLCK3pWFrHeNfDd1VKgyP4O+ng17CA==} - engines: {node: '>=18'} - cpu: [arm64] - os: [win32] - - '@esbuild/win32-arm64@0.28.0': - resolution: {integrity: sha512-Q9StnDmQ/enxnpxCCLSg0oo4+34B9TdXpuyPeTedN/6+iXBJ4J+zwfQI28u/Jl40nOYAxGoNi7mFP40RUtkmUA==} - engines: {node: '>=18'} - cpu: [arm64] - os: [win32] - - '@esbuild/win32-arm64@0.28.1': - resolution: {integrity: sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==} - engines: {node: '>=18'} - cpu: [arm64] - os: [win32] - - '@esbuild/win32-ia32@0.27.7': - resolution: {integrity: sha512-SmwKXe6VHIyZYbBLJrhOoCJRB/Z1tckzmgTLfFYOfpMAx63BJEaL9ExI8x7v0oAO3Zh6D/Oi1gVxEYr5oUCFhw==} - engines: {node: '>=18'} - cpu: [ia32] - os: [win32] - - '@esbuild/win32-ia32@0.28.0': - resolution: {integrity: sha512-zF3ag/gfiCe6U2iczcRzSYJKH1DCI+ByzSENHlM2FcDbEeo5Zd2C86Aq0tKUYAJJ1obRP84ymxIAksZUcdztHA==} - engines: {node: '>=18'} - cpu: [ia32] - os: [win32] - - '@esbuild/win32-ia32@0.28.1': - resolution: {integrity: sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==} - engines: {node: '>=18'} - cpu: [ia32] - os: [win32] - - '@esbuild/win32-x64@0.27.7': - resolution: {integrity: sha512-56hiAJPhwQ1R4i+21FVF7V8kSD5zZTdHcVuRFMW0hn753vVfQN8xlx4uOPT4xoGH0Z/oVATuR82AiqSTDIpaHg==} - engines: {node: '>=18'} - cpu: [x64] - os: [win32] - - '@esbuild/win32-x64@0.28.0': - resolution: {integrity: sha512-pEl1bO9mfAmIC+tW5btTmrKaujg3zGtUmWNdCw/xs70FBjwAL3o9OEKNHvNmnyylD6ubxUERiEhdsL0xBQ9efw==} - engines: {node: '>=18'} - cpu: [x64] - os: [win32] - - '@esbuild/win32-x64@0.28.1': - resolution: {integrity: sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==} - engines: {node: '>=18'} - cpu: [x64] - os: [win32] - - '@isaacs/fs-minipass@4.0.1': - resolution: {integrity: sha512-wgm9Ehl2jpeqP3zw/7mo3kRHFp5MEDhqAdwy1fTGkHAwnkGOVsgpvQhL8B5n1qlb01jV3n/bI0ZfZp5lWA1k4w==} - engines: {node: '>=18.0.0'} - - '@jridgewell/gen-mapping@0.3.13': - resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==} - - '@jridgewell/remapping@2.3.5': - resolution: {integrity: sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==} - - '@jridgewell/resolve-uri@3.1.2': - resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==} - engines: {node: '>=6.0.0'} - - '@jridgewell/sourcemap-codec@1.5.5': - resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==} - - '@jridgewell/trace-mapping@0.3.31': - resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} - - '@kwsites/file-exists@1.1.1': - resolution: {integrity: sha512-m9/5YGR18lIwxSFDwfE3oA7bWuq9kdau6ugN4H2rJeyhFQZcG9AgSHkQtSD15a8WvTgfz9aikZMrKPHvbpqFiw==} - - '@kwsites/promise-deferred@1.1.1': - resolution: {integrity: sha512-GaHYm+c0O9MjZRu0ongGBRbinu8gVAMd2UZjji6jVmqKtZluZnptXGWhz1E8j8D2HJ3f/yMxKAUC0b+57wncIw==} - - '@napi-rs/wasm-runtime@1.1.6': - resolution: {integrity: sha512-ZLv/JdUfkvOy9eCnnBaGfiO+XimbjebAeO+MRQqD/B+FR1tnRN0tpKSJHRbE8sFfS6aqsXZ67TQjfwfsxULVbg==} - peerDependencies: - '@emnapi/core': ^1.7.1 - '@emnapi/runtime': ^1.7.1 - - '@next/env@14.2.35': - resolution: {integrity: sha512-DuhvCtj4t9Gwrx80dmz2F4t/zKQ4ktN8WrMwOuVzkJfBilwAwGr6v16M5eI8yCuZ63H9TTuEU09Iu2HqkzFPVQ==} - - '@oxc-project/types@0.139.0': - resolution: {integrity: sha512-r9gHphtCs+1M7J0pw6Sn/hh/Wpa/iQrOOkrNAlVLF/gHq+/CJmHIWKKUUhdWjcD6CIa8idarspCsASiXCXvFUw==} - - '@roberts_lando/vfs@0.3.3': - resolution: {integrity: sha512-YjkxVSLw5WMZQoARaryRAjcxA+GbBzWMJdwYZX5oLUt9cC/gew9as4Dn7tcLzPp7BPoR221VpTZ+78TRPawnjg==} - engines: {node: '>= 22'} - - '@rolldown/binding-android-arm64@1.1.5': - resolution: {integrity: sha512-lZg8fqIv2v7FF237bwMgzGZEJvGL79/s5knJ/i6FmsGF4XXlzccZ4jb+TrFIxtSSxFtIpdsgrPZeMk1I9AFcyQ==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [arm64] - os: [android] - - '@rolldown/binding-darwin-arm64@1.1.5': - resolution: {integrity: sha512-51Bnx9pNiMRKSUNtBfySkNJ9vMU9Hh3I1ozDd6gyPPYzaXCfnptUcEZxXGYFn+ul2dtcMUiqGR1Yai2K10uoTw==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [arm64] - os: [darwin] - - '@rolldown/binding-darwin-x64@1.1.5': - resolution: {integrity: sha512-Tm+gbfC0aHu1tBA/JvKQh32S0K6YgCHkiAF4/W6xX0K0RmNuc94VeK419dJoE65R5aRxmo+noZQSWrAMF6yb6g==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [x64] - os: [darwin] - - '@rolldown/binding-freebsd-x64@1.1.5': - resolution: {integrity: sha512-JMzDKCCXq93YccG5gz3hvOs1oXRKAf0XYpfOS88e+wZrC8Iugj6j68867vrYZkvpDDpKn/KoKORThmchMpF6TA==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [x64] - os: [freebsd] - - '@rolldown/binding-linux-arm-gnueabihf@1.1.5': - resolution: {integrity: sha512-uML21j2K5TfPGutKxub+M+nLjZIrWjXQ5Grx4lCe/nimTj9B4L63zHpjXLl4y0L3mcm2htEQIb06oCG/szerNw==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [arm] - os: [linux] - - '@rolldown/binding-linux-arm64-gnu@1.1.5': - resolution: {integrity: sha512-navSiuTMogvnQoZoM/v+l3ZWo50/NTwSHSzheABx/RCnmUPaKwq9qSo4Br2OYRs21+Fz8uFqITZM3H4opOB0/Q==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [arm64] - os: [linux] - libc: [glibc] - - '@rolldown/binding-linux-arm64-musl@1.1.5': - resolution: {integrity: sha512-lAryqH7IteztmCXQXk0etKj4wBQ7Gx5S6LjKhsgp9zb8I5bsuvU/2llH1hDQcjsFeqIsovMVN339/8pUDDBXxA==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [arm64] - os: [linux] - libc: [musl] - - '@rolldown/binding-linux-ppc64-gnu@1.1.5': - resolution: {integrity: sha512-fsK/sNBnxzBlL4O1JNrZakVQxPspqpED5dLtNsZS9oOKmtSpdNIzxH2kkol5HYTWJN47sE20ztMJPxfZ89qGOg==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [ppc64] - os: [linux] - libc: [glibc] - - '@rolldown/binding-linux-s390x-gnu@1.1.5': - resolution: {integrity: sha512-gLYb4BIadlfTOYT5gO503n8zQjXflgzpD0FcyKh0Mzx3rqCZKnHoJWV9xe1KXUJ5lx2JfcSHr/mhzS0PC/McAA==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [s390x] - os: [linux] - libc: [glibc] - - '@rolldown/binding-linux-x64-gnu@1.1.5': - resolution: {integrity: sha512-FjcpEKUyJygHgs1o50VYNvkt5+7Le/VEdYt0AkRpkL33MnyQfwr8l5mXwMmfmTbyMPr5vJLC+8/Gd9gXnwU1QQ==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [x64] - os: [linux] - libc: [glibc] - - '@rolldown/binding-linux-x64-musl@1.1.5': - resolution: {integrity: sha512-Me+PfPI2TMeOQk0gYWfLQZtTktrmzbr8cDboqX83XKc7UrgAi55gF+2dUkWdxd19n55Essp2yeca+O9N5rBxHg==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [x64] - os: [linux] - libc: [musl] - - '@rolldown/binding-openharmony-arm64@1.1.5': - resolution: {integrity: sha512-yc5WrLzXks6zCQfn9Oxr8pORKyl/pF+QjHmW/Qx3qu0oyrrNC+y2JLTU1E2rcWYAmzlnqngWXHQjy51VzW70Vw==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [arm64] - os: [openharmony] - - '@rolldown/binding-wasm32-wasi@1.1.5': - resolution: {integrity: sha512-VbQGPX2b4r48TAMIM2cjgluIM1HYutm4pcTEJsle7iEP7sB1dFqtPLBVbdLAZCxy1txCcPxf4QFf4v8uvltPqA==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [wasm32] - - '@rolldown/binding-win32-arm64-msvc@1.1.5': - resolution: {integrity: sha512-gHv82k63z4qpV5+Q1y/12KrK0ltWBukVDI8nZcbT7Tt/ZlOIVwppazneq0F93oDxTo3IgAMEDIoQh3E2n6mVsw==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [arm64] - os: [win32] - - '@rolldown/binding-win32-x64-msvc@1.1.5': - resolution: {integrity: sha512-tTZuDBPw85tEN5PQi1pnEBzDy0Z49HtScLAbD5t6hyeU92A95pRWaSMw1GZZi/RwgSgUIl0xrSlXIT/9QzvYSA==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [x64] - os: [win32] - - '@rolldown/pluginutils@1.0.1': - resolution: {integrity: sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==} - - '@rollup/rollup-android-arm-eabi@4.62.2': - resolution: {integrity: sha512-6o7ZLZK+BeenkZCFNDXqpbjw9bD6nuWonvS/lwQJp7NoVVxm6p3qE7qQ5jGuBjiFsgvqjD8mZAU5oWxTmbOeOg==} - cpu: [arm] - os: [android] - - '@rollup/rollup-android-arm64@4.62.2': - resolution: {integrity: sha512-BaH7BllCACHoH1LguOU56UItGfUWjujlO65kS9LAodViaN4bwIKd7oeW/ZHJ/4ljr/7MIiENnNy3HJ0zXv8Zkw==} - cpu: [arm64] - os: [android] - - '@rollup/rollup-darwin-arm64@4.62.2': - resolution: {integrity: sha512-v39RCCvj4He82I9sFmk+M1VZ0PLM9sfsLVikjfx2hYBNALhrrOR2D3JjQA6AhlaSOgcR+RzrKY7e1+bT6SUO/A==} - cpu: [arm64] - os: [darwin] - - '@rollup/rollup-darwin-x64@4.62.2': - resolution: {integrity: sha512-yl0y2vq3S3lHeuXhEdss6TWfKW8vkujImO12tn4ZkG/4oghr09LvdYm2RElVjokTQiUvDUGXLGsYeLqUMCKpGA==} - cpu: [x64] - os: [darwin] - - '@rollup/rollup-freebsd-arm64@4.62.2': - resolution: {integrity: sha512-tT4pvt4qXD+vEoezupCWi+a1F0vvDiksiHc+PxRlYTOH1I6/X4id9jPxTP+Fg+545euaFT1jJVs4CEdHZAU1vw==} - cpu: [arm64] - os: [freebsd] - - '@rollup/rollup-freebsd-x64@4.62.2': - resolution: {integrity: sha512-6nU5F2wCW+qvCBhTn1pdIU3bzsIoF7EUwsCDRxilWGprQR6yd508YnH9+OKFCwpfS8pjZqDUmnCAr7exax0XCg==} - cpu: [x64] - os: [freebsd] - - '@rollup/rollup-linux-arm-gnueabihf@4.62.2': - resolution: {integrity: sha512-n1GJHPOvpIfhi3TmrCeh6S6URt9BFCt0KQE3qvexyGCTAKpR4Lg+eWvNZEqu7epxwus/8ElT3hacYEucm49SZg==} - cpu: [arm] - os: [linux] - libc: [glibc] - - '@rollup/rollup-linux-arm-musleabihf@4.62.2': - resolution: {integrity: sha512-JqgflS8wEB+UXV/vS1RpRbifGBeN4D5lz8D8oOFbFZw4vedvdOgCFAjfBmIMdW3yL10XpQQ0Ambepw6MXrhOnA==} - cpu: [arm] - os: [linux] - libc: [musl] - - '@rollup/rollup-linux-arm64-gnu@4.62.2': - resolution: {integrity: sha512-wnFJkogWvN4jm/hQRF2UBaeUmk20j5+DmHvoyWii2b8HJDyvz1MF2OU/6ynXt2KR63rbZLWkFpoytpdc/yBuSA==} - cpu: [arm64] - os: [linux] - libc: [glibc] - - '@rollup/rollup-linux-arm64-musl@4.62.2': - resolution: {integrity: sha512-HVu2bp0zhvJ8xHEV9+UUs7S90VadmBSY3LcIMvozbPo4AuMGDWlz3ymHLHZPX4hR67TKTt8Qp5PJ5RBg/i+RMQ==} - cpu: [arm64] - os: [linux] - libc: [musl] - - '@rollup/rollup-linux-loong64-gnu@4.62.2': - resolution: {integrity: sha512-mQqqAV8QaoSgr9I2fKDLY2BAVvmKjWoGiu/cSYQonsLvtqwEn1E4QYfnCOcp5zoEqNhsDYin1s6jx/VJmrxlZg==} - cpu: [loong64] - os: [linux] - libc: [glibc] - - '@rollup/rollup-linux-loong64-musl@4.62.2': - resolution: {integrity: sha512-IxKLoxCQ2IWi6bT2akyDUBGsOImDKB+sPp4EsTmwFQ/fMwpCKm8uLSSgP/Kx/QYUgKis6SEZ5/Nlhup0DIA0PQ==} - cpu: [loong64] - os: [linux] - libc: [musl] - - '@rollup/rollup-linux-ppc64-gnu@4.62.2': - resolution: {integrity: sha512-Mk5ha2RQSgyFfmYYLkBpPnUk8D8FriBxesO1u9O75X0mHgXL1UQcH5Itl2lurWL2tj0RxV9b9tJgipac0hRY9A==} - cpu: [ppc64] - os: [linux] - libc: [glibc] - - '@rollup/rollup-linux-ppc64-musl@4.62.2': - resolution: {integrity: sha512-CjvEnqJL/0/TQ3TXX3OPIJ/kmBellrWd4heXUmHeJlTnmwjKpSJzoehLaL6Xk0ZnMHBu9dZuFADNOrtjF4v+2w==} - cpu: [ppc64] - os: [linux] - libc: [musl] - - '@rollup/rollup-linux-riscv64-gnu@4.62.2': - resolution: {integrity: sha512-1SiZbzwdkaDURsew/tSOrooKiYy7EQGT6m8ufavAi9NEyQb/6VuIxFXAL1fqa4iZe3g4NbNk4P7J32z2tw5Mgg==} - cpu: [riscv64] - os: [linux] - libc: [glibc] - - '@rollup/rollup-linux-riscv64-musl@4.62.2': - resolution: {integrity: sha512-nQts12zJ3NQRoE6uYljOH89v7szzLDvG2JD/vsX+vGXU8w/At1GowTZ5/7qeFQ8m7L55rpR8Okugnuo5bgjy2Q==} - cpu: [riscv64] - os: [linux] - libc: [musl] - - '@rollup/rollup-linux-s390x-gnu@4.62.2': - resolution: {integrity: sha512-E9/ll019jhPIJgpzfZoIkBGhcz+kKNgVWYRY0zr9srBdPPFVpvOKW8VaJKUbeK+eZXyQF9ltME+Kk6affeaPgg==} - cpu: [s390x] - os: [linux] - libc: [glibc] - - '@rollup/rollup-linux-x64-gnu@4.62.2': - resolution: {integrity: sha512-5BqxR/pshjey51iliyzTD5Xi3EN0aLmQ2lZ3lvefVV9c82BvrLo2/6OT55iifpWBufs6kdwWbuOKS841DrmK9A==} - cpu: [x64] - os: [linux] - libc: [glibc] - - '@rollup/rollup-linux-x64-musl@4.62.2': - resolution: {integrity: sha512-uNN83XxQrRAh/w0/pmAfibcwyb6YWt4gP+dpnQKPVJshAloQ785ii8CT8ZCIxkGg9opVsvAlGhFitSm6D1Jjpg==} - cpu: [x64] - os: [linux] - libc: [musl] - - '@rollup/rollup-openbsd-x64@4.62.2': - resolution: {integrity: sha512-srjEIxSH3LRnJN6THczDHWQplqEMFiAJrTab0msUryh9kwNpkICf3Ea6q6MN/2cZwRFUNx5w+h6Hpi4QuHS6Zg==} - cpu: [x64] - os: [openbsd] - - '@rollup/rollup-openharmony-arm64@4.62.2': - resolution: {integrity: sha512-8hOJnxgbyObnCm5AlRA3A931xX19xq80RjVTKgJOvEKWqJruP/Uf12IbAOaDjjEXYRewwHLfmF0YRIdK3OwKWA==} - cpu: [arm64] - os: [openharmony] - - '@rollup/rollup-win32-arm64-msvc@4.62.2': - resolution: {integrity: sha512-mmF4AY1i0hG/bLWUctUq59gtmgaSIRa3cu/A3JFRp/sCNEme2bgDEiDS22P9FbnJB8NJNF4jPJiSP5RHQpUTDg==} - cpu: [arm64] - os: [win32] - - '@rollup/rollup-win32-ia32-msvc@4.62.2': - resolution: {integrity: sha512-DZgkknc6jhHrk46V25vbAM0zZkyP0nSDkJB8/dRkLTxv470dOmWDqGoEJl/9A0dFfS7yE3REOwNDxpHwSLSt0Q==} - cpu: [ia32] - os: [win32] - - '@rollup/rollup-win32-x64-gnu@4.62.2': - resolution: {integrity: sha512-T6xr6ucWSFto+VGajA8YH26LdpHRuP4YLHEKAtCWvJDOlnmWcDZVCI2Jmjr+IFHDlt2zRaTAKE4tfjTaWLgJBg==} - cpu: [x64] - os: [win32] - - '@rollup/rollup-win32-x64-msvc@4.62.2': - resolution: {integrity: sha512-BfzEnDJOt9T8M989/lA37EcJgat01wLRnoi5dQf3QzOH7jzpqTAzdDbVfRljVr5r+jzKqpbHeyOfAaXxAd0PAA==} - cpu: [x64] - os: [win32] - - '@simple-git/args-pathspec@1.0.3': - resolution: {integrity: sha512-ngJMaHlsWDTfjyq9F3VIQ8b7NXbBLq5j9i5bJ6XLYtD6qlDXT7fdKY2KscWWUF8t18xx052Y/PUO1K1TRc9yKA==} - - '@simple-git/argv-parser@1.1.1': - resolution: {integrity: sha512-Q9lBcfQ+VQCpQqGJFHe5yooOS5hGdLFFbJ5R+R5aDsnkPCahtn1hSkMcORX65J2Z5lxSkD0lQorMsncuBQxYUw==} - - '@standard-schema/spec@1.1.0': - resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==} - - '@tybys/wasm-util@0.10.3': - resolution: {integrity: sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg==} - - '@types/chai@5.2.3': - resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==} - - '@types/deep-eql@4.0.2': - resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==} - - '@types/estree@1.0.9': - resolution: {integrity: sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==} - - '@types/node@24.13.3': - resolution: {integrity: sha512-Dh8vAsV36ig5wa9OX4pXvMc9D3Veibfw2wix0CUwYODLD8nkj9UsLjASr49nPg+2eKzxhBV+v7L8pXvT4e639Q==} - - '@vercel/functions@1.6.0': - resolution: {integrity: sha512-R6FKQrYT5MZs5IE1SqeCJWxMuBdHawFcCZboKKw8p7s+6/mcd55Gx6tWmyKnQTyrSEA04NH73Tc9CbqpEle8RA==} - engines: {node: '>= 16'} - peerDependencies: - '@aws-sdk/credential-provider-web-identity': '*' - peerDependenciesMeta: - '@aws-sdk/credential-provider-web-identity': - optional: true - - '@vitest/expect@4.1.10': - resolution: {integrity: sha512-YsCn+qAk1GWjQOWFEsEcL2gNQ0zmVmQu3T03qP6UyjhtmdtwtbuI+DASn/7iQB3HGTXkdBwGddzxPlmiql5vlA==} - - '@vitest/mocker@4.1.10': - resolution: {integrity: sha512-v0xaezt+DKEmKfaxg133ldzADrwLGd7Ze1MfQQTYfvs8OqZIwbxyxaYURivwV7sWy5fqn3rH5uOrSp07bp44Ow==} - peerDependencies: - msw: ^2.4.9 - vite: ^6.0.0 || ^7.0.0 || ^8.0.0 - peerDependenciesMeta: - msw: - optional: true - vite: - optional: true - - '@vitest/pretty-format@4.1.10': - resolution: {integrity: sha512-W1HsjSH4MXQ9YfmmhLAoIYf1HRfekQCGngeIgcei6MP5QQGWUe0gkopdZQaVCFO+JDJMrAJGwa5pRpNpvy4P8Q==} - - '@vitest/runner@4.1.10': - resolution: {integrity: sha512-IKI6kpIH+LmpROplyLwBBaCfMgOZOMsygVa6BARD6ahA04VRuJSa6OaVG7kRvSEMD870Vd91rSSw0eegtWyLGg==} - - '@vitest/snapshot@4.1.10': - resolution: {integrity: sha512-xRkfOT1qpTAi/Ti4Y1LtfRc3kEuqxGw59eN2jN9pRWMtS/XDevekhcFSqvQqjUNGksfjMJu3Y+oJ+4Ypn2OaJw==} - - '@vitest/spy@4.1.10': - resolution: {integrity: sha512-PLf/Ugvoq5wO/b4rwYCR1h2PSIdXz7wnkQFMiUpLdtM7l6pqVFcQIBEHyT1+l+cj7mNwAfZHzqXqDyjvOuwbDw==} - - '@vitest/utils@4.1.10': - resolution: {integrity: sha512-fy9am/HWxbaGt/Sawrp90vt6Y6jQwf1RX77cz3uwoJwJVMli/e1IEwRPnMNJ7vKfPTwo0diXifkpPvwH9v7nGA==} - - '@yao-pkg/pkg-fetch@3.6.4': - resolution: {integrity: sha512-2JXvS9HbMudLlzEjSaJ7bLNnF/WlTv7iaTAJp2Tk8pBsgwCyL9p4rIN8cztHlyZF7qVzr1EaBjd7DQooFk0azQ==} - hasBin: true - - '@yao-pkg/pkg@6.21.0': - resolution: {integrity: sha512-dZl2C7rdwwEI4tv7WW+Cvnl+2K8OqHKUXfNQRq3mZCTC4degoFx1jA4d5wOn9d8lHrUy58xuEt1eJD0pTddW5w==} - engines: {node: '>=22.0.0'} - hasBin: true - - accepts@2.0.0: - resolution: {integrity: sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==} - engines: {node: '>= 0.6'} - - acorn-import-attributes@1.9.5: - resolution: {integrity: sha512-n02Vykv5uA3eHGM/Z2dQrcD56kL8TyDb2p1+0P83PClMnC/nc+anbQRhIOWnSq4Ke/KvDPrY3C9hDtC/A3eHnQ==} - peerDependencies: - acorn: ^8 - - acorn@8.17.0: - resolution: {integrity: sha512-xRQbDb9BnwDafYNn6Vwl839DYVjqXYb1XVGtWAZ1kcDc6iwAL4hg3B1dZlRiuENFeO2H53gFG3in621AdERVAg==} - engines: {node: '>=0.4.0'} - hasBin: true - - ajv@8.20.0: - resolution: {integrity: sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==} - - ansi-regex@5.0.1: - resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==} - engines: {node: '>=8'} - - ansi-styles@4.3.0: - resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==} - engines: {node: '>=8'} - - any-promise@1.3.0: - resolution: {integrity: sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==} - - argparse@2.0.1: - resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==} - - assertion-error@2.0.1: - resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} - engines: {node: '>=12'} - - astring@1.9.0: - resolution: {integrity: sha512-LElXdjswlqjWrPpJFg1Fx4wpkOCxj1TDHlSV4PlaRxHGWko024xICaa97ZkMfs6DRKlCguiAI+rbXv5GWwXIkg==} - hasBin: true - - b4a@1.8.1: - resolution: {integrity: sha512-aiqre1Nr0B/6DgE2N5vwTc+2/oQZ4Wh1t4NznYY4E00y8LCt6NqdRv81so00oo27D8MVKTpUa/MwUUtBLXCoDw==} - peerDependencies: - react-native-b4a: '*' - peerDependenciesMeta: - react-native-b4a: - optional: true - - balanced-match@4.0.4: - resolution: {integrity: sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==} - engines: {node: 18 || 20 || >=22} - - bare-events@2.9.1: - resolution: {integrity: sha512-Z0oHEHAFDZkffN8Qc39zNZjQlMDkPJRyyyZieU1VH7u8c5S+qHZ2S8ixdKIAxEjfHO7FJxXmJWgteOghVanIsg==} - peerDependencies: - bare-abort-controller: '*' - peerDependenciesMeta: - bare-abort-controller: - optional: true - - bare-fs@4.7.4: - resolution: {integrity: sha512-y1kC+ffIx/tPLdTE693uNjHfzTfr+ravR5tvWlMXe25nELbkqV400S71qHDwbkAQ1FVEZobB1NFRzFbCCcyBCQ==} - engines: {bare: '>=1.16.0'} - peerDependencies: - bare-buffer: '*' - peerDependenciesMeta: - bare-buffer: - optional: true - - bare-path@3.1.1: - resolution: {integrity: sha512-JprUlveX3QjApC1cTpsUOiscADftCGVWkzitbHsRqv84hzYwYHw2mbluddsq5TvI8mH/8Ov1f4BiMAdcB0oYnQ==} - - bare-stream@2.13.3: - resolution: {integrity: sha512-Kc+brLqvEqGkjyfiwJmImAOqLZL7OsoLKuavx+hJjgVV3nLTOjloJyPMFxjUPerGGHrNH0fLU06jjykMLWrERQ==} - peerDependencies: - bare-abort-controller: '*' - bare-buffer: '*' - bare-events: '*' - peerDependenciesMeta: - bare-abort-controller: - optional: true - bare-buffer: - optional: true - bare-events: - optional: true - - bare-url@2.4.5: - resolution: {integrity: sha512-K+y9xF1tN+CdPu4qWwr0QiK1Al07eFPGYK5M2pDXcmHdMdgC/tT/bpmMe1hrmRHaidKLkXrC+cRNYf3XVDUhSQ==} - - base64-js@1.5.1: - resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==} - - bl@4.1.0: - resolution: {integrity: sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==} - - bluebird@3.7.2: - resolution: {integrity: sha512-XpNj6GDQzdfW+r2Wnn7xiSAd7TM3jzkxGXBGTtWKuSXv1xUV+azxAm8jdWZN06QTQk+2N2XB9jRDkvbmQmcRtg==} - - body-parser@2.3.0: - resolution: {integrity: sha512-2cGmJupaNgg+QUwVLAucDuWuoMZ6EX9iHDRswZ5lsNYEmwPaRknMPCLZz07yTzVq/83p4o/wzbDZbBrTvGGTIw==} - engines: {node: '>=18'} - - brace-expansion@5.0.7: - resolution: {integrity: sha512-7oFy703dxfY3/NLxC1fh2SUCQ0H9rmAY+5EpDVfXjUTTs+HEwR2nYaqLv+GWcTsumwxPfiz6CzCNkwXwBUwqCA==} - engines: {node: 18 || 20 || >=22} - - braintrust@3.23.1: - resolution: {integrity: sha512-HeTZodgOyN1fwhCayFl8xGE+z98RjTI86P29g4Q/U0SL/787kpM4PTVWqRB1rZ8c0Jw3OalvDfaOL37gPSaXQw==} - hasBin: true - peerDependencies: - zod: ^3.25.34 || ^4.0 - - buffer@5.7.1: - resolution: {integrity: sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==} - - bundle-require@5.1.0: - resolution: {integrity: sha512-3WrrOuZiyaaZPWiEt4G3+IffISVC9HYlWueJEBWED4ZH4aIAC2PnkdnuRrR94M+w6yGWn4AglWtJtBI8YqvgoA==} - engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} - peerDependencies: - esbuild: '>=0.18' - - bytes@3.1.2: - resolution: {integrity: sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==} - engines: {node: '>= 0.8'} - - cac@6.7.14: - resolution: {integrity: sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==} - engines: {node: '>=8'} - - call-bind-apply-helpers@1.0.2: - resolution: {integrity: sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==} - engines: {node: '>= 0.4'} - - call-bound@1.0.4: - resolution: {integrity: sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==} - engines: {node: '>= 0.4'} - - chai@6.2.2: - resolution: {integrity: sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==} - engines: {node: '>=18'} - - chokidar@4.0.3: - resolution: {integrity: sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==} - engines: {node: '>= 14.16.0'} - - chownr@1.1.4: - resolution: {integrity: sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==} - - chownr@3.0.0: - resolution: {integrity: sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g==} - engines: {node: '>=18'} - - cjs-module-lexer@2.2.0: - resolution: {integrity: sha512-4bHTS2YuzUvtoLjdy+98ykbNB5jS0+07EvFNXerqZQJ89F7DI6ET7OQo/HJuW6K0aVsKA9hj9/RVb2kQVOrPDQ==} - - cli-progress@3.12.0: - resolution: {integrity: sha512-tRkV3HJ1ASwm19THiiLIXLO7Im7wlTuKnvkYaTkyoAPefqjNg7W7DHKUlGRxy9vxDvbyCYQkQozvptuMkGCg8A==} - engines: {node: '>=4'} - - cli-table3@0.6.5: - resolution: {integrity: sha512-+W/5efTR7y5HRD7gACw9yQjqMVvEMLBHmboM/kPWam+H+Hmyrgjh6YncVKK122YZkXrLudzTuAukUw9FnMf7IQ==} - engines: {node: 10.* || >= 12.*} - - cliui@7.0.4: - resolution: {integrity: sha512-OcRE68cOsVMXp1Yvonl/fzkQOyjLSu/8bhPDfQt0e0/Eb283TKP20Fs2MqoPsr9SwA595rRCA+QMzYc9nBP+JQ==} - - color-convert@2.0.1: - resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==} - engines: {node: '>=7.0.0'} - - color-name@1.1.4: - resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==} - - commander@4.1.1: - resolution: {integrity: sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==} - engines: {node: '>= 6'} - - commander@9.5.0: - resolution: {integrity: sha512-KRs7WVDKg86PWiuAqhDrAQnTXZKraVcCc6vFdL14qrZ/DcWwuRo7VoiYXalXO7S5GKpqYiVEwCbgFDfxNHKJBQ==} - engines: {node: ^12.20.0 || >=14} - - confbox@0.1.8: - resolution: {integrity: sha512-RMtmw0iFkeR4YV+fUOSucriAQNb9g8zFR52MWCtl+cCZOFRNL6zeB395vPzFhEjjn4fMxXudmELnl/KF/WrK6w==} - - consola@3.4.2: - resolution: {integrity: sha512-5IKcdX0nnYavi6G7TtOhwkYzyjfJlatbjMjuLSfE2kYT5pMDOilZ4OvMhi637CcDICTmz3wARPoyhqyX1Y+XvA==} - engines: {node: ^14.18.0 || >=16.10.0} - - content-disposition@1.1.0: - resolution: {integrity: sha512-5jRCH9Z/+DRP7rkvY83B+yGIGX96OYdJmzngqnw2SBSxqCFPd0w2km3s5iawpGX8krnwSGmF0FW5Nhr0Hfai3g==} - engines: {node: '>=18'} - - content-type@1.0.5: - resolution: {integrity: sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==} - engines: {node: '>= 0.6'} - - content-type@2.0.0: - resolution: {integrity: sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ==} - engines: {node: '>=18'} - - convert-source-map@2.0.0: - resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} - - cookie-signature@1.2.2: - resolution: {integrity: sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==} - engines: {node: '>=6.6.0'} - - cookie@0.7.2: - resolution: {integrity: sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==} - engines: {node: '>= 0.6'} - - core-util-is@1.0.3: - resolution: {integrity: sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==} - - cors@2.8.6: - resolution: {integrity: sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==} - engines: {node: '>= 0.10'} - - dc-browser@1.0.4: - resolution: {integrity: sha512-7oEtnzNlcE+hr4OvO3GR6Gndgw8BhW+wKOEwMqSleyY7N29jbAxzyW5BaJl7qBCw+6OIxfMWtY0T+6dxq8RWLw==} - - debug@4.4.3: - resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} - engines: {node: '>=6.0'} - peerDependencies: - supports-color: '*' - peerDependenciesMeta: - supports-color: - optional: true - - decompress-response@6.0.0: - resolution: {integrity: sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==} - engines: {node: '>=10'} - - deep-extend@0.6.0: - resolution: {integrity: sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==} - engines: {node: '>=4.0.0'} - - depd@2.0.0: - resolution: {integrity: sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==} - engines: {node: '>= 0.8'} - - detect-libc@2.1.2: - resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} - engines: {node: '>=8'} - - dotenv@16.6.1: - resolution: {integrity: sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==} - engines: {node: '>=12'} - - dunder-proto@1.0.1: - resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==} - engines: {node: '>= 0.4'} - - duplexer2@0.1.4: - resolution: {integrity: sha512-asLFVfWWtJ90ZyOUHMqk7/S2w2guQKxUI2itj3d92ADHhxUSbCMGi1f1cBcJ7xM1To+pE/Khbwo1yuNbMEPKeA==} - - ee-first@1.1.1: - resolution: {integrity: sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==} - - emoji-regex@8.0.0: - resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==} - - encodeurl@2.0.0: - resolution: {integrity: sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==} - engines: {node: '>= 0.8'} - - end-of-stream@1.4.5: - resolution: {integrity: sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==} - - es-define-property@1.0.1: - resolution: {integrity: sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==} - engines: {node: '>= 0.4'} - - es-errors@1.3.0: - resolution: {integrity: sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==} - engines: {node: '>= 0.4'} - - es-module-lexer@2.3.1: - resolution: {integrity: sha512-shc1dbU90Yl/xq1QrC7QRtfcwURZuVRfPhZbDoldJ1cn1gzDvBaBWlv0eFolj5+0znnPJz5TXLxsN77X/12KTA==} - - es-object-atoms@1.1.2: - resolution: {integrity: sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==} - engines: {node: '>= 0.4'} - - esbuild@0.27.7: - resolution: {integrity: sha512-IxpibTjyVnmrIQo5aqNpCgoACA/dTKLTlhMHihVHhdkxKyPO1uBBthumT0rdHmcsk9uMonIWS0m4FljWzILh3w==} - engines: {node: '>=18'} - hasBin: true - - esbuild@0.28.0: - resolution: {integrity: sha512-sNR9MHpXSUV/XB4zmsFKN+QgVG82Cc7+/aaxJ8Adi8hyOac+EXptIp45QBPaVyX3N70664wRbTcLTOemCAnyqw==} - engines: {node: '>=18'} - hasBin: true - - esbuild@0.28.1: - resolution: {integrity: sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==} - engines: {node: '>=18'} - hasBin: true - - escalade@3.2.0: - resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==} - engines: {node: '>=6'} - - escape-html@1.0.3: - resolution: {integrity: sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==} - - esquery@1.7.0: - resolution: {integrity: sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==} - engines: {node: '>=0.10'} - - estraverse@5.3.0: - resolution: {integrity: sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==} - engines: {node: '>=4.0'} - - estree-walker@3.0.3: - resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==} - - etag@1.8.1: - resolution: {integrity: sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==} - engines: {node: '>= 0.6'} - - events-universal@1.0.1: - resolution: {integrity: sha512-LUd5euvbMLpwOF8m6ivPCbhQeSiYVNb8Vs0fQ8QjXo0JTkEHpz8pxdQf0gStltaPpw0Cca8b39KxvK9cfKRiAw==} - - eventsource-parser@1.1.2: - resolution: {integrity: sha512-v0eOBUbiaFojBu2s2NPBfYUoRR9GjcDNvCXVaqEf5vVfpIAh9f8RCo4vXTP8c63QRKCFwoLpMpTdPwwhEKVgzA==} - engines: {node: '>=14.18'} - - expand-template@2.0.3: - resolution: {integrity: sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==} - engines: {node: '>=6'} - - expect-type@1.4.0: - resolution: {integrity: sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==} - engines: {node: '>=12.0.0'} - - express@5.2.1: - resolution: {integrity: sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==} - engines: {node: '>= 18'} - - fast-deep-equal@3.1.3: - resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} - - fast-fifo@1.3.2: - resolution: {integrity: sha512-/d9sfos4yxzpwkDkuN7k2SqFKtYNmCTzgfEpz82x34IM9/zc8KGxQoXg1liNC/izpRM/MBdt44Nmx41ZWqk+FQ==} - - fast-uri@3.1.4: - resolution: {integrity: sha512-8JnbkQ4juDyvYs4mgFGQqg4yCYtFDtUtmp2QIQq11ZZe5CFQ5wcqm1rqDgAh/QdMySuBnPzMUiJUNZG5N/AiQw==} - - fdir@6.5.0: - resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} - engines: {node: '>=12.0.0'} - peerDependencies: - picomatch: ^3 || ^4 - peerDependenciesMeta: - picomatch: - optional: true - - finalhandler@2.1.1: - resolution: {integrity: sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==} - engines: {node: '>= 18.0.0'} - - fix-dts-default-cjs-exports@1.0.1: - resolution: {integrity: sha512-pVIECanWFC61Hzl2+oOCtoJ3F17kglZC/6N94eRWycFgBH35hHx0Li604ZIzhseh97mf2p0cv7vVrOZGoqhlEg==} - - forwarded@0.2.0: - resolution: {integrity: sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==} - engines: {node: '>= 0.6'} - - fresh@2.0.0: - resolution: {integrity: sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==} - engines: {node: '>= 0.8'} - - fs-constants@1.0.0: - resolution: {integrity: sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==} - - fs-extra@11.3.1: - resolution: {integrity: sha512-eXvGGwZ5CL17ZSwHWd3bbgk7UUpF6IFHtP57NYYakPvHOs8GDgDe5KJI36jIJzDkJ6eJjuzRA8eBQb6SkKue0g==} - engines: {node: '>=14.14'} - - fsevents@2.3.3: - resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} - engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} - os: [darwin] - - function-bind@1.1.2: - resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==} - - get-caller-file@2.0.5: - resolution: {integrity: sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==} - engines: {node: 6.* || 8.* || >= 10.*} - - get-intrinsic@1.3.0: - resolution: {integrity: sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==} - engines: {node: '>= 0.4'} - - get-proto@1.0.1: - resolution: {integrity: sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==} - engines: {node: '>= 0.4'} - - github-from-package@0.0.0: - resolution: {integrity: sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==} - - gopd@1.2.0: - resolution: {integrity: sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==} - engines: {node: '>= 0.4'} - - graceful-fs@4.2.11: - resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==} - - has-symbols@1.1.0: - resolution: {integrity: sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==} - engines: {node: '>= 0.4'} - - hasown@2.0.4: - resolution: {integrity: sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==} - engines: {node: '>= 0.4'} - - http-errors@2.0.1: - resolution: {integrity: sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==} - engines: {node: '>= 0.8'} - - iconv-lite@0.7.3: - resolution: {integrity: sha512-IKXpvIzjnC9XTAUbVBcMfGS0EPaIXtW6v+zr+RRp+hqULEpo0owZax6wyRwPOJbWbzjYspQwusTsfVr0ifh4uQ==} - engines: {node: '>=0.10.0'} - - ieee754@1.2.1: - resolution: {integrity: sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==} - - inherits@2.0.4: - resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==} - - ini@1.3.8: - resolution: {integrity: sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==} - - into-stream@9.1.0: - resolution: {integrity: sha512-DRsRnQrbzdFjaQ1oe4C6/EIUymIOEix1qROEJTF9dbMq+M4Zrm6VaLp6SD/B9IsiEjPZuBSnWWFN+udajugdWA==} - engines: {node: '>=20'} - - ipaddr.js@1.9.1: - resolution: {integrity: sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==} - engines: {node: '>= 0.10'} - - is-core-module@2.16.2: - resolution: {integrity: sha512-evOr8xfXKxE6qSR0hSXL2r3sd7ALj8+7jQEUvPYcm5sgZFdJ+AYzT6yNmJenvIYQBgIGwfwz08sL8zoL7yq2BA==} - engines: {node: '>= 0.4'} - - is-fullwidth-code-point@3.0.0: - resolution: {integrity: sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==} - engines: {node: '>=8'} - - is-promise@4.0.0: - resolution: {integrity: sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==} - - isarray@1.0.0: - resolution: {integrity: sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==} - - joycon@3.1.1: - resolution: {integrity: sha512-34wB/Y7MW7bzjKRjUKTa46I2Z7eV62Rkhva+KkopW7Qvv/OSWBqvkSY7vusOPrNuZcUG3tApvdVgNB8POj3SPw==} - engines: {node: '>=10'} - - js-tokens@4.0.0: - resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} - - jsesc@3.1.0: - resolution: {integrity: sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==} - engines: {node: '>=6'} - hasBin: true - - json-schema-traverse@1.0.0: - resolution: {integrity: sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==} - - jsonfile@6.2.1: - resolution: {integrity: sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q==} - - lightningcss-android-arm64@1.33.0: - resolution: {integrity: sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg==} - engines: {node: '>= 12.0.0'} - cpu: [arm64] - os: [android] - - lightningcss-darwin-arm64@1.33.0: - resolution: {integrity: sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg==} - engines: {node: '>= 12.0.0'} - cpu: [arm64] - os: [darwin] - - lightningcss-darwin-x64@1.33.0: - resolution: {integrity: sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ==} - engines: {node: '>= 12.0.0'} - cpu: [x64] - os: [darwin] - - lightningcss-freebsd-x64@1.33.0: - resolution: {integrity: sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg==} - engines: {node: '>= 12.0.0'} - cpu: [x64] - os: [freebsd] - - lightningcss-linux-arm-gnueabihf@1.33.0: - resolution: {integrity: sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ==} - engines: {node: '>= 12.0.0'} - cpu: [arm] - os: [linux] - - lightningcss-linux-arm64-gnu@1.33.0: - resolution: {integrity: sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg==} - engines: {node: '>= 12.0.0'} - cpu: [arm64] - os: [linux] - libc: [glibc] - - lightningcss-linux-arm64-musl@1.33.0: - resolution: {integrity: sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ==} - engines: {node: '>= 12.0.0'} - cpu: [arm64] - os: [linux] - libc: [musl] - - lightningcss-linux-x64-gnu@1.33.0: - resolution: {integrity: sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg==} - engines: {node: '>= 12.0.0'} - cpu: [x64] - os: [linux] - libc: [glibc] - - lightningcss-linux-x64-musl@1.33.0: - resolution: {integrity: sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw==} - engines: {node: '>= 12.0.0'} - cpu: [x64] - os: [linux] - libc: [musl] - - lightningcss-win32-arm64-msvc@1.33.0: - resolution: {integrity: sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA==} - engines: {node: '>= 12.0.0'} - cpu: [arm64] - os: [win32] - - lightningcss-win32-x64-msvc@1.33.0: - resolution: {integrity: sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA==} - engines: {node: '>= 12.0.0'} - cpu: [x64] - os: [win32] - - lightningcss@1.33.0: - resolution: {integrity: sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==} - engines: {node: '>= 12.0.0'} - - lilconfig@3.1.3: - resolution: {integrity: sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==} - engines: {node: '>=14'} - - lines-and-columns@1.2.4: - resolution: {integrity: sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==} - - load-tsconfig@0.2.5: - resolution: {integrity: sha512-IXO6OCs9yg8tMKzfPZ1YmheJbZCiEsnBdcB03l0OcfK9prKnJb96siuHCr5Fl37/yo9DnKU+TLpxzTUspw9shg==} - engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} - - magic-string@0.30.21: - resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} - - math-intrinsics@1.1.0: - resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==} - engines: {node: '>= 0.4'} - - media-typer@1.1.0: - resolution: {integrity: sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==} - engines: {node: '>= 0.8'} - - merge-descriptors@2.0.0: - resolution: {integrity: sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==} - engines: {node: '>=18'} - - meriyah@6.1.4: - resolution: {integrity: sha512-Sz8FzjzI0kN13GK/6MVEsVzMZEPvOhnmmI1lU5+/1cGOiK3QUahntrNNtdVeihrO7t9JpoH75iMNXg6R6uWflQ==} - engines: {node: '>=18.0.0'} - - mime-db@1.54.0: - resolution: {integrity: sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==} - engines: {node: '>= 0.6'} - - mime-types@3.0.2: - resolution: {integrity: sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==} - engines: {node: '>=18'} - - mimic-response@3.1.0: - resolution: {integrity: sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==} - engines: {node: '>=10'} - - minimatch@10.2.5: - resolution: {integrity: sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==} - engines: {node: 18 || 20 || >=22} - - minimist@1.2.8: - resolution: {integrity: sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==} - - minipass@7.1.3: - resolution: {integrity: sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==} - engines: {node: '>=16 || 14 >=14.17'} - - minizlib@3.1.0: - resolution: {integrity: sha512-KZxYo1BUkWD2TVFLr0MQoM8vUUigWD3LlD83a/75BqC+4qE0Hb1Vo5v1FgcfaNXvfXzr+5EhQ6ing/CaBijTlw==} - engines: {node: '>= 18'} - - mkdirp-classic@0.5.3: - resolution: {integrity: sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==} - - mlly@1.8.2: - resolution: {integrity: sha512-d+ObxMQFmbt10sretNDytwt85VrbkhhUA/JBGm1MPaWJ65Cl4wOgLaB1NYvJSZ0Ef03MMEU/0xpPMXUIQ29UfA==} - - module-details-from-path@1.0.4: - resolution: {integrity: sha512-EGWKgxALGMgzvxYF1UyGTy0HXX/2vHLkw6+NvDKW2jypWbHpjQuj4UMcqQWXHERJhVGKikolT06G3bcKe4fi7w==} - - ms@2.1.3: - resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} - - multistream@4.1.0: - resolution: {integrity: sha512-J1XDiAmmNpRCBfIWJv+n0ymC4ABcf/Pl+5YvC5B/D2f/2+8PtHvCNxMPKiQcZyi922Hq69J2YOpb1pTywfifyw==} - - mustache@4.2.0: - resolution: {integrity: sha512-71ippSywq5Yb7/tVYyGbkBggbU8H3u5Rz56fH60jGFgr8uHwxs+aSKeqmluIVzM0m0kB7xQjKS6qPfd0b2ZoqQ==} - hasBin: true - - mz@2.7.0: - resolution: {integrity: sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==} - - nanoid@3.3.16: - resolution: {integrity: sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q==} - engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} - hasBin: true - - napi-build-utils@2.0.0: - resolution: {integrity: sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA==} - - negotiator@1.0.0: - resolution: {integrity: sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==} - engines: {node: '>= 0.6'} - - node-abi@3.94.0: - resolution: {integrity: sha512-W5ZNO5KRPB5TkYmGVD9F6YqhsglXJzE6etpbmT+f6EQElhiX/UTG551cnsRGvLG3fyZEg9HwaDmNmj5nwJ4z9g==} - engines: {node: '>=10'} - - node-int64@0.4.0: - resolution: {integrity: sha512-O5lz91xSOeoXP6DulyHfllpq+Eg00MWitZIbtPfoSEvqIHdl5gfcY6hYzDWnj0qD5tz52PI08u9qUvSVeUBeHw==} - - object-assign@4.1.1: - resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==} - engines: {node: '>=0.10.0'} - - object-inspect@1.13.4: - resolution: {integrity: sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==} - engines: {node: '>= 0.4'} - - obug@2.1.4: - resolution: {integrity: sha512-4a+OsYv9UktOJKE+l1A4OufDgdRF9PifWj+tJnHURo/P+WOxpG4GzUFL9qCalmWauao6ogiG+QvnCovwPoyAWA==} - engines: {node: '>=12.20.0'} - - on-finished@2.4.1: - resolution: {integrity: sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==} - engines: {node: '>= 0.8'} - - once@1.4.0: - resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==} - - parseurl@1.3.3: - resolution: {integrity: sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==} - engines: {node: '>= 0.8'} - - path-parse@1.0.7: - resolution: {integrity: sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==} - - path-to-regexp@8.4.2: - resolution: {integrity: sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA==} - - pathe@2.0.3: - resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==} - - picocolors@1.1.1: - resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} - - picomatch@4.0.5: - resolution: {integrity: sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==} - engines: {node: '>=12'} - - pirates@4.0.7: - resolution: {integrity: sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==} - engines: {node: '>= 6'} - - pkg-types@1.3.1: - resolution: {integrity: sha512-/Jm5M4RvtBFVkKWRu2BLUTNP8/M2a+UwuAX+ae4770q1qVGtfjG+WTCupoZixokjmHiry8uI+dlY8KXYV5HVVQ==} - - pluralize@8.0.0: - resolution: {integrity: sha512-Nc3IT5yHzflTfbjgqWcCPpo7DaKy4FnpB0l/zCAW0Tc7jxAiuqSxHasntB3D7887LSrA93kDJ9IXovxJYxyLCA==} - engines: {node: '>=4'} - - postcss-load-config@6.0.1: - resolution: {integrity: sha512-oPtTM4oerL+UXmx+93ytZVN82RrlY/wPUV8IeDxFrzIjXOLF1pN+EmKPLbubvKHT2HC20xXsCAH2Z+CKV6Oz/g==} - engines: {node: '>= 18'} - peerDependencies: - jiti: '>=1.21.0' - postcss: '>=8.0.9' - tsx: ^4.8.1 - yaml: ^2.4.2 - peerDependenciesMeta: - jiti: - optional: true - postcss: - optional: true - tsx: - optional: true - yaml: - optional: true - - postcss@8.5.20: - resolution: {integrity: sha512-lW616l85ucIQL+FocMmL7pQFPqBmwejrCMg+iPxyImlrANNJG9NHq/RkyCZopDhd8C3LA03PHRJDjkbGu8vvug==} - engines: {node: ^10 || ^12 || >=14} - - postject@1.0.0-alpha.6: - resolution: {integrity: sha512-b9Eb8h2eVqNE8edvKdwqkrY6O7kAwmI8kcnBv1NScolYJbo59XUF0noFq+lxbC1yN20bmC0WBEbDC5H/7ASb0A==} - engines: {node: '>=14.0.0'} - hasBin: true - - prebuild-install@7.1.3: - resolution: {integrity: sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug==} - engines: {node: '>=10'} - deprecated: No longer maintained. Please contact the author of the relevant native addon; alternatives are available. - hasBin: true - - process-nextick-args@2.0.1: - resolution: {integrity: sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==} - - progress@2.0.3: - resolution: {integrity: sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA==} - engines: {node: '>=0.4.0'} - - proxy-addr@2.0.7: - resolution: {integrity: sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==} - engines: {node: '>= 0.10'} - - pump@3.0.4: - resolution: {integrity: sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==} - - qs@6.15.3: - resolution: {integrity: sha512-O9gl3zCl5h5blw1KGUzQKhA5oUXSl8rwUIM5o0S3nCXMliSvy5Dzx7/DJcI+SwgICv+IneSZwhBh1oSyEHA71A==} - engines: {node: '>=0.6'} - - range-parser@1.3.0: - resolution: {integrity: sha512-hek2mFQpPuI4E1BBKrSto+BU3e3x4xuarsbiwr3+lf7p44juvFMV0XFWQAP3xUyqXA4RrXLIoaSUGbSt056ZMw==} - engines: {node: '>= 0.6'} - - raw-body@3.0.2: - resolution: {integrity: sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==} - engines: {node: '>= 0.10'} - - rc@1.2.8: - resolution: {integrity: sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==} - hasBin: true - - readable-stream@2.3.8: - resolution: {integrity: sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==} - - readable-stream@3.6.2: - resolution: {integrity: sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==} - engines: {node: '>= 6'} - - readdirp@4.1.2: - resolution: {integrity: sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==} - engines: {node: '>= 14.18.0'} - - require-directory@2.1.1: - resolution: {integrity: sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==} - engines: {node: '>=0.10.0'} - - require-from-string@2.0.2: - resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==} - engines: {node: '>=0.10.0'} - - resolve-from@5.0.0: - resolution: {integrity: sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==} - engines: {node: '>=8'} - - resolve.exports@2.0.3: - resolution: {integrity: sha512-OcXjMsGdhL4XnbShKpAcSqPMzQoYkYyhbEaeSko47MjRP9NfEQMhZkXL1DoFlt9LWQn4YttrdnV6X2OiyzBi+A==} - engines: {node: '>=10'} - - resolve@1.22.12: - resolution: {integrity: sha512-TyeJ1zif53BPfHootBGwPRYT1RUt6oGWsaQr8UyZW/eAm9bKoijtvruSDEmZHm92CwS9nj7/fWttqPCgzep8CA==} - engines: {node: '>= 0.4'} - hasBin: true - - rolldown@1.1.5: - resolution: {integrity: sha512-t9z29cJjXf/vxQ8dyhCSpt6H6aSwHTk8cT5I3iy6SMXuFpk5mB6PL6XfC8PCwrPTx93udwKUm9HRteAlTGBLiA==} - engines: {node: ^20.19.0 || >=22.12.0} - hasBin: true - - rollup@4.62.2: - resolution: {integrity: sha512-RFnrW4lhXA3s3eqHDZvN654g8OTjzRfqpIRJYczCGB6HzphckVAi/Qh4tbPUbRuDi7s1Llv8g/NspLkttY3gTA==} - engines: {node: '>=18.0.0', npm: '>=8.0.0'} - hasBin: true - - router@2.2.0: - resolution: {integrity: sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==} - engines: {node: '>= 18'} - - safe-buffer@5.1.2: - resolution: {integrity: sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==} - - safe-buffer@5.2.1: - resolution: {integrity: sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==} - - safer-buffer@2.1.2: - resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==} - - semifies@1.0.0: - resolution: {integrity: sha512-xXR3KGeoxTNWPD4aBvL5NUpMTT7WMANr3EWnaS190QVkY52lqqcVRD7Q05UVbBhiWDGWMlJEUam9m7uFFGVScw==} - - semver@7.8.5: - resolution: {integrity: sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==} - engines: {node: '>=10'} - hasBin: true - - send@1.2.1: - resolution: {integrity: sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==} - engines: {node: '>= 18'} - - serve-static@2.2.1: - resolution: {integrity: sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==} - engines: {node: '>= 18'} - - setprototypeof@1.2.0: - resolution: {integrity: sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==} - - side-channel-list@1.0.1: - resolution: {integrity: sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==} - engines: {node: '>= 0.4'} - - side-channel-map@1.0.1: - resolution: {integrity: sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==} - engines: {node: '>= 0.4'} - - side-channel-weakmap@1.0.2: - resolution: {integrity: sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==} - engines: {node: '>= 0.4'} - - side-channel@1.1.1: - resolution: {integrity: sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==} - engines: {node: '>= 0.4'} - - siginfo@2.0.0: - resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==} - - simple-concat@1.0.1: - resolution: {integrity: sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q==} - - simple-get@4.0.1: - resolution: {integrity: sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA==} - - simple-git@3.36.0: - resolution: {integrity: sha512-cGQjLjK8bxJw4QuYT7gxHw3/IouVESbhahSsHrX97MzCL1gu2u7oy38W6L2ZIGECEfIBG4BabsWDPjBxJENv9Q==} - - source-map-js@1.2.1: - resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} - engines: {node: '>=0.10.0'} - - source-map@0.7.6: - resolution: {integrity: sha512-i5uvt8C3ikiWeNZSVZNWcfZPItFQOsYTUAOkcUPGd8DqDy1uOUikjt5dG+uRlwyvR108Fb9DOd4GvXfT0N2/uQ==} - engines: {node: '>= 12'} - - stackback@0.0.2: - resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==} - - statuses@2.0.2: - resolution: {integrity: sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==} - engines: {node: '>= 0.8'} - - std-env@4.2.0: - resolution: {integrity: sha512-oCUKSupKTHX53EyjDtuZQ64pjLJ6yYCtpmEw0goYxtjG9KpbRe8KAsl2tBUGU9DyMcJ0RwJ8GqJAFzMXcXW1Rw==} - - stream-meter@1.0.4: - resolution: {integrity: sha512-4sOEtrbgFotXwnEuzzsQBYEV1elAeFSO8rSGeTwabuX1RRn/kEq9JVH7I0MRBhKVRR0sJkr0M0QCH7yOLf9fhQ==} - - streamx@2.28.0: - resolution: {integrity: sha512-1Yowhzjf0ivGMrTIkY9hav5TxobO9qIVqUE41fiCGMGgc3CLlf4MY+9AHmZqBWgDTue0fY9zWjYFVyf6Diuobw==} - - string-width@4.2.3: - resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==} - engines: {node: '>=8'} - - string_decoder@1.1.1: - resolution: {integrity: sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==} - - string_decoder@1.3.0: - resolution: {integrity: sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==} - - strip-ansi@6.0.1: - resolution: {integrity: sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==} - engines: {node: '>=8'} - - strip-json-comments@2.0.1: - resolution: {integrity: sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==} - engines: {node: '>=0.10.0'} - - sucrase@3.35.1: - resolution: {integrity: sha512-DhuTmvZWux4H1UOnWMB3sk0sbaCVOoQZjv8u1rDoTV0HTdGem9hkAZtl4JZy8P2z4Bg0nT+YMeOFyVr4zcG5Tw==} - engines: {node: '>=16 || 14 >=14.17'} - hasBin: true - - supports-preserve-symlinks-flag@1.0.0: - resolution: {integrity: sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==} - engines: {node: '>= 0.4'} - - tar-fs@2.1.5: - resolution: {integrity: sha512-OboTd8mmMhZDNPV+UjQcK9yKAatXu2aJ+r1w4im1Otd4M4fl2hwvdoXUxIYHFTHWK/3y3FarBP70v3vwmGlOxw==} - - tar-fs@3.1.3: - resolution: {integrity: sha512-/hU4AXnIdZu+Gvl1pk0oI5f5HxWsCJRtY2aFaJdk9VvyL48DWU6iU5WAIPG+wIi1YvWA6eTJvIviP/tMAZZNwQ==} - - tar-stream@2.2.0: - resolution: {integrity: sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==} - engines: {node: '>=6'} - - tar-stream@3.2.0: - resolution: {integrity: sha512-ojzvCvVaNp6aOTFmG7jaRD0meowIAuPc3cMMhSgKiVWws1GyHbGd/xvnyuRKcKlMpt3qvxx6r0hreCNITP9hIg==} - - tar@7.5.20: - resolution: {integrity: sha512-9FcyK4PA6+WbzlTM9WhQm6vB5W7cP7dUiPsv1g7YDwEQnQ1CGpK3MGlKk/ITVWMk05kHZuBhmVhiv8LZoy/PFQ==} - engines: {node: '>=18'} - - teex@1.0.1: - resolution: {integrity: sha512-eYE6iEI62Ni1H8oIa7KlDU6uQBtqr4Eajni3wX7rpfXD8ysFx8z0+dri+KWEPWpBsxXfxu58x/0jvTVT1ekOSg==} - - termi-link@1.1.0: - resolution: {integrity: sha512-2qSN6TnomHgVLtk+htSWbaYs4Rd2MH/RU7VpHTy6MBstyNyWbM4yKd1DCYpE3fDg8dmGWojXCngNi/MHCzGuAA==} - engines: {node: '>=12'} - - text-decoder@1.2.7: - resolution: {integrity: sha512-vlLytXkeP4xvEq2otHeJfSQIRyWxo/oZGEbXrtEEF9Hnmrdly59sUbzZ/QgyWuLYHctCHxFF4tRQZNQ9k60ExQ==} - - thenify-all@1.6.0: - resolution: {integrity: sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA==} - engines: {node: '>=0.8'} - - thenify@3.3.1: - resolution: {integrity: sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==} - - tinybench@2.9.0: - resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==} - - tinyexec@0.3.2: - resolution: {integrity: sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==} - - tinyexec@1.2.4: - resolution: {integrity: sha512-SHf/r48b7vOrjve9PxJo3MN5v5yuyjHvdUcrQffT3WXMUfnGmHDVbC4k3sHJaJTgZCwpUplIaAo5ANtMyp3YHg==} - engines: {node: '>=18'} - - tinyglobby@0.2.17: - resolution: {integrity: sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==} - engines: {node: '>=12.0.0'} - - tinyrainbow@3.1.0: - resolution: {integrity: sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==} - engines: {node: '>=14.0.0'} - - toidentifier@1.0.1: - resolution: {integrity: sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==} - engines: {node: '>=0.6'} - - tree-kill@1.2.2: - resolution: {integrity: sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==} - hasBin: true - - ts-interface-checker@0.1.13: - resolution: {integrity: sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==} - - tslib@2.8.1: - resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} - - tsup@8.5.1: - resolution: {integrity: sha512-xtgkqwdhpKWr3tKPmCkvYmS9xnQK3m3XgxZHwSUjvfTjp7YfXe5tT3GgWi0F2N+ZSMsOeWeZFh7ZZFg5iPhing==} - engines: {node: '>=18'} - hasBin: true - peerDependencies: - '@microsoft/api-extractor': ^7.36.0 - '@swc/core': ^1 - postcss: ^8.4.12 - typescript: '>=4.5.0' - peerDependenciesMeta: - '@microsoft/api-extractor': - optional: true - '@swc/core': - optional: true - postcss: - optional: true - typescript: - optional: true - - tsx@4.23.1: - resolution: {integrity: sha512-GQHnkIfxyx1wYCOS/wonik5MVRZU9hi1TEZmzGZSCJB1y9YgoZ8H6itNE/u4suE+yLmOzuE4E5S4TZ/ZX2wcWQ==} - engines: {node: '>=18.0.0'} - hasBin: true - - tunnel-agent@0.6.0: - resolution: {integrity: sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==} - - type-is@2.1.0: - resolution: {integrity: sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA==} - engines: {node: '>= 18'} - - typescript@5.9.3: - resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==} - engines: {node: '>=14.17'} - hasBin: true - - ufo@1.6.4: - resolution: {integrity: sha512-JFNbkD1Svwe0KvGi8GOeLcP4kAWQ609twvCdcHxq1oSL8svv39ZuSvajcD8B+5D0eL4+s1Is2D/O6KN3qcTeRA==} - - undici-types@7.18.2: - resolution: {integrity: sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==} - - undici@7.28.0: - resolution: {integrity: sha512-cRZYrTDwWznlnRiPjggAGxZXanty6M8RV1ff8Wm4LWXBp7/IG8v5DnOm74DtUBp9OONpK75YlPnIjQqX0dBDtA==} - engines: {node: '>=20.18.1'} - - universalify@2.0.1: - resolution: {integrity: sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==} - engines: {node: '>= 10.0.0'} - - unpipe@1.0.0: - resolution: {integrity: sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==} - engines: {node: '>= 0.8'} - - unplugin@2.3.11: - resolution: {integrity: sha512-5uKD0nqiYVzlmCRs01Fhs2BdkEgBS3SAVP6ndrBsuK42iC2+JHyxM05Rm9G8+5mkmRtzMZGY8Ct5+mliZxU/Ww==} - engines: {node: '>=18.12.0'} - - unzipper@0.12.5: - resolution: {integrity: sha512-tXYOi9R57Uj/2Z25SOs5RRSzq886MBQj2gY8dPL+xl/kv6s6SvByoKfAtvfVeEuhntWDgjd2o9p2lb4TVPAz0A==} - - util-deprecate@1.0.2: - resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==} - - uuid@11.1.1: - resolution: {integrity: sha512-vIYxrBCC/N/K+Js3qSN88go7kIfNPssr/hHCesKCQNAjmgvYS2oqr69kIufEG+O4+PfezOH4EbIeHCfFov8ZgQ==} - hasBin: true - - vary@1.1.2: - resolution: {integrity: sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==} - engines: {node: '>= 0.8'} - - vite@8.1.5: - resolution: {integrity: sha512-7ULLwsCdYx/nRyrpiEwvqb5TFHrMVZyBt+rg/OAXT7rgj/z+DtTDyKFeLAdDkubDVDKD8jOsndmy7m55XcfUsw==} - engines: {node: ^20.19.0 || >=22.12.0} - hasBin: true - peerDependencies: - '@types/node': ^20.19.0 || >=22.12.0 - '@vitejs/devtools': ^0.3.0 - esbuild: ^0.27.0 || ^0.28.0 - jiti: '>=1.21.0' - less: ^4.0.0 - sass: ^1.70.0 - sass-embedded: ^1.70.0 - stylus: '>=0.54.8' - sugarss: ^5.0.0 - terser: ^5.16.0 - tsx: ^4.8.1 - yaml: ^2.4.2 - peerDependenciesMeta: - '@types/node': - optional: true - '@vitejs/devtools': - optional: true - esbuild: - optional: true - jiti: - optional: true - less: - optional: true - sass: - optional: true - sass-embedded: - optional: true - stylus: - optional: true - sugarss: - optional: true - terser: - optional: true - tsx: - optional: true - yaml: - optional: true - - vitest@4.1.10: - resolution: {integrity: sha512-R9jUTe5S4Qb0HCd4TNqpC7oGcrMssMRGXLW80ubjWsW9VH5GF8y1Y0SFLY9AbqSk6nt0PnOx4H4WNJYZ13GUPw==} - engines: {node: ^20.0.0 || ^22.0.0 || >=24.0.0} - hasBin: true - peerDependencies: - '@edge-runtime/vm': '*' - '@opentelemetry/api': ^1.9.0 - '@types/node': ^20.0.0 || ^22.0.0 || >=24.0.0 - '@vitest/browser-playwright': 4.1.10 - '@vitest/browser-preview': 4.1.10 - '@vitest/browser-webdriverio': 4.1.10 - '@vitest/coverage-istanbul': 4.1.10 - '@vitest/coverage-v8': 4.1.10 - '@vitest/ui': 4.1.10 - happy-dom: '*' - jsdom: '*' - vite: ^6.0.0 || ^7.0.0 || ^8.0.0 - peerDependenciesMeta: - '@edge-runtime/vm': - optional: true - '@opentelemetry/api': - optional: true - '@types/node': - optional: true - '@vitest/browser-playwright': - optional: true - '@vitest/browser-preview': - optional: true - '@vitest/browser-webdriverio': - optional: true - '@vitest/coverage-istanbul': - optional: true - '@vitest/coverage-v8': - optional: true - '@vitest/ui': - optional: true - happy-dom: - optional: true - jsdom: - optional: true - - webpack-virtual-modules@0.6.2: - resolution: {integrity: sha512-66/V2i5hQanC51vBQKPH4aI8NMAcBW59FVBs+rC7eGHupMyfn34q7rZIE+ETlJ+XTevqfUhVVBgSUNSW2flEUQ==} - - why-is-node-running@2.3.0: - resolution: {integrity: sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==} - engines: {node: '>=8'} - hasBin: true - - wrap-ansi@7.0.0: - resolution: {integrity: sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==} - engines: {node: '>=10'} - - wrappy@1.0.2: - resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==} - - y18n@5.0.8: - resolution: {integrity: sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==} - engines: {node: '>=10'} - - yallist@5.0.0: - resolution: {integrity: sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw==} - engines: {node: '>=18'} - - yargs-parser@20.2.9: - resolution: {integrity: sha512-y11nGElTIV+CT3Zv9t7VKl+Q3hTQoT9a1Qzezhhl6Rp21gJ/IVTW7Z3y9EWXhuUBC2Shnf+DX0antecpAwSP8w==} - engines: {node: '>=10'} - - yargs@16.2.2: - resolution: {integrity: sha512-Nt9ZJjXTv5R8MHbqby/wXQ6Gi0Bb3TcYZkR1bzuL4yB2OxWPkXknz513gEF0GoA6tn00UpbPvERW8rzCuWCA6w==} - engines: {node: '>=10'} - - zod-to-json-schema@3.25.2: - resolution: {integrity: sha512-O/PgfnpT1xKSDeQYSCfRI5Gy3hPf91mKVDuYLUHZJMiDFptvP41MSnWofm8dnCm0256ZNfZIM7DSzuSMAFnjHA==} - peerDependencies: - zod: ^3.25.28 || ^4 - - zod@4.4.3: - resolution: {integrity: sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==} - -snapshots: - - '@babel/code-frame@7.29.7': - dependencies: - '@babel/helper-validator-identifier': 7.29.7 - js-tokens: 4.0.0 - picocolors: 1.1.1 - - '@babel/generator@7.29.7': - dependencies: - '@babel/parser': 7.29.7 - '@babel/types': 7.29.7 - '@jridgewell/gen-mapping': 0.3.13 - '@jridgewell/trace-mapping': 0.3.31 - jsesc: 3.1.0 - - '@babel/helper-globals@7.29.7': {} - - '@babel/helper-string-parser@7.29.7': {} - - '@babel/helper-validator-identifier@7.29.7': {} - - '@babel/parser@7.29.7': - dependencies: - '@babel/types': 7.29.7 - - '@babel/template@7.29.7': - dependencies: - '@babel/code-frame': 7.29.7 - '@babel/parser': 7.29.7 - '@babel/types': 7.29.7 - - '@babel/traverse@7.29.7': - dependencies: - '@babel/code-frame': 7.29.7 - '@babel/generator': 7.29.7 - '@babel/helper-globals': 7.29.7 - '@babel/parser': 7.29.7 - '@babel/template': 7.29.7 - '@babel/types': 7.29.7 - debug: 4.4.3 - transitivePeerDependencies: - - supports-color - - '@babel/types@7.29.7': - dependencies: - '@babel/helper-string-parser': 7.29.7 - '@babel/helper-validator-identifier': 7.29.7 - - '@biomejs/biome@2.5.4': - optionalDependencies: - '@biomejs/cli-darwin-arm64': 2.5.4 - '@biomejs/cli-darwin-x64': 2.5.4 - '@biomejs/cli-linux-arm64': 2.5.4 - '@biomejs/cli-linux-arm64-musl': 2.5.4 - '@biomejs/cli-linux-x64': 2.5.4 - '@biomejs/cli-linux-x64-musl': 2.5.4 - '@biomejs/cli-win32-arm64': 2.5.4 - '@biomejs/cli-win32-x64': 2.5.4 - - '@biomejs/cli-darwin-arm64@2.5.4': - optional: true - - '@biomejs/cli-darwin-x64@2.5.4': - optional: true - - '@biomejs/cli-linux-arm64-musl@2.5.4': - optional: true - - '@biomejs/cli-linux-arm64@2.5.4': - optional: true - - '@biomejs/cli-linux-x64-musl@2.5.4': - optional: true - - '@biomejs/cli-linux-x64@2.5.4': - optional: true - - '@biomejs/cli-win32-arm64@2.5.4': - optional: true - - '@biomejs/cli-win32-x64@2.5.4': - optional: true - - '@braintrust/bt-darwin-arm64@0.12.0': - optional: true - - '@braintrust/bt-darwin-x64@0.12.0': - optional: true - - '@braintrust/bt-linux-arm64@0.12.0': - optional: true - - '@braintrust/bt-linux-x64-musl@0.12.0': - optional: true - - '@braintrust/bt-linux-x64@0.12.0': - optional: true - - '@braintrust/bt-win32-arm64@0.12.0': - optional: true - - '@braintrust/bt-win32-x64@0.12.0': - optional: true - - '@colors/colors@1.5.0': - optional: true - - '@emnapi/core@1.11.1': - dependencies: - '@emnapi/wasi-threads': 1.2.2 - tslib: 2.8.1 - optional: true - - '@emnapi/runtime@1.11.1': - dependencies: - tslib: 2.8.1 - optional: true - - '@emnapi/wasi-threads@1.2.2': - dependencies: - tslib: 2.8.1 - optional: true - - '@esbuild/aix-ppc64@0.27.7': - optional: true - - '@esbuild/aix-ppc64@0.28.0': - optional: true - - '@esbuild/aix-ppc64@0.28.1': - optional: true - - '@esbuild/android-arm64@0.27.7': - optional: true - - '@esbuild/android-arm64@0.28.0': - optional: true - - '@esbuild/android-arm64@0.28.1': - optional: true - - '@esbuild/android-arm@0.27.7': - optional: true - - '@esbuild/android-arm@0.28.0': - optional: true - - '@esbuild/android-arm@0.28.1': - optional: true - - '@esbuild/android-x64@0.27.7': - optional: true - - '@esbuild/android-x64@0.28.0': - optional: true - - '@esbuild/android-x64@0.28.1': - optional: true - - '@esbuild/darwin-arm64@0.27.7': - optional: true - - '@esbuild/darwin-arm64@0.28.0': - optional: true - - '@esbuild/darwin-arm64@0.28.1': - optional: true - - '@esbuild/darwin-x64@0.27.7': - optional: true - - '@esbuild/darwin-x64@0.28.0': - optional: true - - '@esbuild/darwin-x64@0.28.1': - optional: true - - '@esbuild/freebsd-arm64@0.27.7': - optional: true - - '@esbuild/freebsd-arm64@0.28.0': - optional: true - - '@esbuild/freebsd-arm64@0.28.1': - optional: true - - '@esbuild/freebsd-x64@0.27.7': - optional: true - - '@esbuild/freebsd-x64@0.28.0': - optional: true - - '@esbuild/freebsd-x64@0.28.1': - optional: true - - '@esbuild/linux-arm64@0.27.7': - optional: true - - '@esbuild/linux-arm64@0.28.0': - optional: true - - '@esbuild/linux-arm64@0.28.1': - optional: true - - '@esbuild/linux-arm@0.27.7': - optional: true - - '@esbuild/linux-arm@0.28.0': - optional: true - - '@esbuild/linux-arm@0.28.1': - optional: true - - '@esbuild/linux-ia32@0.27.7': - optional: true - - '@esbuild/linux-ia32@0.28.0': - optional: true - - '@esbuild/linux-ia32@0.28.1': - optional: true - - '@esbuild/linux-loong64@0.27.7': - optional: true - - '@esbuild/linux-loong64@0.28.0': - optional: true - - '@esbuild/linux-loong64@0.28.1': - optional: true - - '@esbuild/linux-mips64el@0.27.7': - optional: true - - '@esbuild/linux-mips64el@0.28.0': - optional: true - - '@esbuild/linux-mips64el@0.28.1': - optional: true - - '@esbuild/linux-ppc64@0.27.7': - optional: true - - '@esbuild/linux-ppc64@0.28.0': - optional: true - - '@esbuild/linux-ppc64@0.28.1': - optional: true - - '@esbuild/linux-riscv64@0.27.7': - optional: true - - '@esbuild/linux-riscv64@0.28.0': - optional: true - - '@esbuild/linux-riscv64@0.28.1': - optional: true - - '@esbuild/linux-s390x@0.27.7': - optional: true - - '@esbuild/linux-s390x@0.28.0': - optional: true - - '@esbuild/linux-s390x@0.28.1': - optional: true - - '@esbuild/linux-x64@0.27.7': - optional: true - - '@esbuild/linux-x64@0.28.0': - optional: true - - '@esbuild/linux-x64@0.28.1': - optional: true - - '@esbuild/netbsd-arm64@0.27.7': - optional: true - - '@esbuild/netbsd-arm64@0.28.0': - optional: true - - '@esbuild/netbsd-arm64@0.28.1': - optional: true - - '@esbuild/netbsd-x64@0.27.7': - optional: true - - '@esbuild/netbsd-x64@0.28.0': - optional: true - - '@esbuild/netbsd-x64@0.28.1': - optional: true - - '@esbuild/openbsd-arm64@0.27.7': - optional: true - - '@esbuild/openbsd-arm64@0.28.0': - optional: true - - '@esbuild/openbsd-arm64@0.28.1': - optional: true - - '@esbuild/openbsd-x64@0.27.7': - optional: true - - '@esbuild/openbsd-x64@0.28.0': - optional: true - - '@esbuild/openbsd-x64@0.28.1': - optional: true - - '@esbuild/openharmony-arm64@0.27.7': - optional: true - - '@esbuild/openharmony-arm64@0.28.0': - optional: true - - '@esbuild/openharmony-arm64@0.28.1': - optional: true - - '@esbuild/sunos-x64@0.27.7': - optional: true - - '@esbuild/sunos-x64@0.28.0': - optional: true - - '@esbuild/sunos-x64@0.28.1': - optional: true - - '@esbuild/win32-arm64@0.27.7': - optional: true - - '@esbuild/win32-arm64@0.28.0': - optional: true - - '@esbuild/win32-arm64@0.28.1': - optional: true - - '@esbuild/win32-ia32@0.27.7': - optional: true - - '@esbuild/win32-ia32@0.28.0': - optional: true - - '@esbuild/win32-ia32@0.28.1': - optional: true - - '@esbuild/win32-x64@0.27.7': - optional: true - - '@esbuild/win32-x64@0.28.0': - optional: true - - '@esbuild/win32-x64@0.28.1': - optional: true - - '@isaacs/fs-minipass@4.0.1': - dependencies: - minipass: 7.1.3 - - '@jridgewell/gen-mapping@0.3.13': - dependencies: - '@jridgewell/sourcemap-codec': 1.5.5 - '@jridgewell/trace-mapping': 0.3.31 - - '@jridgewell/remapping@2.3.5': - dependencies: - '@jridgewell/gen-mapping': 0.3.13 - '@jridgewell/trace-mapping': 0.3.31 - - '@jridgewell/resolve-uri@3.1.2': {} - - '@jridgewell/sourcemap-codec@1.5.5': {} - - '@jridgewell/trace-mapping@0.3.31': - dependencies: - '@jridgewell/resolve-uri': 3.1.2 - '@jridgewell/sourcemap-codec': 1.5.5 - - '@kwsites/file-exists@1.1.1': - dependencies: - debug: 4.4.3 - transitivePeerDependencies: - - supports-color - - '@kwsites/promise-deferred@1.1.1': {} - - '@napi-rs/wasm-runtime@1.1.6(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1)': - dependencies: - '@emnapi/core': 1.11.1 - '@emnapi/runtime': 1.11.1 - '@tybys/wasm-util': 0.10.3 - optional: true - - '@next/env@14.2.35': {} - - '@oxc-project/types@0.139.0': {} - - '@roberts_lando/vfs@0.3.3': {} - - '@rolldown/binding-android-arm64@1.1.5': - optional: true - - '@rolldown/binding-darwin-arm64@1.1.5': - optional: true - - '@rolldown/binding-darwin-x64@1.1.5': - optional: true - - '@rolldown/binding-freebsd-x64@1.1.5': - optional: true - - '@rolldown/binding-linux-arm-gnueabihf@1.1.5': - optional: true - - '@rolldown/binding-linux-arm64-gnu@1.1.5': - optional: true - - '@rolldown/binding-linux-arm64-musl@1.1.5': - optional: true - - '@rolldown/binding-linux-ppc64-gnu@1.1.5': - optional: true - - '@rolldown/binding-linux-s390x-gnu@1.1.5': - optional: true - - '@rolldown/binding-linux-x64-gnu@1.1.5': - optional: true - - '@rolldown/binding-linux-x64-musl@1.1.5': - optional: true - - '@rolldown/binding-openharmony-arm64@1.1.5': - optional: true - - '@rolldown/binding-wasm32-wasi@1.1.5': - dependencies: - '@emnapi/core': 1.11.1 - '@emnapi/runtime': 1.11.1 - '@napi-rs/wasm-runtime': 1.1.6(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1) - optional: true - - '@rolldown/binding-win32-arm64-msvc@1.1.5': - optional: true - - '@rolldown/binding-win32-x64-msvc@1.1.5': - optional: true - - '@rolldown/pluginutils@1.0.1': {} - - '@rollup/rollup-android-arm-eabi@4.62.2': - optional: true - - '@rollup/rollup-android-arm64@4.62.2': - optional: true - - '@rollup/rollup-darwin-arm64@4.62.2': - optional: true - - '@rollup/rollup-darwin-x64@4.62.2': - optional: true - - '@rollup/rollup-freebsd-arm64@4.62.2': - optional: true - - '@rollup/rollup-freebsd-x64@4.62.2': - optional: true - - '@rollup/rollup-linux-arm-gnueabihf@4.62.2': - optional: true - - '@rollup/rollup-linux-arm-musleabihf@4.62.2': - optional: true - - '@rollup/rollup-linux-arm64-gnu@4.62.2': - optional: true - - '@rollup/rollup-linux-arm64-musl@4.62.2': - optional: true - - '@rollup/rollup-linux-loong64-gnu@4.62.2': - optional: true - - '@rollup/rollup-linux-loong64-musl@4.62.2': - optional: true - - '@rollup/rollup-linux-ppc64-gnu@4.62.2': - optional: true - - '@rollup/rollup-linux-ppc64-musl@4.62.2': - optional: true - - '@rollup/rollup-linux-riscv64-gnu@4.62.2': - optional: true - - '@rollup/rollup-linux-riscv64-musl@4.62.2': - optional: true - - '@rollup/rollup-linux-s390x-gnu@4.62.2': - optional: true - - '@rollup/rollup-linux-x64-gnu@4.62.2': - optional: true - - '@rollup/rollup-linux-x64-musl@4.62.2': - optional: true - - '@rollup/rollup-openbsd-x64@4.62.2': - optional: true - - '@rollup/rollup-openharmony-arm64@4.62.2': - optional: true - - '@rollup/rollup-win32-arm64-msvc@4.62.2': - optional: true - - '@rollup/rollup-win32-ia32-msvc@4.62.2': - optional: true - - '@rollup/rollup-win32-x64-gnu@4.62.2': - optional: true - - '@rollup/rollup-win32-x64-msvc@4.62.2': - optional: true - - '@simple-git/args-pathspec@1.0.3': {} - - '@simple-git/argv-parser@1.1.1': - dependencies: - '@simple-git/args-pathspec': 1.0.3 - - '@standard-schema/spec@1.1.0': {} - - '@tybys/wasm-util@0.10.3': - dependencies: - tslib: 2.8.1 - optional: true - - '@types/chai@5.2.3': - dependencies: - '@types/deep-eql': 4.0.2 - assertion-error: 2.0.1 - - '@types/deep-eql@4.0.2': {} - - '@types/estree@1.0.9': {} - - '@types/node@24.13.3': - dependencies: - undici-types: 7.18.2 - - '@vercel/functions@1.6.0': {} - - '@vitest/expect@4.1.10': - dependencies: - '@standard-schema/spec': 1.1.0 - '@types/chai': 5.2.3 - '@vitest/spy': 4.1.10 - '@vitest/utils': 4.1.10 - chai: 6.2.2 - tinyrainbow: 3.1.0 - - '@vitest/mocker@4.1.10(vite@8.1.5(@types/node@24.13.3)(esbuild@0.27.7)(tsx@4.23.1))': - dependencies: - '@vitest/spy': 4.1.10 - estree-walker: 3.0.3 - magic-string: 0.30.21 - optionalDependencies: - vite: 8.1.5(@types/node@24.13.3)(esbuild@0.27.7)(tsx@4.23.1) - - '@vitest/pretty-format@4.1.10': - dependencies: - tinyrainbow: 3.1.0 - - '@vitest/runner@4.1.10': - dependencies: - '@vitest/utils': 4.1.10 - pathe: 2.0.3 - - '@vitest/snapshot@4.1.10': - dependencies: - '@vitest/pretty-format': 4.1.10 - '@vitest/utils': 4.1.10 - magic-string: 0.30.21 - pathe: 2.0.3 - - '@vitest/spy@4.1.10': {} - - '@vitest/utils@4.1.10': - dependencies: - '@vitest/pretty-format': 4.1.10 - convert-source-map: 2.0.0 - tinyrainbow: 3.1.0 - - '@yao-pkg/pkg-fetch@3.6.4': - dependencies: - picocolors: 1.1.1 - progress: 2.0.3 - semver: 7.8.5 - tar-fs: 3.1.3 - undici: 7.28.0 - yargs: 16.2.2 - transitivePeerDependencies: - - bare-abort-controller - - bare-buffer - - react-native-b4a - - '@yao-pkg/pkg@6.21.0': - dependencies: - '@babel/generator': 7.29.7 - '@babel/parser': 7.29.7 - '@babel/traverse': 7.29.7 - '@babel/types': 7.29.7 - '@roberts_lando/vfs': 0.3.3 - '@yao-pkg/pkg-fetch': 3.6.4 - esbuild: 0.28.1 - into-stream: 9.1.0 - multistream: 4.1.0 - picocolors: 1.1.1 - picomatch: 4.0.5 - postject: 1.0.0-alpha.6 - prebuild-install: 7.1.3 - resolve: 1.22.12 - resolve.exports: 2.0.3 - stream-meter: 1.0.4 - tar: 7.5.20 - tinyglobby: 0.2.17 - unzipper: 0.12.5 - transitivePeerDependencies: - - bare-abort-controller - - bare-buffer - - react-native-b4a - - supports-color - - accepts@2.0.0: - dependencies: - mime-types: 3.0.2 - negotiator: 1.0.0 - - acorn-import-attributes@1.9.5(acorn@8.17.0): - dependencies: - acorn: 8.17.0 - - acorn@8.17.0: {} - - ajv@8.20.0: - dependencies: - fast-deep-equal: 3.1.3 - fast-uri: 3.1.4 - json-schema-traverse: 1.0.0 - require-from-string: 2.0.2 - - ansi-regex@5.0.1: {} - - ansi-styles@4.3.0: - dependencies: - color-convert: 2.0.1 - - any-promise@1.3.0: {} - - argparse@2.0.1: {} - - assertion-error@2.0.1: {} - - astring@1.9.0: {} - - b4a@1.8.1: {} - - balanced-match@4.0.4: {} - - bare-events@2.9.1: {} - - bare-fs@4.7.4: - dependencies: - bare-events: 2.9.1 - bare-path: 3.1.1 - bare-stream: 2.13.3(bare-events@2.9.1) - bare-url: 2.4.5 - fast-fifo: 1.3.2 - transitivePeerDependencies: - - bare-abort-controller - - react-native-b4a - - bare-path@3.1.1: {} - - bare-stream@2.13.3(bare-events@2.9.1): - dependencies: - b4a: 1.8.1 - streamx: 2.28.0 - teex: 1.0.1 - optionalDependencies: - bare-events: 2.9.1 - transitivePeerDependencies: - - react-native-b4a - - bare-url@2.4.5: - dependencies: - bare-path: 3.1.1 - - base64-js@1.5.1: {} - - bl@4.1.0: - dependencies: - buffer: 5.7.1 - inherits: 2.0.4 - readable-stream: 3.6.2 - - bluebird@3.7.2: {} - - body-parser@2.3.0: - dependencies: - bytes: 3.1.2 - content-type: 2.0.0 - debug: 4.4.3 - http-errors: 2.0.1 - iconv-lite: 0.7.3 - on-finished: 2.4.1 - qs: 6.15.3 - raw-body: 3.0.2 - type-is: 2.1.0 - transitivePeerDependencies: - - supports-color - - brace-expansion@5.0.7: - dependencies: - balanced-match: 4.0.4 - - braintrust@3.23.1(zod@4.4.3): - dependencies: - '@next/env': 14.2.35 - '@vercel/functions': 1.6.0 - acorn: 8.17.0 - acorn-import-attributes: 1.9.5(acorn@8.17.0) - ajv: 8.20.0 - argparse: 2.0.1 - astring: 1.9.0 - cjs-module-lexer: 2.2.0 - cli-progress: 3.12.0 - cli-table3: 0.6.5 - cors: 2.8.6 - dc-browser: 1.0.4 - dotenv: 16.6.1 - esbuild: 0.28.0 - esquery: 1.7.0 - eventsource-parser: 1.1.2 - express: 5.2.1 - http-errors: 2.0.1 - meriyah: 6.1.4 - minimatch: 10.2.5 - module-details-from-path: 1.0.4 - mustache: 4.2.0 - pluralize: 8.0.0 - semifies: 1.0.0 - simple-git: 3.36.0 - source-map: 0.7.6 - termi-link: 1.1.0 - unplugin: 2.3.11 - uuid: 11.1.1 - zod: 4.4.3 - zod-to-json-schema: 3.25.2(zod@4.4.3) - optionalDependencies: - '@braintrust/bt-darwin-arm64': 0.12.0 - '@braintrust/bt-darwin-x64': 0.12.0 - '@braintrust/bt-linux-arm64': 0.12.0 - '@braintrust/bt-linux-x64': 0.12.0 - '@braintrust/bt-linux-x64-musl': 0.12.0 - '@braintrust/bt-win32-arm64': 0.12.0 - '@braintrust/bt-win32-x64': 0.12.0 - transitivePeerDependencies: - - '@aws-sdk/credential-provider-web-identity' - - supports-color - - buffer@5.7.1: - dependencies: - base64-js: 1.5.1 - ieee754: 1.2.1 - - bundle-require@5.1.0(esbuild@0.27.7): - dependencies: - esbuild: 0.27.7 - load-tsconfig: 0.2.5 - - bytes@3.1.2: {} - - cac@6.7.14: {} - - call-bind-apply-helpers@1.0.2: - dependencies: - es-errors: 1.3.0 - function-bind: 1.1.2 - - call-bound@1.0.4: - dependencies: - call-bind-apply-helpers: 1.0.2 - get-intrinsic: 1.3.0 - - chai@6.2.2: {} - - chokidar@4.0.3: - dependencies: - readdirp: 4.1.2 - - chownr@1.1.4: {} - - chownr@3.0.0: {} - - cjs-module-lexer@2.2.0: {} - - cli-progress@3.12.0: - dependencies: - string-width: 4.2.3 - - cli-table3@0.6.5: - dependencies: - string-width: 4.2.3 - optionalDependencies: - '@colors/colors': 1.5.0 - - cliui@7.0.4: - dependencies: - string-width: 4.2.3 - strip-ansi: 6.0.1 - wrap-ansi: 7.0.0 - - color-convert@2.0.1: - dependencies: - color-name: 1.1.4 - - color-name@1.1.4: {} - - commander@4.1.1: {} - - commander@9.5.0: {} - - confbox@0.1.8: {} - - consola@3.4.2: {} - - content-disposition@1.1.0: {} - - content-type@1.0.5: {} - - content-type@2.0.0: {} - - convert-source-map@2.0.0: {} - - cookie-signature@1.2.2: {} - - cookie@0.7.2: {} - - core-util-is@1.0.3: {} - - cors@2.8.6: - dependencies: - object-assign: 4.1.1 - vary: 1.1.2 - - dc-browser@1.0.4: {} - - debug@4.4.3: - dependencies: - ms: 2.1.3 - - decompress-response@6.0.0: - dependencies: - mimic-response: 3.1.0 - - deep-extend@0.6.0: {} - - depd@2.0.0: {} - - detect-libc@2.1.2: {} - - dotenv@16.6.1: {} - - dunder-proto@1.0.1: - dependencies: - call-bind-apply-helpers: 1.0.2 - es-errors: 1.3.0 - gopd: 1.2.0 - - duplexer2@0.1.4: - dependencies: - readable-stream: 2.3.8 - - ee-first@1.1.1: {} - - emoji-regex@8.0.0: {} - - encodeurl@2.0.0: {} - - end-of-stream@1.4.5: - dependencies: - once: 1.4.0 - - es-define-property@1.0.1: {} - - es-errors@1.3.0: {} - - es-module-lexer@2.3.1: {} - - es-object-atoms@1.1.2: - dependencies: - es-errors: 1.3.0 - - esbuild@0.27.7: - optionalDependencies: - '@esbuild/aix-ppc64': 0.27.7 - '@esbuild/android-arm': 0.27.7 - '@esbuild/android-arm64': 0.27.7 - '@esbuild/android-x64': 0.27.7 - '@esbuild/darwin-arm64': 0.27.7 - '@esbuild/darwin-x64': 0.27.7 - '@esbuild/freebsd-arm64': 0.27.7 - '@esbuild/freebsd-x64': 0.27.7 - '@esbuild/linux-arm': 0.27.7 - '@esbuild/linux-arm64': 0.27.7 - '@esbuild/linux-ia32': 0.27.7 - '@esbuild/linux-loong64': 0.27.7 - '@esbuild/linux-mips64el': 0.27.7 - '@esbuild/linux-ppc64': 0.27.7 - '@esbuild/linux-riscv64': 0.27.7 - '@esbuild/linux-s390x': 0.27.7 - '@esbuild/linux-x64': 0.27.7 - '@esbuild/netbsd-arm64': 0.27.7 - '@esbuild/netbsd-x64': 0.27.7 - '@esbuild/openbsd-arm64': 0.27.7 - '@esbuild/openbsd-x64': 0.27.7 - '@esbuild/openharmony-arm64': 0.27.7 - '@esbuild/sunos-x64': 0.27.7 - '@esbuild/win32-arm64': 0.27.7 - '@esbuild/win32-ia32': 0.27.7 - '@esbuild/win32-x64': 0.27.7 - - esbuild@0.28.0: - optionalDependencies: - '@esbuild/aix-ppc64': 0.28.0 - '@esbuild/android-arm': 0.28.0 - '@esbuild/android-arm64': 0.28.0 - '@esbuild/android-x64': 0.28.0 - '@esbuild/darwin-arm64': 0.28.0 - '@esbuild/darwin-x64': 0.28.0 - '@esbuild/freebsd-arm64': 0.28.0 - '@esbuild/freebsd-x64': 0.28.0 - '@esbuild/linux-arm': 0.28.0 - '@esbuild/linux-arm64': 0.28.0 - '@esbuild/linux-ia32': 0.28.0 - '@esbuild/linux-loong64': 0.28.0 - '@esbuild/linux-mips64el': 0.28.0 - '@esbuild/linux-ppc64': 0.28.0 - '@esbuild/linux-riscv64': 0.28.0 - '@esbuild/linux-s390x': 0.28.0 - '@esbuild/linux-x64': 0.28.0 - '@esbuild/netbsd-arm64': 0.28.0 - '@esbuild/netbsd-x64': 0.28.0 - '@esbuild/openbsd-arm64': 0.28.0 - '@esbuild/openbsd-x64': 0.28.0 - '@esbuild/openharmony-arm64': 0.28.0 - '@esbuild/sunos-x64': 0.28.0 - '@esbuild/win32-arm64': 0.28.0 - '@esbuild/win32-ia32': 0.28.0 - '@esbuild/win32-x64': 0.28.0 - - esbuild@0.28.1: - optionalDependencies: - '@esbuild/aix-ppc64': 0.28.1 - '@esbuild/android-arm': 0.28.1 - '@esbuild/android-arm64': 0.28.1 - '@esbuild/android-x64': 0.28.1 - '@esbuild/darwin-arm64': 0.28.1 - '@esbuild/darwin-x64': 0.28.1 - '@esbuild/freebsd-arm64': 0.28.1 - '@esbuild/freebsd-x64': 0.28.1 - '@esbuild/linux-arm': 0.28.1 - '@esbuild/linux-arm64': 0.28.1 - '@esbuild/linux-ia32': 0.28.1 - '@esbuild/linux-loong64': 0.28.1 - '@esbuild/linux-mips64el': 0.28.1 - '@esbuild/linux-ppc64': 0.28.1 - '@esbuild/linux-riscv64': 0.28.1 - '@esbuild/linux-s390x': 0.28.1 - '@esbuild/linux-x64': 0.28.1 - '@esbuild/netbsd-arm64': 0.28.1 - '@esbuild/netbsd-x64': 0.28.1 - '@esbuild/openbsd-arm64': 0.28.1 - '@esbuild/openbsd-x64': 0.28.1 - '@esbuild/openharmony-arm64': 0.28.1 - '@esbuild/sunos-x64': 0.28.1 - '@esbuild/win32-arm64': 0.28.1 - '@esbuild/win32-ia32': 0.28.1 - '@esbuild/win32-x64': 0.28.1 - - escalade@3.2.0: {} - - escape-html@1.0.3: {} - - esquery@1.7.0: - dependencies: - estraverse: 5.3.0 - - estraverse@5.3.0: {} - - estree-walker@3.0.3: - dependencies: - '@types/estree': 1.0.9 - - etag@1.8.1: {} - - events-universal@1.0.1: - dependencies: - bare-events: 2.9.1 - transitivePeerDependencies: - - bare-abort-controller - - eventsource-parser@1.1.2: {} - - expand-template@2.0.3: {} - - expect-type@1.4.0: {} - - express@5.2.1: - dependencies: - accepts: 2.0.0 - body-parser: 2.3.0 - content-disposition: 1.1.0 - content-type: 1.0.5 - cookie: 0.7.2 - cookie-signature: 1.2.2 - debug: 4.4.3 - depd: 2.0.0 - encodeurl: 2.0.0 - escape-html: 1.0.3 - etag: 1.8.1 - finalhandler: 2.1.1 - fresh: 2.0.0 - http-errors: 2.0.1 - merge-descriptors: 2.0.0 - mime-types: 3.0.2 - on-finished: 2.4.1 - once: 1.4.0 - parseurl: 1.3.3 - proxy-addr: 2.0.7 - qs: 6.15.3 - range-parser: 1.3.0 - router: 2.2.0 - send: 1.2.1 - serve-static: 2.2.1 - statuses: 2.0.2 - type-is: 2.1.0 - vary: 1.1.2 - transitivePeerDependencies: - - supports-color - - fast-deep-equal@3.1.3: {} - - fast-fifo@1.3.2: {} - - fast-uri@3.1.4: {} - - fdir@6.5.0(picomatch@4.0.5): - optionalDependencies: - picomatch: 4.0.5 - - finalhandler@2.1.1: - dependencies: - debug: 4.4.3 - encodeurl: 2.0.0 - escape-html: 1.0.3 - on-finished: 2.4.1 - parseurl: 1.3.3 - statuses: 2.0.2 - transitivePeerDependencies: - - supports-color - - fix-dts-default-cjs-exports@1.0.1: - dependencies: - magic-string: 0.30.21 - mlly: 1.8.2 - rollup: 4.62.2 - - forwarded@0.2.0: {} - - fresh@2.0.0: {} - - fs-constants@1.0.0: {} - - fs-extra@11.3.1: - dependencies: - graceful-fs: 4.2.11 - jsonfile: 6.2.1 - universalify: 2.0.1 - - fsevents@2.3.3: - optional: true - - function-bind@1.1.2: {} - - get-caller-file@2.0.5: {} - - get-intrinsic@1.3.0: - dependencies: - call-bind-apply-helpers: 1.0.2 - es-define-property: 1.0.1 - es-errors: 1.3.0 - es-object-atoms: 1.1.2 - function-bind: 1.1.2 - get-proto: 1.0.1 - gopd: 1.2.0 - has-symbols: 1.1.0 - hasown: 2.0.4 - math-intrinsics: 1.1.0 - - get-proto@1.0.1: - dependencies: - dunder-proto: 1.0.1 - es-object-atoms: 1.1.2 - - github-from-package@0.0.0: {} - - gopd@1.2.0: {} - - graceful-fs@4.2.11: {} - - has-symbols@1.1.0: {} - - hasown@2.0.4: - dependencies: - function-bind: 1.1.2 - - http-errors@2.0.1: - dependencies: - depd: 2.0.0 - inherits: 2.0.4 - setprototypeof: 1.2.0 - statuses: 2.0.2 - toidentifier: 1.0.1 - - iconv-lite@0.7.3: - dependencies: - safer-buffer: 2.1.2 - - ieee754@1.2.1: {} - - inherits@2.0.4: {} - - ini@1.3.8: {} - - into-stream@9.1.0: {} - - ipaddr.js@1.9.1: {} - - is-core-module@2.16.2: - dependencies: - hasown: 2.0.4 - - is-fullwidth-code-point@3.0.0: {} - - is-promise@4.0.0: {} - - isarray@1.0.0: {} - - joycon@3.1.1: {} - - js-tokens@4.0.0: {} - - jsesc@3.1.0: {} - - json-schema-traverse@1.0.0: {} - - jsonfile@6.2.1: - dependencies: - universalify: 2.0.1 - optionalDependencies: - graceful-fs: 4.2.11 - - lightningcss-android-arm64@1.33.0: - optional: true - - lightningcss-darwin-arm64@1.33.0: - optional: true - - lightningcss-darwin-x64@1.33.0: - optional: true - - lightningcss-freebsd-x64@1.33.0: - optional: true - - lightningcss-linux-arm-gnueabihf@1.33.0: - optional: true - - lightningcss-linux-arm64-gnu@1.33.0: - optional: true - - lightningcss-linux-arm64-musl@1.33.0: - optional: true - - lightningcss-linux-x64-gnu@1.33.0: - optional: true - - lightningcss-linux-x64-musl@1.33.0: - optional: true - - lightningcss-win32-arm64-msvc@1.33.0: - optional: true - - lightningcss-win32-x64-msvc@1.33.0: - optional: true - - lightningcss@1.33.0: - dependencies: - detect-libc: 2.1.2 - optionalDependencies: - lightningcss-android-arm64: 1.33.0 - lightningcss-darwin-arm64: 1.33.0 - lightningcss-darwin-x64: 1.33.0 - lightningcss-freebsd-x64: 1.33.0 - lightningcss-linux-arm-gnueabihf: 1.33.0 - lightningcss-linux-arm64-gnu: 1.33.0 - lightningcss-linux-arm64-musl: 1.33.0 - lightningcss-linux-x64-gnu: 1.33.0 - lightningcss-linux-x64-musl: 1.33.0 - lightningcss-win32-arm64-msvc: 1.33.0 - lightningcss-win32-x64-msvc: 1.33.0 - - lilconfig@3.1.3: {} - - lines-and-columns@1.2.4: {} - - load-tsconfig@0.2.5: {} - - magic-string@0.30.21: - dependencies: - '@jridgewell/sourcemap-codec': 1.5.5 - - math-intrinsics@1.1.0: {} - - media-typer@1.1.0: {} - - merge-descriptors@2.0.0: {} - - meriyah@6.1.4: {} - - mime-db@1.54.0: {} - - mime-types@3.0.2: - dependencies: - mime-db: 1.54.0 - - mimic-response@3.1.0: {} - - minimatch@10.2.5: - dependencies: - brace-expansion: 5.0.7 - - minimist@1.2.8: {} - - minipass@7.1.3: {} - - minizlib@3.1.0: - dependencies: - minipass: 7.1.3 - - mkdirp-classic@0.5.3: {} - - mlly@1.8.2: - dependencies: - acorn: 8.17.0 - pathe: 2.0.3 - pkg-types: 1.3.1 - ufo: 1.6.4 - - module-details-from-path@1.0.4: {} - - ms@2.1.3: {} - - multistream@4.1.0: - dependencies: - once: 1.4.0 - readable-stream: 3.6.2 - - mustache@4.2.0: {} - - mz@2.7.0: - dependencies: - any-promise: 1.3.0 - object-assign: 4.1.1 - thenify-all: 1.6.0 - - nanoid@3.3.16: {} - - napi-build-utils@2.0.0: {} - - negotiator@1.0.0: {} - - node-abi@3.94.0: - dependencies: - semver: 7.8.5 - - node-int64@0.4.0: {} - - object-assign@4.1.1: {} - - object-inspect@1.13.4: {} - - obug@2.1.4: {} - - on-finished@2.4.1: - dependencies: - ee-first: 1.1.1 - - once@1.4.0: - dependencies: - wrappy: 1.0.2 - - parseurl@1.3.3: {} - - path-parse@1.0.7: {} - - path-to-regexp@8.4.2: {} - - pathe@2.0.3: {} - - picocolors@1.1.1: {} - - picomatch@4.0.5: {} - - pirates@4.0.7: {} - - pkg-types@1.3.1: - dependencies: - confbox: 0.1.8 - mlly: 1.8.2 - pathe: 2.0.3 - - pluralize@8.0.0: {} - - postcss-load-config@6.0.1(postcss@8.5.20)(tsx@4.23.1): - dependencies: - lilconfig: 3.1.3 - optionalDependencies: - postcss: 8.5.20 - tsx: 4.23.1 - - postcss@8.5.20: - dependencies: - nanoid: 3.3.16 - picocolors: 1.1.1 - source-map-js: 1.2.1 - - postject@1.0.0-alpha.6: - dependencies: - commander: 9.5.0 - - prebuild-install@7.1.3: - dependencies: - detect-libc: 2.1.2 - expand-template: 2.0.3 - github-from-package: 0.0.0 - minimist: 1.2.8 - mkdirp-classic: 0.5.3 - napi-build-utils: 2.0.0 - node-abi: 3.94.0 - pump: 3.0.4 - rc: 1.2.8 - simple-get: 4.0.1 - tar-fs: 2.1.5 - tunnel-agent: 0.6.0 - - process-nextick-args@2.0.1: {} - - progress@2.0.3: {} - - proxy-addr@2.0.7: - dependencies: - forwarded: 0.2.0 - ipaddr.js: 1.9.1 - - pump@3.0.4: - dependencies: - end-of-stream: 1.4.5 - once: 1.4.0 - - qs@6.15.3: - dependencies: - es-define-property: 1.0.1 - side-channel: 1.1.1 - - range-parser@1.3.0: {} - - raw-body@3.0.2: - dependencies: - bytes: 3.1.2 - http-errors: 2.0.1 - iconv-lite: 0.7.3 - unpipe: 1.0.0 - - rc@1.2.8: - dependencies: - deep-extend: 0.6.0 - ini: 1.3.8 - minimist: 1.2.8 - strip-json-comments: 2.0.1 - - readable-stream@2.3.8: - dependencies: - core-util-is: 1.0.3 - inherits: 2.0.4 - isarray: 1.0.0 - process-nextick-args: 2.0.1 - safe-buffer: 5.1.2 - string_decoder: 1.1.1 - util-deprecate: 1.0.2 - - readable-stream@3.6.2: - dependencies: - inherits: 2.0.4 - string_decoder: 1.3.0 - util-deprecate: 1.0.2 - - readdirp@4.1.2: {} - - require-directory@2.1.1: {} - - require-from-string@2.0.2: {} - - resolve-from@5.0.0: {} - - resolve.exports@2.0.3: {} - - resolve@1.22.12: - dependencies: - es-errors: 1.3.0 - is-core-module: 2.16.2 - path-parse: 1.0.7 - supports-preserve-symlinks-flag: 1.0.0 - - rolldown@1.1.5: - dependencies: - '@oxc-project/types': 0.139.0 - '@rolldown/pluginutils': 1.0.1 - optionalDependencies: - '@rolldown/binding-android-arm64': 1.1.5 - '@rolldown/binding-darwin-arm64': 1.1.5 - '@rolldown/binding-darwin-x64': 1.1.5 - '@rolldown/binding-freebsd-x64': 1.1.5 - '@rolldown/binding-linux-arm-gnueabihf': 1.1.5 - '@rolldown/binding-linux-arm64-gnu': 1.1.5 - '@rolldown/binding-linux-arm64-musl': 1.1.5 - '@rolldown/binding-linux-ppc64-gnu': 1.1.5 - '@rolldown/binding-linux-s390x-gnu': 1.1.5 - '@rolldown/binding-linux-x64-gnu': 1.1.5 - '@rolldown/binding-linux-x64-musl': 1.1.5 - '@rolldown/binding-openharmony-arm64': 1.1.5 - '@rolldown/binding-wasm32-wasi': 1.1.5 - '@rolldown/binding-win32-arm64-msvc': 1.1.5 - '@rolldown/binding-win32-x64-msvc': 1.1.5 - - rollup@4.62.2: - dependencies: - '@types/estree': 1.0.9 - optionalDependencies: - '@rollup/rollup-android-arm-eabi': 4.62.2 - '@rollup/rollup-android-arm64': 4.62.2 - '@rollup/rollup-darwin-arm64': 4.62.2 - '@rollup/rollup-darwin-x64': 4.62.2 - '@rollup/rollup-freebsd-arm64': 4.62.2 - '@rollup/rollup-freebsd-x64': 4.62.2 - '@rollup/rollup-linux-arm-gnueabihf': 4.62.2 - '@rollup/rollup-linux-arm-musleabihf': 4.62.2 - '@rollup/rollup-linux-arm64-gnu': 4.62.2 - '@rollup/rollup-linux-arm64-musl': 4.62.2 - '@rollup/rollup-linux-loong64-gnu': 4.62.2 - '@rollup/rollup-linux-loong64-musl': 4.62.2 - '@rollup/rollup-linux-ppc64-gnu': 4.62.2 - '@rollup/rollup-linux-ppc64-musl': 4.62.2 - '@rollup/rollup-linux-riscv64-gnu': 4.62.2 - '@rollup/rollup-linux-riscv64-musl': 4.62.2 - '@rollup/rollup-linux-s390x-gnu': 4.62.2 - '@rollup/rollup-linux-x64-gnu': 4.62.2 - '@rollup/rollup-linux-x64-musl': 4.62.2 - '@rollup/rollup-openbsd-x64': 4.62.2 - '@rollup/rollup-openharmony-arm64': 4.62.2 - '@rollup/rollup-win32-arm64-msvc': 4.62.2 - '@rollup/rollup-win32-ia32-msvc': 4.62.2 - '@rollup/rollup-win32-x64-gnu': 4.62.2 - '@rollup/rollup-win32-x64-msvc': 4.62.2 - fsevents: 2.3.3 - - router@2.2.0: - dependencies: - debug: 4.4.3 - depd: 2.0.0 - is-promise: 4.0.0 - parseurl: 1.3.3 - path-to-regexp: 8.4.2 - transitivePeerDependencies: - - supports-color - - safe-buffer@5.1.2: {} - - safe-buffer@5.2.1: {} - - safer-buffer@2.1.2: {} - - semifies@1.0.0: {} - - semver@7.8.5: {} - - send@1.2.1: - dependencies: - debug: 4.4.3 - encodeurl: 2.0.0 - escape-html: 1.0.3 - etag: 1.8.1 - fresh: 2.0.0 - http-errors: 2.0.1 - mime-types: 3.0.2 - ms: 2.1.3 - on-finished: 2.4.1 - range-parser: 1.3.0 - statuses: 2.0.2 - transitivePeerDependencies: - - supports-color - - serve-static@2.2.1: - dependencies: - encodeurl: 2.0.0 - escape-html: 1.0.3 - parseurl: 1.3.3 - send: 1.2.1 - transitivePeerDependencies: - - supports-color - - setprototypeof@1.2.0: {} - - side-channel-list@1.0.1: - dependencies: - es-errors: 1.3.0 - object-inspect: 1.13.4 - - side-channel-map@1.0.1: - dependencies: - call-bound: 1.0.4 - es-errors: 1.3.0 - get-intrinsic: 1.3.0 - object-inspect: 1.13.4 - - side-channel-weakmap@1.0.2: - dependencies: - call-bound: 1.0.4 - es-errors: 1.3.0 - get-intrinsic: 1.3.0 - object-inspect: 1.13.4 - side-channel-map: 1.0.1 - - side-channel@1.1.1: - dependencies: - es-errors: 1.3.0 - object-inspect: 1.13.4 - side-channel-list: 1.0.1 - side-channel-map: 1.0.1 - side-channel-weakmap: 1.0.2 - - siginfo@2.0.0: {} - - simple-concat@1.0.1: {} - - simple-get@4.0.1: - dependencies: - decompress-response: 6.0.0 - once: 1.4.0 - simple-concat: 1.0.1 - - simple-git@3.36.0: - dependencies: - '@kwsites/file-exists': 1.1.1 - '@kwsites/promise-deferred': 1.1.1 - '@simple-git/args-pathspec': 1.0.3 - '@simple-git/argv-parser': 1.1.1 - debug: 4.4.3 - transitivePeerDependencies: - - supports-color - - source-map-js@1.2.1: {} - - source-map@0.7.6: {} - - stackback@0.0.2: {} - - statuses@2.0.2: {} - - std-env@4.2.0: {} - - stream-meter@1.0.4: - dependencies: - readable-stream: 2.3.8 - - streamx@2.28.0: - dependencies: - events-universal: 1.0.1 - fast-fifo: 1.3.2 - text-decoder: 1.2.7 - transitivePeerDependencies: - - bare-abort-controller - - react-native-b4a - - string-width@4.2.3: - dependencies: - emoji-regex: 8.0.0 - is-fullwidth-code-point: 3.0.0 - strip-ansi: 6.0.1 - - string_decoder@1.1.1: - dependencies: - safe-buffer: 5.1.2 - - string_decoder@1.3.0: - dependencies: - safe-buffer: 5.2.1 - - strip-ansi@6.0.1: - dependencies: - ansi-regex: 5.0.1 - - strip-json-comments@2.0.1: {} - - sucrase@3.35.1: - dependencies: - '@jridgewell/gen-mapping': 0.3.13 - commander: 4.1.1 - lines-and-columns: 1.2.4 - mz: 2.7.0 - pirates: 4.0.7 - tinyglobby: 0.2.17 - ts-interface-checker: 0.1.13 - - supports-preserve-symlinks-flag@1.0.0: {} - - tar-fs@2.1.5: - dependencies: - chownr: 1.1.4 - mkdirp-classic: 0.5.3 - pump: 3.0.4 - tar-stream: 2.2.0 - - tar-fs@3.1.3: - dependencies: - pump: 3.0.4 - tar-stream: 3.2.0 - optionalDependencies: - bare-fs: 4.7.4 - bare-path: 3.1.1 - transitivePeerDependencies: - - bare-abort-controller - - bare-buffer - - react-native-b4a - - tar-stream@2.2.0: - dependencies: - bl: 4.1.0 - end-of-stream: 1.4.5 - fs-constants: 1.0.0 - inherits: 2.0.4 - readable-stream: 3.6.2 - - tar-stream@3.2.0: - dependencies: - b4a: 1.8.1 - bare-fs: 4.7.4 - fast-fifo: 1.3.2 - streamx: 2.28.0 - transitivePeerDependencies: - - bare-abort-controller - - bare-buffer - - react-native-b4a - - tar@7.5.20: - dependencies: - '@isaacs/fs-minipass': 4.0.1 - chownr: 3.0.0 - minipass: 7.1.3 - minizlib: 3.1.0 - yallist: 5.0.0 - - teex@1.0.1: - dependencies: - streamx: 2.28.0 - transitivePeerDependencies: - - bare-abort-controller - - react-native-b4a - - termi-link@1.1.0: {} - - text-decoder@1.2.7: - dependencies: - b4a: 1.8.1 - transitivePeerDependencies: - - react-native-b4a - - thenify-all@1.6.0: - dependencies: - thenify: 3.3.1 - - thenify@3.3.1: - dependencies: - any-promise: 1.3.0 - - tinybench@2.9.0: {} - - tinyexec@0.3.2: {} - - tinyexec@1.2.4: {} - - tinyglobby@0.2.17: - dependencies: - fdir: 6.5.0(picomatch@4.0.5) - picomatch: 4.0.5 - - tinyrainbow@3.1.0: {} - - toidentifier@1.0.1: {} - - tree-kill@1.2.2: {} - - ts-interface-checker@0.1.13: {} - - tslib@2.8.1: - optional: true - - tsup@8.5.1(postcss@8.5.20)(tsx@4.23.1)(typescript@5.9.3): - dependencies: - bundle-require: 5.1.0(esbuild@0.27.7) - cac: 6.7.14 - chokidar: 4.0.3 - consola: 3.4.2 - debug: 4.4.3 - esbuild: 0.27.7 - fix-dts-default-cjs-exports: 1.0.1 - joycon: 3.1.1 - picocolors: 1.1.1 - postcss-load-config: 6.0.1(postcss@8.5.20)(tsx@4.23.1) - resolve-from: 5.0.0 - rollup: 4.62.2 - source-map: 0.7.6 - sucrase: 3.35.1 - tinyexec: 0.3.2 - tinyglobby: 0.2.17 - tree-kill: 1.2.2 - optionalDependencies: - postcss: 8.5.20 - typescript: 5.9.3 - transitivePeerDependencies: - - jiti - - supports-color - - tsx - - yaml - - tsx@4.23.1: - dependencies: - esbuild: 0.28.1 - optionalDependencies: - fsevents: 2.3.3 - - tunnel-agent@0.6.0: - dependencies: - safe-buffer: 5.2.1 - - type-is@2.1.0: - dependencies: - content-type: 2.0.0 - media-typer: 1.1.0 - mime-types: 3.0.2 - - typescript@5.9.3: {} - - ufo@1.6.4: {} - - undici-types@7.18.2: {} - - undici@7.28.0: {} - - universalify@2.0.1: {} - - unpipe@1.0.0: {} - - unplugin@2.3.11: - dependencies: - '@jridgewell/remapping': 2.3.5 - acorn: 8.17.0 - picomatch: 4.0.5 - webpack-virtual-modules: 0.6.2 - - unzipper@0.12.5: - dependencies: - bluebird: 3.7.2 - duplexer2: 0.1.4 - fs-extra: 11.3.1 - graceful-fs: 4.2.11 - node-int64: 0.4.0 - - util-deprecate@1.0.2: {} - - uuid@11.1.1: {} - - vary@1.1.2: {} - - vite@8.1.5(@types/node@24.13.3)(esbuild@0.27.7)(tsx@4.23.1): - dependencies: - lightningcss: 1.33.0 - picomatch: 4.0.5 - postcss: 8.5.20 - rolldown: 1.1.5 - tinyglobby: 0.2.17 - optionalDependencies: - '@types/node': 24.13.3 - esbuild: 0.27.7 - fsevents: 2.3.3 - tsx: 4.23.1 - - vitest@4.1.10(@types/node@24.13.3)(vite@8.1.5(@types/node@24.13.3)(esbuild@0.27.7)(tsx@4.23.1)): - dependencies: - '@vitest/expect': 4.1.10 - '@vitest/mocker': 4.1.10(vite@8.1.5(@types/node@24.13.3)(esbuild@0.27.7)(tsx@4.23.1)) - '@vitest/pretty-format': 4.1.10 - '@vitest/runner': 4.1.10 - '@vitest/snapshot': 4.1.10 - '@vitest/spy': 4.1.10 - '@vitest/utils': 4.1.10 - es-module-lexer: 2.3.1 - expect-type: 1.4.0 - magic-string: 0.30.21 - obug: 2.1.4 - pathe: 2.0.3 - picomatch: 4.0.5 - std-env: 4.2.0 - tinybench: 2.9.0 - tinyexec: 1.2.4 - tinyglobby: 0.2.17 - tinyrainbow: 3.1.0 - vite: 8.1.5(@types/node@24.13.3)(esbuild@0.27.7)(tsx@4.23.1) - why-is-node-running: 2.3.0 - optionalDependencies: - '@types/node': 24.13.3 - transitivePeerDependencies: - - msw - - webpack-virtual-modules@0.6.2: {} - - why-is-node-running@2.3.0: - dependencies: - siginfo: 2.0.0 - stackback: 0.0.2 - - wrap-ansi@7.0.0: - dependencies: - ansi-styles: 4.3.0 - string-width: 4.2.3 - strip-ansi: 6.0.1 - - wrappy@1.0.2: {} - - y18n@5.0.8: {} - - yallist@5.0.0: {} - - yargs-parser@20.2.9: {} - - yargs@16.2.2: - dependencies: - cliui: 7.0.4 - escalade: 3.2.0 - get-caller-file: 2.0.5 - require-directory: 2.1.1 - string-width: 4.2.3 - y18n: 5.0.8 - yargs-parser: 20.2.9 - - zod-to-json-schema@3.25.2(zod@4.4.3): - dependencies: - zod: 4.4.3 - - zod@4.4.3: {} diff --git a/src/plugins/codex/content/plugins/trace-codex/pnpm-workspace.yaml b/src/plugins/codex/content/plugins/trace-codex/pnpm-workspace.yaml deleted file mode 100644 index ad55a33..0000000 --- a/src/plugins/codex/content/plugins/trace-codex/pnpm-workspace.yaml +++ /dev/null @@ -1,11 +0,0 @@ -# pnpm settings live here (not package.json) as of pnpm 10+. This package is a -# single project, not a multi-package workspace; the file exists only to record -# a decision on dependency install/build scripts. pnpm 11 treats un-acknowledged -# build scripts as a hard error, which otherwise fails `pnpm run