diff --git a/.github/workflows/warden.yml b/.github/workflows/warden.yml index b5c7579d..4fc99042 100644 --- a/.github/workflows/warden.yml +++ b/.github/workflows/warden.yml @@ -16,4 +16,4 @@ jobs: - uses: actions/checkout@v4 - uses: getsentry/warden@v0 with: - anthropic-api-key: ${{ secrets.WARDEN_ANTHROPIC_API_KEY }} + anthropic-api-key: ${{ secrets.ANTHROPIC_API_KEY }} diff --git a/docs/docs/agents.md b/docs/docs/agents.md index 415adf6e..ffd70462 100644 --- a/docs/docs/agents.md +++ b/docs/docs/agents.md @@ -13,6 +13,7 @@ perry shell myproject claude opencode codex +pi ``` ## What gets synced @@ -20,6 +21,7 @@ codex - Agent credentials and configs from the host - `~/.claude/` and `~/.codex/` if present - OpenCode config plus `auth.json` and any MCP server settings +- Pi credentials and settings from `~/.pi/agent/` Sync happens on workspace start and when you run `perry sync`. @@ -31,6 +33,7 @@ The Sessions tab is a history and shortcut list. Opening a session drops you int - [OpenCode Workflow](./workflows/opencode.md) - [Claude Code and Codex Workflow](./workflows/claude-code.md) +- [Pi Workflow](./workflows/pi.md) ## Perry skill diff --git a/docs/docs/configuration/agents.md b/docs/docs/configuration/agents.md index dc341ea3..298773dd 100644 --- a/docs/docs/configuration/agents.md +++ b/docs/docs/configuration/agents.md @@ -13,6 +13,7 @@ Perry copies host credentials into each workspace when they exist: - Claude Code: `~/.claude/.credentials.json` - OpenCode: `~/.config/opencode/opencode.json`, `~/.local/share/opencode/auth.json` - Codex CLI: `~/.codex/` +- Pi: `~/.pi/agent/auth.json`, `~/.pi/agent/settings.json`, `~/.pi/agent/models.json` ## OpenCode diff --git a/docs/docs/workflows/claude-code.md b/docs/docs/workflows/claude-code.md index 96935b5c..34bbb3fa 100644 --- a/docs/docs/workflows/claude-code.md +++ b/docs/docs/workflows/claude-code.md @@ -2,9 +2,9 @@ sidebar_position: 2 --- -# Claude Code Workflow +# Claude Code and Codex Workflow -Claude Code runs inside workspaces. There is no external server to attach to, so you connect via a terminal and run the client in the workspace. +Claude Code and Codex run inside workspaces. There is no external server to attach to, so you connect via a terminal and run the client in the workspace. ## Overview diff --git a/docs/docs/workflows/pi.md b/docs/docs/workflows/pi.md new file mode 100644 index 00000000..bdaabd24 --- /dev/null +++ b/docs/docs/workflows/pi.md @@ -0,0 +1,58 @@ +--- +sidebar_position: 3 +--- + +# Pi Workflow + +Pi runs inside workspaces as a terminal-based coding agent. Connect via a terminal and run the client in the workspace. + +## 1) Configure credentials + +Sign in to Pi on the host via `pi` then `/login`, or set the `ANTHROPIC_API_KEY` environment variable. Perry syncs the following from the host if they exist: + +- `~/.pi/agent/auth.json` +- `~/.pi/agent/settings.json` +- `~/.pi/agent/models.json` + +You can also set `ANTHROPIC_API_KEY` in Perry's environment config: + +```json +{ + "credentials": { + "env": { + "ANTHROPIC_API_KEY": "sk-ant-..." + } + } +} +``` + +## 2) Start a workspace + +```bash +perry start myproject +``` + +## 3) Run inside the workspace + +```bash +perry shell myproject +pi +``` + +## Resume a session + +Pi sessions are tracked in the Web UI. Clicking a session runs `pi --session ` in a terminal. + +You can also resume manually: + +```bash +pi -c # continue most recent +pi --session # resume specific session +pi -r # browse past sessions +``` + +## Ways to connect + +- `perry shell` from any machine pointed at the agent +- Web UI terminal from the workspace page +- SSH directly (Tailscale) or with a client like Termius diff --git a/mobile/src/components/AgentIcon.tsx b/mobile/src/components/AgentIcon.tsx index 09c52aa2..535115dd 100644 --- a/mobile/src/components/AgentIcon.tsx +++ b/mobile/src/components/AgentIcon.tsx @@ -11,6 +11,7 @@ const ICON_COLORS: Record = { 'claude-code': { bg: 'rgba(249, 115, 22, 0.1)', border: 'rgba(249, 115, 22, 0.2)' }, opencode: { bg: 'rgba(34, 197, 94, 0.1)', border: 'rgba(34, 197, 94, 0.2)' }, codex: { bg: 'rgba(59, 130, 246, 0.1)', border: 'rgba(59, 130, 246, 0.2)' }, + pi: { bg: 'rgba(139, 92, 246, 0.1)', border: 'rgba(139, 92, 246, 0.2)' }, } function ClaudeIcon({ size }: { size: number }) { @@ -52,6 +53,20 @@ function CodexIcon({ size }: { size: number }) { ) } +function PiIcon({ size }: { size: number }) { + return ( + + + + ) +} + export function AgentIcon({ agentType, size = 'sm' }: AgentIconProps) { const containerSize = size === 'sm' ? styles.containerSm : styles.containerMd const iconSize = size === 'sm' ? 14 : 18 @@ -66,6 +81,7 @@ export function AgentIcon({ agentType, size = 'sm' }: AgentIconProps) { {agentType === 'claude-code' && } {agentType === 'opencode' && } {agentType === 'codex' && } + {agentType === 'pi' && } ) } diff --git a/mobile/src/lib/api.ts b/mobile/src/lib/api.ts index fe09799d..413b7651 100644 --- a/mobile/src/lib/api.ts +++ b/mobile/src/lib/api.ts @@ -72,7 +72,7 @@ export interface CodingAgents { }; } -export type AgentType = 'claude-code' | 'opencode' | 'codex'; +export type AgentType = 'claude-code' | 'opencode' | 'codex' | 'pi'; export interface SessionInfo { id: string; diff --git a/mobile/src/screens/WorkspaceDetailScreen.tsx b/mobile/src/screens/WorkspaceDetailScreen.tsx index dfd818b3..7ecd7a81 100644 --- a/mobile/src/screens/WorkspaceDetailScreen.tsx +++ b/mobile/src/screens/WorkspaceDetailScreen.tsx @@ -30,6 +30,8 @@ function getAgentResumeCommand(agentType: AgentType, sessionId: string): string return `opencode --session ${sessionId}` case 'codex': return `codex resume ${sessionId}` + case 'pi': + return `pi --session ${sessionId}` } } @@ -41,6 +43,8 @@ function getAgentStartCommand(agentType: AgentType): string { return 'opencode' case 'codex': return 'codex' + case 'pi': + return 'pi' } } diff --git a/perry/Dockerfile.base b/perry/Dockerfile.base index 42f83120..18700820 100644 --- a/perry/Dockerfile.base +++ b/perry/Dockerfile.base @@ -149,6 +149,8 @@ RUN curl -fsSL https://claude.ai/install.sh | bash RUN curl -fsSL https://opencode.ai/install | bash || echo "OpenCode install failed; will retry on workspace start" +RUN npm install -g @mariozechner/pi-coding-agent || echo "Pi install failed; will retry on workspace start" + USER root # Install Tailscale diff --git a/src/agent/router.ts b/src/agent/router.ts index 1e911782..3123cd05 100644 --- a/src/agent/router.ts +++ b/src/agent/router.ts @@ -105,7 +105,7 @@ const CodingAgentsSchema = z.object({ .optional(), }); -const AgentTypeSchema = z.enum(['claude-code', 'opencode', 'codex']); +const AgentTypeSchema = z.enum(['claude-code', 'opencode', 'codex', 'pi']); const SkillAppliesToSchema = z.union([z.literal('all'), z.array(AgentTypeSchema)]); @@ -785,7 +785,7 @@ export function createRouter(ctx: RouterContext) { type ListSessionsInput = { workspaceName: string; - agentType?: 'claude-code' | 'opencode' | 'codex'; + agentType?: 'claude-code' | 'opencode' | 'codex' | 'pi'; limit?: number; offset?: number; }; @@ -793,17 +793,17 @@ export function createRouter(ctx: RouterContext) { const hostSessionIndex = new SessionIndex(); let hostSessionIndexInitialized = false; - function toRegistryAgentType(agentType: 'claude-code' | 'opencode' | 'codex' | 'claude') { + function toRegistryAgentType(agentType: 'claude-code' | 'opencode' | 'codex' | 'pi' | 'claude') { return agentType === 'claude-code' ? 'claude' : agentType; } - function toClientAgentType(agentType: 'claude' | 'opencode' | 'codex') { + function toClientAgentType(agentType: 'claude' | 'opencode' | 'codex' | 'pi') { return agentType === 'claude' ? 'claude-code' : agentType; } async function ensureRegistrySession( workspaceName: string, - agentType: 'claude-code' | 'opencode' | 'codex' | 'claude', + agentType: 'claude-code' | 'opencode' | 'codex' | 'pi' | 'claude', agentSessionId: string, options?: { projectPath?: string | null; createdAt?: string; lastActivity?: string } ) { @@ -891,7 +891,7 @@ export function createRouter(ctx: RouterContext) { async function getHostSession( sessionId: string, - _agentType?: 'claude-code' | 'opencode' | 'codex' + _agentType?: 'claude-code' | 'opencode' | 'codex' | 'pi' ) { if (!hostSessionIndexInitialized) { await hostSessionIndex.initialize(); @@ -1007,7 +1007,7 @@ export function createRouter(ctx: RouterContext) { .input( z.object({ workspaceName: AnyWorkspaceNameSchema, - agentType: z.enum(['claude-code', 'opencode', 'codex']).optional(), + agentType: z.enum(['claude-code', 'opencode', 'codex', 'pi']).optional(), limit: z.number().optional().default(50), offset: z.number().optional().default(0), }) @@ -1021,7 +1021,7 @@ export function createRouter(ctx: RouterContext) { z.object({ workspaceName: AnyWorkspaceNameSchema, sessionId: z.string(), - agentType: z.enum(['claude-code', 'opencode', 'codex']).optional(), + agentType: z.enum(['claude-code', 'opencode', 'codex', 'pi']).optional(), projectPath: z.string().optional(), limit: z.number().optional(), offset: z.number().optional(), @@ -1150,7 +1150,7 @@ export function createRouter(ctx: RouterContext) { z.object({ workspaceName: AnyWorkspaceNameSchema, sessionId: z.string(), - agentType: z.enum(['claude-code', 'opencode', 'codex']), + agentType: z.enum(['claude-code', 'opencode', 'codex', 'pi']), }) ) .handler(async ({ input }) => { @@ -1163,7 +1163,7 @@ export function createRouter(ctx: RouterContext) { z.object({ workspaceName: AnyWorkspaceNameSchema, sessionId: z.string(), - agentType: z.enum(['claude-code', 'opencode', 'codex']), + agentType: z.enum(['claude-code', 'opencode', 'codex', 'pi']), }) ) .handler(async ({ input }) => { @@ -1280,7 +1280,7 @@ export function createRouter(ctx: RouterContext) { async function searchHostSessions(query: string): Promise< Array<{ sessionId: string; - agentType: 'claude-code' | 'opencode' | 'codex'; + agentType: 'claude-code' | 'opencode' | 'codex' | 'pi'; matchCount: number; agentSessionId?: string; }> @@ -1291,6 +1291,7 @@ export function createRouter(ctx: RouterContext) { path.join(homeDir, '.claude', 'projects'), path.join(homeDir, '.local', 'share', 'opencode', 'storage'), path.join(homeDir, '.codex', 'sessions'), + path.join(homeDir, '.pi', 'agent', 'sessions'), ].filter((p) => { try { require('fs').accessSync(p); @@ -1317,14 +1318,14 @@ export function createRouter(ctx: RouterContext) { const files = output.trim().split('\n').filter(Boolean); const results: Array<{ sessionId: string; - agentType: 'claude-code' | 'opencode' | 'codex'; + agentType: 'claude-code' | 'opencode' | 'codex' | 'pi'; matchCount: number; agentSessionId?: string; }> = []; for (const file of files) { let sessionId: string | null = null; - let agentType: 'claude-code' | 'opencode' | 'codex' | null = null; + let agentType: 'claude-code' | 'opencode' | 'codex' | 'pi' | null = null; if (file.includes('/.claude/projects/')) { const match = file.match(/\/([^/]+)\.jsonl$/); @@ -1346,6 +1347,12 @@ export function createRouter(ctx: RouterContext) { sessionId = match[1]; agentType = 'codex'; } + } else if (file.includes('/.pi/agent/sessions/')) { + const match = file.match(/\/([^/]+)\.jsonl$/); + if (match) { + sessionId = match[1]; + agentType = 'pi'; + } } if (sessionId && agentType) { @@ -1365,9 +1372,43 @@ export function createRouter(ctx: RouterContext) { } } + async function findPiSessionFileOnHost( + baseDir: string, + sessionId: string + ): Promise { + async function scan(dir: string): Promise { + try { + const entries = await fs.readdir(dir); + for (const entry of entries) { + const entryPath = path.join(dir, entry); + const entryStat = await fs.stat(entryPath); + if (entryStat.isDirectory()) { + const found = await scan(entryPath); + if (found) return found; + } else if (entry.endsWith('.jsonl') && entry.includes(sessionId)) { + return entryPath; + } else if (entry.endsWith('.jsonl')) { + try { + const content = await fs.readFile(entryPath, 'utf-8'); + const firstLine = content.split('\n')[0]; + const header = JSON.parse(firstLine) as { id?: string }; + if (header.id === sessionId) return entryPath; + } catch { + continue; + } + } + } + } catch { + // directory doesn't exist + } + return null; + } + return scan(baseDir); + } + async function deleteHostSession( sessionId: string, - agentType: 'claude-code' | 'opencode' | 'codex' + agentType: 'claude-code' | 'opencode' | 'codex' | 'pi' ): Promise<{ success: boolean; error?: string }> { const homeDir = os_module.homedir(); @@ -1428,6 +1469,20 @@ export function createRouter(ctx: RouterContext) { return { success: false, error: 'Session not found' }; } + if (agentType === 'pi') { + const piSessionsDir = path.join(homeDir, '.pi', 'agent', 'sessions'); + try { + const found = await findPiSessionFileOnHost(piSessionsDir, sessionId); + if (found) { + await fs.unlink(found); + return { success: true }; + } + } catch { + return { success: false, error: 'Session not found' }; + } + return { success: false, error: 'Session not found' }; + } + return { success: false, error: 'Unsupported agent type' }; } diff --git a/src/agents/__tests__/sync.test.ts b/src/agents/__tests__/sync.test.ts index 2f7b6283..a044b662 100644 --- a/src/agents/__tests__/sync.test.ts +++ b/src/agents/__tests__/sync.test.ts @@ -1,6 +1,7 @@ import { describe, it, expect } from 'vitest'; import { syncAgent, syncAllAgents, getCredentialFilePaths, createSyncContext } from '../index'; import { createMockFileCopier } from '../sync/copier'; +import { piSync } from '../sync/pi'; import type { AgentSyncProvider, SyncContext } from '../types'; function createMockContext(overrides: Partial = {}): SyncContext { @@ -242,7 +243,7 @@ describe('syncAgent', () => { }); describe('syncAllAgents', () => { - it('syncs all three agents', async () => { + it('syncs all agents', async () => { const copier = createMockFileCopier(); const results = await syncAllAgents( @@ -258,6 +259,7 @@ describe('syncAllAgents', () => { expect(results['claude-code']).toBeDefined(); expect(results['opencode']).toBeDefined(); expect(results['codex']).toBeDefined(); + expect(results['pi']).toBeDefined(); }); it('returns results per agent', async () => { @@ -273,7 +275,7 @@ describe('syncAllAgents', () => { copier ); - for (const agentType of ['claude-code', 'opencode', 'codex'] as const) { + for (const agentType of ['claude-code', 'opencode', 'codex', 'pi'] as const) { const result = results[agentType]; expect(result).toHaveProperty('copied'); expect(result).toHaveProperty('generated'); @@ -291,6 +293,7 @@ describe('getCredentialFilePaths', () => { expect(paths).toContain('~/.codex/auth.json'); expect(paths).toContain('~/.local/share/opencode/auth.json'); expect(paths).toContain('~/.local/share/opencode/mcp-auth.json'); + expect(paths).toContain('~/.pi/agent/auth.json'); }); it('does not include preference-only files', () => { @@ -298,6 +301,8 @@ describe('getCredentialFilePaths', () => { expect(paths).not.toContain('~/.claude/settings.json'); expect(paths).not.toContain('~/.codex/config.toml'); + expect(paths).not.toContain('~/.pi/agent/settings.json'); + expect(paths).not.toContain('~/.pi/agent/models.json'); }); }); @@ -325,3 +330,67 @@ describe('createSyncContext', () => { expect(context.agentConfig).toBe(config); }); }); + +describe('piSync', () => { + it('requires .pi/agent directory', () => { + const dirs = piSync.getRequiredDirs(); + expect(dirs).toContain('/home/workspace/.pi/agent'); + }); + + it('syncs auth.json as credential', async () => { + const context = createMockContext(); + const files = await piSync.getFilesToSync(context); + + const authFile = files.find((f) => f.source === '~/.pi/agent/auth.json'); + expect(authFile).toBeDefined(); + expect(authFile!.category).toBe('credential'); + expect(authFile!.permissions).toBe('600'); + expect(authFile!.optional).toBe(true); + }); + + it('syncs settings.json as preference', async () => { + const context = createMockContext(); + const files = await piSync.getFilesToSync(context); + + const settingsFile = files.find((f) => f.source === '~/.pi/agent/settings.json'); + expect(settingsFile).toBeDefined(); + expect(settingsFile!.category).toBe('preference'); + expect(settingsFile!.permissions).toBe('644'); + }); + + it('syncs models.json as preference', async () => { + const context = createMockContext(); + const files = await piSync.getFilesToSync(context); + + const modelsFile = files.find((f) => f.source === '~/.pi/agent/models.json'); + expect(modelsFile).toBeDefined(); + expect(modelsFile!.category).toBe('preference'); + expect(modelsFile!.permissions).toBe('644'); + }); + + it('has no directories to sync', async () => { + const context = createMockContext(); + const dirs = await piSync.getDirectoriesToSync(context); + expect(dirs).toHaveLength(0); + }); + + it('has no generated configs', async () => { + const context = createMockContext(); + const configs = await piSync.getGeneratedConfigs(context); + expect(configs).toHaveLength(0); + }); + + it('copies auth when file exists on host', async () => { + const context = createMockContext({ + hostFileExists: async (path) => path === '~/.pi/agent/auth.json', + }); + const copier = createMockFileCopier(); + + const result = await syncAgent(piSync, context, copier); + + expect(result.copied).toContain('~/.pi/agent/auth.json'); + expect(result.skipped).toContain('~/.pi/agent/settings.json'); + expect(result.skipped).toContain('~/.pi/agent/models.json'); + expect(result.errors).toHaveLength(0); + }); +}); diff --git a/src/agents/index.ts b/src/agents/index.ts index 5797d769..9226224f 100644 --- a/src/agents/index.ts +++ b/src/agents/index.ts @@ -3,10 +3,11 @@ import type { AgentType } from '../sessions/types'; import type { AgentConfig } from '../shared/types'; import type { Agent, AgentSyncProvider, SyncContext, SyncResult } from './types'; import type { FileCopier } from './sync/types'; -import { claudeProvider, opencodeProvider, codexProvider } from '../sessions/agents'; +import { claudeProvider, opencodeProvider, codexProvider, piProvider } from '../sessions/agents'; import { claudeCodeSync } from './sync/claude-code'; import { opencodeSync } from './sync/opencode'; import { codexSync } from './sync/codex'; +import { piSync } from './sync/pi'; import { createDockerFileCopier } from './sync/copier'; import { expandPath } from '../config/loader'; import * as docker from '../docker'; @@ -27,6 +28,11 @@ export const agents: Record = { sync: codexSync, sessions: codexProvider, }, + pi: { + agentType: 'pi', + sync: piSync, + sessions: piProvider, + }, }; export function createSyncContext(containerName: string, agentConfig: AgentConfig): SyncContext { @@ -164,6 +170,7 @@ export async function syncAllAgents( 'claude-code': { copied: [], generated: [], skipped: [], errors: [] }, opencode: { copied: [], generated: [], skipped: [], errors: [] }, codex: { copied: [], generated: [], skipped: [], errors: [] }, + pi: { copied: [], generated: [], skipped: [], errors: [] }, }; for (const [agentType, agent] of Object.entries(agents)) { @@ -179,6 +186,7 @@ export function getCredentialFilePaths(): string[] { '~/.codex/auth.json', '~/.local/share/opencode/auth.json', '~/.local/share/opencode/mcp-auth.json', + '~/.pi/agent/auth.json', ]; } diff --git a/src/agents/sync/pi.ts b/src/agents/sync/pi.ts new file mode 100644 index 00000000..83d594d8 --- /dev/null +++ b/src/agents/sync/pi.ts @@ -0,0 +1,47 @@ +import type { + AgentSyncProvider, + SyncContext, + SyncFile, + SyncDirectory, + GeneratedConfig, +} from '../types'; + +export const piSync: AgentSyncProvider = { + getRequiredDirs(): string[] { + return ['/home/workspace/.pi/agent']; + }, + + async getFilesToSync(_context: SyncContext): Promise { + return [ + { + source: '~/.pi/agent/auth.json', + dest: '/home/workspace/.pi/agent/auth.json', + category: 'credential', + permissions: '600', + optional: true, + }, + { + source: '~/.pi/agent/settings.json', + dest: '/home/workspace/.pi/agent/settings.json', + category: 'preference', + permissions: '644', + optional: true, + }, + { + source: '~/.pi/agent/models.json', + dest: '/home/workspace/.pi/agent/models.json', + category: 'preference', + permissions: '644', + optional: true, + }, + ]; + }, + + async getDirectoriesToSync(_context: SyncContext): Promise { + return []; + }, + + async getGeneratedConfigs(_context: SyncContext): Promise { + return []; + }, +}; diff --git a/src/session-manager/manager.ts b/src/session-manager/manager.ts index b17e65a4..be8b2faa 100644 --- a/src/session-manager/manager.ts +++ b/src/session-manager/manager.ts @@ -49,6 +49,8 @@ export class SessionManager { return new OpenCodeAdapter(); case 'codex': throw new Error('Codex adapter not yet implemented'); + case 'pi': + throw new Error('Pi adapter not yet implemented'); } } diff --git a/src/session-manager/types.ts b/src/session-manager/types.ts index 4037b7a5..d3370daa 100644 --- a/src/session-manager/types.ts +++ b/src/session-manager/types.ts @@ -2,7 +2,7 @@ import type { ChatMessage } from '../chat/types'; export type SessionStatus = 'idle' | 'running' | 'error' | 'interrupted'; -export type AgentType = 'claude' | 'opencode' | 'codex'; +export type AgentType = 'claude' | 'opencode' | 'codex' | 'pi'; export interface SessionInfo { id: string; diff --git a/src/sessions/agents/index.ts b/src/sessions/agents/index.ts index 101336e7..928a068e 100644 --- a/src/sessions/agents/index.ts +++ b/src/sessions/agents/index.ts @@ -3,6 +3,7 @@ import type { RawSession, SessionListItem, ExecInContainer, AgentSessionProvider import { claudeProvider } from './claude'; import { opencodeProvider } from './opencode'; import { codexProvider } from './codex'; +import { piProvider } from './pi'; import { discoverSessionsViaWorker, getSessionDetailsViaWorker, @@ -14,12 +15,14 @@ export type { RawSession, SessionListItem, ExecInContainer, AgentSessionProvider export { claudeProvider } from './claude'; export { opencodeProvider } from './opencode'; export { codexProvider } from './codex'; +export { piProvider } from './pi'; export { clearWorkerClientCache } from './worker-provider'; const _providers: Record = { 'claude-code': claudeProvider, opencode: opencodeProvider, codex: codexProvider, + pi: piProvider, }; export async function discoverAllSessions( @@ -107,6 +110,7 @@ export async function searchSessions( '/home/workspace/.claude/projects', '/home/workspace/.local/share/opencode/storage', '/home/workspace/.codex/sessions', + '/home/workspace/.pi/agent/sessions', ]; const rgCommand = `rg -l -i --no-messages "${safeQuery}" ${searchPaths.join(' ')} 2>/dev/null | head -100`; @@ -148,6 +152,12 @@ export async function searchSessions( sessionId = match[1]; agentType = 'codex'; } + } else if (file.includes('/.pi/agent/sessions/')) { + const match = file.match(/\/([^/]+)\.jsonl$/); + if (match) { + sessionId = match[1]; + agentType = 'pi'; + } } if (sessionId && agentType) { diff --git a/src/sessions/agents/pi.ts b/src/sessions/agents/pi.ts new file mode 100644 index 00000000..7644d130 --- /dev/null +++ b/src/sessions/agents/pi.ts @@ -0,0 +1,260 @@ +import type { SessionMessage } from '../types'; +import type { RawSession, SessionListItem, ExecInContainer, AgentSessionProvider } from './types'; +import { extractContent } from './utils'; + +interface PiEntry { + type?: string; + id?: string; + parentId?: string; + timestamp?: string; + role?: 'user' | 'assistant'; + content?: unknown; + sessionId?: string; +} + +function parsePiMessages(content: string): { + sessionId: string | null; + messages: SessionMessage[]; +} { + const lines = content.split('\n').filter(Boolean); + let sessionId: string | null = null; + const messages: SessionMessage[] = []; + + for (const line of lines) { + try { + const entry = JSON.parse(line) as PiEntry; + + if (!sessionId && entry.type === 'header' && entry.id) { + sessionId = entry.id; + } + if (!sessionId && entry.sessionId) { + sessionId = entry.sessionId; + } + + if (entry.type === 'message' && (entry.role === 'user' || entry.role === 'assistant')) { + const textContent = extractContent(entry.content); + messages.push({ + type: entry.role, + content: textContent || undefined, + timestamp: entry.timestamp, + }); + } + } catch { + continue; + } + } + + return { sessionId, messages }; +} + +export async function findPiSessionFile( + baseDir: string, + sessionId: string, + exec?: ExecInContainer, + containerName?: string +): Promise { + if (!exec || !containerName) return null; + + const result = await exec( + containerName, + ['bash', '-c', `find ${baseDir} -name "*.jsonl" -type f 2>/dev/null`], + { user: 'workspace' } + ); + + if (result.exitCode !== 0 || !result.stdout.trim()) { + return null; + } + + const files = result.stdout.trim().split('\n').filter(Boolean); + + for (const file of files) { + const basename = file.split('/').pop()?.replace('.jsonl', '') || ''; + if (basename.includes(sessionId)) { + return file; + } + + const headResult = await exec(containerName, ['head', '-1', file], { + user: 'workspace', + }); + if (headResult.exitCode === 0) { + try { + const header = JSON.parse(headResult.stdout) as PiEntry; + if (header.id === sessionId) { + return file; + } + } catch { + continue; + } + } + } + + return null; +} + +export const piProvider: AgentSessionProvider = { + async discoverSessions(containerName: string, exec: ExecInContainer): Promise { + const result = await exec( + containerName, + [ + 'sh', + '-c', + 'find /home/workspace/.pi/agent/sessions -name "*.jsonl" -type f -printf "%p\\t%T@\\t" -exec wc -l {} \\; 2>/dev/null || true', + ], + { user: 'workspace' } + ); + + const sessions: RawSession[] = []; + + if (result.exitCode === 0 && result.stdout.trim()) { + const lines = result.stdout.trim().split('\n').filter(Boolean); + for (const line of lines) { + const parts = line.split('\t'); + if (parts.length >= 2) { + const file = parts[0]; + const mtime = Math.floor(parseFloat(parts[1]) || 0); + + const basename = file.split('/').pop()?.replace('.jsonl', '') || ''; + const idParts = basename.split('_'); + const id = idParts.length > 1 ? idParts[idParts.length - 1] : basename; + + const projPath = file + .replace('/home/workspace/.pi/agent/sessions/', '') + .replace(/\/[^/]+$/, ''); + + sessions.push({ + id, + agentType: 'pi', + projectPath: projPath, + mtime, + filePath: file, + }); + } + } + } + + return sessions; + }, + + async getSessionDetails( + containerName: string, + rawSession: RawSession, + exec: ExecInContainer + ): Promise { + const catResult = await exec(containerName, ['cat', rawSession.filePath], { + user: 'workspace', + }); + + if (catResult.exitCode !== 0) { + return null; + } + + const { sessionId, messages } = parsePiMessages(catResult.stdout); + + const firstPrompt = messages.find( + (msg) => msg.type === 'user' && msg.content && msg.content.trim().length > 0 + )?.content; + + if (messages.length === 0) { + return null; + } + + return { + id: sessionId || rawSession.id, + name: null, + agentType: rawSession.agentType, + projectPath: rawSession.projectPath, + messageCount: messages.length, + lastActivity: new Date(rawSession.mtime * 1000).toISOString(), + firstPrompt: firstPrompt ? firstPrompt.slice(0, 200) : null, + }; + }, + + async getSessionMessages( + containerName: string, + sessionId: string, + exec: ExecInContainer, + _projectPath?: string + ): Promise<{ id: string; messages: SessionMessage[] } | null> { + const findResult = await exec( + containerName, + ['bash', '-c', 'find /home/workspace/.pi/agent/sessions -name "*.jsonl" -type f 2>/dev/null'], + { user: 'workspace' } + ); + + if (findResult.exitCode !== 0 || !findResult.stdout.trim()) { + return null; + } + + const files = findResult.stdout.trim().split('\n').filter(Boolean); + + for (const file of files) { + const catResult = await exec(containerName, ['cat', file], { + user: 'workspace', + }); + if (catResult.exitCode !== 0) continue; + + const { sessionId: parsedId, messages } = parsePiMessages(catResult.stdout); + const basename = file.split('/').pop()?.replace('.jsonl', '') || ''; + + if (parsedId === sessionId || basename.includes(sessionId)) { + return { id: parsedId || sessionId, messages }; + } + } + + return null; + }, + + async deleteSession( + containerName: string, + sessionId: string, + exec: ExecInContainer + ): Promise<{ success: boolean; error?: string }> { + const findResult = await exec( + containerName, + ['bash', '-c', 'find /home/workspace/.pi/agent/sessions -name "*.jsonl" -type f 2>/dev/null'], + { user: 'workspace' } + ); + + if (findResult.exitCode !== 0 || !findResult.stdout.trim()) { + return { success: false, error: 'No session files found' }; + } + + const files = findResult.stdout.trim().split('\n').filter(Boolean); + + for (const file of files) { + const basename = file.split('/').pop()?.replace('.jsonl', '') || ''; + + if (basename.includes(sessionId)) { + const rmResult = await exec(containerName, ['rm', '-f', file], { + user: 'workspace', + }); + if (rmResult.exitCode !== 0) { + return { success: false, error: rmResult.stderr || 'Failed to delete session file' }; + } + return { success: true }; + } + + const headResult = await exec(containerName, ['head', '-1', file], { + user: 'workspace', + }); + if (headResult.exitCode === 0) { + try { + const header = JSON.parse(headResult.stdout) as PiEntry; + if (header.id === sessionId) { + const rmResult = await exec(containerName, ['rm', '-f', file], { + user: 'workspace', + }); + if (rmResult.exitCode !== 0) { + return { success: false, error: rmResult.stderr || 'Failed to delete session file' }; + } + return { success: true }; + } + } catch { + continue; + } + } + } + + return { success: false, error: 'Session not found' }; + }, +}; diff --git a/src/sessions/cache.ts b/src/sessions/cache.ts index 43d1cb99..60449959 100644 --- a/src/sessions/cache.ts +++ b/src/sessions/cache.ts @@ -4,7 +4,7 @@ import path from 'path'; export interface RecentSession { workspaceName: string; sessionId: string; - agentType: 'claude-code' | 'opencode' | 'codex'; + agentType: 'claude-code' | 'opencode' | 'codex' | 'pi'; lastAccessed: string; } @@ -52,7 +52,7 @@ export class SessionsCacheManager { async recordAccess( workspaceName: string, sessionId: string, - agentType: 'claude-code' | 'opencode' | 'codex' + agentType: 'claude-code' | 'opencode' | 'codex' | 'pi' ): Promise { const cache = await this.load(); diff --git a/src/sessions/parser.ts b/src/sessions/parser.ts index 0e5310ac..64ff2721 100644 --- a/src/sessions/parser.ts +++ b/src/sessions/parser.ts @@ -283,6 +283,11 @@ export async function getSessionDetail( if (result) return result; } + if (!agentType || agentType === 'pi') { + const result = await getPiSessionDetail(sessionId, homeDir); + if (result) return result; + } + return null; } @@ -703,13 +708,14 @@ async function getCodexSessionDetail( } export async function listAllSessions(homeDir: string): Promise { - const [claudeSessions, openCodeSessions, codexSessions] = await Promise.all([ + const [claudeSessions, openCodeSessions, codexSessions, piSessions] = await Promise.all([ listClaudeCodeSessions(homeDir), listOpenCodeSessions(homeDir), listCodexSessions(homeDir), + listPiSessions(homeDir), ]); - const allSessions = [...claudeSessions, ...openCodeSessions, ...codexSessions]; + const allSessions = [...claudeSessions, ...openCodeSessions, ...codexSessions, ...piSessions]; allSessions.sort( (a, b) => new Date(b.lastActivity).getTime() - new Date(a.lastActivity).getTime() @@ -717,3 +723,133 @@ export async function listAllSessions(homeDir: string): Promise; + provider?: string; + modelId?: string; + sessionId?: string; +} + +function parsePiLine(line: string): SessionMessage | null { + try { + const obj = JSON.parse(line) as PiSessionEntry; + + if (obj.type === 'message' && (obj.role === 'user' || obj.role === 'assistant')) { + const textContent = extractContent(obj.content); + return { + type: obj.role, + content: textContent || undefined, + timestamp: obj.timestamp, + }; + } + + return null; + } catch { + return null; + } +} + +function parsePiSessionFile(content: string): { + sessionId: string | null; + messages: SessionMessage[]; +} { + const lines = content.split('\n').filter((l) => l.trim()); + let sessionId: string | null = null; + const messages: SessionMessage[] = []; + + for (const line of lines) { + try { + const obj = JSON.parse(line) as PiSessionEntry; + if (!sessionId && obj.sessionId) { + sessionId = obj.sessionId; + } + if (!sessionId && obj.type === 'header' && obj.id) { + sessionId = obj.id; + } + } catch { + continue; + } + + const msg = parsePiLine(line); + if (msg) { + messages.push(msg); + } + } + + return { sessionId, messages }; +} + +export async function listPiSessions(homeDir: string): Promise { + const piDir = join(homeDir, '.pi', 'agent', 'sessions'); + const sessions: SessionMetadata[] = []; + + async function scanDirectory(dir: string): Promise { + try { + const entries = await readdir(dir); + + for (const entry of entries) { + const entryPath = join(dir, entry); + const entryStat = await stat(entryPath); + + if (entryStat.isDirectory()) { + await scanDirectory(entryPath); + } else if (entry.endsWith('.jsonl')) { + try { + const content = await readFile(entryPath, 'utf-8'); + const { sessionId, messages } = parsePiSessionFile(content); + const userMessages = messages.filter((m) => m.type === 'user'); + const firstPrompt = userMessages.length > 0 ? userMessages[0].content || null : null; + + const id = sessionId || basename(entry, '.jsonl'); + const projectPath = dir.replace(piDir, '').replace(/^\//, '') || 'unknown'; + + sessions.push({ + id, + name: null, + agentType: 'pi', + projectPath, + messageCount: messages.length, + lastActivity: entryStat.mtime.toISOString(), + firstPrompt: firstPrompt ? firstPrompt.slice(0, 200) : null, + filePath: entryPath, + }); + } catch { + continue; + } + } + } + } catch { + return; + } + } + + await scanDirectory(piDir); + + sessions.sort((a, b) => new Date(b.lastActivity).getTime() - new Date(a.lastActivity).getTime()); + + return sessions; +} + +async function getPiSessionDetail( + sessionId: string, + homeDir: string +): Promise { + const sessions = await listPiSessions(homeDir); + const session = sessions.find((s) => s.id === sessionId); + + if (!session) return null; + + const content = await readFile(session.filePath, 'utf-8'); + const { messages } = parsePiSessionFile(content); + + return { + ...session, + messages, + }; +} diff --git a/src/sessions/registry.ts b/src/sessions/registry.ts index 87efd1bc..8d1adfd4 100644 --- a/src/sessions/registry.ts +++ b/src/sessions/registry.ts @@ -2,7 +2,7 @@ import { readFile, writeFile, mkdir } from 'fs/promises'; import { join, dirname } from 'path'; import lockfile from 'proper-lockfile'; -export type AgentType = 'claude' | 'opencode' | 'codex'; +export type AgentType = 'claude' | 'opencode' | 'codex' | 'pi'; export interface SessionRecord { perrySessionId: string; diff --git a/src/sessions/types.ts b/src/sessions/types.ts index 79600683..cd1d6837 100644 --- a/src/sessions/types.ts +++ b/src/sessions/types.ts @@ -7,7 +7,7 @@ export interface SessionMessage { toolInput?: string; } -export type AgentType = 'claude-code' | 'opencode' | 'codex'; +export type AgentType = 'claude-code' | 'opencode' | 'codex' | 'pi'; export interface SessionMetadata { id: string; diff --git a/src/shared/client-types.ts b/src/shared/client-types.ts index 8200c73a..771559d0 100644 --- a/src/shared/client-types.ts +++ b/src/shared/client-types.ts @@ -129,7 +129,7 @@ export type Skill = SkillDefinition; export type McpServer = McpServerDefinition; -export const AGENT_TYPES = ['claude-code', 'opencode', 'codex'] as const; +export const AGENT_TYPES = ['claude-code', 'opencode', 'codex', 'pi'] as const; export interface ModelInfo { id: string; @@ -138,7 +138,7 @@ export interface ModelInfo { provider?: string; } -export type AgentType = 'claude-code' | 'opencode' | 'codex'; +export type AgentType = 'claude-code' | 'opencode' | 'codex' | 'pi'; export interface SessionInfo { id: string; diff --git a/src/shared/constants.ts b/src/shared/constants.ts index cd83bc15..d1eb720f 100644 --- a/src/shared/constants.ts +++ b/src/shared/constants.ts @@ -16,4 +16,5 @@ export const AGENT_SESSION_PATHS = { claudeCode: '.claude/projects', opencode: '.local/share/opencode/storage', codex: '.codex/sessions', + pi: '.pi/agent/sessions', } as const; diff --git a/src/shared/types.ts b/src/shared/types.ts index ac4f4b06..36ff5aad 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -65,7 +65,7 @@ export interface WorkspaceTailscale { error?: string; } -export type SkillAppliesTo = 'all' | Array<'claude-code' | 'opencode' | 'codex'>; +export type SkillAppliesTo = 'all' | Array<'claude-code' | 'opencode' | 'codex' | 'pi'>; export interface SkillDefinition { id: string; diff --git a/src/worker/session-index.ts b/src/worker/session-index.ts index c43d6f95..851276c7 100644 --- a/src/worker/session-index.ts +++ b/src/worker/session-index.ts @@ -5,7 +5,7 @@ import { watch, type FSWatcher } from 'node:fs'; export interface IndexedSession { id: string; - agentType: 'claude' | 'opencode'; + agentType: 'claude' | 'opencode' | 'pi'; title: string; directory: string; filePath: string; @@ -27,13 +27,21 @@ class SessionIndex { async initialize(): Promise { if (this.initialized) return; - await Promise.all([this.discoverClaudeSessions(), this.discoverOpencodeSessions()]); + await Promise.all([ + this.discoverClaudeSessions(), + this.discoverOpencodeSessions(), + this.discoverPiSessions(), + ]); this.initialized = true; } async refresh(): Promise { - await Promise.all([this.discoverClaudeSessions(), this.discoverOpencodeSessions()]); + await Promise.all([ + this.discoverClaudeSessions(), + this.discoverOpencodeSessions(), + this.discoverPiSessions(), + ]); } startWatchers(): void { @@ -46,9 +54,11 @@ class SessionIndex { 'storage', 'session' ); + const piDir = path.join(os.homedir(), '.pi', 'agent', 'sessions'); this.watchDirectory(claudeDir, 'claude'); this.watchDirectory(opencodeDir, 'opencode'); + this.watchDirectory(piDir, 'pi'); } stopWatchers(): void { @@ -80,6 +90,8 @@ class SessionIndex { if (session.agentType === 'claude') { return this.getClaudeMessages(session, opts); + } else if (session.agentType === 'pi') { + return this.getPiMessages(session, opts); } else { return this.getOpencodeMessages(session, opts); } @@ -94,6 +106,8 @@ class SessionIndex { try { if (session.agentType === 'claude') { await fs.unlink(session.filePath); + } else if (session.agentType === 'pi') { + await fs.unlink(session.filePath); } else { const { deleteOpencodeSession } = await import('../sessions/agents/opencode-storage'); const result = await deleteOpencodeSession(id); @@ -214,7 +228,7 @@ class SessionIndex { } } - private watchDirectory(dir: string, agentType: 'claude' | 'opencode'): void { + private watchDirectory(dir: string, agentType: 'claude' | 'opencode' | 'pi'): void { try { const watcher = watch(dir, { recursive: true }, (event, filename) => { if (!filename) return; @@ -244,7 +258,7 @@ class SessionIndex { private async handleFileChange( baseDir: string, filename: string, - agentType: 'claude' | 'opencode' + agentType: 'claude' | 'opencode' | 'pi' ): Promise { const filePath = path.join(baseDir, filename); @@ -259,6 +273,19 @@ class SessionIndex { const sessionId = path.basename(filename, '.jsonl'); this.sessions.delete(sessionId); } + } else if (agentType === 'pi') { + if (!filename.endsWith('.jsonl')) return; + + try { + await fs.access(filePath); + const dirName = path.dirname(filename); + await this.indexPiSession(filePath, dirName); + } catch { + const basename = path.basename(filename, '.jsonl'); + const idParts = basename.split('_'); + const sessionId = idParts.length > 1 ? idParts[idParts.length - 1] : basename; + this.sessions.delete(sessionId); + } } else { if (!filename.endsWith('.json') || !filename.includes('ses_')) return; @@ -394,6 +421,146 @@ class SessionIndex { return { id: session.id, messages: [], total: 0 }; } } + + private async discoverPiSessions(): Promise { + const piDir = path.join(os.homedir(), '.pi', 'agent', 'sessions'); + + try { + await this.scanPiDirectory(piDir); + } catch { + // Pi directory doesn't exist + } + } + + private async scanPiDirectory(dir: string): Promise { + try { + const entries = await fs.readdir(dir, { withFileTypes: true }); + + await Promise.all( + entries.map(async (entry) => { + const entryPath = path.join(dir, entry.name); + if (entry.isDirectory()) { + await this.scanPiDirectory(entryPath); + } else if (entry.name.endsWith('.jsonl')) { + const dirName = path.basename(dir); + await this.indexPiSession(entryPath, dirName); + } + }) + ); + } catch { + // Directory may not exist + } + } + + private async indexPiSession(filePath: string, dirName: string): Promise { + try { + const fileStat = await fs.stat(filePath); + const content = await fs.readFile(filePath, 'utf-8'); + const lines = content.trim().split('\n').filter(Boolean); + + if (lines.length === 0) return; + + const basename = path.basename(filePath, '.jsonl'); + const idParts = basename.split('_'); + let sessionId = idParts.length > 1 ? idParts[idParts.length - 1] : basename; + let firstPrompt: string | null = null; + + for (const line of lines) { + try { + const entry = JSON.parse(line) as PiLogEntry; + if (entry.type === 'header' && entry.id) { + sessionId = entry.id; + } + if (entry.type === 'message' && entry.role === 'user' && !firstPrompt) { + if (typeof entry.content === 'string' && entry.content.trim()) { + firstPrompt = entry.content.slice(0, 200); + } else if (Array.isArray(entry.content)) { + const textContent = entry.content.find((c: { type: string }) => c.type === 'text'); + if (textContent?.text) { + firstPrompt = textContent.text.slice(0, 200); + } + } + } + } catch { + continue; + } + } + + const messageCount = lines.filter((line) => { + try { + const entry = JSON.parse(line) as PiLogEntry; + return entry.type === 'message' && (entry.role === 'user' || entry.role === 'assistant'); + } catch { + return false; + } + }).length; + + this.sessions.set(sessionId, { + id: sessionId, + agentType: 'pi', + title: firstPrompt || dirName, + directory: dirName, + filePath, + messageCount, + firstPrompt, + lastActivity: fileStat.mtimeMs, + }); + } catch { + // File may have been removed or is invalid + } + } + + private async getPiMessages( + session: IndexedSession, + opts: { limit: number; offset: number } + ): Promise<{ id: string; messages: Message[]; total: number }> { + try { + const content = await fs.readFile(session.filePath, 'utf-8'); + const lines = content.trim().split('\n').filter(Boolean); + + const messages: Message[] = []; + for (const line of lines) { + try { + const entry = JSON.parse(line) as PiLogEntry; + if (entry.type === 'message' && (entry.role === 'user' || entry.role === 'assistant')) { + let textContent: string | undefined; + if (typeof entry.content === 'string') { + textContent = entry.content; + } else if (Array.isArray(entry.content)) { + const text = entry.content.find((c: { type: string }) => c.type === 'text'); + textContent = text?.text; + } + if (textContent) { + messages.push({ + type: entry.role, + content: textContent, + timestamp: entry.timestamp, + }); + } + } + } catch { + continue; + } + } + + const total = messages.length; + const startIndex = Math.max(0, total - opts.offset - opts.limit); + const endIndex = total - opts.offset; + const slice = messages.slice(startIndex, endIndex); + + return { id: session.id, messages: slice, total }; + } catch { + return { id: session.id, messages: [], total: 0 }; + } + } +} + +interface PiLogEntry { + type: string; + id?: string; + role?: 'user' | 'assistant'; + content?: string | Array<{ type: string; text?: string }>; + timestamp?: string; } export interface Message { diff --git a/src/workspace/manager.ts b/src/workspace/manager.ts index fac0e42c..00e9b623 100644 --- a/src/workspace/manager.ts +++ b/src/workspace/manager.ts @@ -383,6 +383,10 @@ export class WorkspaceManager { name: 'opencode', command: 'curl -fsSL https://opencode.ai/install | bash', }, + { + name: 'pi', + command: 'npm install -g @mariozechner/pi-coding-agent', + }, ]; for (const update of updates) { diff --git a/web/src/components/AgentIcon.tsx b/web/src/components/AgentIcon.tsx index 5f7710a4..1443805c 100644 --- a/web/src/components/AgentIcon.tsx +++ b/web/src/components/AgentIcon.tsx @@ -12,6 +12,7 @@ const AGENT_COLORS: Record = { 'claude-code': 'bg-orange-500/10 border-orange-500/20', opencode: 'bg-emerald-500/10 border-emerald-500/20', codex: 'bg-blue-500/10 text-blue-600 border-blue-500/20', + pi: 'bg-violet-500/10 border-violet-500/20', } // Claude's official brand icon (from Bootstrap Icons / Anthropic branding) @@ -71,17 +72,39 @@ function CodexIcon({ className }: { className?: string }) { ) } +// Pi icon - pi symbol +function PiIcon({ className }: { className?: string }) { + return ( + + + + ) +} + export function AgentIcon({ agentType, className, size = 'sm', 'data-testid': testId }: AgentIconProps) { const sizeClasses = size === 'sm' ? 'h-4 w-4' : 'h-5 w-5' const containerClasses = size === 'sm' ? 'px-1 py-0.5 rounded' : 'px-1.5 py-1 rounded-md' - const IconComponent = agentType === 'claude-code' - ? ClaudeIcon - : agentType === 'opencode' - ? OpenCodeIcon - : CodexIcon + const IconComponent = agentType === 'claude-code' + ? ClaudeIcon + : agentType === 'opencode' + ? OpenCodeIcon + : agentType === 'pi' + ? PiIcon + : CodexIcon return ( = { 'claude-code': 'Claude Code', opencode: 'OpenCode', codex: 'Codex', + pi: 'Pi', }; function slugify(name: string): string { diff --git a/web/src/pages/WorkspaceDetail.tsx b/web/src/pages/WorkspaceDetail.tsx index dcf99394..35fc3605 100644 --- a/web/src/pages/WorkspaceDetail.tsx +++ b/web/src/pages/WorkspaceDetail.tsx @@ -63,6 +63,7 @@ const AGENT_LABELS: Record = { 'claude-code': 'Claude Code', opencode: 'OpenCode', codex: 'Codex', + pi: 'Pi', } type DateGroup = 'Today' | 'Yesterday' | 'This Week' | 'Older' @@ -75,6 +76,8 @@ function getAgentResumeCommand(agentType: AgentType, sessionId: string): string return `opencode --session ${sessionId}` case 'codex': return `codex resume ${sessionId}` + case 'pi': + return `pi --session ${sessionId}` } } @@ -86,6 +89,8 @@ function getAgentNewCommand(agentType: AgentType): string { return 'opencode' case 'codex': return 'codex' + case 'pi': + return 'pi' } }