diff --git a/README.md b/README.md
index 42f88fe..4610160 100644
--- a/README.md
+++ b/README.md
@@ -19,7 +19,7 @@
-By task type, tool, model, MCP server, and project. Supports **Claude Code**, **Codex** (OpenAI), **Cursor**, and **OpenCode** with a provider plugin system. Tracks one-shot success rate per activity type so you can see where the AI nails it first try vs. burns tokens on edit/test/fix retries. Interactive TUI dashboard with gradient charts, responsive panels, and keyboard navigation. macOS menu bar widget via SwiftBar. CSV/JSON export.
+By task type, tool, model, MCP server, and project. Supports **Claude Code**, **Codex** (OpenAI), **Cursor**, **OpenCode**, and **Pi** with a provider plugin system. Tracks one-shot success rate per activity type so you can see where the AI nails it first try vs. burns tokens on edit/test/fix retries. Interactive TUI dashboard with gradient charts, responsive panels, and keyboard navigation. macOS menu bar widget via SwiftBar. CSV/JSON export.
Works by reading session data directly from disk. No wrapper, no proxy, no API keys. Pricing from LiteLLM (auto-cached, all models supported).
@@ -38,7 +38,7 @@ npx codeburn
### Requirements
- Node.js 20+
-- Claude Code (`~/.claude/projects/`), Codex (`~/.codex/sessions/`), Cursor, and/or OpenCode
+- Claude Code (`~/.claude/projects/`), Codex (`~/.codex/sessions/`), Cursor, OpenCode, and/or Pi (`~/.pi/agent/sessions/`)
- For Cursor/OpenCode support: `better-sqlite3` is installed automatically as an optional dependency
## Usage
@@ -67,6 +67,7 @@ codeburn report --provider claude # Claude Code only
codeburn report --provider codex # Codex only
codeburn report --provider cursor # Cursor only
codeburn report --provider opencode # OpenCode only
+codeburn report --provider pi # Pi only
codeburn today --provider codex # Codex today
codeburn export --provider claude # export Claude data only
```
@@ -82,7 +83,8 @@ The `--provider` flag works on all commands: `report`, `today`, `month`, `status
| Codex (OpenAI) | `~/.codex/sessions/` | Supported |
| Cursor | `~/Library/Application Support/Cursor/User/globalStorage/state.vscdb` | Supported |
| OpenCode | `~/.local/share/opencode/` (SQLite) | Supported |
-| Pi, Amp | -- | Planned (provider plugin system) |
+| Pi | `~/.pi/agent/sessions/` | Supported |
+| Amp | -- | Planned (provider plugin system) |
Codex tool names are normalized to match Claude's conventions (`exec_command` shows as `Bash`, `read_file` as `Read`, etc.) so the activity classifier and tool breakdown work across providers.
@@ -157,7 +159,9 @@ Requires [SwiftBar](https://github.com/swiftbar/SwiftBar) (`brew install --cask
**OpenCode** stores sessions in SQLite databases at `~/.local/share/opencode/opencode*.db`. CodeBurn queries the `session`, `message`, and `part` tables read-only, extracts token counts and tool usage, and recalculates cost using the LiteLLM pricing engine. Falls back to OpenCode's own cost field for models not in our pricing data. Subtask sessions (`parent_id IS NOT NULL`) are excluded to avoid double-counting. Supports multiple channel databases and respects `XDG_DATA_HOME`.
-CodeBurn reads these files, deduplicates messages (by API message ID for Claude, by cumulative token cross-check for Codex, by conversation/timestamp for Cursor, by session+message ID for OpenCode), filters by date range per entry, and classifies each turn.
+**Pi** stores sessions as JSONL at `~/.pi/agent/sessions//*.jsonl`. Each assistant message carries token usage (input, output, cacheRead, cacheWrite) plus inline `toolCall` content blocks. CodeBurn extracts token counts, normalizes Pi's lowercase tool names to the standard set (`bash` -> `Bash`, `dispatch_agent` -> `Agent`), and pulls bash commands from `toolCall.arguments.command` for the shell breakdown.
+
+CodeBurn reads these files, deduplicates messages (by API message ID for Claude, by cumulative token cross-check for Codex, by conversation/timestamp for Cursor, by session+message ID for OpenCode, by responseId for Pi), filters by date range per entry, and classifies each turn.
## Environment variables
@@ -190,6 +194,7 @@ src/
codex.ts Codex session discovery and JSONL parsing
cursor.ts Cursor SQLite parsing, language extraction
opencode.ts OpenCode SQLite session discovery and parsing
+ pi.ts Pi agent JSONL session discovery and parsing
```
## License
diff --git a/package.json b/package.json
index 2fdcb27..15cc02d 100644
--- a/package.json
+++ b/package.json
@@ -21,6 +21,7 @@
"cursor",
"codex",
"opencode",
+ "pi",
"ai-coding",
"token-usage",
"cost-tracking",
diff --git a/src/bash-utils.ts b/src/bash-utils.ts
index 08b215a..c578972 100644
--- a/src/bash-utils.ts
+++ b/src/bash-utils.ts
@@ -30,10 +30,12 @@ export function extractBashCommands(command: string): string[] {
const segment = command.slice(start, end).trim()
if (!segment) continue
- const firstToken = segment.split(/\s+/)[0]
- const base = basename(firstToken)
+ const tokens = segment.split(/\s+/)
+ let i = 0
+ while (i < tokens.length && /^\w+=/.test(tokens[i]!)) i++
+ const base = i < tokens.length ? basename(tokens[i]!) : ''
- if (base && base !== 'cd') {
+ if (base && base !== 'cd' && base !== 'true' && base !== 'false') {
commands.push(base)
}
}
diff --git a/src/dashboard.tsx b/src/dashboard.tsx
index abd3abe..e8aabf6 100644
--- a/src/dashboard.tsx
+++ b/src/dashboard.tsx
@@ -48,6 +48,8 @@ const PROVIDER_COLORS: Record = {
claude: '#FF8C42',
codex: '#5BF5A0',
cursor: '#00B4D8',
+ opencode: '#A78BFA',
+ pi: '#F472B6',
all: '#FF8C42',
}
@@ -427,6 +429,8 @@ const PROVIDER_DISPLAY_NAMES: Record = {
claude: 'Claude',
codex: 'Codex',
cursor: 'Cursor',
+ opencode: 'OpenCode',
+ pi: 'Pi',
}
function getProviderDisplayName(name: string): string {
diff --git a/src/providers/index.ts b/src/providers/index.ts
index cee5d61..0ac83c6 100644
--- a/src/providers/index.ts
+++ b/src/providers/index.ts
@@ -1,5 +1,6 @@
import { claude } from './claude.js'
import { codex } from './codex.js'
+import { pi } from './pi.js'
import type { Provider, SessionSource } from './types.js'
let cursorProvider: Provider | null = null
@@ -32,7 +33,7 @@ async function loadOpenCode(): Promise {
}
}
-const coreProviders: Provider[] = [claude, codex]
+const coreProviders: Provider[] = [claude, codex, pi]
export async function getAllProviders(): Promise {
const [cursor, opencode] = await Promise.all([loadCursor(), loadOpenCode()])
@@ -68,4 +69,3 @@ export async function getProvider(name: string): Promise {
}
return coreProviders.find(p => p.name === name)
}
-
diff --git a/src/providers/pi.ts b/src/providers/pi.ts
new file mode 100644
index 0000000..4fb42cf
--- /dev/null
+++ b/src/providers/pi.ts
@@ -0,0 +1,227 @@
+import { readdir, readFile, stat } from 'fs/promises'
+import { basename, join } from 'path'
+import { homedir } from 'os'
+
+import { calculateCost } from '../models.js'
+import { extractBashCommands } from '../bash-utils.js'
+import type { Provider, SessionSource, SessionParser, ParsedProviderCall } from './types.js'
+
+const modelDisplayNames: Record = {
+ 'gpt-5.4': 'GPT-5.4',
+ 'gpt-5.4-mini': 'GPT-5.4 Mini',
+ 'gpt-5': 'GPT-5',
+ 'gpt-4o': 'GPT-4o',
+ 'gpt-4o-mini': 'GPT-4o Mini',
+}
+
+const toolNameMap: Record = {
+ bash: 'Bash',
+ read: 'Read',
+ edit: 'Edit',
+ write: 'Write',
+ glob: 'Glob',
+ grep: 'Grep',
+ task: 'Agent',
+ dispatch_agent: 'Agent',
+ fetch: 'WebFetch',
+ search: 'WebSearch',
+ todo: 'TodoWrite',
+ patch: 'Patch',
+}
+
+// Pre-sorted by key length descending so longer/more-specific keys match first
+const modelDisplayEntries = Object.entries(modelDisplayNames).sort((a, b) => b[0].length - a[0].length)
+
+type PiEntry = {
+ type: string
+ id?: string
+ timestamp?: string
+ cwd?: string
+ message?: {
+ role?: string
+ content?: Array<{ type?: string; text?: string; name?: string; arguments?: Record }>
+ model?: string
+ responseId?: string
+ usage?: {
+ input: number
+ output: number
+ cacheRead: number
+ cacheWrite: number
+ }
+ }
+}
+
+function getPiSessionsDir(override?: string): string {
+ return override ?? join(homedir(), '.pi', 'agent', 'sessions')
+}
+
+async function readFirstEntry(filePath: string): Promise {
+ try {
+ const content = await readFile(filePath, 'utf-8')
+ const line = content.split('\n')[0]
+ if (!line?.trim()) return null
+ return JSON.parse(line) as PiEntry
+ } catch {
+ return null
+ }
+}
+
+async function discoverSessionsInDir(sessionsDir: string): Promise {
+ const sources: SessionSource[] = []
+
+ let projectDirs: string[]
+ try {
+ projectDirs = await readdir(sessionsDir)
+ } catch {
+ return sources
+ }
+
+ for (const dirName of projectDirs) {
+ const dirPath = join(sessionsDir, dirName)
+ const dirStat = await stat(dirPath).catch(() => null)
+ if (!dirStat?.isDirectory()) continue
+
+ let files: string[]
+ try {
+ files = await readdir(dirPath)
+ } catch {
+ continue
+ }
+
+ for (const file of files) {
+ if (!file.endsWith('.jsonl')) continue
+ const filePath = join(dirPath, file)
+ const fileStat = await stat(filePath).catch(() => null)
+ if (!fileStat?.isFile()) continue
+
+ const first = await readFirstEntry(filePath)
+ if (!first || first.type !== 'session') continue
+
+ const cwd = first.cwd ?? dirName
+ sources.push({ path: filePath, project: basename(cwd), provider: 'pi' })
+ }
+ }
+
+ return sources
+}
+
+function createParser(source: SessionSource, seenKeys: Set): SessionParser {
+ return {
+ async *parse(): AsyncGenerator {
+ let content: string
+ try {
+ content = await readFile(source.path, 'utf-8')
+ } catch {
+ return
+ }
+
+ const lines = content.split('\n').filter(l => l.trim())
+ let sessionId = basename(source.path, '.jsonl')
+ let pendingUserMessage = ''
+
+ for (const [lineIdx, line] of lines.entries()) {
+ let entry: PiEntry
+ try {
+ entry = JSON.parse(line) as PiEntry
+ } catch {
+ continue
+ }
+
+ if (entry.type === 'session') {
+ sessionId = entry.id ?? sessionId
+ continue
+ }
+
+ if (entry.type !== 'message') continue
+
+ const msg = entry.message
+ if (!msg) continue
+
+ if (msg.role === 'user') {
+ const texts = (msg.content ?? [])
+ .filter(c => c.type === 'text')
+ .map(c => c.text ?? '')
+ .filter(Boolean)
+ if (texts.length > 0) pendingUserMessage = texts.join(' ')
+ continue
+ }
+
+ if (msg.role !== 'assistant' || !msg.usage) continue
+
+ const { input, output, cacheRead, cacheWrite } = msg.usage
+ if (input === 0 && output === 0) continue
+
+ const model = msg.model ?? 'gpt-5'
+ const responseId = msg.responseId ?? ''
+ const dedupKey = `pi:${source.path}:${responseId || entry.id || entry.timestamp || String(lineIdx)}`
+
+ if (seenKeys.has(dedupKey)) continue
+ seenKeys.add(dedupKey)
+
+ const toolCalls = (msg.content ?? []).filter(c => c.type === 'toolCall' && c.name)
+ const tools = toolCalls.map(c => toolNameMap[c.name!] ?? c.name!)
+ const bashCommands = toolCalls
+ .filter(c => c.name === 'bash')
+ .flatMap(c => {
+ const cmd = c.arguments?.['command']
+ return typeof cmd === 'string' ? extractBashCommands(cmd) : []
+ })
+
+ const costUSD = calculateCost(model, input, output, cacheWrite, cacheRead, 0)
+ const timestamp = entry.timestamp ?? ''
+
+ yield {
+ provider: 'pi',
+ model,
+ inputTokens: input,
+ outputTokens: output,
+ cacheCreationInputTokens: cacheWrite,
+ cacheReadInputTokens: cacheRead,
+ cachedInputTokens: cacheRead,
+ reasoningTokens: 0,
+ webSearchRequests: 0,
+ costUSD,
+ tools,
+ bashCommands,
+ timestamp,
+ speed: 'standard',
+ deduplicationKey: dedupKey,
+ userMessage: pendingUserMessage,
+ sessionId,
+ }
+
+ pendingUserMessage = ''
+ }
+ },
+ }
+}
+
+export function createPiProvider(sessionsDir?: string): Provider {
+ const dir = getPiSessionsDir(sessionsDir)
+
+ return {
+ name: 'pi',
+ displayName: 'Pi',
+
+ modelDisplayName(model: string): string {
+ for (const [key, name] of modelDisplayEntries) {
+ if (model.startsWith(key)) return name
+ }
+ return model
+ },
+
+ toolDisplayName(rawTool: string): string {
+ return toolNameMap[rawTool] ?? rawTool
+ },
+
+ async discoverSessions(): Promise {
+ return discoverSessionsInDir(dir)
+ },
+
+ createSessionParser(source: SessionSource, seenKeys: Set): SessionParser {
+ return createParser(source, seenKeys)
+ },
+ }
+}
+
+export const pi = createPiProvider()
diff --git a/tests/bash-commands.test.ts b/tests/bash-commands.test.ts
index e5e52e7..2c830e9 100644
--- a/tests/bash-commands.test.ts
+++ b/tests/bash-commands.test.ts
@@ -58,6 +58,21 @@ describe('extractBashCommands', () => {
it('handles single-quoted separators', () => {
expect(extractBashCommands("echo 'hello && world'")).toEqual(['echo'])
})
+
+ it('skips leading env var assignments', () => {
+ expect(extractBashCommands('NODE_ENV=prod npm test')).toEqual(['npm'])
+ expect(extractBashCommands('FOO=bar BAZ=qux ls -la')).toEqual(['ls'])
+ })
+
+ it('skips standalone true/false', () => {
+ expect(extractBashCommands('true && git status')).toEqual(['git'])
+ expect(extractBashCommands('false || echo done')).toEqual(['echo'])
+ expect(extractBashCommands('true')).toEqual([])
+ })
+
+ it('handles env vars combined with chained commands', () => {
+ expect(extractBashCommands('NODE_ENV=test npm test && git push')).toEqual(['npm', 'git'])
+ })
})
describe('BASH_TOOLS', () => {
diff --git a/tests/provider-registry.test.ts b/tests/provider-registry.test.ts
index 9e71eae..79e5e24 100644
--- a/tests/provider-registry.test.ts
+++ b/tests/provider-registry.test.ts
@@ -3,7 +3,7 @@ import { providers, getAllProviders } from '../src/providers/index.js'
describe('provider registry', () => {
it('has core providers registered synchronously', () => {
- expect(providers.map(p => p.name)).toEqual(['claude', 'codex'])
+ expect(providers.map(p => p.name)).toEqual(['claude', 'codex', 'pi'])
})
it('includes sqlite providers after async load', async () => {
diff --git a/tests/providers/pi.test.ts b/tests/providers/pi.test.ts
new file mode 100644
index 0000000..74f8274
--- /dev/null
+++ b/tests/providers/pi.test.ts
@@ -0,0 +1,336 @@
+import { describe, it, expect, beforeEach, afterEach } from 'vitest'
+import { mkdtemp, mkdir, writeFile, rm } from 'fs/promises'
+import { join } from 'path'
+import { tmpdir } from 'os'
+
+import { createPiProvider } from '../../src/providers/pi.js'
+import type { ParsedProviderCall } from '../../src/providers/types.js'
+
+let tmpDir: string
+
+beforeEach(async () => {
+ tmpDir = await mkdtemp(join(tmpdir(), 'pi-test-'))
+})
+
+afterEach(async () => {
+ await rm(tmpDir, { recursive: true, force: true })
+})
+
+function sessionMeta(opts: { id?: string; cwd?: string } = {}) {
+ return JSON.stringify({
+ type: 'session',
+ version: 3,
+ id: opts.id ?? 'sess-001',
+ timestamp: '2026-04-14T10:00:00.000Z',
+ cwd: opts.cwd ?? '/Users/test/myproject',
+ })
+}
+
+function userMessage(text: string, timestamp?: string) {
+ return JSON.stringify({
+ type: 'message',
+ id: 'msg-user-1',
+ timestamp: timestamp ?? '2026-04-14T10:00:10.000Z',
+ message: {
+ role: 'user',
+ content: [{ type: 'text', text }],
+ timestamp: 1776023210000,
+ },
+ })
+}
+
+function assistantMessage(opts: {
+ id?: string
+ responseId?: string
+ timestamp?: string
+ model?: string
+ input?: number
+ output?: number
+ cacheRead?: number
+ cacheWrite?: number
+ tools?: Array<{ name: string; command?: string }>
+}) {
+ const content = (opts.tools ?? []).map(t => ({
+ type: 'toolCall',
+ id: `call-${t.name}`,
+ name: t.name,
+ arguments: t.command !== undefined ? { command: t.command } : {},
+ }))
+
+ return JSON.stringify({
+ type: 'message',
+ id: opts.id ?? 'msg-asst-1',
+ timestamp: opts.timestamp ?? '2026-04-14T10:00:30.000Z',
+ message: {
+ role: 'assistant',
+ content,
+ api: 'openai-codex-responses',
+ provider: 'openai-codex',
+ model: opts.model ?? 'gpt-5.4',
+ responseId: opts.responseId ?? 'resp-001',
+ usage: {
+ input: opts.input ?? 1000,
+ output: opts.output ?? 200,
+ cacheRead: opts.cacheRead ?? 0,
+ cacheWrite: opts.cacheWrite ?? 0,
+ totalTokens: (opts.input ?? 1000) + (opts.output ?? 200) + (opts.cacheRead ?? 0),
+ cost: { input: 0.0025, output: 0.003, cacheRead: 0, cacheWrite: 0, total: 0.0055 },
+ },
+ stopReason: 'stop',
+ timestamp: 1776023230000,
+ },
+ })
+}
+
+async function writeSession(projectDir: string, filename: string, lines: string[]) {
+ await mkdir(projectDir, { recursive: true })
+ const filePath = join(projectDir, filename)
+ await writeFile(filePath, lines.join('\n') + '\n')
+ return filePath
+}
+
+describe('pi provider - session discovery', () => {
+ it('discovers sessions grouped by project directory', async () => {
+ const projectDir = join(tmpDir, '--Users-test-myproject--')
+ await writeSession(projectDir, '2026-04-14T10-00-00-000Z_sess-001.jsonl', [
+ sessionMeta({ cwd: '/Users/test/myproject' }),
+ assistantMessage({}),
+ ])
+
+ const provider = createPiProvider(tmpDir)
+ const sessions = await provider.discoverSessions()
+
+ expect(sessions).toHaveLength(1)
+ expect(sessions[0]!.provider).toBe('pi')
+ expect(sessions[0]!.project).toBe('myproject')
+ expect(sessions[0]!.path).toContain('sess-001.jsonl')
+ })
+
+ it('discovers sessions across multiple project directories', async () => {
+ const dir1 = join(tmpDir, '--Users-test-project-a--')
+ const dir2 = join(tmpDir, '--Users-test-project-b--')
+ await writeSession(dir1, 'session1.jsonl', [sessionMeta({ cwd: '/Users/test/project-a' }), assistantMessage({})])
+ await writeSession(dir2, 'session2.jsonl', [sessionMeta({ cwd: '/Users/test/project-b' }), assistantMessage({})])
+
+ const provider = createPiProvider(tmpDir)
+ const sessions = await provider.discoverSessions()
+
+ expect(sessions).toHaveLength(2)
+ const projects = sessions.map(s => s.project).sort()
+ expect(projects).toEqual(['project-a', 'project-b'])
+ })
+
+ it('returns empty for non-existent directory', async () => {
+ const provider = createPiProvider('/nonexistent/path/that/does/not/exist')
+ const sessions = await provider.discoverSessions()
+ expect(sessions).toEqual([])
+ })
+
+ it('skips files whose first line is not a session entry', async () => {
+ const projectDir = join(tmpDir, '--Users-test-myproject--')
+ await writeSession(projectDir, 'bad.jsonl', [
+ JSON.stringify({ type: 'message', id: 'x' }),
+ ])
+
+ const provider = createPiProvider(tmpDir)
+ const sessions = await provider.discoverSessions()
+ expect(sessions).toEqual([])
+ })
+
+ it('skips non-jsonl files', async () => {
+ const projectDir = join(tmpDir, '--Users-test-myproject--')
+ await mkdir(projectDir, { recursive: true })
+ await writeFile(join(projectDir, 'notes.txt'), 'not a session')
+
+ const provider = createPiProvider(tmpDir)
+ const sessions = await provider.discoverSessions()
+ expect(sessions).toEqual([])
+ })
+})
+
+describe('pi provider - JSONL parsing', () => {
+ it('extracts token usage and metadata from an assistant message', async () => {
+ const projectDir = join(tmpDir, '--Users-test-myproject--')
+ const filePath = await writeSession(projectDir, 'session.jsonl', [
+ sessionMeta({ id: 'sess-abc', cwd: '/Users/test/myproject' }),
+ userMessage('implement the feature'),
+ assistantMessage({
+ responseId: 'resp-abc',
+ timestamp: '2026-04-14T10:00:30.000Z',
+ model: 'gpt-5.4',
+ input: 2000,
+ output: 400,
+ cacheRead: 5000,
+ cacheWrite: 100,
+ }),
+ ])
+
+ const provider = createPiProvider(tmpDir)
+ const source = { path: filePath, project: 'myproject', provider: 'pi' }
+ const calls: ParsedProviderCall[] = []
+ for await (const call of provider.createSessionParser(source, new Set()).parse()) {
+ calls.push(call)
+ }
+
+ expect(calls).toHaveLength(1)
+ const call = calls[0]!
+ expect(call.provider).toBe('pi')
+ expect(call.model).toBe('gpt-5.4')
+ expect(call.inputTokens).toBe(2000)
+ expect(call.outputTokens).toBe(400)
+ expect(call.cacheReadInputTokens).toBe(5000)
+ expect(call.cachedInputTokens).toBe(5000)
+ expect(call.cacheCreationInputTokens).toBe(100)
+ expect(call.sessionId).toBe('sess-abc')
+ expect(call.userMessage).toBe('implement the feature')
+ expect(call.timestamp).toBe('2026-04-14T10:00:30.000Z')
+ expect(call.costUSD).toBeGreaterThan(0)
+ expect(call.deduplicationKey).toContain('pi:')
+ expect(call.deduplicationKey).toContain('resp-abc')
+ })
+
+ it('collects tool names from toolCall content items', async () => {
+ const projectDir = join(tmpDir, '--Users-test-myproject--')
+ const filePath = await writeSession(projectDir, 'session.jsonl', [
+ sessionMeta(),
+ assistantMessage({
+ tools: [
+ { name: 'read' },
+ { name: 'edit' },
+ { name: 'bash', command: 'git status' },
+ ],
+ }),
+ ])
+
+ const provider = createPiProvider(tmpDir)
+ const source = { path: filePath, project: 'myproject', provider: 'pi' }
+ const calls: ParsedProviderCall[] = []
+ for await (const call of provider.createSessionParser(source, new Set()).parse()) {
+ calls.push(call)
+ }
+
+ expect(calls[0]!.tools).toEqual(['Read', 'Edit', 'Bash'])
+ })
+
+ it('extracts bash commands from bash tool arguments', async () => {
+ const projectDir = join(tmpDir, '--Users-test-myproject--')
+ const filePath = await writeSession(projectDir, 'session.jsonl', [
+ sessionMeta(),
+ assistantMessage({
+ tools: [
+ { name: 'bash', command: 'git status && bun test' },
+ ],
+ }),
+ ])
+
+ const provider = createPiProvider(tmpDir)
+ const source = { path: filePath, project: 'myproject', provider: 'pi' }
+ const calls: ParsedProviderCall[] = []
+ for await (const call of provider.createSessionParser(source, new Set()).parse()) {
+ calls.push(call)
+ }
+
+ expect(calls[0]!.bashCommands).toEqual(['git', 'bun'])
+ })
+
+ it('skips assistant messages with zero tokens', async () => {
+ const projectDir = join(tmpDir, '--Users-test-myproject--')
+ const filePath = await writeSession(projectDir, 'session.jsonl', [
+ sessionMeta(),
+ assistantMessage({ input: 0, output: 0 }),
+ ])
+
+ const provider = createPiProvider(tmpDir)
+ const source = { path: filePath, project: 'myproject', provider: 'pi' }
+ const calls: ParsedProviderCall[] = []
+ for await (const call of provider.createSessionParser(source, new Set()).parse()) {
+ calls.push(call)
+ }
+
+ expect(calls).toHaveLength(0)
+ })
+
+ it('deduplicates calls seen across multiple parses', async () => {
+ const projectDir = join(tmpDir, '--Users-test-myproject--')
+ const filePath = await writeSession(projectDir, 'session.jsonl', [
+ sessionMeta(),
+ assistantMessage({ responseId: 'resp-dup' }),
+ ])
+
+ const provider = createPiProvider(tmpDir)
+ const source = { path: filePath, project: 'myproject', provider: 'pi' }
+ const seenKeys = new Set()
+
+ const firstRun: ParsedProviderCall[] = []
+ for await (const call of provider.createSessionParser(source, seenKeys).parse()) {
+ firstRun.push(call)
+ }
+
+ const secondRun: ParsedProviderCall[] = []
+ for await (const call of provider.createSessionParser(source, seenKeys).parse()) {
+ secondRun.push(call)
+ }
+
+ expect(firstRun).toHaveLength(1)
+ expect(secondRun).toHaveLength(0)
+ })
+
+ it('yields one call per assistant message in a multi-turn session', async () => {
+ const projectDir = join(tmpDir, '--Users-test-myproject--')
+ const filePath = await writeSession(projectDir, 'session.jsonl', [
+ sessionMeta({ id: 'sess-multi' }),
+ userMessage('first question'),
+ assistantMessage({ responseId: 'resp-1', timestamp: '2026-04-14T10:00:30.000Z', input: 500, output: 100 }),
+ userMessage('second question'),
+ assistantMessage({ responseId: 'resp-2', timestamp: '2026-04-14T10:01:00.000Z', input: 600, output: 120 }),
+ ])
+
+ const provider = createPiProvider(tmpDir)
+ const source = { path: filePath, project: 'myproject', provider: 'pi' }
+ const calls: ParsedProviderCall[] = []
+ for await (const call of provider.createSessionParser(source, new Set()).parse()) {
+ calls.push(call)
+ }
+
+ expect(calls).toHaveLength(2)
+ expect(calls[0]!.userMessage).toBe('first question')
+ expect(calls[0]!.inputTokens).toBe(500)
+ expect(calls[1]!.userMessage).toBe('second question')
+ expect(calls[1]!.inputTokens).toBe(600)
+ })
+
+ it('handles missing session file gracefully', async () => {
+ const provider = createPiProvider(tmpDir)
+ const source = { path: '/nonexistent/session.jsonl', project: 'test', provider: 'pi' }
+ const calls: ParsedProviderCall[] = []
+ for await (const call of provider.createSessionParser(source, new Set()).parse()) {
+ calls.push(call)
+ }
+ expect(calls).toHaveLength(0)
+ })
+})
+
+describe('pi provider - display names', () => {
+ const provider = createPiProvider('/tmp')
+
+ it('has correct name and displayName', () => {
+ expect(provider.name).toBe('pi')
+ expect(provider.displayName).toBe('Pi')
+ })
+
+ it('maps known models to readable names', () => {
+ expect(provider.modelDisplayName('gpt-5.4')).toBe('GPT-5.4')
+ expect(provider.modelDisplayName('gpt-5.4-mini')).toBe('GPT-5.4 Mini')
+ expect(provider.modelDisplayName('gpt-5')).toBe('GPT-5')
+ })
+
+ it('returns raw name for unknown models', () => {
+ expect(provider.modelDisplayName('some-future-model')).toBe('some-future-model')
+ })
+
+ it('normalizes tool names to capitalized form', () => {
+ expect(provider.toolDisplayName('bash')).toBe('Bash')
+ expect(provider.toolDisplayName('read')).toBe('Read')
+ expect(provider.toolDisplayName('unknown_tool')).toBe('unknown_tool')
+ })
+})