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'