Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down
2 changes: 1 addition & 1 deletion src/client/__tests__/settingsModal.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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(() => {
Expand Down
23 changes: 23 additions & 0 deletions src/client/components/AgentIcon.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -37,12 +37,35 @@ function OpenAIIcon({ className }: { className?: string }) {
)
}

function PiIcon({ className }: { className?: string }) {
return (
<svg
viewBox="0 0 24 24"
fill="currentColor"
className={className}
aria-label="Pi"
>
<text
x="12"
y="19"
textAnchor="middle"
fontSize="26"
fontWeight="bold"
fontFamily="system-ui, sans-serif"
>
π
</text>
</svg>
)
}

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({
Expand Down
20 changes: 13 additions & 7 deletions src/client/stores/settingsStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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')
)
}

Expand Down Expand Up @@ -255,7 +256,7 @@ export const useSettingsStore = create<SettingsState>()(
{
name: 'agentboard-settings',
storage: createJSONStorage(() => safeStorage),
version: 2,
version: 4,
partialize: (state) => {
// Exclude manualSessionOrder from persistence (session-only state)
const { manualSessionOrder: _, ...rest } = state
Expand All @@ -279,7 +280,7 @@ export const useSettingsStore = create<SettingsState>()(
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,
}
}

Expand Down Expand Up @@ -339,13 +340,18 @@ export const useSettingsStore = create<SettingsState>()(
...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
Expand Down
2 changes: 1 addition & 1 deletion src/server/__tests__/db.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
2 changes: 1 addition & 1 deletion src/server/__tests__/integration.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,7 @@ if (!tmuxAvailable) {
drainStream(serverProcess.stderr)

await waitForHealth(port)
})
}, 10000)

afterAll(async () => {
if (serverProcess) {
Expand Down
6 changes: 6 additions & 0 deletions src/server/__tests__/logDiscovery.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,22 +15,28 @@ 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 () => {
if (originalClaude) process.env.CLAUDE_CONFIG_DIR = originalClaude
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 })
})

Expand Down
9 changes: 9 additions & 0 deletions src/server/__tests__/logMatchWorker.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ const tmuxOutputs = new Map<string, string>()

const originalClaude = process.env.CLAUDE_CONFIG_DIR
const originalCodex = process.env.CODEX_HOME
const originalPi = process.env.PI_HOME

let tempRoot = ''

Expand Down Expand Up @@ -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 () => {
Expand All @@ -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') {
Expand Down Expand Up @@ -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 = {
Expand Down
37 changes: 37 additions & 0 deletions src/server/__tests__/logMatcher.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import {
verifyWindowLogAssociation,
extractRecentTraceLinesFromTmux,
extractRecentUserMessagesFromTmux,
extractPiUserMessagesFromAnsi,
extractActionFromUserAction,
hasMessageInValidUserContext,
isToolNotificationText,
Expand Down Expand Up @@ -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(
Expand Down
6 changes: 6 additions & 0 deletions src/server/__tests__/logPollData.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<void> {
await fs.mkdir(path.dirname(filePath), { recursive: true })
Expand All @@ -19,15 +21,19 @@ 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 () => {
if (originalClaude) process.env.CLAUDE_CONFIG_DIR = originalClaude
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 })
})

Expand Down
4 changes: 4 additions & 0 deletions src/server/__tests__/logPoller.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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') {
Expand Down Expand Up @@ -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 })
})

Expand Down
3 changes: 3 additions & 0 deletions src/server/agentDetection.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading
Loading