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 .github/workflows/warden.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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 }}
3 changes: 3 additions & 0 deletions docs/docs/agents.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,13 +13,15 @@ perry shell myproject
claude
opencode
codex
pi
```

## What gets synced

- 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`.

Expand All @@ -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

Expand Down
1 change: 1 addition & 0 deletions docs/docs/configuration/agents.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
4 changes: 2 additions & 2 deletions docs/docs/workflows/claude-code.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
58 changes: 58 additions & 0 deletions docs/docs/workflows/pi.md
Original file line number Diff line number Diff line change
@@ -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 <id>` in a terminal.

You can also resume manually:

```bash
pi -c # continue most recent
pi --session <id> # 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
16 changes: 16 additions & 0 deletions mobile/src/components/AgentIcon.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ const ICON_COLORS: Record<AgentType, { bg: string; border: string }> = {
'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 }) {
Expand Down Expand Up @@ -52,6 +53,20 @@ function CodexIcon({ size }: { size: number }) {
)
}

function PiIcon({ size }: { size: number }) {
return (
<Svg width={size} height={size} viewBox="0 0 24 24" fill="none">
<Path
d="M6 7h12M9 7v10M15 7v10"
stroke="#8B5CF6"
strokeWidth={2.5}
strokeLinecap="round"
strokeLinejoin="round"
/>
</Svg>
)
}

export function AgentIcon({ agentType, size = 'sm' }: AgentIconProps) {
const containerSize = size === 'sm' ? styles.containerSm : styles.containerMd
const iconSize = size === 'sm' ? 14 : 18
Expand All @@ -66,6 +81,7 @@ export function AgentIcon({ agentType, size = 'sm' }: AgentIconProps) {
{agentType === 'claude-code' && <ClaudeIcon size={iconSize} />}
{agentType === 'opencode' && <OpenCodeIcon size={iconSize} />}
{agentType === 'codex' && <CodexIcon size={iconSize} />}
{agentType === 'pi' && <PiIcon size={iconSize} />}
</View>
)
}
Expand Down
2 changes: 1 addition & 1 deletion mobile/src/lib/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
4 changes: 4 additions & 0 deletions mobile/src/screens/WorkspaceDetailScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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}`
}
}

Expand All @@ -41,6 +43,8 @@ function getAgentStartCommand(agentType: AgentType): string {
return 'opencode'
case 'codex':
return 'codex'
case 'pi':
return 'pi'
}
}

Expand Down
2 changes: 2 additions & 0 deletions perry/Dockerfile.base
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
83 changes: 69 additions & 14 deletions src/agent/router.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)]);

Expand Down Expand Up @@ -785,25 +785,25 @@ export function createRouter(ctx: RouterContext) {

type ListSessionsInput = {
workspaceName: string;
agentType?: 'claude-code' | 'opencode' | 'codex';
agentType?: 'claude-code' | 'opencode' | 'codex' | 'pi';
limit?: number;
offset?: number;
};

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 }
) {
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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),
})
Expand All @@ -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(),
Expand Down Expand Up @@ -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 }) => {
Expand All @@ -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 }) => {
Expand Down Expand Up @@ -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;
}>
Expand All @@ -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);
Expand All @@ -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$/);
Expand All @@ -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) {
Expand All @@ -1365,9 +1372,43 @@ export function createRouter(ctx: RouterContext) {
}
}

async function findPiSessionFileOnHost(
baseDir: string,
sessionId: string
): Promise<string | null> {
async function scan(dir: string): Promise<string | null> {
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();

Expand Down Expand Up @@ -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' };
}

Expand Down
Loading
Loading