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) 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__/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__/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__/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/__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/server/logMatcher.ts b/src/server/logMatcher.ts index 8340321..950b798 100644 --- a/src/server/logMatcher.ts +++ b/src/server/logMatcher.ts @@ -701,6 +701,50 @@ function isPromptLine(line: string): boolean { return isClaudePromptLine(line) || isCodexPromptLine(line) } +// 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 +// 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 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, + 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 +853,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 +1226,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 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'