Backend: - /api/pi/models now calls 'pi --list-models' with stored API keys - pi-bridge.ts: callback-based event handling (matches pi-monorepo) - pi-bridge.ts: correct RPC format (type: 'prompt' not jsonrpc) - pi-bridge.ts: pass API keys to Pi process env - websocket.ts: event handler runs in background, no blocking - rest.ts: fix user home path (getHomeDir instead of hardcoded) Frontend: - Fix /api/ double prefix in useChatSessions, useChatGroups, useModels - Add PROVIDER_DISPLAY mapping in SystemSettings.tsx - Provider tabs show friendly names (e.g., 'OpenCode Zen') UI (from previous session): - Grouped session list with collapsible folders - CreateGroupDialog, GroupContextMenu, SessionContextMenu components
Pi Harness — Architecture & Implementation Guide
Version: 1.0
Date: February 20, 2026
Status: Production
Overview
The Pi harness is a unified WebSocket + REST API that manages long-running chat sessions with AI models through the Pi CLI tool. It replaces three legacy harnesses (Claude, OpenCode, Pi-Mono) with a single, streamlined implementation.
Key Features
- Session Management: One Pi process per chat session, spawned on demand
- Resume from History: Load conversations from disk and continue seamlessly
- Multi-Model Support: Switch models per session (GPT, Claude, etc.)
- Concurrent Sessions: Users can run multiple sessions simultaneously
- Idle Cleanup: Automatic Pi process termination after 1 hour of inactivity
- Structured Logging: Comprehensive logging with context for debugging
Architecture
Components
┌─ Pi Harness
│
├─ websocket.ts WebSocket handler (chat lifecycle)
├─ rest.ts REST API (sessions, models, search)
├─ session-manager.ts In-memory session tracking
├─ storage.ts Disk persistence (messages, metadata)
├─ pi-bridge.ts Pi process spawning & RPC communication
├─ types.ts TypeScript type definitions
└─ logger.ts Structured logging utility
Data Flow
Client
↓ WebSocket (chat message)
websocket.ts
↓ Spawn Pi process if needed
pi-bridge.ts → Pi CLI (RPC mode)
↓ Stream events back
session-manager.ts (track state)
↓ Save to disk on completion
storage.ts → {cwd}/.pi-sessions/{sessionId}/
Session Lifecycle
1. New Chat
Client → { type: "chat", prompt: "Hello", model: "gpt-4o", cwd: "/home/user" }
↓
Server: Spawn Pi process
Send prompt via RPC
Stream responses (text, tool calls, results)
Save session to disk
↓
Client ← { type: "session:init", sessionId: "...", model: "...", cwd: "..." }
Client ← { type: "assistant:delta", text: "..." }
Client ← { type: "result", sessionId: "...", cost: {...} }
2. Resume Existing Chat
Client → { type: "resume", sessionId: "existing-session-id" }
↓
Server: Load session from disk
Spawn fresh Pi process
Inject conversation history as system context
↓
Client ← { type: "session:init", ... }
Client ← { type: "sync:messages", messages: [...], isGenerating: false }
3. WebSocket Disconnect
Client disconnects
↓
Server: Detach WebSocket from session
Start 1-hour idle timer
(Session and Pi process remain alive)
4. Idle Timeout
1 hour passes without reconnection
↓
Server: Kill Pi process
Save session to disk (if not already saved)
Remove from memory
5. Stop Generation
Client → { type: "stop" }
↓
Server: Send abort RPC to Pi process
Set isGenerating = false
↓
Client ← { type: "stopped" }
Wire Protocol
Client → Server Messages
Chat Message
{
"type": "chat",
"prompt": "Explain TypeScript generics",
"sessionId": "uuid", // optional: omit for new chat
"model": "gpt-4o", // optional: defaults to gpt-4o
"cwd": "/home/user/project", // optional: defaults to user home
"attachmentIds": ["file-1"] // optional: file references
}
Resume Session
{
"type": "resume",
"sessionId": "existing-uuid"
}
Stop Generation
{
"type": "stop"
}
Server → Client Messages
Session Initialized
{
"type": "session:init",
"sessionId": "uuid",
"model": "gpt-4o",
"cwd": "/home/user/project"
}
Assistant Text (Complete Block)
{
"type": "assistant:text",
"text": "TypeScript generics allow..."
}
Assistant Text (Streaming Delta)
{
"type": "assistant:delta",
"text": "you to"
}
Tool Execution Started
{
"type": "tool:start",
"toolCallId": "call-uuid",
"toolName": "bash",
"toolInput": { "command": "ls -la" }
}
Tool Execution Result
{
"type": "tool:result",
"toolCallId": "call-uuid",
"output": "file1.txt\nfile2.txt",
"isError": false
}
Generation Completed
{
"type": "result",
"sessionId": "uuid",
"cost": {
"inputTokens": 500,
"outputTokens": 300,
"totalUSD": 0.012
}
}
Full Sync (Resume)
{
"type": "sync:messages",
"sessionId": "uuid",
"messages": [
{ "id": "msg-1", "role": "user", "text": "Hello", "timestamp": 1708396000000 },
{ "id": "msg-2", "role": "assistant", "text": "Hi!", "timestamp": 1708396001000, "model": "gpt-4o" }
],
"isGenerating": false,
"streamingText": ""
}
Error
{
"type": "error",
"message": "Failed to spawn Pi process",
"errorCode": "SPAWN_ERROR" // optional
}
Generation Stopped
{
"type": "stopped"
}
Session Storage
Directory Structure
{userCwd}/.pi-sessions/
└── {sessionId}/
├── meta.json (metadata: title, model, cwd, timestamps, cost)
└── messages.json (full conversation history)
meta.json
{
"id": "session-uuid",
"title": "First 100 characters of initial prompt",
"model": "gpt-4o",
"cwd": "/home/user/project",
"createdAt": 1708396000000,
"updatedAt": 1708396123000,
"messageCount": 15,
"cost": {
"inputTokens": 5000,
"outputTokens": 3000,
"totalUSD": 0.15
}
}
messages.json
{
"messages": [
{
"id": "msg-uuid-1",
"timestamp": 1708396000000,
"role": "user",
"text": "Hello, can you help me?"
},
{
"id": "msg-uuid-2",
"timestamp": 1708396001000,
"role": "assistant",
"text": "Of course! What do you need?",
"model": "gpt-4o",
"cost": {
"inputTokens": 100,
"outputTokens": 20,
"totalUSD": 0.001
}
},
{
"id": "msg-uuid-3",
"timestamp": 1708396002000,
"role": "tool",
"toolCallId": "call-uuid",
"toolName": "bash",
"output": "file1.txt\nfile2.txt",
"isError": false
}
]
}
REST API Endpoints
List Sessions
POST /api/pi/sessions
Authorization: Bearer {token}
Response: { sessions: SessionMeta[] }
Get Session Detail
GET /api/pi/sessions/{sessionId}
Authorization: Bearer {token}
Response: { session: { ...meta, messages: Message[] } }
Update Session (Rename)
PATCH /api/pi/sessions/{sessionId}
Authorization: Bearer {token}
Body: { title: "New Title" }
Response: { success: true, session: SessionMeta }
Delete Session
DELETE /api/pi/sessions/{sessionId}
Authorization: Bearer {token}
Response: { success: true }
Search Sessions
GET /api/pi/sessions/search?q={query}
Authorization: Bearer {token}
Response: { results: SessionMeta[] }
List Models
GET /api/pi/models
Authorization: Bearer {token}
Response: { models: ModelInfo[] }
Error Handling
Edge Cases & Solutions
1. Corrupted messages.json
Problem: JSON parse error when loading session
Solution: Catch error, return SESSION_NOT_FOUND to client, allow deletion
try {
const { meta, messages } = await storage.loadSession(homeDir, sessionId);
// ...
} catch (err) {
logger.error('Failed to load session from disk', { sessionId, error: String(err) });
ws.send({ type: 'error', message: 'Session not found', errorCode: 'SESSION_NOT_FOUND' });
}
2. Pi Process Crash During Streaming
Problem: Pi process exits unexpectedly while generating
Solution: readEvents generator exits naturally, session marked as isGenerating: false
for await (const event of piBridge.readEvents(session.piProcess)) {
// Handle events...
}
// If process dies, loop exits and session.isGenerating remains false
3. WebSocket Disconnect During Generation
Problem: User closes browser while AI is generating
Solution: Session continues in background until Pi completes, then auto-saves
// In close handler:
sessionManager.detachWs(sessionId);
sessionManager.setIdleTimeout(sessionId, IDLE_TIMEOUT_MS);
// Pi process keeps running, will clean up after 1 hour
4. Concurrent WebSocket Connections to Same Session
Problem: User opens same session in two tabs
Solution: attachWs overwrites previous WebSocket reference, old tab receives no updates
// Only the most recent WebSocket receives updates
sessionManager.attachWs(sessionId, ws);
5. CWD Resolution When Not Provided
Problem: Client doesn't send cwd parameter
Solution: Default to user's home directory from email
const cwd = msg.cwd || getHomeDir(email);
6. Session Save Failure
Problem: Disk write fails (permissions, disk full)
Solution: Log error but continue, session remains in memory
try {
await storage.saveSession(cwd, sessionId, session.meta, session.messages);
} catch (err) {
logger.error('Failed to save session', { sessionId, error: String(err) });
// Continue — session still in memory
}
Logging
Structured Logging Format
logger.info('Session saved to disk', { sessionId, messageCount: 15 });
// Output:
// [2026-02-20T19:49:00.000Z] [Pi] [INFO] Session saved to disk {"sessionId":"...","messageCount":15}
Log Levels
- DEBUG: Detailed flow information (currently unused)
- INFO: Normal operations (spawns, saves, connections)
- WARN: Recoverable issues (currently unused)
- ERROR: Failures requiring attention (parse errors, spawn failures)
Key Log Points
- WebSocket open/close
- Session creation/resumption
- Pi process spawn/kill
- Session save/load
- Errors in message handling, streaming, RPC communication
Performance Considerations
Memory Usage
- Each active session holds:
- Full message history (in-memory)
- Pi process (separate OS process)
- WebSocket connection (if attached)
Mitigation: Idle timeout (1 hour) cleans up inactive sessions
Disk I/O
- Sessions saved synchronously after each AI response
- File writes are fast (JSON serialization)
- No buffering — each message persisted immediately
Optimization: Could batch writes or use async queue (not currently needed)
Pi Process Overhead
- Each session spawns one
piCLI process - Process runs in
--mode rpc --no-extensions --no-skills - Minimal resource usage when idle
Limitation: Max concurrent sessions limited by system resources
Development Guide
Adding a New Message Type
-
Add type to
types.ts:export type ServerMessage = | { type: 'new-type'; data: string } | ...; -
Handle in
websocket.ts:if (event.type === 'new-type') { const msg: ServerMessage = { type: 'new-type', data: event.data }; ws.send(JSON.stringify(msg)); } -
Update Pi bridge if needed (
pi-bridge.ts)
Adding a New REST Endpoint
-
Add route in
rest.ts:piRestRouter.get('/pi/new-endpoint', async (ctx: Context) => { const email = ctx.get('email'); // Implementation return ctx.json({ result: '...' }); }); -
Wire into
hono.ts:protectedRouter.route('/', piRestRouter);
Debugging Tips
- Check logs for session lifecycle events
- Verify Pi process spawned:
ps aux | grep "pi --mode rpc" - Inspect session files:
cat ~/.pi-sessions/{sessionId}/meta.json - Test WebSocket manually:
wscat -c ws://localhost:3000/api/pi/chat/ws
Migration from Legacy Harnesses
Differences from Claude/OpenCode/Pi-Mono
| Feature | Legacy Harnesses | New Pi Harness |
|---|---|---|
| Processes | 3 separate handlers | Single unified handler |
| Session Storage | Per-provider directories | Unified .pi-sessions/ |
| Resume | Limited support | Full history injection |
| Models | Provider-specific | Any model via Pi CLI |
| WebSocket Protocol | Different per provider | Unified wire protocol |
| REST API | Scattered endpoints | Centralized /api/pi/* |
Migration Checklist
- Delete old harness code (
src/servers/api/claude, etc.) - Remove old routes from
hono.tsandserver.tsx - Update frontend to use new WebSocket protocol
- Migrate old session storage to new format (manual or script)
- Update user settings to reference only 'pi' provider
Testing
Manual Testing
# 1. Start server
bun dev
# 2. Test WebSocket (in separate terminal)
wscat -c "ws://localhost:3000/api/pi/chat/ws?token=your-jwt-token"
# Send chat message
> {"type":"chat","prompt":"Hello!","model":"gpt-4o"}
# 3. Test REST API
curl -H "Authorization: Bearer your-jwt-token" \
http://localhost:3000/api/pi/models
curl -H "Authorization: Bearer your-jwt-token" \
http://localhost:3000/api/pi/sessions
Integration Tests (TODO)
- Session creation and resumption
- Concurrent sessions per user
- Idle timeout cleanup
- WebSocket disconnect/reconnect
- Error handling (corrupt files, Pi crashes)
Future Improvements
Potential Enhancements
- Streaming Buffer Optimization: Batch small deltas to reduce WebSocket overhead
- Session Compression: Gzip old messages to save disk space
- Model Auto-Detection: Dynamically discover available models from Pi CLI
- Cost Tracking Dashboard: Aggregate cost data across sessions
- Session Export: Export conversations to Markdown or JSON
- Real-time Collaboration: Multiple users in same session
- Attachment Support: Full implementation of file attachments
Troubleshooting
Common Issues
Issue: "Failed to spawn Pi process"
Solution: Verify pi CLI is installed and in PATH
Check: which pi → should return path
Issue: "Session not found" on resume
Solution: Check session files exist in {cwd}/.pi-sessions/{sessionId}/
Check: ls ~/.pi-sessions/
Issue: WebSocket disconnects immediately
Solution: Verify JWT token is valid and not expired
Check: Decode token, check exp claim
Issue: High memory usage
Solution: Check idle timeout is working, verify old sessions cleaned up
Check: ps aux | grep pi → should show minimal processes
Appendix
Type Definitions
See types.ts for complete type definitions:
ClientMessageServerMessageMessageSessionMetaUserSessionPiEventModelInfoMessageCost
Configuration
| Setting | Value | Environment Variable |
|---|---|---|
| Idle Timeout | 1 hour | N/A (hardcoded) |
| Session Directory | {cwd}/.pi-sessions/ |
N/A |
| Default Model | gpt-4o |
N/A |
| Pi RPC Mode | --mode rpc --no-extensions --no-skills |
N/A |
Last Updated: February 20, 2026
Maintainer: Pi Harness Team
Questions? Check logs, read code, ask the team.