diff --git a/src/discovery/adapters/pi.js b/src/discovery/adapters/pi.js index 82e5e1fc..1b841c36 100644 --- a/src/discovery/adapters/pi.js +++ b/src/discovery/adapters/pi.js @@ -16,12 +16,17 @@ import { /** * Pi writes standalone sessions under - * `~/.pi/agent/sessions//_.jsonl`. BB's Pi bridge writes the - * same JSONL shape directly under `/pi-bridge-sessions/`. + * `~/.pi/agent/sessions//_.jsonl`. omp (Oh My Pi) uses the + * same JSONL shape under `~/.omp/agent/sessions/` and honors `PI_CODING_AGENT_DIR`, but + * prepends a fixed-width `{type:"title"}` record, so the `{type:"session", cwd, id}` + * entry is line 2 there. omp also writes subagent transcripts one level deeper, at + * `//.jsonl`. BB's Pi bridge writes the same JSONL shape + * directly under `/pi-bridge-sessions/`. * - * Line 1 is `{type:"session", cwd, id}`. Entries form a parent/child tree but arrive in + * Entries form a parent/child tree but arrive in * order, so a linear read is faithful. `model_change` / `thinking_level_change` records - * give the model actually used. No remote is recorded - dead worktrees reach tier 3 only. + * give the model actually used (`modelId` on pi, `model` on omp). No remote is + * recorded - dead worktrees reach tier 3 only. */ export const name = "pi"; @@ -49,6 +54,7 @@ function realpathOrResolve(value) { function storeSpecs() { const specs = [ { path: storeRoot(), direct: false, nested: true }, + { path: home(".omp", "agent", "sessions"), direct: false, nested: true }, { path: home(".bb", "pi-bridge-sessions"), direct: true, nested: false }, ]; const piAgentDir = expandEnvPath(process.env.PI_CODING_AGENT_DIR); @@ -84,7 +90,13 @@ export function enumerate() { for (const spec of storeSpecs()) { const files = [ ...(spec.direct ? listFiles(spec.path, ".jsonl") : []), - ...(spec.nested ? listDirs(spec.path).flatMap((dir) => listFiles(dir, ".jsonl")) : []), + ...(spec.nested + ? listDirs(spec.path).flatMap((dir) => [ + ...listFiles(dir, ".jsonl"), + // omp nests subagent transcripts one level below the session files. + ...listDirs(dir).flatMap((sub) => listFiles(sub, ".jsonl")), + ]) + : []), ]; for (const file of files) { const key = realpathOrResolve(file); @@ -99,18 +111,21 @@ export function enumerate() { } export function classify(candidate) { - const [first] = readHeadLines(candidate.path, 1); - const entry = first && parseJsonLine(first); - if (!entry || entry.type !== "session" || !entry.cwd) return null; - return { - id: entry.id || path.basename(candidate.path, ".jsonl"), - cwd: entry.cwd, - gitBranch: null, - remotes: [], - startedAt: entry.timestamp ? Date.parse(entry.timestamp) : candidate.mtimeMs, - model: null, - interactionSignals: emptyInteractionSignals(), - }; + // omp prepends a fixed-width title record, so the session entry is not always line 1. + for (const line of readHeadLines(candidate.path, 8)) { + const entry = parseJsonLine(line); + if (!entry || entry.type !== "session" || !entry.cwd) continue; + return { + id: entry.id || path.basename(candidate.path, ".jsonl"), + cwd: entry.cwd, + gitBranch: null, + remotes: [], + startedAt: entry.timestamp ? Date.parse(entry.timestamp) : candidate.mtimeMs, + model: null, + interactionSignals: emptyInteractionSignals(), + }; + } + return null; } export function read(ref) { @@ -120,7 +135,7 @@ export function read(ref) { for (const entry of entries) { if (entry.type === "model_change") { - model = entry.modelId || model; + model = entry.modelId || entry.model || model; continue; } if (entry.type !== "message" || !entry.message) continue; diff --git a/test/adapters.test.js b/test/adapters.test.js index e73bdfd3..b255b01d 100644 --- a/test/adapters.test.js +++ b/test/adapters.test.js @@ -146,6 +146,28 @@ test("pi adapter reads the session header and drops thinking blocks", () => { assert.equal(toolCall.result, "nothing to commit"); }); +test("pi adapter classifies omp sessions past the title record and reads model", () => { + const file = path.join(FIXTURES, "omp-session.jsonl"); + const descriptor = pi.classify(candidateFor(file)); + assert.equal(descriptor.id, "omp-5678"); + assert.equal(descriptor.cwd, "/repo/demo"); + + const { events, model } = pi.read({ path: file }); + assert.equal(model, "cursor/composer-2.5", "omp model_change carries model, not modelId"); + const [toolCall] = tools(events); + assert.equal(toolCall.name, "bash"); + assert.equal(toolCall.result, "nothing to commit"); +}); + +function writeOmpSession(file, { id, cwd }) { + fs.mkdirSync(path.dirname(file), { recursive: true }); + fs.writeFileSync( + file, + `${JSON.stringify({ type: "title", v: 1, title: "", updatedAt: "2026-08-27T00:00:00.000Z", pad: " " })}\n` + + `${JSON.stringify({ type: "session", version: 3, id, timestamp: "2026-08-27T00:00:00.000Z", cwd })}\n`, + ); +} + function writePiSession(file, { id, cwd }) { fs.mkdirSync(path.dirname(file), { recursive: true }); fs.writeFileSync( @@ -195,6 +217,14 @@ test("pi adapter enumerates standalone and BB-managed session roots without dupl id: "standalone", cwd: "/repo/demo", }); + writeOmpSession(path.join(fakeHome, ".omp", "agent", "sessions", "-repo-demo", "omp-standalone.jsonl"), { + id: "omp-standalone", + cwd: "/repo/demo", + }); + writeOmpSession(path.join(fakeHome, ".omp", "agent", "sessions", "-repo-demo", "omp-standalone", "Subagent.jsonl"), { + id: "omp-subagent", + cwd: "/repo/demo", + }); writePiSession(path.join(piAgentDir, "sessions", "-repo-demo", "custom-agent.jsonl"), { id: "custom-agent", cwd: "/repo/demo", @@ -228,8 +258,10 @@ test("pi adapter enumerates standalone and BB-managed session roots without dupl "custom-session.jsonl", "default-bb.jsonl", "direct-override.jsonl", + "omp-standalone.jsonl", "standalone.jsonl", - ], + "Subagent.jsonl", + ].sort(), ); }); diff --git a/test/fixtures/omp-session.jsonl b/test/fixtures/omp-session.jsonl new file mode 100644 index 00000000..7ee32648 --- /dev/null +++ b/test/fixtures/omp-session.jsonl @@ -0,0 +1,6 @@ +{"type":"title","v":1,"title":"","updatedAt":"2026-08-03T08:00:00.000Z","pad":" "} +{"type":"session","version":3,"id":"omp-5678","timestamp":"2026-08-03T08:00:00.000Z","cwd":"/repo/demo"} +{"type":"model_change","id":"m1","parentId":null,"timestamp":"2026-08-03T08:00:00.100Z","model":"cursor/composer-2.5","resolvedModelIsFallback":false} +{"type":"message","id":"e1","parentId":"m1","timestamp":"2026-08-03T08:00:01.000Z","message":{"role":"user","content":[{"type":"text","text":"Add the changelog entry."}]}} +{"type":"message","id":"e2","parentId":"e1","timestamp":"2026-08-03T08:00:02.000Z","message":{"role":"assistant","content":[{"type":"thinking","thinking":"internal reasoning that must be dropped"},{"type":"text","text":"Editing CHANGELOG.md."},{"type":"toolCall","id":"tc1","name":"bash","arguments":{"command":"git status"}}]}} +{"type":"message","id":"e3","parentId":"e2","timestamp":"2026-08-03T08:00:03.000Z","message":{"role":"toolResult","toolCallId":"tc1","content":[{"type":"text","text":"nothing to commit"}]}}