From 9269451349d8aec8eab3e1611313f5a90e6a4601 Mon Sep 17 00:00:00 2001 From: "Tim.Seriakov" Date: Sun, 24 May 2026 09:17:47 +0300 Subject: [PATCH 1/2] feat(pi): add Pi and Oh My Pi support Adds Pi and Oh My Pi as first-class agents instead of treating them as a combined sidebar entry. The sidebar exposes separate Pi and Oh My Pi filters, while leaderboard aggregation keeps their stats distinct and renders lowercase badges only in the leaderboard context. Resume reliability: - Scan Pi and Oh My Pi session directories recursively enough to find canonical nested ~/.omp/agent/sessions/ JSONL files. - Store resume_target on scanned sessions and use the JSONL path for Pi and Oh My Pi copy, CLI, and UI resume commands. - Pass resumeTarget through /api/launch separately from sessionId so terminal tracking remains keyed by safe IDs while Pi and Oh My Pi resumes can use validated session file paths. - Quote resume targets before passing them to pi/omp launch commands. Analytics/UI: - Split leaderboard agent keys for Pi vs Oh My Pi instead of aggregating under pi. - Remove the combined Pi/OhMyPi install/sidebar item; keep Pi and Oh My Pi as separate sidebar entries. - Keep proper display casing outside Leaderboard and lowercase badges inside Leaderboard. Tests: - node --test - Manual: omp --resume --print 'respond ok' --- README.md | 5 +- bin/cli.js | 16 +- docs/ARCHITECTURE.md | 42 ++- src/agents-detect.js | 22 +- src/data.js | 475 +++++++++++++++++++++++++- src/frontend/analytics.js | 8 +- src/frontend/app.js | 109 +++++- src/frontend/calendar.js | 26 +- src/frontend/detail.js | 12 +- src/frontend/heatmap.js | 1 + src/frontend/index.html | 16 + src/frontend/leaderboard.js | 8 +- src/frontend/sidebar-config.js | 6 +- src/frontend/styles.css | 19 +- src/server.js | 35 +- src/settings.js | 2 +- src/terminals.js | 14 +- test/agents-detect.test.js | 31 +- test/frontend-escaping.test.js | 22 +- test/pi-session.test.js | 268 +++++++++++++++ test/settings.test.js | 1 + test/sidebar-config.test.js | 20 +- test/terminals-windows-launch.test.js | 7 + test/wsl-windows.test.js | 4 +- 24 files changed, 1107 insertions(+), 62 deletions(-) create mode 100644 test/pi-session.test.js diff --git a/README.md b/README.md index 66118f1..7467884 100644 --- a/README.md +++ b/README.md @@ -63,7 +63,7 @@ codbash run [--port=N] [--no-browser] codbash search codbash show codbash handoff [target] [--verbosity=full] [--out=file.md] -codbash convert claude|codex +codbash convert claude|codex|qwen codbash list [limit] codbash stats codbash export [file.tar.gz] @@ -81,6 +81,8 @@ codbash stop ~/.claude/ Claude Code sessions + PID tracking ~/.codex/ Codex CLI sessions ~/.cursor/projects/*/agent-transcripts/ Cursor agent sessions +~/.pi/agent/sessions/*/*.jsonl Pi coding-agent sessions +~/.omp/agent/sessions/*/*.jsonl OhMyPi coding-agent sessions ~/.local/share/opencode/opencode.db OpenCode (SQLite) ~/Library/Application Support/kiro-cli/ Kiro CLI (SQLite) /workspaceStorage/ Copilot Chat (JSON/JSONL) @@ -97,6 +99,7 @@ Zero dependencies. Everything runs on `localhost`. curl -fsSL https://claude.ai/install.sh | bash # Claude Code npm i -g @openai/codex # Codex CLI curl -fsSL https://cli.kiro.dev/install | bash # Kiro CLI +bun install -g @oh-my-pi/pi-coding-agent # OhMyPi / Pi curl -fsSL https://opencode.ai/install | bash # OpenCode ``` diff --git a/bin/cli.js b/bin/cli.js index ec1c572..b5711bf 100755 --- a/bin/cli.js +++ b/bin/cli.js @@ -56,6 +56,7 @@ const TOOL_LABELS = { 'claude-ext': { label: 'claude-ext', ansi: '\x1b[34mclaude-ext\x1b[0m' }, codex: { label: 'codex', ansi: '\x1b[36mcodex\x1b[0m' }, qwen: { label: 'qwen', ansi: '\x1b[33mqwen\x1b[0m' }, + pi: { label: 'pi', ansi: '\x1b[95mpi\x1b[0m' }, cursor: { label: 'cursor', ansi: '\x1b[35mcursor\x1b[0m' }, opencode: { label: 'opencode', ansi: '\x1b[95mopencode\x1b[0m' }, kiro: { label: 'kiro', ansi: '\x1b[91mkiro\x1b[0m' }, @@ -65,9 +66,19 @@ function getToolDisplay(tool) { return TOOL_LABELS[tool] || { label: tool || 'unknown', ansi: tool || 'unknown' }; } -function getResumeCommand(tool, sessionId) { +function quoteShellArg(value) { + return "'" + String(value).replace(/'/g, "'\\''") + "'"; +} + +function getResumeCommand(session) { + const tool = session && session.tool; + const sessionId = session && session.id; if (tool === 'codex') return `codex resume ${sessionId}`; if (tool === 'qwen') return `qwen -r ${sessionId}`; + if (tool === 'pi') { + const target = session.resume_target || sessionId; + return `${session.agent_variant === 'ohmypi' ? 'omp' : 'pi'} --resume ${quoteShellArg(target)}`; + } if (tool === 'cursor') return 'cursor'; return `claude --resume ${sessionId}`; } @@ -76,6 +87,7 @@ const STATS_TOOL_ROWS = [ { label: 'Claude sessions', match: (s) => s.tool === 'claude' || s.tool === 'claude-ext' }, { label: 'Codex sessions', match: (s) => s.tool === 'codex' }, { label: 'Qwen sessions', match: (s) => s.tool === 'qwen' }, + { label: 'Pi/OhMyPi sessions', match: (s) => s.tool === 'pi' }, { label: 'Cursor sessions', match: (s) => s.tool === 'cursor' }, { label: 'OpenCode sessions', match: (s) => s.tool === 'opencode' }, { label: 'Kiro sessions', match: (s) => s.tool === 'kiro' }, @@ -206,7 +218,7 @@ switch (command) { console.log(''); } - console.log(` Resume: \x1b[2m${getResumeCommand(session.tool, session.id)}\x1b[0m`); + console.log(` Resume: \x1b[2m${getResumeCommand(session)}\x1b[0m`); console.log(''); break; } diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 30d14d2..3ad7901 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -25,6 +25,8 @@ Browser (localhost:3847) Node.js Server ~/.claude/ ~/.codex/ ~/.cursor/ ~/.local/share/opencode/opencode.db ~/Library/Application Support/kiro-cli/data.sqlite3 + ~/.pi/agent/sessions/*/*.jsonl + ~/.omp/agent/sessions/*/*.jsonl ~/.config/Code/User/workspaceStorage/*/chatSessions/ ``` @@ -112,6 +114,7 @@ Detection logic in `data.js`: | History index | `~/.codex/history.jsonl` | | Session data | `~/.codex/sessions///
/rollout--.jsonl` | + **history.jsonl**: ```json {"session_id": "uuid", "ts": 1712345678, "text": "user prompt", "display": "...", "project": "/path", "cwd": "/path"} @@ -127,7 +130,20 @@ Note: `ts` is in **seconds** (not milliseconds like Claude). Session ID extracted from filename: `rollout-20260406-.jsonl` → UUID part. -### 4. Cursor (Agent Mode) + +### 4. OhMyPi / Pi + +| File | Purpose | +|------|---------| +| Pi session data | `~/.pi/agent/sessions/----/_.jsonl` | +| OhMyPi session data | `~/.omp/agent/sessions/----/_.jsonl` | + +**JSONL format**: +- First line is a session header: `{ type: "session", id, timestamp, cwd, title }`. +- Conversation rows use `{ type: "message", message: { role, content, usage } }`. +- Token usage maps `usage.input`, `usage.output`, `usage.cacheRead`, `usage.cacheWrite`, and optional `usage.cost.total` into codbash analytics. + +### 5. Cursor (Agent Mode) | Item | Location | |------|----------| @@ -146,7 +162,7 @@ Session ID extracted from filename: `rollout-20260406-.jsonl` → UUID par User messages wrapped in `...` tags — stripped during parsing. -### 5. OpenCode +### 6. OpenCode | Item | Location | |------|----------| @@ -171,7 +187,7 @@ GROUP BY m.id ORDER BY m.time_created Tables: `session`, `message`, `part`. Message `data` is JSON with `{role, tokens, model}`. Part `data` is JSON with `{type, text}`. -### 6. Kiro CLI +### 7. Kiro CLI | Item | Location | |------|----------| @@ -198,7 +214,7 @@ FROM conversations_v2 ORDER BY updated_at DESC } ``` -### 7. Copilot (VS Code Extension) +### 8. Copilot (VS Code Extension) | Item | Location | |------|----------| @@ -241,15 +257,16 @@ FROM conversations_v2 ORDER BY updated_at DESC ``` 1. Read ~/.claude/history.jsonl → sessions{} keyed by sessionId (tool: "claude") 2. scanCodexSessions() → merge into sessions{} (tool: "codex") -3. scanOpenCodeSessions() → merge (tool: "opencode") -4. scanCursorSessions() → merge (tool: "cursor") -5. scanKiroSessions() → merge (tool: "kiro") -5a. scanCopilotSessions() → merge (tool: "copilot-chat") -6. Enrich Claude sessions with detail files: +3. scanPiSessions() → merge (tool: "pi") +4. scanOpenCodeSessions() → merge (tool: "opencode") +5. scanCursorSessions() → merge (tool: "cursor") +6. scanKiroSessions() → merge (tool: "kiro") +7. scanCopilotSessions() → merge (tool: "copilot-chat") +8. Enrich Claude sessions with detail files: - Count messages, get file size - Check entrypoint → change tool to "claude-ext" if not "cli" -7. Scan orphan sessions from ~/.claude/projects/ (Claude Extension) -8. Sort by last_ts DESC, format dates +9. Scan orphan sessions from ~/.claude/projects/ (Claude Extension) +10. Sort by last_ts DESC, format dates ``` ### Search Index @@ -271,12 +288,13 @@ cost = input_tokens * input_price Model pricing in `MODEL_PRICING` object (per-token rates for opus, sonnet, haiku, codex-mini, gpt-5). Codex fallback: estimate from file size (~4 bytes per token). +OhMyPi / Pi uses `usage.cost.total` when present; otherwise it uses mapped token counts only when the model has known pricing. ### Active Session Detection ``` 1. Read ~/.claude/sessions/*.json → PID-to-session map -2. ps aux | grep "claude|codex|opencode|kiro-cli|cursor-agent" +2. ps aux | grep "claude|codex|qwen|omp|opencode|kiro-cli|cursor-agent|kilo" 3. For each process: parse PID, CPU%, memory, state 4. Status: "active" (CPU >= 1%) or "waiting" (sleeping/stopped) 5. Map PID → sessionId via PID files diff --git a/src/agents-detect.js b/src/agents-detect.js index 5f5862c..29c6f9f 100644 --- a/src/agents-detect.js +++ b/src/agents-detect.js @@ -33,6 +33,7 @@ const AGENT_DEFS = Object.freeze([ { id: 'codex', label: 'Codex', bin: 'codex' }, { id: 'cursor', label: 'Cursor', bin: 'cursor-agent', appBundle: 'Cursor.app' }, { id: 'qwen', label: 'Qwen Code', bin: 'qwen' }, + { id: 'pi', label: 'OhMyPi', customCheck: 'piPath' }, { id: 'kilo', label: 'Kilo', bin: 'kilo' }, { id: 'kiro', label: 'Kiro CLI', bin: 'kiro-cli' }, { id: 'opencode', label: 'OpenCode', bin: 'opencode' }, @@ -75,9 +76,21 @@ function vscodeCopilotChatExtension() { return null; } +function piPath(ctx) { + const which = ctx && ctx.which ? ctx.which : realWhich; + const pi = which('pi'); + const omp = which('omp'); + if (!pi && !omp) return null; + const commands = []; + if (pi) commands.push('pi'); + if (omp) commands.push('omp'); + return { ok: true, detectedVia: 'path', binPath: pi || omp, command: pi ? 'pi' : 'omp', commands }; +} + var CUSTOM_CHECKS = { ghCopilotExtension: ghCopilotExtension, vscodeCopilotChatExtension: vscodeCopilotChatExtension, + piPath: piPath, }; function realWhich(bin) { @@ -128,6 +141,8 @@ async function detect(ctx) { for (const def of AGENT_DEFS) { let detectedVia = null; let binPath; + let detectedCommand; + let detectedCommands; if (def.bin) { const found = which(def.bin); if (found) { @@ -142,14 +157,19 @@ async function detect(ctx) { } if (!detectedVia && def.customCheck) { const fn = customChecks[def.customCheck]; - const result = typeof fn === 'function' ? fn() : null; + const result = typeof fn === 'function' ? fn({ which, platform, appBundleExists }) : null; if (result && result.ok) { detectedVia = result.detectedVia || 'custom'; + if (result.binPath) binPath = result.binPath; + if (result.command) detectedCommand = result.command; + if (Array.isArray(result.commands) && result.commands.length) detectedCommands = result.commands.slice(); } } if (detectedVia) { const entry = { id: def.id, label: def.label, detectedVia }; if (binPath) entry.binPath = binPath; + if (detectedCommand) entry.command = detectedCommand; + if (detectedCommands) entry.commands = detectedCommands; out.push(entry); } } diff --git a/src/data.js b/src/data.js index f84e23e..2f05ff3 100644 --- a/src/data.js +++ b/src/data.js @@ -94,6 +94,8 @@ function detectWindowsWslHomes({ path.join(uncHome, '.codex'), path.join(uncHome, '.cursor'), path.join(uncHome, '.local', 'share', 'opencode'), + path.join(uncHome, '.pi'), + path.join(uncHome, '.omp'), ].some((candidate) => fsImpl.existsSync(candidate)); if (hasRelevantAgentData) homes.push(uncHome); } catch {} @@ -155,6 +157,12 @@ const CLAUDE_DIR = path.join(ALL_HOMES[0], '.claude'); const CODEX_DIR = path.join(ALL_HOMES[0], '.codex'); const QWEN_DIR = path.join(ALL_HOMES[0], '.qwen'); const OPENCODE_DB = path.join(ALL_HOMES[0], '.local', 'share', 'opencode', 'opencode.db'); +const PI_CONFIG_DIR_NAME = process.env.PI_CONFIG_DIR || '.pi'; +const OMP_CONFIG_DIR_NAME = process.env.OMP_CONFIG_DIR || '.omp'; +const PI_AGENT_DIR = process.env.PI_CODING_AGENT_DIR || path.join(ALL_HOMES[0], PI_CONFIG_DIR_NAME, 'agent'); +const OMP_AGENT_DIR = process.env.OMP_CODING_AGENT_DIR || path.join(ALL_HOMES[0], OMP_CONFIG_DIR_NAME, 'agent'); +const PI_SESSIONS_DIR = path.join(PI_AGENT_DIR, 'sessions'); +const OMP_SESSIONS_DIR = path.join(OMP_AGENT_DIR, 'sessions'); const KIRO_DB = path.join(ALL_HOMES[0], 'Library', 'Application Support', 'kiro-cli', 'data.sqlite3'); const COPILOT_SESSION_DIR = path.join(ALL_HOMES[0], '.copilot', 'session-state'); const COPILOT_JB_DIR = path.join(ALL_HOMES[0], '.copilot', 'jb'); @@ -213,6 +221,8 @@ const EXTRA_CLAUDE_DIRS = [ ]; const EXTRA_CODEX_DIRS = ALL_HOMES.slice(1).map(h => path.join(h, '.codex')).filter(d => fs.existsSync(d)); const EXTRA_QWEN_DIRS = ALL_HOMES.slice(1).map(h => path.join(h, '.qwen')).filter(d => fs.existsSync(d)); +const EXTRA_PI_AGENT_DIRS = process.env.PI_CODING_AGENT_DIR ? [] : ALL_HOMES.slice(1).map(h => path.join(h, '.pi', 'agent')).filter(d => fs.existsSync(d)); +const EXTRA_OMP_AGENT_DIRS = process.env.OMP_CODING_AGENT_DIR ? [] : ALL_HOMES.slice(1).map(h => path.join(h, '.omp', 'agent')).filter(d => fs.existsSync(d)); const EXTRA_CURSOR_DIRS = ALL_HOMES.slice(1).map(h => path.join(h, '.cursor')).filter(d => fs.existsSync(d)); // Extra OpenCode/Kiro DBs on Windows side @@ -225,6 +235,8 @@ if (IS_WSL) { if (EXTRA_CLAUDE_DIRS.length) console.log(' \x1b[36m[WSL]\x1b[0m Windows-side Claude dirs:', EXTRA_CLAUDE_DIRS.join(', ')); if (EXTRA_CODEX_DIRS.length) console.log(' \x1b[36m[WSL]\x1b[0m Windows-side Codex dirs:', EXTRA_CODEX_DIRS.join(', ')); if (EXTRA_QWEN_DIRS.length) console.log(' \x1b[36m[WSL]\x1b[0m Windows-side Qwen dirs:', EXTRA_QWEN_DIRS.join(', ')); + if (EXTRA_PI_AGENT_DIRS.length) console.log(' \x1b[36m[WSL]\x1b[0m Windows-side Pi dirs:', EXTRA_PI_AGENT_DIRS.join(', ')); + if (EXTRA_OMP_AGENT_DIRS.length) console.log(' \x1b[36m[WSL]\x1b[0m Windows-side OhMyPi dirs:', EXTRA_OMP_AGENT_DIRS.join(', ')); if (EXTRA_CURSOR_DIRS.length) console.log(' \x1b[36m[WSL]\x1b[0m Windows-side Cursor dirs:', EXTRA_CURSOR_DIRS.join(', ')); } @@ -633,6 +645,224 @@ function scanQwenSessions(qwenDir) { return sessions; } +function listPiSessionFiles(agentDir) { + const files = []; + const sessionsDir = path.join(agentDir, 'sessions'); + if (!fs.existsSync(sessionsDir)) return files; + + function walk(dir, depth) { + let entries; + try { entries = fs.readdirSync(dir, { withFileTypes: true }); } catch { return; } + for (const entry of entries) { + const fullPath = path.join(dir, entry.name); + if (entry.isFile() && entry.name.endsWith('.jsonl')) { + files.push(fullPath); + } else if (entry.isDirectory() && depth < 3) { + walk(fullPath, depth + 1); + } + } + } + + walk(sessionsDir, 0); + return files; +} + +function extractPiText(content) { + if (typeof content === 'string') return content.trim(); + if (!Array.isArray(content)) return ''; + + const lines = []; + for (const part of content) { + if (!part || typeof part !== 'object') continue; + if (typeof part.text === 'string' && part.text.trim()) { + lines.push(part.text.trim()); + } else if (typeof part.content === 'string' && part.content.trim()) { + lines.push(part.content.trim()); + } + } + + return lines.join('\n').trim(); +} + +function normalizePiUsage(usage) { + if (!usage || typeof usage !== 'object') return null; + const inputTokens = Number(usage.input) || 0; + const outputTokens = Number(usage.output) || 0; + const cacheReadTokens = Number(usage.cacheRead) || 0; + const cacheCreateTokens = Number(usage.cacheWrite) || 0; + const totalTokens = inputTokens + outputTokens + cacheReadTokens + cacheCreateTokens; + const rawCost = usage.cost && typeof usage.cost === 'object' ? usage.cost.total : usage.cost; + const totalCost = Number(rawCost); + if (totalTokens === 0 && !Number.isFinite(totalCost)) return null; + + return { + inputTokens, + outputTokens, + cacheReadTokens, + cacheCreateTokens, + totalTokens, + cost: Number.isFinite(totalCost) ? totalCost : null, + }; +} + +function parsePiSessionFile(sessionFile) { + if (!fs.existsSync(sessionFile)) return null; + + let stat; + let lines; + try { + stat = fs.statSync(sessionFile); + lines = readLines(sessionFile); + } catch { + return null; + } + if (lines.length === 0) return null; + + let header; + try { header = JSON.parse(lines[0]); } catch { return null; } + if (!header || header.type !== 'session' || !header.id) return null; + + let sessionId = String(header.id); + let projectPath = typeof header.cwd === 'string' ? header.cwd : ''; + let title = typeof header.title === 'string' ? header.title.trim().slice(0, 200) : ''; + let msgCount = 0; + let userMsgCount = 0; + let firstMsg = ''; + let firstTs = parseTimestamp(header.timestamp); + let lastTs = firstTs; + if (!Number.isFinite(firstTs)) firstTs = stat.mtimeMs; + if (!Number.isFinite(lastTs)) lastTs = stat.mtimeMs; + let model = ''; + let hasUsage = false; + let explicitCost = false; + + for (let i = 1; i < lines.length; i++) { + try { + const entry = JSON.parse(lines[i]); + const ts = parseTimestamp(entry.timestamp || entry.ts); + if (Number.isFinite(ts)) { + if (ts < firstTs) firstTs = ts; + if (ts > lastTs) lastTs = ts; + } + + if (!projectPath && typeof entry.cwd === 'string') projectPath = entry.cwd; + const msg = entry.message || {}; + if (!model && typeof entry.model === 'string') model = entry.model; + if (!model && typeof msg.model === 'string') model = msg.model; + + if (entry.type !== 'message') continue; + const role = msg.role || entry.role; + if (role !== 'user' && role !== 'assistant') continue; + const text = extractPiText(msg.content !== undefined ? msg.content : entry.content); + if (!text || isSystemMessage(text)) continue; + + msgCount++; + if (role === 'user') userMsgCount++; + if (!firstMsg && role === 'user') firstMsg = text.slice(0, 200); + + const usage = normalizePiUsage(msg.usage || entry.usage); + if (usage) { + hasUsage = true; + if (usage.cost !== null) explicitCost = true; + } + } catch {} + } + + return { + sessionId, + projectPath, + title, + msgCount, + userMsgCount, + firstMsg, + firstTs, + lastTs, + fileSize: stat.size, + model, + hasUsage, + explicitCost, + }; +} + +function piResumeTarget(sessionFile) { + if (typeof sessionFile === 'string' && sessionFile) return sessionFile; + return ''; +} + +function loadPiDetail(sessionId, filePath, options) { + options = options || {}; + const maxMessages = options.maxMessages || 0; + const messages = []; + if (!filePath || !fs.existsSync(filePath)) return { messages }; + + let lines; + try { lines = readLines(filePath); } catch { return { messages }; } + + for (let i = 1; i < lines.length; i++) { + if (maxMessages && messages.length >= maxMessages) break; + try { + const entry = JSON.parse(lines[i]); + if (entry.type !== 'message') continue; + const raw = entry.message || {}; + const role = raw.role || entry.role; + if (role !== 'user' && role !== 'assistant') continue; + const content = extractPiText(raw.content !== undefined ? raw.content : entry.content); + if (!content || isSystemMessage(content)) continue; + + const msg = { + role, + content: content.slice(0, 2000), + uuid: entry.uuid || raw.id || '', + timestamp: entry.timestamp || raw.timestamp || '', + model: role === 'assistant' ? (raw.model || entry.model || '') : '', + }; + const usage = normalizePiUsage(raw.usage || entry.usage); + if (usage) msg.tokens = usage; + messages.push(msg); + } catch {} + } + + return { messages }; +} + +function scanPiSessions(agentDir, variant) { + const sessions = []; + const files = listPiSessionFiles(agentDir); + + for (const filePath of files) { + const summary = parsePiSessionFile(filePath); + if (!summary || !summary.sessionId) continue; + + const projectPath = summary.projectPath || ''; + sessions.push({ + id: summary.sessionId, + tool: 'pi', + project: projectPath, + project_short: projectPath ? projectPath.replace(os.homedir(), '~') : '', + session_name: summary.title || '', + first_ts: summary.firstTs, + last_ts: summary.lastTs, + messages: summary.msgCount, + first_message: summary.firstMsg || '', + has_detail: true, + file_size: summary.fileSize, + detail_messages: summary.msgCount, + user_messages: summary.userMsgCount || 0, + model: summary.model || '', + mcp_servers: [], + skills: [], + _session_file: filePath, + _pi_agent_dir: agentDir, + agent_variant: variant || 'pi', + _has_usage: summary.hasUsage, + _has_explicit_cost: summary.explicitCost, + resume_target: piResumeTarget(filePath), + }); + } + + return sessions; +} + function parseClaudeSessionFile(sessionFile) { if (!fs.existsSync(sessionFile)) return null; @@ -2555,6 +2785,37 @@ let _codexSessionsDirMtimes = {}; // { dayDirPath: mtimeMs } — shallow leaf di // check. Reused by _updateScanMarkers() to avoid a second filesystem walk // (which would race against the first and yield inconsistent snapshots). let _codexDayDirMtimesPending = null; +let _ompSessionDirMtimes = {}; +let _ompSessionDirMtimesPending = null; + +function _piSessionDirMtimes(agentDirs) { + const out = {}; + for (const agentDir of agentDirs) { + const root = path.join(agentDir, 'sessions'); + if (!fs.existsSync(root)) continue; + function walk(dir, depth) { + let entries; + try { + const st = fs.statSync(dir); + out[dir] = st.mtimeMs; + entries = fs.readdirSync(dir, { withFileTypes: true }); + } catch { return; } + for (const entry of entries) { + const fullPath = path.join(dir, entry.name); + if (entry.isFile() && entry.name.endsWith('.jsonl')) { + try { + const st = fs.statSync(fullPath); + out[fullPath] = st.mtimeMs + ':' + st.size; + } catch {} + } else if (entry.isDirectory() && depth < 3) { + walk(fullPath, depth + 1); + } + } + } + walk(root, 0); + } + return out; +} // Collect mtimes of YYYY/MM/DD leaf dirs under ~/.codex/sessions. // Returns { [dayDirPath]: mtimeMs } — fingerprint used for cache invalidation. @@ -2630,6 +2891,14 @@ function _sessionsNeedRescan() { for (const k of curKeys) { if (dayMtimes[k] !== _codexSessionsDirMtimes[k]) return true; } + const piDirMtimes = _piSessionDirMtimes([PI_AGENT_DIR, OMP_AGENT_DIR].concat(EXTRA_PI_AGENT_DIRS, EXTRA_OMP_AGENT_DIRS)); + _ompSessionDirMtimesPending = piDirMtimes; + const prevPiKeys = Object.keys(_ompSessionDirMtimes); + const curPiKeys = Object.keys(piDirMtimes); + if (prevPiKeys.length !== curPiKeys.length) return true; + for (const k of curPiKeys) { + if (piDirMtimes[k] !== _ompSessionDirMtimes[k]) return true; + } } catch {} return false; } @@ -2676,6 +2945,8 @@ function _updateScanMarkers() { // otherwise (first call / direct invocation) walk now. _codexSessionsDirMtimes = _codexDayDirMtimesPending || _codexDayDirMtimes(); _codexDayDirMtimesPending = null; + _ompSessionDirMtimes = _ompSessionDirMtimesPending || _piSessionDirMtimes([PI_AGENT_DIR, OMP_AGENT_DIR].concat(EXTRA_PI_AGENT_DIRS, EXTRA_OMP_AGENT_DIRS)); + _ompSessionDirMtimesPending = null; } catch {} } @@ -2813,8 +3084,8 @@ function _loadCursorVscdbInBackground() { function loadSessions() { const now = Date.now(); if (_sessionsCache) { - // Hot cache: return immediately if within TTL and no file changes - if ((now - _sessionsCacheTs) < SESSIONS_CACHE_TTL) return _sessionsCache; + // Hot cache: return immediately only when the tracked files have not changed. + if ((now - _sessionsCacheTs) < SESSIONS_CACHE_TTL && !_sessionsNeedRescan()) return _sessionsCache; // Extended cache: even after TTL, only rescan if files actually changed if (!_sessionsNeedRescan()) { _sessionsCacheTs = now; // extend TTL @@ -2879,6 +3150,26 @@ function loadSessions() { } catch {} } + // Load Pi sessions + if (fs.existsSync(PI_SESSIONS_DIR)) { + try { + const piSessions = scanPiSessions(PI_AGENT_DIR, 'pi'); + for (const ps of piSessions) { + sessions[ps.id] = ps; + } + } catch {} + } + + // Load OhMyPi sessions + if (fs.existsSync(OMP_SESSIONS_DIR)) { + try { + const ompSessions = scanPiSessions(OMP_AGENT_DIR, 'ohmypi'); + for (const ps of ompSessions) { + sessions[ps.id] = ps; + } + } catch {} + } + // Load OpenCode sessions try { const opencodeSessions = scanOpenCodeSessions(); @@ -3008,6 +3299,24 @@ function loadSessions() { } catch {} } + for (const extraPiDir of EXTRA_PI_AGENT_DIRS) { + try { + const piSessions = scanPiSessions(extraPiDir, 'pi'); + for (const ps of piSessions) { + sessions[ps.id] = ps; + } + } catch {} + } + + for (const extraOmpDir of EXTRA_OMP_AGENT_DIRS) { + try { + const ompSessions = scanPiSessions(extraOmpDir, 'ohmypi'); + for (const ps of ompSessions) { + sessions[ps.id] = ps; + } + } catch {} + } + // Enrich Claude sessions with detail file info // Build file index once to avoid O(sessions*projects) existsSync scans _buildSessionFileIndex(); @@ -3145,6 +3454,11 @@ function loadSessionDetail(sessionId, project) { return loadQwenDetail(sessionId, found.file, { maxMessages: 200 }); } + // OhMyPi + if (found.format === 'pi') { + return loadPiDetail(sessionId, found.file, { maxMessages: 200 }); + } + // Kiro uses SQLite if (found.format === 'kiro') { return loadKiroDetail(sessionId); @@ -3383,6 +3697,7 @@ function exportSessionMarkdown(sessionId, project) { found.format === 'kiro' ? loadKiroDetail(sessionId) : found.format === 'kilo' ? loadKiloCliDetail(sessionId) : found.format === 'qwen' ? loadQwenDetail(sessionId, found.file) : + found.format === 'pi' ? loadPiDetail(sessionId, found.file) : null; if (detail && detail.messages && detail.messages.length > 0) { const parts = [`# Session ${sessionId}\n\n**Project:** ${project || '(none)'}\n`]; @@ -3495,6 +3810,16 @@ function _buildSessionFileIndex() { } catch {} } + // Index Pi / OhMyPi session files + for (const agentDir of [PI_AGENT_DIR, OMP_AGENT_DIR].concat(EXTRA_PI_AGENT_DIRS, EXTRA_OMP_AGENT_DIRS)) { + const files = listPiSessionFiles(agentDir); + for (const filePath of files) { + const summary = parsePiSessionFile(filePath); + if (summary && summary.sessionId && !_sessionFileIndex[summary.sessionId]) { + _sessionFileIndex[summary.sessionId] = { file: filePath, format: 'pi' }; + } + } + } // Index Copilot chat session files if (fs.existsSync(VSCODE_WORKSPACE_STORAGE)) { try { @@ -4000,6 +4325,13 @@ function getSessionPreview(sessionId, project, limit) { }); } + if (found.format === 'pi') { + const detail = loadPiDetail(sessionId, found.file, { maxMessages: limit }); + return detail.messages.map(function(m) { + return { role: m.role, content: m.content.slice(0, 300) }; + }); + } + // Kiro: use loadKiroDetail and slice if (found.format === 'kiro') { var detail = loadKiroDetail(sessionId); @@ -4146,6 +4478,13 @@ function buildSearchIndex(sessions) { texts.push({ role: msg.role, content: msg.content.slice(0, 500) }); } } + } else if (found.format === 'pi') { + const detail = loadPiDetail(s.id, found.file); + for (const msg of detail.messages) { + if (msg.content && !isSystemMessage(msg.content)) { + texts.push({ role: msg.role, content: msg.content.slice(0, 500) }); + } + } } else { const lines = readLines(found.file); @@ -4289,6 +4628,19 @@ function getSessionReplay(sessionId, project) { }); } } + } else if (found.format === 'pi') { + const detail = loadPiDetail(sessionId, found.file); + for (const msg of detail.messages) { + if (msg.content && !isSystemMessage(msg.content)) { + const ms = msg.timestamp ? new Date(msg.timestamp).getTime() : 0; + messages.push({ + role: msg.role, + content: msg.content.slice(0, 3000), + timestamp: msg.timestamp || '', + ms: Number.isFinite(ms) ? ms : 0, + }); + } + } } else { const lines = readLines(found.file); @@ -4606,6 +4958,66 @@ function computeSessionCost(sessionId, project) { }; } + if (found.format === 'pi') { + try { + const lines = readLines(found.file); + for (let i = 1; i < lines.length; i++) { + try { + const entry = JSON.parse(lines[i]); + if (entry.type !== 'message') continue; + const msg = entry.message || {}; + const role = msg.role || entry.role; + if (role !== 'assistant') continue; + if (!model && typeof msg.model === 'string') model = msg.model; + if (!model && typeof entry.model === 'string') model = entry.model; + + const usage = normalizePiUsage(msg.usage || entry.usage); + if (!usage) continue; + totalInput += usage.inputTokens; + totalOutput += usage.outputTokens; + totalCacheRead += usage.cacheReadTokens; + totalCacheCreate += usage.cacheCreateTokens; + + if (usage.cost !== null) { + totalCost += usage.cost; + } else { + const pricing = findModelPricing(msg.model || entry.model || model || ''); + if (pricing) { + totalCost += usage.inputTokens * pricing.input + + usage.cacheCreateTokens * pricing.cache_create + + usage.cacheReadTokens * pricing.cache_read + + usage.outputTokens * pricing.output; + } else if (usage.totalTokens > 0) { + unavailable = true; + } + } + + const contextThisTurn = usage.inputTokens + usage.cacheCreateTokens + usage.cacheReadTokens; + if (contextThisTurn > 0) { + contextPctSum += (contextThisTurn / CONTEXT_WINDOW) * 100; + contextTurnCount++; + } + } catch {} + } + } catch {} + + const result = { + cost: totalCost, + inputTokens: totalInput, + outputTokens: totalOutput, + cacheReadTokens: totalCacheRead, + cacheCreateTokens: totalCacheCreate, + contextPctSum, + contextTurnCount, + model, + estimated: false, + unavailable, + }; + if (cacheKey) _costDiskCache[cacheKey] = result; + _costMemCache[sessionId] = result; + return result; + } + try { const lines = readLines(found.file); for (const line of lines) { @@ -4982,8 +5394,10 @@ function _computeCostAnalytics(sessions) { function extractSessionIdFromCommand(cmd, tool) { if (!cmd || !tool) return ''; + const safePiId = '[A-Za-z0-9._:-]{1,128}'; const patternsByTool = { qwen: [/(?:^|\s)(?:-r|--resume)\s+([0-9a-f-]{36})(?:\s|$)/i, /(?:^|\s)--session-id\s+([0-9a-f-]{36})(?:\s|$)/i], + pi: [new RegExp('(?:^|\\s)--resume\\s+(' + safePiId + ')(?:\\s|$)', 'i')], codex: [/(?:^|\s)resume\s+([0-9a-f-]{36})(?:\s|$)/i], claude: [/(?:^|\s)--resume\s+([0-9a-f-]{36})(?:\s|$)/i], }; @@ -4997,6 +5411,27 @@ function extractSessionIdFromCommand(cmd, tool) { return ''; } +function decodeShellToken(token) { + if (!token) return ''; + if ((token[0] === "'" && token[token.length - 1] === "'") || (token[0] === '"' && token[token.length - 1] === '"')) { + const body = token.slice(1, -1); + if (token[0] === "'") return body.replace(/'\\''/g, "'"); + try { return JSON.parse(token); } catch { return body; } + } + return token; +} + +function extractPiResumeTargetFromCommand(cmd) { + const match = String(cmd || '').match(/(?:^|\s)--resume\s+((?:'[^']*(?:'\\''[^']*)*')|(?:"(?:\\.|[^"])*")|\S+)/); + return match ? decodeShellToken(match[1]) : ''; +} + +function findPiSessionByResumeTarget(resumeTarget, allSessions) { + if (!resumeTarget) return null; + const resolvedTarget = path.resolve(resumeTarget); + return (allSessions || []).find(s => s.tool === 'pi' && s.resume_target && path.resolve(s.resume_target) === resolvedTarget) || null; +} + function findQwenSessionByPid(pid, cwd, allSessions) { const byOpenFile = []; const byCwd = []; @@ -5057,6 +5492,7 @@ function getActiveSessions() { { pattern: 'claude', tool: 'claude', match: /\/claude\s|^claude\s|\bclaude\b/ }, { pattern: 'codex', tool: 'codex', match: /\/codex\s|^codex\s|codex app-server|\bcodex\b/ }, { pattern: 'qwen', tool: 'qwen', match: /(?:^|[\/\s])qwen(?:\s|$)/ }, + { pattern: 'omp', tool: 'pi', match: /(?:^|[\/\s])(?:omp|omp\.cmd|pi)(?:\s|$)/ }, { pattern: 'opencode', tool: 'opencode', match: /\/opencode\s|^opencode\s|\bopencode\b/ }, { pattern: 'kiro', tool: 'kiro', match: /kiro-cli/ }, { pattern: 'cursor-agent', tool: 'cursor', match: /cursor-agent/ }, @@ -5068,7 +5504,7 @@ function getActiveSessions() { try { const psOut = execSync( - 'ps aux 2>/dev/null | grep -E "claude|codex|qwen|opencode|kiro-cli|cursor-agent|kilo" | grep -v grep || true', + 'ps aux 2>/dev/null | grep -E "claude|codex|qwen|omp|omp.cmd|(^|[[:space:]/])pi([[:space:]]|$)|opencode|kiro-cli|cursor-agent|kilo" | grep -v grep || true', { encoding: 'utf8', timeout: 3000, stdio: ['pipe', 'pipe', 'pipe'] } ); @@ -5115,6 +5551,14 @@ function getActiveSessions() { sessionId = explicitSessionId; sessionSource = 'cmd-arg'; } + if (tool === 'pi') { + const piResumeTarget = extractPiResumeTargetFromCommand(cmd); + const piMatch = findPiSessionByResumeTarget(piResumeTarget, allSessions); + if (piMatch) { + sessionId = piMatch.id; + sessionSource = 'cmd-arg'; + } + } if (claudePidMap[pid]) { sessionId = claudePidMap[pid].sessionId || ''; cwd = claudePidMap[pid].cwd || ''; @@ -5263,6 +5707,13 @@ const fmtLocalDay = (ts) => { return d.getFullYear() + '-' + String(d.getMonth()+1).padStart(2,'0') + '-' + String(d.getDate()).padStart(2,'0'); }; +function leaderboardAgentKey(session) { + if (!session || typeof session !== 'object') return 'unknown'; + if (session.tool === 'pi') return session.agent_variant === 'ohmypi' ? 'ohmypi' : 'pi'; + return session.tool || 'unknown'; +} + + // Disk cache for per-session daily message breakdown const DAILY_STATS_CACHE_FILE = path.join(os.tmpdir(), 'codedash-daily-stats-cache.json'); let _dailyStatsDiskCache = null; @@ -5360,7 +5811,7 @@ function _computeSessionDailyBreakdown(s, found) { } // Daily stats result cache -const DAILY_RESULT_CACHE_FILE = path.join(os.tmpdir(), 'codedash-daily-result-cache.json'); +const DAILY_RESULT_CACHE_FILE = path.join(os.tmpdir(), 'codedash-daily-result-cache-v2.json'); let _dailyResultCache = null; let _dailyResultCacheKey = null; @@ -5400,7 +5851,7 @@ function _computeDailyStats(sessions) { for (const s of sessions) { if (!s.first_ts || !s.last_ts) continue; - const tool = s.tool || 'unknown'; + const tool = leaderboardAgentKey(s); // Cost per session const costData = computeSessionCost(s.id, s.project); @@ -5548,6 +5999,10 @@ module.exports = { CLAUDE_DIR, CODEX_DIR, QWEN_DIR, + PI_AGENT_DIR, + PI_SESSIONS_DIR, + OMP_AGENT_DIR, + OMP_SESSIONS_DIR, OPENCODE_DB, KIRO_DB, COPILOT_SESSION_DIR, @@ -5574,5 +6029,15 @@ module.exports = { _parseMainWorktree, resolveGitRoot, ALL_HOMES, + listPiSessionFiles, + extractPiText, + normalizePiUsage, + parsePiSessionFile, + loadPiDetail, + scanPiSessions, + leaderboardAgentKey, + _piSessionDirMtimes, + extractPiResumeTargetFromCommand, + findPiSessionByResumeTarget, }, }; diff --git a/src/frontend/analytics.js b/src/frontend/analytics.js index fa4e6b4..61b9a48 100644 --- a/src/frontend/analytics.js +++ b/src/frontend/analytics.js @@ -95,6 +95,12 @@ async function renderAnalytics(container) { ? 'Qwen tokens only' : 'Qwen Code \u2713'); } + if (byAgent['pi'] && byAgent['pi'].sessions > 0) { + var piLabel = getPiAggregateLabel(); + coverageparts.push(byAgent['pi'].unavailable + ? '' + escHtml(piLabel) + ' tokens only' + : '' + escHtml(piLabel) + ' ✓'); + } if (byAgent['opencode'] && byAgent['opencode'].sessions > 0) coverageparts.push(byAgent['opencode'].estimated ? 'OpenCode ~est.' @@ -120,7 +126,7 @@ async function renderAnalytics(container) { agentEntriesOv.forEach(function(entry) { var name = entry[0]; var info = entry[1]; var pct = maxAgentCostOv > 0 ? (info.cost / maxAgentCostOv * 100) : 0; - var label = getToolLabel(name); + var label = name === 'pi' ? getPiAggregateLabel() : getToolLabel(name); var estMark = info.unavailable ? ' tokens only' : (info.estimated ? ' ~est.' : ''); diff --git a/src/frontend/app.js b/src/frontend/app.js index 584ba00..2c185fa 100644 --- a/src/frontend/app.js +++ b/src/frontend/app.js @@ -11,6 +11,7 @@ let layout = localStorage.getItem('codedash-layout') || 'grid'; // 'grid' or 'li let groupingMode = normalizeGroupingMode(localStorage.getItem('codedash-grouping-mode')); let searchQuery = ''; let toolFilter = null; // null, 'claude', 'codex' +let piVariantFilter = null; // null, 'pi', 'ohmypi' let gitProjectFilter = null; // null or { key, name } — drill-down from Projects view let tagFilter = ''; let dateFrom = ''; @@ -473,6 +474,8 @@ var TOOL_META = { 'claude-ext': { label: 'Claude Ext', shortLabel: 'claude ext', color: '#60a5fa' }, codex: { label: 'Codex', shortLabel: 'codex', color: '#22d3ee' }, qwen: { label: 'Qwen Code', shortLabel: 'qwen', color: '#fbbf24' }, + pi: { label: 'Pi', shortLabel: 'Pi', color: '#a78bfa' }, + ohmypi: { label: 'OhMyPi', shortLabel: 'OhMyPi', color: '#a78bfa' }, cursor: { label: 'Cursor', shortLabel: 'cursor', color: '#4a9eff' }, opencode: { label: 'OpenCode', shortLabel: 'opencode', color: '#c084fc' }, kiro: { label: 'Kiro', shortLabel: 'kiro', color: '#fb923c' }, @@ -486,9 +489,66 @@ function getToolLabel(tool, shortLabel) { return shortLabel ? meta.shortLabel : meta.label; } -function getResumeCommand(tool, sessionId, project) { +function getToolAliases(tool) { + var meta = TOOL_META[tool]; + return meta && Array.isArray(meta.aliases) ? meta.aliases : []; +} + +function getPiVariantLabel(session) { + if (!session || session.agent_variant !== 'ohmypi') return 'Pi'; + return 'OhMyPi'; +} + +function getPiAggregateLabel() { + var hasPi = false; + var hasOhMyPi = false; + for (var i = 0; i < allSessions.length; i++) { + var s = allSessions[i]; + if (!s || s.tool !== 'pi') continue; + if (s.agent_variant === 'ohmypi') hasOhMyPi = true; + else hasPi = true; + if (hasPi && hasOhMyPi) return 'Pi/OhMyPi'; + } + if (hasPi) return 'Pi'; + if (hasOhMyPi) return 'OhMyPi'; + return 'Pi/OhMyPi'; +} + +function getPiDisplayLabel(session) { + return session ? getPiVariantLabel(session) : getPiAggregateLabel(); +} + + +function renderToolBadges(tool, session) { + var toolClass = 'tool-' + tool; + var labels; + if (tool === 'pi') { + labels = [getPiDisplayLabel(session)]; + } else { + labels = [getToolLabel(tool, true)].concat(getToolAliases(tool)); + } + return labels.map(function(label, idx) { + var aliasClass = idx === 0 ? '' : ' tool-alias-badge'; + return '' + escHtml(label) + ''; + }).join(''); +} + +function getPiCommand() { + var found = (window.installedAgents || []).find(function(a) { return a.id === 'pi'; }); + return found && found.command === 'omp' ? 'omp' : 'pi'; +} + +function quoteShellArg(value) { + return "'" + String(value).replace(/'/g, "'\\''") + "'"; +} + +function getResumeCommand(tool, sessionId, project, session) { if (tool === 'codex') return 'codex resume ' + sessionId; if (tool === 'qwen') return 'qwen -r ' + sessionId; + if (tool === 'pi') { + var target = session && session.resume_target ? session.resume_target : sessionId; + return getPiCommand() + ' --resume ' + quoteShellArg(target); + } if (tool === 'cursor') return 'cursor ' + (project ? '"' + project + '"' : '.'); return 'claude --resume ' + sessionId; } @@ -608,7 +668,7 @@ function getEstimatedSessionCost(session) { // Pricing verified 2026-05-15 against vendor pages. // Sources: claude.com/pricing, openai.com/chatgpt/pricing, cursor.com/pricing, // github.com/features/copilot/plans + docs.github.com, kiro.dev/pricing, -// opencode.ai/go + opencode.ai/zen +// opencode.ai/go + opencode.ai/zen; OhMyPi is API-provider backed. var SERVICE_PLANS = { 'Claude Code': { label: 'Claude Code (Anthropic)', kind: 'subscription', plans: [ { name: 'Pro', price: 20 }, @@ -642,6 +702,8 @@ var SERVICE_PLANS = { ]}, 'Qwen Code': { label: 'Qwen Code', kind: 'api-only', plans: [], note: 'Free / API-only — use "API (custom)" to track deposits' }, + 'OhMyPi': { label: 'OhMyPi', kind: 'api-only', plans: [], + note: 'API-provider backed — use "API (custom)" to track deposits' }, 'Kilo': { label: 'Kilo', kind: 'api-only', plans: [], note: 'Free / API-only — use "API (custom)" to track deposits' }, 'API (custom)': { label: 'API (custom)', kind: 'api', plans: [], @@ -1208,6 +1270,11 @@ function applyFilters() { var toolMatch = s.tool === toolFilter || (s.tool === 'claude-ext' && toolFilter === 'claude'); if (!toolMatch) continue; } + if (piVariantFilter) { + if (s.tool !== 'pi') continue; + var variant = s.agent_variant === 'ohmypi' ? 'ohmypi' : 'pi'; + if (variant !== piVariantFilter) continue; + } // Git project drill-down filter (always uses git-root key, independent of groupingMode) if (gitProjectFilter) { @@ -1300,7 +1367,7 @@ function renderCard(s, idx) { var html = '
'; html += '
'; html += ''; - html += '' + escHtml(toolLabel) + ''; + html += renderToolBadges(s.tool, s); html += '' + escHtml(projName) + ''; html += '' + timeAgo(s.last_ts) + ''; if (costStr) { @@ -1386,8 +1453,7 @@ function renderListCard(s, idx) { if (isFocused) classes += ' focused'; var html = '
'; - var listToolLabel = getToolLabel(s.tool, true); - html += '' + escHtml(listToolLabel) + ''; + html += renderToolBadges(s.tool, s); if (showBadges && s.mcp_servers && s.mcp_servers.length > 0) { s.mcp_servers.forEach(function(m) { html += '' + escHtml(m) + ''; @@ -1542,7 +1608,8 @@ function render() { // Stats if (stats) { var statsText = sessions.length + ' sessions'; - if (toolFilter) statsText += ' (' + toolFilter + ')'; + if (piVariantFilter) statsText += ' (' + (piVariantFilter === 'ohmypi' ? 'OhMyPi' : 'Pi') + ')'; + else if (toolFilter) statsText += ' (' + toolFilter + ')'; if (tagFilter) statsText += ' [' + tagFilter + ']'; stats.textContent = statsText; } @@ -1744,7 +1811,7 @@ function renderQACard(s, idx) { var classes = 'qa-item' + (selectedIds.has(s.id) ? ' selected' : ''); var html = '
'; - html += '' + escHtml(toolLabel) + ''; + html += renderToolBadges(s.tool, s); html += '' + escHtml(getSessionDisplayName(s).slice(0, 160)) + ''; html += ''; html += '' + s.messages + ' msgs'; @@ -2056,7 +2123,7 @@ function pickPreferredTool(projectPath, lastSession) { // Hard-coded labels so we can render a friendly name even when /api/agents/installed // has not returned yet (or when the tool is no longer detected on this box). const _AGENT_LABEL_FALLBACK = { - claude: 'Claude Code', codex: 'Codex', cursor: 'Cursor', qwen: 'Qwen Code', + claude: 'Claude Code', codex: 'Codex', cursor: 'Cursor', qwen: 'Qwen Code', pi: 'Pi/OhMyPi', kilo: 'Kilo', kiro: 'Kiro CLI', opencode: 'OpenCode', copilot: 'Copilot CLI', 'copilot-chat': 'Copilot Chat', }; @@ -2509,8 +2576,8 @@ var SIDEBAR_ITEM_META = [ ]}, { group: 'Agents', items: [ ['claude-only', 'Claude Code'], ['codex-only', 'Codex'], ['qwen-only', 'Qwen Code'], - ['kiro-only', 'Kiro'], ['cursor-only', 'Cursor'], ['copilot-chat-only', 'Copilot Chat'], - ['copilot-only', 'Copilot CLI'], ['opencode-only', 'OpenCode'], ['kilo-only', 'Kilo'] + ['pi-original-only', 'Pi'], ['ohmypi-only', 'Oh My Pi'], ['kiro-only', 'Kiro'], ['cursor-only', 'Cursor'], + ['copilot-chat-only', 'Copilot Chat'], ['copilot-only', 'Copilot CLI'], ['opencode-only', 'OpenCode'], ['kilo-only', 'Kilo'] ]}, { group: 'Tools', items: [ ['export-import', 'Export / Import'], ['changelog', 'Changelog'], @@ -2518,8 +2585,8 @@ var SIDEBAR_ITEM_META = [ ]}, { group: 'Install agents', items: [ ['install:claude', 'Claude Code'], ['install:codex', 'Codex CLI'], ['install:qwen', 'Qwen Code'], - ['install:kiro', 'Kiro CLI'], ['install:opencode', 'OpenCode'], ['install:kilo', 'Kilo CLI'], - ['install:copilot', 'Copilot CLI'] + ['install:pi', 'Pi'], ['install:ohmypi', 'Oh My Pi'], ['install:kiro', 'Kiro CLI'], + ['install:opencode', 'OpenCode'], ['install:kilo', 'Kilo CLI'], ['install:copilot', 'Copilot CLI'] ]} ]; @@ -3207,6 +3274,18 @@ var AGENT_INSTALL = { alt: null, url: 'https://github.com/QwenLM/qwen-code', }, + pi: { + name: 'Pi', + cmd: 'npm i -g @earendil-works/pi-coding-agent', + alt: 'npm install -g @earendil-works/pi-coding-agent', + url: 'https://github.com/earendil-works/pi-mono', + }, + ohmypi: { + name: 'Oh My Pi', + cmd: 'curl -fsSL https://omp.sh/install | sh', + alt: 'bun install -g @oh-my-pi/pi-coding-agent', + url: 'https://github.com/can1357/oh-my-pi', + }, kiro: { name: 'Kiro CLI', cmd: 'curl -fsSL https://cli.kiro.dev/install | bash', @@ -3579,6 +3658,7 @@ const _ALL_AGENT_META = [ { id: 'codex', label: 'Codex', expects: 'codex on PATH' }, { id: 'cursor', label: 'Cursor', expects: 'cursor-agent on PATH, or Cursor.app on macOS' }, { id: 'qwen', label: 'Qwen Code', expects: 'qwen on PATH' }, + { id: 'pi', label: 'Pi/OhMyPi', expects: 'pi or omp on PATH' }, { id: 'kilo', label: 'Kilo', expects: 'kilo on PATH' }, { id: 'kiro', label: 'Kiro CLI', expects: 'kiro-cli on PATH' }, { id: 'opencode', label: 'OpenCode', expects: 'opencode on PATH' }, @@ -3711,6 +3791,11 @@ async function resumeLastProjectSession(sessionId, tool, projectPath, btn) { headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ sessionId: sessionId, + resumeTarget: (function() { + if ((tool || '') !== 'pi') return ''; + var s = (window.allSessions || allSessions || []).find(function(x) { return x.id === sessionId; }); + return s && s.resume_target ? s.resume_target : ''; + })(), tool: tool || 'claude', flags: [], project: projectPath || '', diff --git a/src/frontend/calendar.js b/src/frontend/calendar.js index 74be852..4bf602f 100644 --- a/src/frontend/calendar.js +++ b/src/frontend/calendar.js @@ -180,33 +180,53 @@ function setView(view) { // Handle tool filter views if (view === 'claude-only') { toolFilter = toolFilter === 'claude' ? null : 'claude'; + piVariantFilter = null; currentView = 'sessions'; } else if (view === 'codex-only') { toolFilter = toolFilter === 'codex' ? null : 'codex'; + piVariantFilter = null; currentView = 'sessions'; } else if (view === 'qwen-only') { toolFilter = toolFilter === 'qwen' ? null : 'qwen'; + piVariantFilter = null; + currentView = 'sessions'; + } else if (view === 'pi-original-only') { + var activePi = toolFilter === 'pi' && piVariantFilter === 'pi'; + toolFilter = activePi ? null : 'pi'; + piVariantFilter = activePi ? null : 'pi'; + currentView = 'sessions'; + } else if (view === 'ohmypi-only') { + var activeOhMyPi = toolFilter === 'pi' && piVariantFilter === 'ohmypi'; + toolFilter = activeOhMyPi ? null : 'pi'; + piVariantFilter = activeOhMyPi ? null : 'ohmypi'; currentView = 'sessions'; } else if (view === 'cursor-only') { toolFilter = toolFilter === 'cursor' ? null : 'cursor'; + piVariantFilter = null; currentView = 'sessions'; } else if (view === 'kiro-only') { toolFilter = toolFilter === 'kiro' ? null : 'kiro'; + piVariantFilter = null; currentView = 'sessions'; } else if (view === 'copilot-chat-only') { toolFilter = toolFilter === 'copilot-chat' ? null : 'copilot-chat'; + piVariantFilter = null; currentView = 'sessions'; } else if (view === 'copilot-only') { toolFilter = toolFilter === 'copilot' ? null : 'copilot'; + piVariantFilter = null; currentView = 'sessions'; } else if (view === 'opencode-only') { toolFilter = toolFilter === 'opencode' ? null : 'opencode'; + piVariantFilter = null; currentView = 'sessions'; } else if (view === 'kilo-only') { toolFilter = toolFilter === 'kilo' ? null : 'kilo'; + piVariantFilter = null; currentView = 'sessions'; } else { toolFilter = null; + piVariantFilter = null; currentView = view; } @@ -215,7 +235,11 @@ function setView(view) { // Update sidebar active state document.querySelectorAll('.sidebar-item').forEach(function(el) { - el.classList.toggle('active', el.getAttribute('data-view') === view); + var itemView = el.getAttribute('data-view'); + var active = itemView === view; + if (itemView === 'pi-original-only') active = toolFilter === 'pi' && piVariantFilter === 'pi'; + else if (itemView === 'ohmypi-only') active = toolFilter === 'pi' && piVariantFilter === 'ohmypi'; + el.classList.toggle('active', active); }); applyFilters(); diff --git a/src/frontend/detail.js b/src/frontend/detail.js index 40a96ed..bed5fc9 100644 --- a/src/frontend/detail.js +++ b/src/frontend/detail.js @@ -76,7 +76,7 @@ async function openDetail(s) { } else if (activeSessions[s.id]) { infoHtml += ''; } else { - infoHtml += ''; + infoHtml += ''; if (s.tool === 'claude') { infoHtml += ''; } @@ -417,13 +417,14 @@ function launchDangerous(sessionId, project) { launchSession(sessionId, 'claude', project, ['skip-permissions']); } -function launchSession(sessionId, tool, project, flags) { +function launchSession(sessionId, tool, project, flags, resumeTarget) { var terminal = localStorage.getItem('codedash-terminal') || ''; fetch('/api/launch', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ sessionId: sessionId, + resumeTarget: resumeTarget || '', tool: tool, flags: flags || [], project: project, @@ -441,10 +442,15 @@ function launchSession(sessionId, tool, project, flags) { function copyResume(sessionId, tool) { var s = allSessions.find(function(x) { return x.id === sessionId; }); - var cmd = getResumeCommand(tool, sessionId, s && s.project ? s.project : ''); + var cmd = getResumeCommand(tool, sessionId, s && s.project ? s.project : '', s); copyText(cmd, 'Copied: ' + cmd); } +function launchPiSession(sessionId, tool, project) { + var s = allSessions.find(function(x) { return x.id === sessionId; }); + launchSession(sessionId, tool, project, [], s && s.resume_target ? s.resume_target : ''); +} + function exportMd(sessionId, project) { window.open('/api/session/' + sessionId + '/export?project=' + encodeURIComponent(project)); } diff --git a/src/frontend/heatmap.js b/src/frontend/heatmap.js index ac01c69..303cdbc 100644 --- a/src/frontend/heatmap.js +++ b/src/frontend/heatmap.js @@ -184,6 +184,7 @@ function renderHeatmap(container) { 'claude-ext': '#60a5fa', codex: '#22d3ee', qwen: '#fbbf24', + pi: '#a78bfa', cursor: '#4a9eff', opencode: '#c084fc', kiro: '#fb923c', diff --git a/src/frontend/index.html b/src/frontend/index.html index 5e91223..e28c1ff 100644 --- a/src/frontend/index.html +++ b/src/frontend/index.html @@ -112,6 +112,14 @@ Qwen Code
+ + + + '; @@ -236,7 +240,7 @@ async function renderLeaderboard(container) { agentEntries.forEach(function(e) { var pct = data.totals.sessions > 0 ? Math.round(e[1] / data.totals.sessions * 100) : 0; html += '
'; - html += '' + escHtml(getToolLabel(e[0], true)) + ''; + html += '' + escHtml(getLeaderboardAgentLabel(e[0])) + ''; html += '
'; html += '' + e[1] + ' (' + pct + '%)'; html += '
'; diff --git a/src/frontend/sidebar-config.js b/src/frontend/sidebar-config.js index c70c83b..f608b9e 100644 --- a/src/frontend/sidebar-config.js +++ b/src/frontend/sidebar-config.js @@ -33,13 +33,13 @@ 'sessions', 'projects', 'timeline', 'activity', 'running', 'analytics', 'starred', 'leaderboard', 'cloud', // Agents - 'claude-only', 'codex-only', 'qwen-only', 'kiro-only', 'cursor-only', + 'claude-only', 'codex-only', 'qwen-only', 'pi-original-only', 'ohmypi-only', 'kiro-only', 'cursor-only', 'copilot-chat-only', 'copilot-only', 'opencode-only', 'kilo-only', // Tools (Settings is intentionally absent — always visible) 'export-import', 'changelog', // Install agents - 'install:claude', 'install:codex', 'install:qwen', 'install:kiro', - 'install:opencode', 'install:kilo', 'install:copilot' + 'install:claude', 'install:codex', 'install:qwen', 'install:pi', 'install:ohmypi', + 'install:kiro', 'install:opencode', 'install:kilo', 'install:copilot' ]; var DEFAULT_COLLAPSED = { diff --git a/src/frontend/styles.css b/src/frontend/styles.css index 54bcbcd..96f61db 100644 --- a/src/frontend/styles.css +++ b/src/frontend/styles.css @@ -490,6 +490,7 @@ body { .tool-chip.active-claude { background: rgba(96, 165, 250, 0.2); border-color: var(--accent-blue); color: var(--accent-blue); } .tool-chip.active-codex { background: rgba(34, 211, 238, 0.2); border-color: var(--accent-cyan); color: var(--accent-cyan); } .tool-chip.active-qwen { background: rgba(251, 191, 36, 0.2); border-color: #fbbf24; color: #fbbf24; } +.tool-chip.active-pi { background: rgba(167, 139, 250, 0.2); border-color: #a78bfa; color: #a78bfa; } .stats { color: var(--text-muted); font-size: 13px; white-space: nowrap; } @@ -658,6 +659,7 @@ body { .badge-claude { background: rgba(96, 165, 250, 0.15); color: var(--accent-blue); } .badge-codex { background: rgba(34, 211, 238, 0.15); color: var(--accent-cyan); } .badge-qwen { background: rgba(251, 191, 36, 0.15); color: #fbbf24; } +.badge-pi { background: rgba(167, 139, 250, 0.15); color: #a78bfa; } [data-theme="light"] .badge { background: rgba(0,0,0,0.05); } @@ -1487,6 +1489,14 @@ body { padding: 2px 8px; border-radius: 4px; } +.tool-pi, .tool-ohmypi { + text-transform: none; +} + +.tool-alias-badge { + margin-left: 3px; + opacity: 0.75; +} .tool-claude { background: rgba(96, 165, 250, 0.15); @@ -1503,6 +1513,11 @@ body { color: #fbbf24; } +.tool-pi, .tool-ohmypi { + background: rgba(167, 139, 250, 0.15); + color: #a78bfa; +} + .tool-opencode { background: rgba(192, 132, 252, 0.15); color: var(--accent-purple); @@ -2446,7 +2461,7 @@ body { align-items: center; gap: 12px; } -.lb-agent-row .tool-badge { min-width: 80px; text-align: center; } +.lb-agent-row .tool-badge { min-width: 104px; text-align: center; } .lb-agent-bar { flex: 1; @@ -2617,6 +2632,8 @@ body { .lb-global-agents { display: flex; gap: 4px; margin-top: 2px; } .lb-agent-mini { font-size: 10px; padding: 1px 5px; border-radius: 4px; background: rgba(99,102,241,0.1); color: var(--text-muted); } +.lb-agent-mini.tool-pi, +.lb-agent-mini.tool-ohmypi { background: rgba(167, 139, 250, 0.15); color: #a78bfa; } /* ── Cloud Sync ────────────────────────────────────────────── */ .cloud-container { padding: 24px; max-width: 800px; } diff --git a/src/server.js b/src/server.js index 00683f9..fc25aae 100644 --- a/src/server.js +++ b/src/server.js @@ -3,7 +3,8 @@ const http = require('http'); const https = require('https'); const { URL } = require('url'); const { exec, execFile, execFileSync } = require('child_process'); -const { loadSessions, loadSessionDetail, deleteSession, getGitCommits, exportSessionMarkdown, getSessionPreview, searchFullText, getActiveSessions, getSessionReplay, getCostAnalytics, computeSessionCost, getProjectGitInfo, getLeaderboardStats } = require('./data'); +const dataApi = require('./data'); +const { loadSessions, loadSessionDetail, deleteSession, getGitCommits, exportSessionMarkdown, getSessionPreview, searchFullText, getActiveSessions, getSessionReplay, getCostAnalytics, computeSessionCost, getProjectGitInfo, getLeaderboardStats } = dataApi; const { detectTerminals, openInTerminal, focusTerminalByPid, isWSL } = require('./terminals'); const { convertSession } = require('./convert'); const { generateHandoff } = require('./handoff'); @@ -21,6 +22,16 @@ const pathLib = require('path'); const { repoRefreshManager } = require('./repo-refresh'); const { handleRepoRefreshRoute } = require('./repo-refresh-routes'); +function isValidPiResumeTarget(sessionId, resumeTarget) { + if (typeof sessionId !== 'string' || !/^[A-Za-z0-9._-]{1,128}$/.test(sessionId)) return false; + if (typeof resumeTarget !== 'string' || !resumeTarget.endsWith('.jsonl')) return false; + if (/['`$\\\n\r\0]/.test(resumeTarget)) return false; + const resolvedTarget = pathLib.resolve(resumeTarget); + const found = dataApi.findSessionFile(sessionId); + if (!found || found.format !== 'pi' || !found.file) return false; + return pathLib.resolve(found.file) === resolvedTarget; +} + // ── Logging ────────────────────────────────── const LOG_VERBOSE = process.env.CODEDASH_LOG !== '0'; const DEFAULT_HOST = '127.0.0.1'; @@ -165,10 +176,13 @@ function startServer(host, port, openBrowser = true) { (async () => { try { const parsed = JSON.parse(body); - const { sessionId, tool, flags, project, terminal, mode, autoRegister } = parsed; + const { sessionId, resumeTarget, tool, flags, project, terminal, mode, autoRegister } = parsed; const fresh = mode === 'fresh'; - if (!fresh && !/^[A-Za-z0-9._-]{1,128}$/.test(String(sessionId || ''))) { - throw new Error('invalid sessionId'); + if (!fresh) { + const isSafeId = /^[A-Za-z0-9._-]{1,128}$/.test(String(sessionId || '')); + const hasResumeTarget = resumeTarget !== undefined && resumeTarget !== null && resumeTarget !== ''; + const isSafePiTarget = tool === 'pi' && hasResumeTarget && isValidPiResumeTarget(sessionId, resumeTarget); + if (!isSafeId || (hasResumeTarget && !isSafePiTarget)) throw new Error('invalid sessionId'); } if (fresh && !project) { throw new Error('project path required for fresh session'); @@ -181,15 +195,24 @@ function startServer(host, port, openBrowser = true) { if (project && !projectsApi.isSafeLaunchPath(project)) { throw new Error('invalid or unsafe project path'); } - const resolvedTool = settingsApi.isKnownAgent(tool) ? tool : 'claude'; + const detection = await agentsDetect.detectRealOS(); + const knownTool = settingsApi.isKnownAgent(tool); + const detectedAgent = detection.agents.find(a => a.id === tool); + if (knownTool && !detectedAgent) { + throw new Error('agent not installed: ' + tool); + } + const resolvedTool = knownTool ? tool : 'claude'; // Explicit allowlist for flags — element-level. Defense-in-depth in // case a future code path interpolates a flag string into a shell // command. Today only --dangerously-skip-permissions is allowed. const safeFlags = Array.isArray(flags) ? flags.filter(f => typeof f === 'string' && ALLOWED_LAUNCH_FLAGS.has(f)) : []; + const launchCommand = resolvedTool === tool && detectedAgent && typeof detectedAgent.command === 'string' + ? detectedAgent.command + : undefined; log('LAUNCH', `mode=${fresh ? 'fresh' : 'resume'} session=${sessionId || '(none)'} tool=${resolvedTool} terminal=${terminal || 'default'} project=${project || '(none)'} flags=${safeFlags.join(',') || '(none)'}`); - openInTerminal(fresh ? '' : sessionId, resolvedTool, safeFlags, project || '', terminal || '', fresh ? 'fresh' : 'resume'); + openInTerminal(fresh ? '' : sessionId, resolvedTool, safeFlags, project || '', terminal || '', fresh ? 'fresh' : 'resume', launchCommand, fresh ? '' : (resumeTarget || '')); // Auto-register: when a fresh launch fires for a path under $HOME // that is either a git repo or has been launched ≥2 times, add it diff --git a/src/settings.js b/src/settings.js index d548e40..84ac260 100644 --- a/src/settings.js +++ b/src/settings.js @@ -12,7 +12,7 @@ const os = require('os'); const path = require('path'); const KNOWN_AGENTS = Object.freeze([ - 'claude', 'codex', 'cursor', 'qwen', + 'claude', 'codex', 'cursor', 'qwen', 'pi', 'kilo', 'kiro', 'opencode', 'copilot', 'copilot-chat', ]); const KNOWN_AGENT_SET = new Set(KNOWN_AGENTS); diff --git a/src/terminals.js b/src/terminals.js index 97a549c..6b7b8f2 100644 --- a/src/terminals.js +++ b/src/terminals.js @@ -43,6 +43,10 @@ function wslDistro() { // use IDs that fit this regex (UUIDs, slugs, integers). const SAFE_SESSION_ID = /^[A-Za-z0-9._-]{1,128}$/; +function quotePosixArg(value) { + return "'" + String(value).replace(/'/g, "'\\''") + "'"; +} + function assertSafeSessionId(sessionId) { if (!SAFE_SESSION_ID.test(String(sessionId || ''))) { throw new Error(`Invalid session id: ${JSON.stringify(sessionId)}`); @@ -192,9 +196,10 @@ function termLog(tag, msg) { console.log(` ${color}${ts} [${tag}]\x1b[0m ${msg}`); } -function buildAgentCommand(sessionId, tool, flags, mode) { +function buildAgentCommand(sessionId, tool, flags, mode, commandOverride, resumeTarget) { const skipPerms = (flags || []).includes('skip-permissions'); const fresh = mode === 'fresh'; + const piCommand = commandOverride === 'omp' ? 'omp' : 'pi'; if (fresh) { // Start a brand new session in projectDir (no --resume). We map known @@ -203,6 +208,7 @@ function buildAgentCommand(sessionId, tool, flags, mode) { switch (tool) { case 'codex': return 'codex'; case 'qwen': return 'qwen'; + case 'pi': return piCommand; case 'kilo': return 'kilo'; case 'kiro': return 'kiro-cli'; case 'opencode': return 'opencode'; @@ -217,6 +223,7 @@ function buildAgentCommand(sessionId, tool, flags, mode) { if (tool === 'codex') return `codex resume ${sessionId}`; if (tool === 'qwen') return `qwen -r ${sessionId}`; if (tool === 'kilo') return `kilo resume ${sessionId}`; + if (tool === 'pi') return `${piCommand} --resume ${quotePosixArg(resumeTarget || sessionId)}`; return `claude --resume ${sessionId}` + (skipPerms ? ' --dangerously-skip-permissions' : ''); } @@ -253,8 +260,8 @@ function buildWindowsTerminalArgs(cmd, projectDir) { return args; } -function openInTerminal(sessionId, tool, flags, projectDir, terminalId, mode) { - const cmd = buildAgentCommand(sessionId, tool, flags, mode); +function openInTerminal(sessionId, tool, flags, projectDir, terminalId, mode, commandOverride, resumeTarget) { + const cmd = buildAgentCommand(sessionId, tool, flags, mode, commandOverride, resumeTarget); const fullCmd = buildPosixShellCommand(cmd, projectDir); const escapedCmd = fullCmd.replace(/"/g, '\\"'); termLog('TERM', `openInTerminal: terminal=${terminalId || 'default'} tool=${tool} cmd="${fullCmd}"`); @@ -1054,6 +1061,7 @@ module.exports = { focusTerminalByPid, isWSL, __test: { + quotePosixArg, buildAgentCommand, buildPosixShellCommand, buildWindowsCmdStartArgs, diff --git a/test/agents-detect.test.js b/test/agents-detect.test.js index cc98d62..24f76b8 100644 --- a/test/agents-detect.test.js +++ b/test/agents-detect.test.js @@ -12,7 +12,7 @@ function loadFresh() { } // Default stub for custom detectors so tests don't probe the real filesystem. -const NO_CUSTOM = { ghCopilotExtension: () => null, vscodeCopilotChatExtension: () => null }; +const NO_CUSTOM = { ghCopilotExtension: () => null, vscodeCopilotChatExtension: () => null, piPath: () => null }; test('detect picks up an agent found on PATH', async () => { const { detect } = loadFresh(); @@ -83,13 +83,39 @@ test('detect uses customCheck only when PATH and app-bundle miss', async () => { assert.equal(chat, undefined, 'copilot-chat should NOT be detected when extension is missing'); }); +test('detect prefers pi and falls back to omp for Pi', async () => { + const { detect } = loadFresh(); + const pi = await detect({ + platform: 'linux', + which: (bin) => bin === 'pi' ? '/usr/local/bin/pi' : (bin === 'omp' ? '/usr/local/bin/omp' : null), + appBundleExists: () => false, + }); + const detectedPi = pi.agents.find(a => a.id === 'pi'); + assert.ok(detectedPi, 'Pi should be detected when pi exists'); + assert.equal(detectedPi.detectedVia, 'path'); + assert.equal(detectedPi.binPath, '/usr/local/bin/pi'); + assert.equal(detectedPi.command, 'pi'); + assert.deepEqual(detectedPi.commands, ['pi', 'omp']); + + const fallback = await detect({ + platform: 'linux', + which: (bin) => bin === 'omp' ? '/usr/local/bin/omp' : null, + appBundleExists: () => false, + }); + const fallbackPi = fallback.agents.find(a => a.id === 'pi'); + assert.ok(fallbackPi, 'Pi should be detected by omp fallback binary'); + assert.equal(fallbackPi.binPath, '/usr/local/bin/omp'); + assert.equal(fallbackPi.command, 'omp'); + assert.deepEqual(fallbackPi.commands, ['omp']); +}); + test('detect labels each agent with a human-readable string', async () => { const { detect } = loadFresh(); const got = await detect({ platform: 'darwin', which: (bin) => '/usr/bin/' + bin, appBundleExists: () => false, - customChecks: NO_CUSTOM, + customChecks: Object.assign({}, NO_CUSTOM, { piPath: ({ which }) => ({ ok: true, detectedVia: 'path', binPath: which('pi'), command: 'pi', commands: ['pi', 'omp'] }) }), }); // All 7 agents from terminals.js plus the synthetic copilot-chat alias. const ids = got.agents.map(a => a.id).sort(); @@ -97,6 +123,7 @@ test('detect labels each agent with a human-readable string', async () => { assert.ok(ids.includes('codex')); assert.ok(ids.includes('cursor')); assert.ok(ids.includes('qwen')); + assert.ok(ids.includes('pi')); assert.ok(ids.includes('kilo')); assert.ok(ids.includes('kiro')); assert.ok(ids.includes('opencode')); diff --git a/test/frontend-escaping.test.js b/test/frontend-escaping.test.js index ea62d16..3f01925 100644 --- a/test/frontend-escaping.test.js +++ b/test/frontend-escaping.test.js @@ -24,7 +24,27 @@ test('detail resume button uses JS-string escaping for project paths', () => { const source = fs.readFileSync(path.join(__dirname, '..', 'src', 'frontend', 'detail.js'), 'utf8'); assert.match(source, /var jsProject = escJsString\(s\.project \|\| ''\);/); assert.ok( - source.includes("launchSession(\\'' + jsId + '\\',\\'' + jsTool + '\\',\\'' + jsProject + '\\')"), + source.includes("launchPiSession(\\'' + jsId + '\\',\\'' + jsTool + '\\',\\'' + jsProject + '\\')"), 'Resume onclick should pass jsProject, not raw escHtml(project)' ); }); + +test('detail Pi resume button routes through launchPiSession for resume_target support', () => { + const source = fs.readFileSync(path.join(__dirname, '..', 'src', 'frontend', 'detail.js'), 'utf8'); + assert.ok(source.includes("launchPiSession(\\'' + jsId + '\\',\\'' + jsTool + '\\',\\'' + jsProject + '\\')")); + assert.match(source, /resumeTarget: resumeTarget \|\| ''/); +}); + +test('frontend Pi resume commands use shell-safe single quotes', () => { + const source = fs.readFileSync(path.join(__dirname, '..', 'src', 'frontend', 'app.js'), 'utf8'); + assert.match(source, /function quoteShellArg\(value\)/); + assert.match(source, /return getPiCommand\(\) \+ ' --resume ' \+ quoteShellArg\(target\);/); + assert.doesNotMatch(source, /--resume ' \+ JSON\.stringify\(target\)/); +}); + +test('project Last resume sends Pi resume target when available', () => { + const source = fs.readFileSync(path.join(__dirname, '..', 'src', 'frontend', 'app.js'), 'utf8'); + assert.match(source, /resumeTarget: \(function\(\) \{/); + assert.match(source, /if \(\(tool \|\| ''\) !== 'pi'\) return '';/); + assert.match(source, /return s && s\.resume_target \? s\.resume_target : '';/); +}); diff --git a/test/pi-session.test.js b/test/pi-session.test.js new file mode 100644 index 0000000..1c0f5bf --- /dev/null +++ b/test/pi-session.test.js @@ -0,0 +1,268 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('fs'); +const os = require('os'); +const path = require('path'); + +const data = require('../src/data'); +const { + parsePiSessionFile, + loadPiDetail, + scanPiSessions, + normalizePiUsage, + leaderboardAgentKey, + _piSessionDirMtimes, + extractPiResumeTargetFromCommand, + findPiSessionByResumeTarget, +} = data.__test; + + +function withEnv(env, fn) { + const old = {}; + for (const key of Object.keys(env)) { + old[key] = process.env[key]; + process.env[key] = env[key]; + } + try { return fn(); } + finally { + for (const key of Object.keys(env)) { + if (old[key] === undefined) delete process.env[key]; + else process.env[key] = old[key]; + } + } +} + +function freshModulesWithPiDirs(home, piAgentDir, ompAgentDir) { + const dataPath = require.resolve('../src/data'); + const handoffPath = require.resolve('../src/handoff'); + delete require.cache[handoffPath]; + delete require.cache[dataPath]; + const oldHome = os.homedir; + os.homedir = () => home; + return withEnv({ PI_CODING_AGENT_DIR: piAgentDir, OMP_CODING_AGENT_DIR: ompAgentDir }, () => { + try { + const freshData = require('../src/data'); + delete require.cache[handoffPath]; + const freshHandoff = require('../src/handoff'); + return { data: freshData, handoff: freshHandoff }; + } finally { + os.homedir = oldHome; + } + }); +} + +function tmpDir() { + return fs.mkdtempSync(path.join(os.tmpdir(), 'codbash-pi-')); +} + +function writeJsonl(filePath, entries) { + fs.mkdirSync(path.dirname(filePath), { recursive: true }); + fs.writeFileSync(filePath, entries.map(e => JSON.stringify(e)).join('\n') + '\n'); +} + +test('parsePiSessionFile reads OMP header and message summary', () => { + const dir = tmpDir(); + const file = path.join(dir, 'sessions', '--tmp--project--', '2026-05-24T10-00-00.000Z_pi-session-1.jsonl'); + writeJsonl(file, [ + { type: 'session', version: 3, id: 'pi-session-1', timestamp: '2026-05-24T10:00:00.000Z', cwd: '/tmp/project', title: 'Fix parser' }, + { type: 'branch', timestamp: '2026-05-24T10:00:01.000Z', note: 'not a message' }, + { type: 'message', timestamp: '2026-05-24T10:00:02.000Z', message: { role: 'user', content: [{ type: 'text', text: 'Please fix this' }] } }, + { type: 'message', timestamp: '2026-05-24T10:00:04.000Z', message: { role: 'assistant', model: 'claude-sonnet-4-6', content: [{ type: 'text', text: 'Done' }], usage: { input: 10, output: 20, cacheRead: 3, cacheWrite: 4, cost: { total: 0.001 } } } }, + ]); + + const summary = parsePiSessionFile(file); + assert.equal(summary.sessionId, 'pi-session-1'); + assert.equal(summary.projectPath, '/tmp/project'); + assert.equal(summary.title, 'Fix parser'); + assert.equal(summary.msgCount, 2); + assert.equal(summary.userMsgCount, 1); + assert.equal(summary.firstMsg, 'Please fix this'); + assert.equal(summary.model, 'claude-sonnet-4-6'); + assert.equal(summary.hasUsage, true); + assert.equal(summary.explicitCost, true); + assert.equal(summary.firstTs, Date.parse('2026-05-24T10:00:00.000Z')); + assert.equal(summary.lastTs, Date.parse('2026-05-24T10:00:04.000Z')); +}); + +test('loadPiDetail returns role-compatible display messages with tokens', () => { + const dir = tmpDir(); + const file = path.join(dir, 'sessions', '--tmp--project--', '2026_pi-session-2.jsonl'); + writeJsonl(file, [ + { type: 'session', id: 'pi-session-2', cwd: '/tmp/project', timestamp: '2026-05-24T10:00:00.000Z' }, + { type: 'message', timestamp: '2026-05-24T10:00:01.000Z', message: { role: 'user', content: 'hello' } }, + { type: 'message', timestamp: '2026-05-24T10:00:02.000Z', message: { role: 'assistant', content: [{ type: 'text', text: 'hi' }], usage: { input: 1, output: 2, cacheRead: 3, cacheWrite: 4 } } }, + { type: 'message', timestamp: '2026-05-24T10:00:03.000Z', message: { role: 'tool', content: 'skip' } }, + ]); + + const detail = loadPiDetail('pi-session-2', file, { maxMessages: 10 }); + assert.equal(detail.messages.length, 2); + assert.deepEqual(detail.messages.map(m => m.role), ['user', 'assistant']); + assert.equal(detail.messages[0].content, 'hello'); + assert.equal(detail.messages[1].tokens.inputTokens, 1); + assert.equal(detail.messages[1].tokens.outputTokens, 2); + assert.equal(detail.messages[1].tokens.cacheReadTokens, 3); + assert.equal(detail.messages[1].tokens.cacheCreateTokens, 4); +}); + +test('scanPiSessions ignores malformed and non-OMP files', () => { + const agentDir = tmpDir(); + const valid = path.join(agentDir, 'sessions', '--tmp--project--', '2026_pi-session-3.jsonl'); + const invalid = path.join(agentDir, 'sessions', '--tmp--project--', 'bad.jsonl'); + writeJsonl(valid, [ + { type: 'session', id: 'pi-session-3', cwd: '/tmp/project', timestamp: '2026-05-24T10:00:00.000Z' }, + { type: 'message', timestamp: '2026-05-24T10:00:01.000Z', message: { role: 'user', content: 'find me' } }, + ]); + writeJsonl(invalid, [ + { type: 'message', message: { role: 'user', content: 'not omp' } }, + ]); + + const sessions = scanPiSessions(agentDir); + assert.equal(sessions.length, 1); + assert.equal(sessions[0].id, 'pi-session-3'); + assert.equal(sessions[0].tool, 'pi'); + assert.equal(sessions[0].project, '/tmp/project'); + assert.equal(sessions[0].first_message, 'find me'); + assert.equal(sessions[0].agent_variant, 'pi'); +}); + +test('scanPiSessions marks OhMyPi variant when scanning omp directory', () => { + const agentDir = tmpDir(); + const file = path.join(agentDir, 'sessions', '--tmp--project--', '2026_omp-session-1.jsonl'); + writeJsonl(file, [ + { type: 'session', id: 'omp-session-1', cwd: '/tmp/project', timestamp: '2026-05-24T10:00:00.000Z' }, + { type: 'message', timestamp: '2026-05-24T10:00:01.000Z', message: { role: 'user', content: 'hello omp' } }, + ]); + + const sessions = scanPiSessions(agentDir, 'ohmypi'); + assert.equal(sessions.length, 1); + assert.equal(sessions[0].tool, 'pi'); + assert.equal(sessions[0].agent_variant, 'ohmypi'); + assert.equal(sessions[0].first_message, 'hello omp'); +}); + +test('scanPiSessions finds canonical nested OMP session directories and exposes resume path', () => { + const agentDir = tmpDir(); + const file = path.join(agentDir, 'sessions', '-tmp-project', 'nested', '2026_nested-session.jsonl'); + writeJsonl(file, [ + { type: 'session', id: 'nested-session', cwd: '/tmp/project', timestamp: '2026-05-24T10:00:00.000Z' }, + { type: 'message', timestamp: '2026-05-24T10:00:01.000Z', message: { role: 'user', content: 'hello nested' } }, + ]); + + const sessions = scanPiSessions(agentDir, 'ohmypi'); + assert.equal(sessions.length, 1); + assert.equal(sessions[0].id, 'nested-session'); + assert.equal(sessions[0].resume_target, file); +}); + +test('Pi mtime fingerprint includes nested session files', () => { + const agentDir = tmpDir(); + const file = path.join(agentDir, 'sessions', '-tmp-project', 'nested', 'deeper', '2026_nested-cache.jsonl'); + writeJsonl(file, [ + { type: 'session', id: 'nested-cache', cwd: '/tmp/project', timestamp: '2026-05-24T10:00:00.000Z' }, + ]); + + const mtimes = _piSessionDirMtimes([agentDir]); + assert.ok(Object.prototype.hasOwnProperty.call(mtimes, file)); + assert.match(String(mtimes[file]), /^\d+(\.\d+)?:\d+$/); +}); + +test('active Pi command parsing maps quoted resume path back to session id', () => { + const resumeTarget = "/tmp/project with spaces/session'one.jsonl"; + const cmd = "omp --resume '/tmp/project with spaces/session'\\''one.jsonl'"; + const parsed = extractPiResumeTargetFromCommand(cmd); + assert.equal(parsed, resumeTarget); + + const session = findPiSessionByResumeTarget(parsed, [ + { id: 'pi-session-quoted', tool: 'pi', resume_target: resumeTarget }, + ]); + assert.equal(session.id, 'pi-session-quoted'); +}); + +test('normalizePiUsage maps OMP token and cost fields', () => { + assert.deepEqual(normalizePiUsage({ input: 5, output: 7, cacheRead: 11, cacheWrite: 13, cost: { total: 0.42 } }), { + inputTokens: 5, + outputTokens: 7, + cacheReadTokens: 11, + cacheCreateTokens: 13, + totalTokens: 36, + cost: 0.42, + }); + assert.equal(normalizePiUsage({}), null); +}); + +test('leaderboardAgentKey splits Pi and OhMyPi instead of aggregating them', () => { + assert.equal(leaderboardAgentKey({ tool: 'pi', agent_variant: 'pi' }), 'pi'); + assert.equal(leaderboardAgentKey({ tool: 'pi', agent_variant: 'ohmypi' }), 'ohmypi'); + assert.equal(leaderboardAgentKey({ tool: 'pi' }), 'pi'); + assert.equal(leaderboardAgentKey({ tool: 'codex' }), 'codex'); +}); + +test('Pi sessions support preview, replay, markdown export, handoff, analytics, and leaderboard stats', () => { + const home = tmpDir(); + const piAgentDir = path.join(home, '.pi', 'agent'); + const ompAgentDir = path.join(home, '.omp', 'agent'); + const project = path.join(home, 'pi-project'); + fs.mkdirSync(project, { recursive: true }); + const file = path.join(piAgentDir, 'sessions', 'pi-project', '2026_pi-parity.jsonl'); + writeJsonl(file, [ + { type: 'session', id: 'pi-parity', cwd: project, timestamp: '2026-05-24T10:00:00.000Z', title: 'Pi parity' }, + { type: 'message', timestamp: '2026-05-24T10:00:01.000Z', message: { role: 'user', content: [{ type: 'text', text: 'Need parity coverage' }] } }, + { type: 'message', timestamp: '2026-05-24T10:00:02.000Z', message: { role: 'assistant', model: 'pi-model', content: [{ type: 'text', text: 'Parity coverage complete' }], usage: { input: 10, output: 20, cacheRead: 3, cacheWrite: 4, cost: { total: 0.25 } } } }, + ]); + + const { data: freshData, handoff } = freshModulesWithPiDirs(home, piAgentDir, ompAgentDir); + const sessions = freshData.loadSessions(); + assert.equal(sessions.length, 1); + assert.equal(sessions[0].id, 'pi-parity'); + assert.equal(sessions[0].resume_target, file); + + const preview = freshData.getSessionPreview('pi-parity', project, 10); + assert.deepEqual(preview.map(m => m.content), ['Need parity coverage', 'Parity coverage complete']); + + const replay = freshData.getSessionReplay('pi-parity', project); + assert.deepEqual(replay.messages.map(m => m.content), ['Need parity coverage', 'Parity coverage complete']); + + const md = freshData.exportSessionMarkdown('pi-parity', project); + assert.match(md, /Need parity coverage/); + assert.match(md, /Parity coverage complete/); + + const handoffDoc = handoff.generateHandoff('pi-parity', project, { verbosity: 'full' }); + assert.equal(handoffDoc.ok, true); + assert.match(handoffDoc.markdown, /Need parity coverage/); + assert.match(handoffDoc.markdown, /Parity coverage complete/); + + const analytics = freshData.getCostAnalytics(sessions); + assert.equal(analytics.byAgent.pi.sessions, 1); + assert.equal(analytics.byAgent.pi.cost, 0.25); + + const stats = freshData.getLeaderboardStats(); + assert.equal(stats.agents.pi, 1); +}); + +test('Oh My Pi sessions have separate leaderboard stats while sharing Pi analytics coverage', () => { + const home = tmpDir(); + const piAgentDir = path.join(home, '.pi', 'agent'); + const ompAgentDir = path.join(home, '.omp', 'agent'); + const project = path.join(home, 'omp-project'); + fs.mkdirSync(project, { recursive: true }); + const file = path.join(ompAgentDir, 'sessions', 'omp-project', 'nested', '2026_omp-parity.jsonl'); + writeJsonl(file, [ + { type: 'session', id: 'omp-parity', cwd: project, timestamp: '2026-05-24T10:05:00.000Z', title: 'Oh My Pi parity' }, + { type: 'message', timestamp: '2026-05-24T10:05:01.000Z', message: { role: 'user', content: [{ type: 'text', text: 'Need Oh My Pi coverage' }] } }, + { type: 'message', timestamp: '2026-05-24T10:05:02.000Z', message: { role: 'assistant', model: 'omp-model', content: [{ type: 'text', text: 'Oh My Pi coverage complete' }], usage: { input: 30, output: 40, cacheRead: 5, cacheWrite: 6, cost: { total: 0.75 } } } }, + ]); + + const { data: freshData } = freshModulesWithPiDirs(home, piAgentDir, ompAgentDir); + const sessions = freshData.loadSessions(); + assert.equal(sessions.length, 1); + assert.equal(sessions[0].agent_variant, 'ohmypi'); + assert.equal(sessions[0].resume_target, file); + + const analytics = freshData.getCostAnalytics(sessions); + assert.equal(analytics.byAgent.pi.sessions, 1); + assert.equal(analytics.byAgent.pi.cost, 0.75); + + const stats = freshData.getLeaderboardStats(); + assert.equal(stats.agents.ohmypi, 1); + assert.equal(stats.agents.pi, undefined); +}); diff --git a/test/settings.test.js b/test/settings.test.js index a0a4832..ea010d0 100644 --- a/test/settings.test.js +++ b/test/settings.test.js @@ -102,6 +102,7 @@ test('validateAgentId rejects unknown agents', () => { assert.equal(s.isKnownAgent('kiro'), true); assert.equal(s.isKnownAgent('kilo'), true); assert.equal(s.isKnownAgent('qwen'), true); + assert.equal(s.isKnownAgent('pi'), true); assert.equal(s.isKnownAgent('copilot'), true); assert.equal(s.isKnownAgent('copilot-chat'), true); assert.equal(s.isKnownAgent('bogus'), false); diff --git a/test/sidebar-config.test.js b/test/sidebar-config.test.js index 78d6cc2..d3d48fe 100644 --- a/test/sidebar-config.test.js +++ b/test/sidebar-config.test.js @@ -221,6 +221,20 @@ test('setItemHidden refuses to hide Settings (returns config unchanged)', () => 'Settings must never be persistable as hidden'); }); +test('Pi sidebar keys round-trip through hidden config', () => { + const m = loadModule(); + const cfg = m.parseSidebarConfig(JSON.stringify({ + v: 1, + hidden: { 'pi-original-only': true, 'ohmypi-only': true }, + collapsed: {} + })); + assert.equal(m.isItemHidden(cfg, 'pi-original-only'), true); + assert.equal(m.isItemHidden(cfg, 'ohmypi-only'), true); + const next = m.setItemHidden(cfg, 'pi-original-only', false); + assert.equal(next.hidden['pi-original-only'], undefined); + assert.equal(next.hidden['ohmypi-only'], true); +}); + test('setSectionCollapsed returns a new config object (immutability)', () => { const m = loadModule(); const cfg = m.parseSidebarConfig(null); @@ -241,7 +255,7 @@ test('KNOWN_ITEM_KEYS includes all current sidebar entries', () => { assert.ok(keys.has(k), 'workspace key missing: ' + k); }); // Agents - ['claude-only','codex-only','qwen-only','kiro-only','cursor-only', + ['claude-only','codex-only','qwen-only','pi-original-only','ohmypi-only','kiro-only','cursor-only', 'copilot-chat-only','copilot-only','opencode-only','kilo-only'].forEach(k => { assert.ok(keys.has(k), 'agent key missing: ' + k); }); @@ -250,8 +264,8 @@ test('KNOWN_ITEM_KEYS includes all current sidebar entries', () => { assert.ok(keys.has(k), 'tools key missing: ' + k); }); // Install - ['install:claude','install:codex','install:qwen','install:kiro', - 'install:opencode','install:kilo','install:copilot'].forEach(k => { + ['install:claude','install:codex','install:qwen','install:pi','install:ohmypi', + 'install:kiro','install:opencode','install:kilo','install:copilot'].forEach(k => { assert.ok(keys.has(k), 'install key missing: ' + k); }); }); diff --git a/test/terminals-windows-launch.test.js b/test/terminals-windows-launch.test.js index be7525e..73b0d2d 100644 --- a/test/terminals-windows-launch.test.js +++ b/test/terminals-windows-launch.test.js @@ -33,3 +33,10 @@ test('Windows PowerShell launcher also uses WorkingDirectory', () => { assert.match(script, /'-NoExit','-NoProfile','-Command','claude --resume abc123 --dangerously-skip-permissions'/); assert.equal(script.includes('&&'), false); }); + +test('Pi launch commands prefer pi with omp override fallback and quote resume targets', () => { + assert.equal(__test.buildAgentCommand('', 'pi', [], 'fresh'), 'pi'); + assert.equal(__test.buildAgentCommand('pi-session-1', 'pi', [], 'resume'), "pi --resume 'pi-session-1'"); + assert.equal(__test.buildAgentCommand('', 'pi', [], 'fresh', 'omp'), 'omp'); + assert.equal(__test.buildAgentCommand('safe-session-id', 'pi', [], 'resume', 'omp', "/tmp/session file's.jsonl"), "omp --resume '/tmp/session file'\\''s.jsonl'"); +}); diff --git a/test/wsl-windows.test.js b/test/wsl-windows.test.js index ebf5bf9..5124a10 100644 --- a/test/wsl-windows.test.js +++ b/test/wsl-windows.test.js @@ -51,8 +51,8 @@ test('buildWslUncPath and shortenHomePath normalize WSL-visible paths', () => { // is covered by the test below. test('detectWindowsWslHomes discovers only running distros with supported agent data', { skip: process.platform !== 'win32' && 'win32-only: uses backslash path joining' }, () => { const existing = new Set([ - '\\\\wsl$\\Ubuntu-24.04\\home\\dius\\.codex', - '\\\\wsl$\\Debian\\home\\tester\\.cursor', + '\\\\wsl$\\Ubuntu-24.04\\home\\dius\\.pi', + '\\\\wsl$\\Debian\\home\\tester\\.omp', ]); const mockFs = { existsSync: (candidate) => existing.has(candidate) }; const distros = ['Ubuntu-24.04', 'Debian', 'Stopped']; From 63140439353d77aaca0060ebd82dfc39f131193e Mon Sep 17 00:00:00 2001 From: "Tim.Seriakov" Date: Mon, 25 May 2026 00:36:25 +0300 Subject: [PATCH 2/2] docs(pi): document Pi and Oh My Pi support --- README.md | 24 ++++++++++++++---------- 1 file changed, 14 insertions(+), 10 deletions(-) diff --git a/README.md b/README.md index 7467884..71726cc 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # Codbash -Control room for AI coding sessions. Search, replay, and resume Claude Code, Codex, Qwen, Cursor, OpenCode, Kiro, Kilo, and Copilot Chat sessions without digging through scattered logs. +Control room for AI coding sessions. Search, replay, and resume Claude Code, Codex, Qwen, Pi, Oh My Pi, Cursor, OpenCode, Kiro, Kilo, and Copilot Chat sessions without digging through scattered logs. [Russian / Русский](docs/README_RU.md) | [Chinese / 中文](docs/README_ZH.md) @@ -21,6 +21,8 @@ codbash run |-------|----------|---------|--------|-------------|---------|---------|--------| | Claude Code | JSONL | Yes | Yes | Yes | Yes | Yes | Terminal / cmux | | Codex CLI | JSONL | Yes | Yes | Yes | Yes | Yes | Terminal | +| Pi | JSONL | Yes | Yes | Yes | - | Yes | Terminal | +| Oh My Pi | JSONL | Yes | Yes | Yes | - | Yes | Terminal | | Cursor | JSONL | Yes | Yes | Yes | - | Yes | Open in Cursor | | OpenCode | SQLite | Yes | Yes | Yes | - | Yes | Terminal | | Kiro CLI | SQLite | Yes | Yes | Yes | - | Yes | Terminal | @@ -32,7 +34,7 @@ Also detects Claude Code running inside Cursor (via `claude-vscode` entrypoint). **Browser Dashboard** - Grid and List view with project grouping -- Trigram fuzzy search + full-text deep search across all messages +- Trigram fuzzy search + full-text deep search across all supported message logs - Filter by agent, tags, date range - Star/pin sessions, tag with labels - GitHub-style SVG activity heatmap with streak stats @@ -41,21 +43,21 @@ Also detects Claude Code running inside Cursor (via `claude-vscode` entrypoint). - Themes: Dark, Light, System **Live Monitoring** -- LIVE/WAITING badges on all agent types +- LIVE/WAITING badges for terminal-launched agents when their processes can be matched - Animated border on active session cards - Running view with CPU, Memory, PID, Uptime - Focus Terminal / Open in Cursor buttons - Polling every 5 seconds **Cost Analytics** -- Real cost from actual token usage (input, output, cache) +- Real cost from actual token usage when agents record usage (input, output, cache) - Per-model pricing: Opus, Sonnet, Haiku, Codex, GPT-5 - Daily cost chart, cost by project, most expensive sessions **Cross-Agent** -- Convert sessions between Claude Code and Codex -- Handoff: generate context document to continue in any agent -- Install Agents: one-click install commands for all agents +- Convert sessions between Claude Code, Codex, and Qwen formats +- Handoff: generate context document from any session with readable messages +- Install Agents: one-click install commands for supported agent CLIs **CLI** ```bash @@ -81,8 +83,8 @@ codbash stop ~/.claude/ Claude Code sessions + PID tracking ~/.codex/ Codex CLI sessions ~/.cursor/projects/*/agent-transcripts/ Cursor agent sessions -~/.pi/agent/sessions/*/*.jsonl Pi coding-agent sessions -~/.omp/agent/sessions/*/*.jsonl OhMyPi coding-agent sessions +~/.pi/agent/sessions/**/*.jsonl Pi coding-agent sessions +~/.omp/agent/sessions/**/*.jsonl Oh My Pi coding-agent sessions ~/.local/share/opencode/opencode.db OpenCode (SQLite) ~/Library/Application Support/kiro-cli/ Kiro CLI (SQLite) /workspaceStorage/ Copilot Chat (JSON/JSONL) @@ -99,7 +101,9 @@ Zero dependencies. Everything runs on `localhost`. curl -fsSL https://claude.ai/install.sh | bash # Claude Code npm i -g @openai/codex # Codex CLI curl -fsSL https://cli.kiro.dev/install | bash # Kiro CLI -bun install -g @oh-my-pi/pi-coding-agent # OhMyPi / Pi +npm i -g @earendil-works/pi-coding-agent # Pi +curl -fsSL https://omp.sh/install | sh # Oh My Pi +bun install -g @oh-my-pi/pi-coding-agent # Oh My Pi (Bun) curl -fsSL https://opencode.ai/install | bash # OpenCode ```