From 918023196c1f2e5ca2ee69740f7f69622ee7125f Mon Sep 17 00:00:00 2001 From: Gary Basin Date: Tue, 3 Feb 2026 11:57:35 -0500 Subject: [PATCH 1/4] Add Pi agent support Introduce support for a new 'pi' agent across client and server. Client: add Pi icon, include a built-in Pi command preset, extend settings validation/types, bump settings storage version and add migration to ensure built-in presets are present. Server: accept 'pi' in agent_type CHECKs, add migratePiAgentType to recreate agent_sessions with updated constraint, update agent detection and log discovery to recognize Pi paths and session IDs. Tests: update multiple tests to set up PI_HOME and expect the new built-in preset. Overall changes add end-to-end recognition and persistence for the Pi agent type. --- src/client/__tests__/settingsModal.test.tsx | 2 +- src/client/components/AgentIcon.tsx | 23 +++++++ src/client/stores/settingsStore.ts | 20 +++--- src/server/__tests__/db.test.ts | 2 +- src/server/__tests__/logDiscovery.test.ts | 6 ++ src/server/__tests__/logMatchWorker.test.ts | 9 +++ src/server/__tests__/logPollData.test.ts | 6 ++ src/server/__tests__/logPoller.test.ts | 4 ++ src/server/agentDetection.ts | 3 + src/server/db.ts | 68 ++++++++++++++++++++- src/server/logDiscovery.ts | 21 ++++++- src/shared/types.ts | 2 +- 12 files changed, 154 insertions(+), 12 deletions(-) diff --git a/src/client/__tests__/settingsModal.test.tsx b/src/client/__tests__/settingsModal.test.tsx index 0b265ec..10df31a 100644 --- a/src/client/__tests__/settingsModal.test.tsx +++ b/src/client/__tests__/settingsModal.test.tsx @@ -110,7 +110,7 @@ describe('SettingsModal', () => { expect(state.defaultProjectDir).toBe(DEFAULT_PROJECT_DIR) expect(state.sessionSortMode).toBe('status') expect(state.sessionSortDirection).toBe('desc') - expect(state.commandPresets.length).toBe(2) + expect(state.commandPresets.length).toBe(3) expect(closed).toBe(1) act(() => { diff --git a/src/client/components/AgentIcon.tsx b/src/client/components/AgentIcon.tsx index 0ad05e2..106f6a0 100644 --- a/src/client/components/AgentIcon.tsx +++ b/src/client/components/AgentIcon.tsx @@ -37,12 +37,35 @@ function OpenAIIcon({ className }: { className?: string }) { ) } +function PiIcon({ className }: { className?: string }) { + return ( + + + π + + + ) +} + type IconComponent = ({ className }: { className?: string }) => JSX.Element /** Prefix patterns mapped to icons - order matters, first match wins */ const iconPrefixes: [string, IconComponent][] = [ ['claude', AnthropicIcon], ['codex', OpenAIIcon], + ['pi', PiIcon], ] export default function AgentIcon({ diff --git a/src/client/stores/settingsStore.ts b/src/client/stores/settingsStore.ts index b6c7c2d..1adf230 100644 --- a/src/client/stores/settingsStore.ts +++ b/src/client/stores/settingsStore.ts @@ -45,12 +45,13 @@ export interface CommandPreset { label: string command: string isBuiltIn: boolean - agentType?: 'claude' | 'codex' + agentType?: 'claude' | 'codex' | 'pi' } export const DEFAULT_PRESETS: CommandPreset[] = [ { id: 'claude', label: 'Claude', command: 'claude', isBuiltIn: true, agentType: 'claude' }, { id: 'codex', label: 'Codex', command: 'codex', isBuiltIn: true, agentType: 'codex' }, + { id: 'pi', label: 'Pi', command: 'pi', isBuiltIn: true, agentType: 'pi' }, ] // Validation and helper functions @@ -62,7 +63,7 @@ export function isValidPreset(p: unknown): p is CommandPreset { typeof obj.label === 'string' && obj.label.trim().length >= 1 && obj.label.length <= 64 && typeof obj.command === 'string' && obj.command.trim().length >= 1 && obj.command.length <= 1024 && typeof obj.isBuiltIn === 'boolean' && - (obj.agentType === undefined || obj.agentType === 'claude' || obj.agentType === 'codex') + (obj.agentType === undefined || obj.agentType === 'claude' || obj.agentType === 'codex' || obj.agentType === 'pi') ) } @@ -255,7 +256,7 @@ export const useSettingsStore = create()( { name: 'agentboard-settings', storage: createJSONStorage(() => safeStorage), - version: 2, + version: 4, partialize: (state) => { // Exclude manualSessionOrder from persistence (session-only state) const { manualSessionOrder: _, ...rest } = state @@ -279,7 +280,7 @@ export const useSettingsStore = create()( label: p.label as string, command: command || 'claude', isBuiltIn: p.isBuiltIn as boolean, - agentType: p.agentType as 'claude' | 'codex' | undefined, + agentType: p.agentType as 'claude' | 'codex' | 'pi' | undefined, } } @@ -339,13 +340,18 @@ export const useSettingsStore = create()( ...validPresets.filter(p => !p.isBuiltIn).slice(0, MAX_PRESETS - 2)] : validPresets - if (version === 1) { - console.info('[agentboard:settings] Migrated from v1 to v2') + // Ensure all built-in presets from DEFAULT_PRESETS are present + const existingIds = new Set(trimmedPresets.map(p => p.id)) + const missingBuiltIns = DEFAULT_PRESETS.filter(p => p.isBuiltIn && !existingIds.has(p.id)) + const finalPresets = [...trimmedPresets, ...missingBuiltIns] + + if (version < 4) { + console.info(`[agentboard:settings] Migrated from v${version} to v4 (ensured all built-in presets)`) } return { ...state, - commandPresets: trimmedPresets.map(normalizePreset), + commandPresets: finalPresets.map(normalizePreset), defaultPresetId: resolveDefaultPresetId( trimmedPresets, state.defaultPresetId as string diff --git a/src/server/__tests__/db.test.ts b/src/server/__tests__/db.test.ts index bb08504..651afea 100644 --- a/src/server/__tests__/db.test.ts +++ b/src/server/__tests__/db.test.ts @@ -149,7 +149,7 @@ describe('db', () => { session_id TEXT UNIQUE, log_file_path TEXT NOT NULL UNIQUE, project_path TEXT, - agent_type TEXT NOT NULL CHECK (agent_type IN ('claude', 'codex')), + agent_type TEXT NOT NULL CHECK (agent_type IN ('claude', 'codex', 'pi')), display_name TEXT, created_at TEXT NOT NULL, last_activity_at TEXT NOT NULL, diff --git a/src/server/__tests__/logDiscovery.test.ts b/src/server/__tests__/logDiscovery.test.ts index b471530..182653d 100644 --- a/src/server/__tests__/logDiscovery.test.ts +++ b/src/server/__tests__/logDiscovery.test.ts @@ -15,15 +15,19 @@ import { let tempRoot: string let claudeDir: string let codexDir: string +let piDir: string const originalClaude = process.env.CLAUDE_CONFIG_DIR const originalCodex = process.env.CODEX_HOME +const originalPi = process.env.PI_HOME beforeEach(async () => { tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'agentboard-logs-')) claudeDir = path.join(tempRoot, 'claude') codexDir = path.join(tempRoot, 'codex') + piDir = path.join(tempRoot, 'pi') process.env.CLAUDE_CONFIG_DIR = claudeDir process.env.CODEX_HOME = codexDir + process.env.PI_HOME = piDir }) afterEach(async () => { @@ -31,6 +35,8 @@ afterEach(async () => { else delete process.env.CLAUDE_CONFIG_DIR if (originalCodex) process.env.CODEX_HOME = originalCodex else delete process.env.CODEX_HOME + if (originalPi) process.env.PI_HOME = originalPi + else delete process.env.PI_HOME await fs.rm(tempRoot, { recursive: true, force: true }) }) diff --git a/src/server/__tests__/logMatchWorker.test.ts b/src/server/__tests__/logMatchWorker.test.ts index 80cb627..0270815 100644 --- a/src/server/__tests__/logMatchWorker.test.ts +++ b/src/server/__tests__/logMatchWorker.test.ts @@ -22,6 +22,7 @@ const tmuxOutputs = new Map() const originalClaude = process.env.CLAUDE_CONFIG_DIR const originalCodex = process.env.CODEX_HOME +const originalPi = process.env.PI_HOME let tempRoot = '' @@ -151,6 +152,8 @@ afterAll(() => { else delete process.env.CLAUDE_CONFIG_DIR if (originalCodex) process.env.CODEX_HOME = originalCodex else delete process.env.CODEX_HOME + if (originalPi) process.env.PI_HOME = originalPi + else delete process.env.PI_HOME }) beforeEach(async () => { @@ -160,12 +163,16 @@ beforeEach(async () => { tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'agentboard-logmatch-worker-')) process.env.CLAUDE_CONFIG_DIR = path.join(tempRoot, 'claude') process.env.CODEX_HOME = path.join(tempRoot, 'codex') + process.env.PI_HOME = path.join(tempRoot, 'pi') await fs.mkdir(path.join(process.env.CLAUDE_CONFIG_DIR, 'projects'), { recursive: true, }) await fs.mkdir(path.join(process.env.CODEX_HOME, 'sessions'), { recursive: true, }) + await fs.mkdir(path.join(process.env.PI_HOME, 'agent', 'sessions'), { + recursive: true, + }) bunAny.spawnSync = ((args: string[]) => { if (args[0] === 'tmux' && args[1] === 'capture-pane') { @@ -199,6 +206,8 @@ afterEach(async () => { else delete process.env.CLAUDE_CONFIG_DIR if (originalCodex) process.env.CODEX_HOME = originalCodex else delete process.env.CODEX_HOME + if (originalPi) process.env.PI_HOME = originalPi + else delete process.env.PI_HOME }) const baseSession: Session = { diff --git a/src/server/__tests__/logPollData.test.ts b/src/server/__tests__/logPollData.test.ts index 9a5d233..6bf30fd 100644 --- a/src/server/__tests__/logPollData.test.ts +++ b/src/server/__tests__/logPollData.test.ts @@ -7,8 +7,10 @@ import { collectLogEntryBatch } from '../logPollData' let tempRoot: string let claudeDir: string let codexDir: string +let piDir: string const originalClaude = process.env.CLAUDE_CONFIG_DIR const originalCodex = process.env.CODEX_HOME +const originalPi = process.env.PI_HOME async function writeJsonl(filePath: string, lines: string[]): Promise { await fs.mkdir(path.dirname(filePath), { recursive: true }) @@ -19,8 +21,10 @@ beforeEach(async () => { tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'agentboard-logpolldata-')) claudeDir = path.join(tempRoot, 'claude') codexDir = path.join(tempRoot, 'codex') + piDir = path.join(tempRoot, 'pi') process.env.CLAUDE_CONFIG_DIR = claudeDir process.env.CODEX_HOME = codexDir + process.env.PI_HOME = piDir }) afterEach(async () => { @@ -28,6 +32,8 @@ afterEach(async () => { else delete process.env.CLAUDE_CONFIG_DIR if (originalCodex) process.env.CODEX_HOME = originalCodex else delete process.env.CODEX_HOME + if (originalPi) process.env.PI_HOME = originalPi + else delete process.env.PI_HOME await fs.rm(tempRoot, { recursive: true, force: true }) }) diff --git a/src/server/__tests__/logPoller.test.ts b/src/server/__tests__/logPoller.test.ts index 4eb60cb..e2a3f77 100644 --- a/src/server/__tests__/logPoller.test.ts +++ b/src/server/__tests__/logPoller.test.ts @@ -34,6 +34,7 @@ const baseSession: Session = { let tempRoot: string const originalClaude = process.env.CLAUDE_CONFIG_DIR const originalCodex = process.env.CODEX_HOME +const originalPi = process.env.PI_HOME function setTmuxOutput(target: string, content: string) { tmuxOutputs.set(target, content) @@ -166,6 +167,7 @@ beforeEach(async () => { tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'agentboard-poller-')) process.env.CLAUDE_CONFIG_DIR = path.join(tempRoot, 'claude') process.env.CODEX_HOME = path.join(tempRoot, 'codex') + process.env.PI_HOME = path.join(tempRoot, 'pi') bunAny.spawnSync = ((args: string[]) => { if (args[0] === 'tmux' && args[1] === 'capture-pane') { @@ -196,6 +198,8 @@ afterEach(async () => { else delete process.env.CLAUDE_CONFIG_DIR if (originalCodex) process.env.CODEX_HOME = originalCodex else delete process.env.CODEX_HOME + if (originalPi) process.env.PI_HOME = originalPi + else delete process.env.PI_HOME await fs.rm(tempRoot, { recursive: true, force: true }) }) diff --git a/src/server/agentDetection.ts b/src/server/agentDetection.ts index e9209a8..3fb97bc 100644 --- a/src/server/agentDetection.ts +++ b/src/server/agentDetection.ts @@ -42,6 +42,9 @@ export function inferAgentType(command: string): AgentType | undefined { if (baseName === 'codex') { return 'codex' } + if (baseName === 'pi') { + return 'pi' + } // Found a non-skippable command that isn't a known agent break diff --git a/src/server/db.ts b/src/server/db.ts index 6a434bf..d33ccea 100644 --- a/src/server/db.ts +++ b/src/server/db.ts @@ -55,7 +55,7 @@ const AGENT_SESSIONS_COLUMNS_SQL = ` session_id TEXT UNIQUE, log_file_path TEXT NOT NULL UNIQUE, project_path TEXT, - agent_type TEXT NOT NULL CHECK (agent_type IN ('claude', 'codex')), + agent_type TEXT NOT NULL CHECK (agent_type IN ('claude', 'codex', 'pi')), display_name TEXT, created_at TEXT NOT NULL, last_activity_at TEXT NOT NULL, @@ -109,6 +109,7 @@ export function initDatabase(options: { path?: string } = {}): SessionDatabase { migrateLastResumeErrorColumn(db) migrateLastKnownLogSizeColumn(db) migrateIsCodexExecColumn(db) + migratePiAgentType(db) const insertStmt = db.prepare( `INSERT INTO agent_sessions @@ -518,6 +519,71 @@ function migrateDeduplicateDisplayNames(db: SQLiteDatabase) { } } +/** + * Migrate agent_type CHECK constraint to include 'pi'. + * SQLite doesn't support modifying constraints, so we recreate the table. + */ +function migratePiAgentType(db: SQLiteDatabase) { + // Check if table exists and if constraint already includes 'pi' + const tableInfo = db + .prepare("SELECT sql FROM sqlite_master WHERE type='table' AND name='agent_sessions'") + .get() as { sql: string } | undefined + + if (!tableInfo?.sql) { + return // Table doesn't exist yet, will be created with correct constraint + } + + // If 'pi' is already in the constraint, no migration needed + if (tableInfo.sql.includes("'pi'")) { + return + } + + db.exec('BEGIN') + try { + db.exec('ALTER TABLE agent_sessions RENAME TO agent_sessions_old_pi_migrate') + createAgentSessionsTable(db, 'agent_sessions') + db.exec(` + INSERT INTO agent_sessions ( + id, + session_id, + log_file_path, + project_path, + agent_type, + display_name, + created_at, + last_activity_at, + last_user_message, + current_window, + is_pinned, + last_resume_error, + last_known_log_size, + is_codex_exec + ) + SELECT + id, + session_id, + log_file_path, + project_path, + agent_type, + display_name, + created_at, + last_activity_at, + last_user_message, + current_window, + is_pinned, + last_resume_error, + last_known_log_size, + is_codex_exec + FROM agent_sessions_old_pi_migrate + `) + db.exec('DROP TABLE agent_sessions_old_pi_migrate') + db.exec('COMMIT') + } catch (error) { + db.exec('ROLLBACK') + throw error + } +} + function getColumnNames(db: SQLiteDatabase, tableName: string): string[] { const rows = db .prepare(`PRAGMA table_info(${tableName})`) diff --git a/src/server/logDiscovery.ts b/src/server/logDiscovery.ts index 60bde10..92c6eb6 100644 --- a/src/server/logDiscovery.ts +++ b/src/server/logDiscovery.ts @@ -58,10 +58,20 @@ function getCodexHomeDir(): string { return path.join(getHomeDir(), '.codex') } +function getPiHomeDir(): string { + const override = process.env.PI_HOME + if (override && override.trim()) { + const normalized = normalizeProjectPath(override) + return normalized || override.trim() + } + return path.join(getHomeDir(), '.pi') +} + export function getLogSearchDirs(): string[] { return [ path.join(getClaudeConfigDir(), 'projects'), path.join(getCodexHomeDir(), 'sessions'), + path.join(getPiHomeDir(), 'agent', 'sessions'), ] } @@ -94,9 +104,11 @@ export function scanAllLogDirs(): string[] { const paths: string[] = [] const claudeRoot = path.join(getClaudeConfigDir(), 'projects') const codexRoot = path.join(getCodexHomeDir(), 'sessions') + const piRoot = path.join(getPiHomeDir(), 'agent', 'sessions') paths.push(...scanDirForJsonl(claudeRoot, 3)) paths.push(...scanDirForJsonl(codexRoot, 4)) + paths.push(...scanDirForJsonl(piRoot, 4)) return paths } @@ -151,17 +163,20 @@ export function getLogTimes( } } -export function inferAgentTypeFromPath(logPath: string): 'claude' | 'codex' | null { +export function inferAgentTypeFromPath(logPath: string): 'claude' | 'codex' | 'pi' | null { const normalized = path.resolve(logPath) const claudeRoot = path.resolve(getClaudeConfigDir()) const codexRoot = path.resolve(getCodexHomeDir()) + const piRoot = path.resolve(getPiHomeDir()) if (normalized.startsWith(claudeRoot + path.sep)) return 'claude' if (normalized.startsWith(codexRoot + path.sep)) return 'codex' + if (normalized.startsWith(piRoot + path.sep)) return 'pi' const fallback = logPath.replace(/\\/g, '/') if (fallback.includes('/.claude/')) return 'claude' if (fallback.includes('/.codex/')) return 'codex' + if (fallback.includes('/.pi/')) return 'pi' return null } @@ -289,6 +304,10 @@ function getSessionIdFromEntry(entry: Record): string | null { if (typeof entry.session_id === 'string' && entry.session_id.trim()) { return entry.session_id.trim() } + // Pi uses top-level "id" field with type: "session" + if (entry.type === 'session' && typeof entry.id === 'string' && entry.id.trim()) { + return entry.id.trim() + } if (entry.payload && typeof entry.payload === 'object') { const payload = entry.payload as Record diff --git a/src/shared/types.ts b/src/shared/types.ts index 460c893..b6c712f 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -5,7 +5,7 @@ export const INACTIVE_MAX_AGE_MAX_HOURS = 168 // 7 days export type SessionStatus = 'working' | 'waiting' | 'permission' | 'unknown' export type SessionSource = 'managed' | 'external' -export type AgentType = 'claude' | 'claude-rp' | 'codex' +export type AgentType = 'claude' | 'claude-rp' | 'codex' | 'pi' export type TerminalErrorCode = | 'ERR_INVALID_WINDOW' | 'ERR_SESSION_CREATE_FAILED' From 46830a475aaf1d8a226f1800a9cd4095bb532332 Mon Sep 17 00:00:00 2001 From: Gary Basin Date: Tue, 3 Feb 2026 12:27:48 -0500 Subject: [PATCH 2/4] Support Pi TUI user message extraction Detect and extract user messages from Pi's TUI which uses an RGB background color for user input. Adds extractPiUserMessagesFromAnsi to parse ANSI scrollback for the 48;2;52;53;65 background pattern, and getTerminalScrollbackWithAnsi to capture tmux content with ANSI escape codes preserved. The main matching flow now falls back to Pi detection when no Claude/Codex messages are found. Unit tests for Pi extraction were added, and an integration test timeout was increased to 10s to reduce flakiness. --- src/server/__tests__/integration.test.ts | 2 +- src/server/__tests__/logMatcher.test.ts | 37 +++++++++++++ src/server/logMatcher.ts | 68 +++++++++++++++++++++++- 3 files changed, 105 insertions(+), 2 deletions(-) diff --git a/src/server/__tests__/integration.test.ts b/src/server/__tests__/integration.test.ts index 73d247e..f050450 100644 --- a/src/server/__tests__/integration.test.ts +++ b/src/server/__tests__/integration.test.ts @@ -53,7 +53,7 @@ if (!tmuxAvailable) { drainStream(serverProcess.stderr) await waitForHealth(port) - }) + }, 10000) afterAll(async () => { if (serverProcess) { diff --git a/src/server/__tests__/logMatcher.test.ts b/src/server/__tests__/logMatcher.test.ts index 4f635e1..eb917f8 100644 --- a/src/server/__tests__/logMatcher.test.ts +++ b/src/server/__tests__/logMatcher.test.ts @@ -11,6 +11,7 @@ import { verifyWindowLogAssociation, extractRecentTraceLinesFromTmux, extractRecentUserMessagesFromTmux, + extractPiUserMessagesFromAnsi, extractActionFromUserAction, hasMessageInValidUserContext, isToolNotificationText, @@ -455,6 +456,42 @@ describe('message extraction regression tests', () => { expect(userMessages).toEqual([]) }) + test('Pi TUI: extracts user messages from background color', () => { + // Pi uses RGB(52,53,65) background for user messages + // Using \x1b for ESC character + const ESC = '\x1b' + const piScrollback = ` +${ESC}[38;2;129;162;190m───────────────────────────────────────────────────────────────────────────────── +${ESC}[48;2;52;53;65m + hello world + + +${ESC}[49m Hello! How can I help? +${ESC}[38;2;129;162;190m───────────────────────────────────────────────────────────────────────────────── +` + const messages = extractPiUserMessagesFromAnsi(piScrollback) + expect(messages).toContain('hello world') + }) + + test('Pi TUI: extracts multiple user messages', () => { + const ESC = '\x1b' + const piScrollback = ` +${ESC}[48;2;52;53;65m first message ${ESC}[49m Response 1 +${ESC}[48;2;52;53;65m second message ${ESC}[49m Response 2 +` + const messages = extractPiUserMessagesFromAnsi(piScrollback) + expect(messages).toHaveLength(2) + // Most recent first + expect(messages[0]).toBe('second message') + expect(messages[1]).toBe('first message') + }) + + test('Pi TUI: returns empty array for non-pi content', () => { + const claudeScrollback = '❯ hello world' + const messages = extractPiUserMessagesFromAnsi(claudeScrollback) + expect(messages).toEqual([]) + }) + test('Codex /review: extracts trace lines for fallback', () => { const traces = extractRecentTraceLinesFromTmux(CODEX_REVIEW_SCROLLBACK) expect(traces).toContain( diff --git a/src/server/logMatcher.ts b/src/server/logMatcher.ts index 8340321..29173da 100644 --- a/src/server/logMatcher.ts +++ b/src/server/logMatcher.ts @@ -701,6 +701,46 @@ function isPromptLine(line: string): boolean { return isClaudePromptLine(line) || isCodexPromptLine(line) } +// Pi TUI uses a specific background color (RGB 52,53,65) for user messages +// Pattern: \x1b[48;2;52;53;65m...message...\x1b[49m (or end of content) +// eslint-disable-next-line no-control-regex +const PI_USER_MESSAGE_BG_START = /\x1b\[48;2;52;53;65m/g +// eslint-disable-next-line no-control-regex +const PI_USER_MESSAGE_BG_END = /\x1b\[49m/ + +/** + * Extract user messages from pi's TUI by detecting the background color pattern. + * Pi uses RGB(52,53,65) background for user messages. + */ +export function extractPiUserMessagesFromAnsi( + ansiContent: string, + maxMessages = MAX_RECENT_USER_MESSAGES +): string[] { + const messages: string[] = [] + const matches = [...ansiContent.matchAll(PI_USER_MESSAGE_BG_START)] + + // Process from end (most recent) to beginning + for (let i = matches.length - 1; i >= 0 && messages.length < maxMessages; i--) { + const match = matches[i] + if (!match || match.index === undefined) continue + + const startIdx = match.index + match[0].length + const rest = ansiContent.slice(startIdx) + const endMatch = rest.match(PI_USER_MESSAGE_BG_END) + const endIdx = endMatch ? endMatch.index! : rest.length + + const rawMessage = rest.slice(0, endIdx) + // Strip any remaining ANSI codes and clean up + const cleaned = stripAnsi(rawMessage).trim() + + if (cleaned && cleaned.length > 0 && !messages.includes(cleaned)) { + messages.push(cleaned) + } + } + + return messages +} + function extractUserFromPrompt(line: string): string { let cleaned = stripAnsi(line).trim() cleaned = cleaned.replace(TMUX_PROMPT_PREFIX, '').trim() @@ -809,6 +849,25 @@ export function getTerminalScrollback( return result.stdout.toString() } +/** + * Get terminal scrollback with ANSI escape codes preserved. + * Used for detecting pi's TUI which uses background colors for user messages. + */ +export function getTerminalScrollbackWithAnsi( + tmuxWindow: string, + lines = DEFAULT_SCROLLBACK_LINES +): string { + const safeLines = Math.max(1, lines) + const result = Bun.spawnSync( + ['tmux', 'capture-pane', '-t', tmuxWindow, '-p', '-J', '-e', '-S', `-${safeLines}`], + { stdout: 'pipe', stderr: 'pipe' } + ) + if (result.exitCode !== 0) { + return '' + } + return result.stdout.toString() +} + export function readLogContent( logPath: string, { lineLimit, byteLimit }: LogReadOptions = DEFAULT_LOG_READ_OPTIONS @@ -1163,7 +1222,14 @@ export function tryExactMatchWindowToLog( profile.tmuxCaptureMs += performance.now() - tmuxStart } const extractStart = performance.now() - const userMessages = extractRecentUserMessagesFromTmux(scrollback) + let userMessages = extractRecentUserMessagesFromTmux(scrollback) + + // If no Claude/Codex prompts found, try pi TUI detection (uses ANSI background colors) + if (userMessages.length === 0) { + const ansiScrollback = getTerminalScrollbackWithAnsi(tmuxWindow, scrollbackLines) + userMessages = extractPiUserMessagesFromAnsi(ansiScrollback) + } + if (profile) { profile.messageExtractRuns += 1 profile.messageExtractMs += performance.now() - extractStart From d6e0c79322c7f9c2efdd76600a9fe68c7fb663ef Mon Sep 17 00:00:00 2001 From: Gary Basin Date: Tue, 3 Feb 2026 12:31:25 -0500 Subject: [PATCH 3/4] docs: document Pi theme color dependency for user message detection --- src/server/logMatcher.ts | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/src/server/logMatcher.ts b/src/server/logMatcher.ts index 29173da..950b798 100644 --- a/src/server/logMatcher.ts +++ b/src/server/logMatcher.ts @@ -701,7 +701,10 @@ function isPromptLine(line: string): boolean { return isClaudePromptLine(line) || isCodexPromptLine(line) } -// Pi TUI uses a specific background color (RGB 52,53,65) for user messages +// Pi TUI uses a specific background color (RGB 52,53,65) for user messages. +// This color is defined in Pi's built-in "tokyo-night" theme (userMessageBg). +// See: https://github.com/anthropics/pi/blob/main/src/themes/tokyo-night.ts +// NOTE: If Pi changes this color or the user selects a different theme, detection will fail. // Pattern: \x1b[48;2;52;53;65m...message...\x1b[49m (or end of content) // eslint-disable-next-line no-control-regex const PI_USER_MESSAGE_BG_START = /\x1b\[48;2;52;53;65m/g @@ -709,8 +712,9 @@ const PI_USER_MESSAGE_BG_START = /\x1b\[48;2;52;53;65m/g const PI_USER_MESSAGE_BG_END = /\x1b\[49m/ /** - * Extract user messages from pi's TUI by detecting the background color pattern. - * Pi uses RGB(52,53,65) background for user messages. + * Extract user messages from Pi's TUI by detecting the background color pattern. + * Pi uses RGB(52,53,65) background for user messages in the default tokyo-night theme. + * Returns empty array if no Pi-style messages are detected (e.g., different theme). */ export function extractPiUserMessagesFromAnsi( ansiContent: string, From a1ed219be666809c1924dea8b4ad1a6d8de8deb6 Mon Sep 17 00:00:00 2001 From: Gary Basin Date: Tue, 3 Feb 2026 12:32:32 -0500 Subject: [PATCH 4/4] docs: update README to list Pi agent support --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 582b309..69a2e18 100644 --- a/README.md +++ b/README.md @@ -11,7 +11,7 @@ Run your desktop/server, then connect from your phone or laptop over Tailscale/L - Touch scrolling - Virtual arrow keys / d-pad - Quick keys toolbar (ctrl, esc, etc.) -- Tracks Claude and Codex sessions by parsing their logs, auto-matching to active tmux windows. Inactive sessions can be restored with one click. +- Out-of-the-box log tracking and matching for Claude, Codex, and Pi — auto-matches sessions to active tmux windows, with one-click restore for inactive sessions. - Shows the last user prompt for each session, so you can remember what each agent is working on - Pin agent TUI sessions to auto-resume them when the server restarts (useful if your machine reboots or tmux dies)