diff --git a/bt-daemon/docs/protocol.md b/bt-daemon/docs/protocol.md index 8f0eaf3c..41583bb0 100644 --- a/bt-daemon/docs/protocol.md +++ b/bt-daemon/docs/protocol.md @@ -271,6 +271,11 @@ coding-agent span falls back to Rust SDK provenance. Transcript backfills captur versions available at materialization, not unrecorded historical versions. See the [span-origin contract](../../docs/coding-agent-tracing-plugin-spec.md#621-span-origin-versions). +Pi-compatible hosts retain the canonical transport source `pi` so they share its +translator. The Pi extension sends the running host package's version as +`source_version` and its package name as `payload.host_name`. The translator records +`metadata.host_name`; the session-root label remains `Pi` for every Pi-compatible host. + For acknowledged spans from legacy ledgers without saved snapshots, stateless updates omit `context.span_origin` entirely, including SDK defaults and inherited parent origin fields. This preserves existing backend provenance without guessing diff --git a/bt-daemon/src/translate/pi.rs b/bt-daemon/src/translate/pi.rs index 196b1abf..584b4bd2 100644 --- a/bt-daemon/src/translate/pi.rs +++ b/bt-daemon/src/translate/pi.rs @@ -573,6 +573,14 @@ impl PiTranslator { metadata.insert("model".into(), json!(model)); } metadata.insert("pi_version".into(), json!(envelope.source_version)); + if let Some(host_name) = envelope + .payload + .get("host_name") + .and_then(Value::as_str) + .filter(|name| !name.is_empty()) + { + metadata.insert("host_name".into(), json!(host_name)); + } metadata.insert( "extension_version".into(), envelope diff --git a/bt-daemon/tests/pi_translator.rs b/bt-daemon/tests/pi_translator.rs index 03ad6bfe..953b1a5d 100644 --- a/bt-daemon/tests/pi_translator.rs +++ b/bt-daemon/tests/pi_translator.rs @@ -72,6 +72,33 @@ fn reduce(ops: Vec) -> HashMap { rows } +#[test] +fn pi_records_the_running_host_identity() { + let registry = Registry::default_agents(); + let mut translator = registry.create("pi", "pi-session"); + let ctx = SessionCtx { + session_id: "pi-session".into(), + config: None, + }; + let mut start = event("session_start", 1, json!({"reason":"new"})); + start.source_version = Some("18.12.6".into()); + start.payload["host_name"] = json!("@oh-my-pi/pi-coding-agent"); + let ops = translator.handle(&start, &ctx).unwrap(); + let root = ops + .iter() + .find_map(|op| match op { + SpanOp::Insert(row) if row.parent_span_ids.is_empty() => Some(row), + _ => None, + }) + .unwrap(); + assert_eq!(root.name, "Pi"); + assert_eq!( + root.metadata.as_ref().unwrap()["host_name"], + "@oh-my-pi/pi-coding-agent" + ); + assert_eq!(root.metadata.as_ref().unwrap()["pi_version"], "18.12.6"); +} + #[test] fn pi_builds_turn_llm_tool_compaction_and_shutdown_spans() { let registry = Registry::default_agents(); diff --git a/src/plugins/pi/content/README.md b/src/plugins/pi/content/README.md index 119d1705..ea2b1544 100644 --- a/src/plugins/pi/content/README.md +++ b/src/plugins/pi/content/README.md @@ -69,6 +69,11 @@ from each of the last five stable Pi release lines. The compatibility job resolves these versions on each run, including releases from Pi's former npm package name when needed. +Package detection prefers the running CLI to an extension-local peer dependency. +Pi-compatible hosts are recognized by a `piConfig` field or a known package name, +including Oh My Pi (`@oh-my-pi/pi-coding-agent`). The trace records the host +package name in `metadata.host_name` and its version in `metadata.pi_version`. + ## Configuration Settings load in this order, with later values taking precedence: diff --git a/src/plugins/pi/content/src/daemon-adapter.test.ts b/src/plugins/pi/content/src/daemon-adapter.test.ts index 76e10658..5bad8f93 100644 --- a/src/plugins/pi/content/src/daemon-adapter.test.ts +++ b/src/plugins/pi/content/src/daemon-adapter.test.ts @@ -70,6 +70,14 @@ vi.mock("./legacy-session.ts", () => ({ }, })); +vi.mock("./pi-package.ts", () => ({ + runningPiPackage: () => ({ + name: "@oh-my-pi/pi-coding-agent", + version: "18.12.6", + configDir: ".pi", + }), +})); + type Handler = (...args: unknown[]) => Promise; /** Load the extension against a fake Pi and return its registered handlers. */ @@ -112,6 +120,16 @@ describe("Pi daemon adapter", () => { expect(handlers.size).toBe(0); }); + it("forwards the running host name and version instead of an installed peer", async () => { + const handlers = await loadExtension(); + await handlers.get("session_start")?.({ reason: "new" }, fakeContext()); + expect(mockState.logs[0]).toMatchObject({ + source: "pi", + source_version: "18.12.6", + payload: { host_name: "@oh-my-pi/pi-coding-agent" }, + }); + }); + it("does not block session startup on daemon status", async () => { const ctx = fakeContext(); const handlers = await loadExtension(); diff --git a/src/plugins/pi/content/src/index.ts b/src/plugins/pi/content/src/index.ts index 1d169186..479349fb 100644 --- a/src/plugins/pi/content/src/index.ts +++ b/src/plugins/pi/content/src/index.ts @@ -11,6 +11,7 @@ const STATUS_KEY = "braintrust-tracing"; const WIDGET_KEY = "braintrust-trace-link"; const UI_STATUS_TIMEOUT_MS = 250; const PI_VERSION = runningPiPackage().version; +const PI_HOST_NAME = runningPiPackage().name; function sessionKeyFor( sessionFile: string | undefined, @@ -145,6 +146,7 @@ export default function braintrustPiExtension(pi: ExtensionAPI): void { payload: { event: nativePayload(event), extension_version: EXTENSION_VERSION, + host_name: PI_HOST_NAME, session_file: descriptor?.sessionFile, native_session_id: descriptor?.nativeSessionId, legacy_resume: legacyContinuation, diff --git a/src/plugins/pi/content/src/pi-package.test.ts b/src/plugins/pi/content/src/pi-package.test.ts index e3d8d822..dae29385 100644 --- a/src/plugins/pi/content/src/pi-package.test.ts +++ b/src/plugins/pi/content/src/pi-package.test.ts @@ -42,7 +42,7 @@ describe("loadPiPackageMetadata", () => { symlinkSync(installPi(`node_modules/${PI}`, { name: PI, version: "1.0.0" }), bin); return [bin]; }, - { version: "1.0.0", configDir: ".pi" }, + { name: PI, version: "1.0.0", configDir: ".pi" }, ], [ "a fork with its own name and config directory", @@ -53,7 +53,7 @@ describe("loadPiPackageMetadata", () => { piConfig: { configDir: ".acme" }, }), ], - { version: "3.2.1", configDir: ".acme" }, + { name: "@acme/agent", version: "3.2.1", configDir: ".acme" }, ], [ "a PI_PACKAGE_DIR override", @@ -61,7 +61,7 @@ describe("loadPiPackageMetadata", () => { installPi("store/pi", { name: PI, version: "1.0.0" }); return [join(root, "store", "pi", "package.json"), join(root, "store", "bin")]; }, - { version: "1.0.0", configDir: ".pi" }, + { name: PI, version: "1.0.0", configDir: ".pi" }, ], [ "a Bun-compiled binary with package.json beside it", @@ -69,7 +69,7 @@ describe("loadPiPackageMetadata", () => { writeJson(join(root, "pi-linux-x64", "package.json"), { name: PI, version: "1.0.0" }); return [undefined, "/$bunfs/root/pi", join(root, "pi-linux-x64", "pi")]; }, - { version: "1.0.0", configDir: ".pi" }, + { name: PI, version: "1.0.0", configDir: ".pi" }, ], [ "an SDK application that depends on Pi", @@ -78,17 +78,47 @@ describe("loadPiPackageMetadata", () => { const pi = installPi(`app/node_modules/${PI}`, { name: PI, version: "0.99.2" }); return [join(root, "app", "main.js"), pi]; }, - { version: "0.99.2", configDir: ".pi" }, + { name: PI, version: "0.99.2", configDir: ".pi" }, ], [ "no discoverable Pi", () => [join(root, "missing.js")], - { version: undefined, configDir: ".pi" }, + { name: undefined, version: undefined, configDir: ".pi" }, ], ])("finds %s", (_name, setup, expected) => { expect(loadPiPackageMetadata(setup())).toEqual(expected); }); + it("prefers OMP's running manifest over an auto-installed upstream peer", () => { + const host = installPi("omp", { + name: "@oh-my-pi/pi-coding-agent", + version: "18.12.6", + bin: { omp: "dist/cli.js" }, + }); + const peer = installPi(`plugins/node_modules/${PI}`, { name: PI, version: "1.4.0" }); + // Recognizing OMP must not move the extension's existing `.pi` settings. + expect(loadPiPackageMetadata([host, peer])).toEqual({ + name: "@oh-my-pi/pi-coding-agent", + version: "18.12.6", + configDir: ".pi", + }); + }); + + it("reports the embedded Pi for an SDK application's own CLI", () => { + const app = installPi("app", { + name: "acme-agent", + version: "0.3.0", + bin: { acme: "dist/cli.js" }, + dependencies: { [PI]: "^1.4.0" }, + }); + const pi = installPi(`app/node_modules/${PI}`, { name: PI, version: "1.4.0" }); + expect(loadPiPackageMetadata([app, pi])).toEqual({ + name: PI, + version: "1.4.0", + configDir: ".pi", + }); + }); + it("resolves the installed Pi development dependency through ESM conditions", () => { const metadata = loadPiPackageMetadata([resolvePiPackage()]); expect(metadata.version).toMatch(/^\d+\.\d+\.\d+/); diff --git a/src/plugins/pi/content/src/pi-package.ts b/src/plugins/pi/content/src/pi-package.ts index 4482a6bb..b1d1aa9b 100644 --- a/src/plugins/pi/content/src/pi-package.ts +++ b/src/plugins/pi/content/src/pi-package.ts @@ -4,7 +4,11 @@ import { dirname, join } from "node:path"; import { fileURLToPath } from "node:url"; const PI_PACKAGE = "@earendil-works/pi-coding-agent"; -const PI_PACKAGE_NAMES = new Set([PI_PACKAGE, "@mariozechner/pi-coding-agent"]); +const PI_PACKAGE_NAMES = new Set([ + PI_PACKAGE, + "@mariozechner/pi-coding-agent", + "@oh-my-pi/pi-coding-agent", +]); interface PiPackageManifest { name?: unknown; @@ -15,6 +19,7 @@ interface PiPackageManifest { } export interface PiPackageMetadata { + name?: string; version?: string; configDir: string; } @@ -37,6 +42,7 @@ export function loadPiPackageMetadata(candidates: (string | undefined)[]): PiPac if (manifest) break; } return { + name: typeof manifest?.name === "string" ? manifest.name : undefined, version: typeof manifest?.version === "string" ? manifest.version : undefined, configDir: typeof manifest?.piConfig?.configDir === "string" ? manifest.piConfig.configDir : ".pi",