From 7ac512a7e4726c2707796abb03cf954c76282487 Mon Sep 17 00:00:00 2001
From: Damian Jackson
Date: Wed, 15 Apr 2026 20:49:24 +0100
Subject: [PATCH 1/5] feat: add Pi provider for tracking Pi agent sessions
- Adds support for Pi (pi.ai) as a new session provider.
- Pi sessions are stored as JSONL files under `~/.pi/agent/sessions//` and use OpenAI-compatible model IDs (gpt-5, gpt-5.4, gpt-4o, etc.).
- `src/providers/pi.ts` (new): Pi provider - discovers JSONL session files, parses assistant turns, extracts token counts, tool calls, and bash commands, deduplicates via response ID with line-index fallback
- `src/providers/types.ts`: added bashCommands field to `ParsedProviderCall` so all providers carry extracted bash command lists
- `src/providers/index.ts`: registered Pi as a core provider alongside Claude and Codex
- `src/providers/codex.ts`, `cursor.ts`: added `bashCommands: []` to satisfy the new required field on `ParsedProviderCall`
- `src/parser.ts`: fixed bug where `providerCallToTurn` always emitted an empty bashCommands array instead of passing through the parsed commands
- `src/classifier.ts`: added lowercase tool name variants (bash, edit, read, write) to match Pi's tool naming convention in JSONL output
- `src/bash-utils.ts`: exclude `true`, `false`, and shell variable assignments from extracted commands; scan past leading `NAME=val` tokens so `FOO=bar ls` correctly records `ls` rather than being dropped
- `package.json`: added pi to keywords
- `tests/providers/pi.test.ts` (new): 16 unit tests covering session discovery, multi-turn parsing, tool/bash extraction, deduplication, zero-token filtering, and display name mapping
- `tests/provider-registry.test.ts`: updated core provider list to include pi
- [X] Unit tests pass (`npx vitest run`, 56 tests across 6 files);
- [X] Manually verified via `npx tsx src/cli.ts` report and showing Pi sessions alongside Claude and Codex in the dashboard.
---
package.json | 1 +
src/bash-utils.ts | 8 +-
src/classifier.ts | 6 +-
src/providers/index.ts | 4 +-
src/providers/pi.ts | 210 ++++++++++++++++++++
tests/provider-registry.test.ts | 2 +-
tests/providers/pi.test.ts | 335 ++++++++++++++++++++++++++++++++
7 files changed, 557 insertions(+), 9 deletions(-)
create mode 100644 src/providers/pi.ts
create mode 100644 tests/providers/pi.test.ts
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/classifier.ts b/src/classifier.ts
index 33b52b2..984757b 100644
--- a/src/classifier.ts
+++ b/src/classifier.ts
@@ -15,9 +15,9 @@ const FILE_PATTERNS = /\.(py|js|ts|tsx|jsx|json|yaml|yml|toml|sql|sh|go|rs|java|
const SCRIPT_PATTERNS = /\b(run\s+\S+\.\w+|execute|scrip?t|curl|api\s+\S+|endpoint|request\s+url|fetch\s+\S+|query|database|db\s+\S+)\b/i
const URL_PATTERN = /https?:\/\/\S+/i
-const EDIT_TOOLS = new Set(['Edit', 'Write', 'FileEditTool', 'FileWriteTool', 'NotebookEdit', 'cursor:edit'])
-const READ_TOOLS = new Set(['Read', 'Grep', 'Glob', 'FileReadTool', 'GrepTool', 'GlobTool'])
-export const BASH_TOOLS = new Set(['Bash', 'BashTool', 'PowerShellTool'])
+const EDIT_TOOLS = new Set(['Edit', 'Write', 'FileEditTool', 'FileWriteTool', 'NotebookEdit', 'cursor:edit', 'edit', 'write'])
+const READ_TOOLS = new Set(['Read', 'Grep', 'Glob', 'FileReadTool', 'GrepTool', 'GlobTool', 'read'])
+export const BASH_TOOLS = new Set(['Bash', 'BashTool', 'PowerShellTool', 'bash'])
const TASK_TOOLS = new Set(['TaskCreate', 'TaskUpdate', 'TaskGet', 'TaskList', 'TaskOutput', 'TaskStop', 'TodoWrite'])
const SEARCH_TOOLS = new Set(['WebSearch', 'WebFetch', 'ToolSearch'])
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..576ee78
--- /dev/null
+++ b/src/providers/pi.ts
@@ -0,0 +1,210 @@
+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',
+}
+
+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 => 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 {
+ const entries = Object.entries(modelDisplayNames).sort((a, b) => b[0].length - a[0].length)
+ for (const [key, name] of entries) {
+ if (model.startsWith(key)) return name
+ }
+ return model
+ },
+
+ toolDisplayName(rawTool: string): string {
+ return 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/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..658b0cb
--- /dev/null
+++ b/tests/providers/pi.test.ts
@@ -0,0 +1,335 @@
+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('returns tool name as-is', () => {
+ expect(provider.toolDisplayName('bash')).toBe('bash')
+ expect(provider.toolDisplayName('read')).toBe('read')
+ })
+})
From d92d5b3f26b7b35c5d027187e7370f631b625c48 Mon Sep 17 00:00:00 2001
From: AgentSeal
Date: Thu, 16 Apr 2026 01:57:39 -0700
Subject: [PATCH 2/5] chore: normalize Pi tool names via toolNameMap
Maps Pi's lowercase tool names (bash, read, edit, write...) to
the capitalized form used by every other provider, so the
dashboard shows consistent tool names across providers and the
activity classifier works without extra lowercase entries.
Reverts the lowercase additions to classifier.ts since the
provider now normalizes tool names at the source.
---
src/classifier.ts | 6 +++---
src/providers/pi.ts | 18 ++++++++++++++++--
tests/providers/pi.test.ts | 9 +++++----
3 files changed, 24 insertions(+), 9 deletions(-)
diff --git a/src/classifier.ts b/src/classifier.ts
index 984757b..33b52b2 100644
--- a/src/classifier.ts
+++ b/src/classifier.ts
@@ -15,9 +15,9 @@ const FILE_PATTERNS = /\.(py|js|ts|tsx|jsx|json|yaml|yml|toml|sql|sh|go|rs|java|
const SCRIPT_PATTERNS = /\b(run\s+\S+\.\w+|execute|scrip?t|curl|api\s+\S+|endpoint|request\s+url|fetch\s+\S+|query|database|db\s+\S+)\b/i
const URL_PATTERN = /https?:\/\/\S+/i
-const EDIT_TOOLS = new Set(['Edit', 'Write', 'FileEditTool', 'FileWriteTool', 'NotebookEdit', 'cursor:edit', 'edit', 'write'])
-const READ_TOOLS = new Set(['Read', 'Grep', 'Glob', 'FileReadTool', 'GrepTool', 'GlobTool', 'read'])
-export const BASH_TOOLS = new Set(['Bash', 'BashTool', 'PowerShellTool', 'bash'])
+const EDIT_TOOLS = new Set(['Edit', 'Write', 'FileEditTool', 'FileWriteTool', 'NotebookEdit', 'cursor:edit'])
+const READ_TOOLS = new Set(['Read', 'Grep', 'Glob', 'FileReadTool', 'GrepTool', 'GlobTool'])
+export const BASH_TOOLS = new Set(['Bash', 'BashTool', 'PowerShellTool'])
const TASK_TOOLS = new Set(['TaskCreate', 'TaskUpdate', 'TaskGet', 'TaskList', 'TaskOutput', 'TaskStop', 'TodoWrite'])
const SEARCH_TOOLS = new Set(['WebSearch', 'WebFetch', 'ToolSearch'])
diff --git a/src/providers/pi.ts b/src/providers/pi.ts
index 576ee78..56100e0 100644
--- a/src/providers/pi.ts
+++ b/src/providers/pi.ts
@@ -14,6 +14,20 @@ const modelDisplayNames: Record = {
'gpt-4o-mini': 'GPT-4o Mini',
}
+const toolNameMap: Record = {
+ bash: 'Bash',
+ read: 'Read',
+ edit: 'Edit',
+ write: 'Write',
+ glob: 'Glob',
+ grep: 'Grep',
+ task: 'Agent',
+ fetch: 'WebFetch',
+ search: 'WebSearch',
+ todo: 'TodoWrite',
+ patch: 'Patch',
+}
+
type PiEntry = {
type: string
id?: string
@@ -141,7 +155,7 @@ function createParser(source: SessionSource, seenKeys: Set): SessionPars
seenKeys.add(dedupKey)
const toolCalls = (msg.content ?? []).filter(c => c.type === 'toolCall' && c.name)
- const tools = toolCalls.map(c => c.name!)
+ const tools = toolCalls.map(c => toolNameMap[c.name!] ?? c.name!)
const bashCommands = toolCalls
.filter(c => c.name === 'bash')
.flatMap(c => {
@@ -194,7 +208,7 @@ export function createPiProvider(sessionsDir?: string): Provider {
},
toolDisplayName(rawTool: string): string {
- return rawTool
+ return toolNameMap[rawTool] ?? rawTool
},
async discoverSessions(): Promise {
diff --git a/tests/providers/pi.test.ts b/tests/providers/pi.test.ts
index 658b0cb..74f8274 100644
--- a/tests/providers/pi.test.ts
+++ b/tests/providers/pi.test.ts
@@ -209,7 +209,7 @@ describe('pi provider - JSONL parsing', () => {
calls.push(call)
}
- expect(calls[0]!.tools).toEqual(['read', 'edit', 'bash'])
+ expect(calls[0]!.tools).toEqual(['Read', 'Edit', 'Bash'])
})
it('extracts bash commands from bash tool arguments', async () => {
@@ -328,8 +328,9 @@ describe('pi provider - display names', () => {
expect(provider.modelDisplayName('some-future-model')).toBe('some-future-model')
})
- it('returns tool name as-is', () => {
- expect(provider.toolDisplayName('bash')).toBe('bash')
- expect(provider.toolDisplayName('read')).toBe('read')
+ 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')
})
})
From 97c08697634f554ba7ea8de5f5fdf3a12d4df046 Mon Sep 17 00:00:00 2001
From: AgentSeal
Date: Thu, 16 Apr 2026 02:02:32 -0700
Subject: [PATCH 3/5] chore: hoist Pi model sort + cover bash-utils edge cases
- Move modelDisplayEntries sort to module scope so it runs once
instead of on every modelDisplayName call.
- Add bash-utils tests for the env var prefix and true/false
skip behaviors that came in with the Pi commit.
Verified Pi token semantics against real session data:
input + cacheRead + cacheWrite + output equals totalTokens
exactly, confirming Anthropic-style accounting (cached tokens
disjoint from input). No double-counting.
---
src/providers/pi.ts | 6 ++++--
tests/bash-commands.test.ts | 15 +++++++++++++++
2 files changed, 19 insertions(+), 2 deletions(-)
diff --git a/src/providers/pi.ts b/src/providers/pi.ts
index 56100e0..79f39bc 100644
--- a/src/providers/pi.ts
+++ b/src/providers/pi.ts
@@ -28,6 +28,9 @@ const toolNameMap: Record = {
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
@@ -200,8 +203,7 @@ export function createPiProvider(sessionsDir?: string): Provider {
displayName: 'Pi',
modelDisplayName(model: string): string {
- const entries = Object.entries(modelDisplayNames).sort((a, b) => b[0].length - a[0].length)
- for (const [key, name] of entries) {
+ for (const [key, name] of modelDisplayEntries) {
if (model.startsWith(key)) return name
}
return model
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', () => {
From 1a101b586c208d373c498b9fe7f06e675c29a8ed Mon Sep 17 00:00:00 2001
From: AgentSeal
Date: Thu, 16 Apr 2026 02:08:07 -0700
Subject: [PATCH 4/5] chore: map Pi dispatch_agent to Agent for activity
classification
dispatch_agent is Pi's delegation tool. Mapping it to Agent makes
the classifier route those turns into the Delegation category
instead of Conversation, matching the existing semantics for the
task tool.
---
src/providers/pi.ts | 1 +
1 file changed, 1 insertion(+)
diff --git a/src/providers/pi.ts b/src/providers/pi.ts
index 79f39bc..4fb42cf 100644
--- a/src/providers/pi.ts
+++ b/src/providers/pi.ts
@@ -22,6 +22,7 @@ const toolNameMap: Record = {
glob: 'Glob',
grep: 'Grep',
task: 'Agent',
+ dispatch_agent: 'Agent',
fetch: 'WebFetch',
search: 'WebSearch',
todo: 'TodoWrite',
From 5e095bf86e1a25830453a2abb36655cec348bc3f Mon Sep 17 00:00:00 2001
From: AgentSeal
Date: Thu, 16 Apr 2026 02:11:48 -0700
Subject: [PATCH 5/5] chore: polish Pi provider integration
- Add Pi entry to PROVIDER_COLORS (pink) and PROVIDER_DISPLAY_NAMES.
- Update README with Pi in supported providers, requirements,
command examples, and the data-format section.
---
README.md | 13 +++++++++----
src/dashboard.tsx | 4 ++++
2 files changed, 13 insertions(+), 4 deletions(-)
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/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 {