feat: Complete Pi Harness Rebuild - Phases 1 till 7.2
This commit is contained in:
@@ -0,0 +1,151 @@
|
||||
# Pi Harness Integration Test
|
||||
|
||||
## Phase 5 Completion Checklist
|
||||
|
||||
### ✅ 1. WebSocket Integration
|
||||
- [x] WebSocket handler exported from `websocket.ts`
|
||||
- [x] Route `/api/pi/chat/ws` configured in `src/server.tsx`
|
||||
- [x] Authentication middleware applied via token query param
|
||||
- [x] Handler registered in handlers Map
|
||||
|
||||
### ✅ 2. REST API Integration
|
||||
- [x] REST router exported from `rest.ts`
|
||||
- [x] Router mounted in `src/servers/hono.ts` as `piRestRouter`
|
||||
- [x] Protected routes middleware applied
|
||||
- [x] All endpoints implemented:
|
||||
- GET `/api/pi/models` - List available models
|
||||
- POST `/api/pi/sessions` - List user sessions
|
||||
- GET `/api/pi/sessions/:sessionId` - Get session detail
|
||||
- PATCH `/api/pi/sessions/:sessionId` - Update session (rename)
|
||||
- DELETE `/api/pi/sessions/:sessionId` - Delete session
|
||||
- GET `/api/pi/sessions/search` - Search sessions
|
||||
|
||||
### ✅ 3. Type System
|
||||
- [x] All types defined in `src/servers/api/pi/types.ts`
|
||||
- [x] Legacy `chat-types.ts` updated to re-export new types
|
||||
- [x] Deprecation notice added to `chat-types.ts`
|
||||
- [x] No TypeScript compilation errors in Pi module
|
||||
|
||||
### ✅ 4. Architecture Verification
|
||||
```
|
||||
src/servers/api/pi/
|
||||
├── types.ts ✅ All message and session types
|
||||
├── storage.ts ✅ Session persistence to disk
|
||||
├── pi-bridge.ts ✅ Pi process spawning and RPC
|
||||
├── session-manager.ts ✅ In-memory session tracking
|
||||
├── websocket.ts ✅ WebSocket handler
|
||||
└── rest.ts ✅ REST endpoints
|
||||
```
|
||||
|
||||
## Manual Testing Steps
|
||||
|
||||
### Test 1: REST Endpoints
|
||||
```bash
|
||||
# Get JWT token
|
||||
TOKEN="your-jwt-token"
|
||||
|
||||
# List models
|
||||
curl -H "Authorization: Bearer $TOKEN" \
|
||||
http://localhost:5000/api/pi/models
|
||||
|
||||
# List sessions
|
||||
curl -X POST -H "Authorization: Bearer $TOKEN" \
|
||||
http://localhost:5000/api/pi/sessions
|
||||
|
||||
# Get session detail
|
||||
curl -H "Authorization: Bearer $TOKEN" \
|
||||
http://localhost:5000/api/pi/sessions/{sessionId}
|
||||
|
||||
# Update session title
|
||||
curl -X PATCH -H "Authorization: Bearer $TOKEN" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"title": "New Title"}' \
|
||||
http://localhost:5000/api/pi/sessions/{sessionId}
|
||||
|
||||
# Delete session
|
||||
curl -X DELETE -H "Authorization: Bearer $TOKEN" \
|
||||
http://localhost:5000/api/pi/sessions/{sessionId}
|
||||
|
||||
# Search sessions
|
||||
curl -H "Authorization: Bearer $TOKEN" \
|
||||
"http://localhost:5000/api/pi/sessions/search?q=test"
|
||||
```
|
||||
|
||||
### Test 2: WebSocket Connection
|
||||
```javascript
|
||||
const token = "your-jwt-token";
|
||||
const ws = new WebSocket(`ws://localhost:5000/api/pi/chat/ws?token=${token}`);
|
||||
|
||||
ws.onopen = () => {
|
||||
console.log("Connected");
|
||||
// Start new chat
|
||||
ws.send(JSON.stringify({
|
||||
type: "chat",
|
||||
prompt: "Hello, world!",
|
||||
model: "gpt-4o",
|
||||
cwd: "/home/user/test"
|
||||
}));
|
||||
};
|
||||
|
||||
ws.onmessage = (event) => {
|
||||
const msg = JSON.parse(event.data);
|
||||
console.log("Received:", msg.type, msg);
|
||||
};
|
||||
```
|
||||
|
||||
### Test 3: Session Resume
|
||||
```javascript
|
||||
// First create a session (see Test 2)
|
||||
// Then close the WebSocket
|
||||
|
||||
// Reconnect and resume
|
||||
const ws2 = new WebSocket(`ws://localhost:5000/api/pi/chat/ws?token=${token}`);
|
||||
ws2.onopen = () => {
|
||||
ws2.send(JSON.stringify({
|
||||
type: "resume",
|
||||
sessionId: "previous-session-id"
|
||||
}));
|
||||
};
|
||||
|
||||
// Should receive sync:messages with full history
|
||||
```
|
||||
|
||||
### Test 4: Stop Generation
|
||||
```javascript
|
||||
ws.send(JSON.stringify({ type: "stop" }));
|
||||
// Should receive { type: "stopped" }
|
||||
```
|
||||
|
||||
## Expected Behaviors
|
||||
|
||||
### WebSocket Flow
|
||||
1. **Connect** → Authentication via token query param
|
||||
2. **Send chat** → Receive `session:init` → Stream of `assistant:delta` → `tool:*` events → `result`
|
||||
3. **Send resume** → Receive `sync:messages` with history
|
||||
4. **Send stop** → Generation stops → Receive `stopped`
|
||||
5. **Disconnect** → Idle timer starts (1 hour)
|
||||
|
||||
### Session Persistence
|
||||
- Sessions saved to `{cwd}/.pi-sessions/{sessionId}/`
|
||||
- `meta.json` - Session metadata
|
||||
- `messages.json` - Full conversation history
|
||||
- Updates written after each completed turn
|
||||
|
||||
### Error Handling
|
||||
- Invalid token → 401 Unauthorized
|
||||
- Invalid session ID → 404 Not Found
|
||||
- Pi process crash → Error message to client
|
||||
- Malformed JSON → Error message, connection stays open
|
||||
|
||||
## Integration Status
|
||||
|
||||
**Phase 5: COMPLETE ✅**
|
||||
|
||||
All components properly wired:
|
||||
- ✅ WebSocket handler integrated
|
||||
- ✅ REST endpoints integrated
|
||||
- ✅ Type system unified
|
||||
- ✅ No compilation errors
|
||||
- ✅ Architecture matches design document
|
||||
|
||||
**Next Steps**: Manual testing with real client to verify end-to-end functionality.
|
||||
@@ -0,0 +1,656 @@
|
||||
# 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
|
||||
|
||||
```typescript
|
||||
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
|
||||
|
||||
```typescript
|
||||
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
|
||||
|
||||
```typescript
|
||||
Client disconnects
|
||||
↓
|
||||
Server: Detach WebSocket from session
|
||||
Start 1-hour idle timer
|
||||
(Session and Pi process remain alive)
|
||||
```
|
||||
|
||||
### 4. Idle Timeout
|
||||
|
||||
```typescript
|
||||
1 hour passes without reconnection
|
||||
↓
|
||||
Server: Kill Pi process
|
||||
Save session to disk (if not already saved)
|
||||
Remove from memory
|
||||
```
|
||||
|
||||
### 5. Stop Generation
|
||||
|
||||
```typescript
|
||||
Client → { type: "stop" }
|
||||
↓
|
||||
Server: Send abort RPC to Pi process
|
||||
Set isGenerating = false
|
||||
↓
|
||||
Client ← { type: "stopped" }
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Wire Protocol
|
||||
|
||||
### Client → Server Messages
|
||||
|
||||
#### Chat Message
|
||||
```json
|
||||
{
|
||||
"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
|
||||
```json
|
||||
{
|
||||
"type": "resume",
|
||||
"sessionId": "existing-uuid"
|
||||
}
|
||||
```
|
||||
|
||||
#### Stop Generation
|
||||
```json
|
||||
{
|
||||
"type": "stop"
|
||||
}
|
||||
```
|
||||
|
||||
### Server → Client Messages
|
||||
|
||||
#### Session Initialized
|
||||
```json
|
||||
{
|
||||
"type": "session:init",
|
||||
"sessionId": "uuid",
|
||||
"model": "gpt-4o",
|
||||
"cwd": "/home/user/project"
|
||||
}
|
||||
```
|
||||
|
||||
#### Assistant Text (Complete Block)
|
||||
```json
|
||||
{
|
||||
"type": "assistant:text",
|
||||
"text": "TypeScript generics allow..."
|
||||
}
|
||||
```
|
||||
|
||||
#### Assistant Text (Streaming Delta)
|
||||
```json
|
||||
{
|
||||
"type": "assistant:delta",
|
||||
"text": "you to"
|
||||
}
|
||||
```
|
||||
|
||||
#### Tool Execution Started
|
||||
```json
|
||||
{
|
||||
"type": "tool:start",
|
||||
"toolCallId": "call-uuid",
|
||||
"toolName": "bash",
|
||||
"toolInput": { "command": "ls -la" }
|
||||
}
|
||||
```
|
||||
|
||||
#### Tool Execution Result
|
||||
```json
|
||||
{
|
||||
"type": "tool:result",
|
||||
"toolCallId": "call-uuid",
|
||||
"output": "file1.txt\nfile2.txt",
|
||||
"isError": false
|
||||
}
|
||||
```
|
||||
|
||||
#### Generation Completed
|
||||
```json
|
||||
{
|
||||
"type": "result",
|
||||
"sessionId": "uuid",
|
||||
"cost": {
|
||||
"inputTokens": 500,
|
||||
"outputTokens": 300,
|
||||
"totalUSD": 0.012
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### Full Sync (Resume)
|
||||
```json
|
||||
{
|
||||
"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
|
||||
```json
|
||||
{
|
||||
"type": "error",
|
||||
"message": "Failed to spawn Pi process",
|
||||
"errorCode": "SPAWN_ERROR" // optional
|
||||
}
|
||||
```
|
||||
|
||||
#### Generation Stopped
|
||||
```json
|
||||
{
|
||||
"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
|
||||
|
||||
```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
|
||||
|
||||
```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
|
||||
```http
|
||||
POST /api/pi/sessions
|
||||
Authorization: Bearer {token}
|
||||
|
||||
Response: { sessions: SessionMeta[] }
|
||||
```
|
||||
|
||||
### Get Session Detail
|
||||
```http
|
||||
GET /api/pi/sessions/{sessionId}
|
||||
Authorization: Bearer {token}
|
||||
|
||||
Response: { session: { ...meta, messages: Message[] } }
|
||||
```
|
||||
|
||||
### Update Session (Rename)
|
||||
```http
|
||||
PATCH /api/pi/sessions/{sessionId}
|
||||
Authorization: Bearer {token}
|
||||
Body: { title: "New Title" }
|
||||
|
||||
Response: { success: true, session: SessionMeta }
|
||||
```
|
||||
|
||||
### Delete Session
|
||||
```http
|
||||
DELETE /api/pi/sessions/{sessionId}
|
||||
Authorization: Bearer {token}
|
||||
|
||||
Response: { success: true }
|
||||
```
|
||||
|
||||
### Search Sessions
|
||||
```http
|
||||
GET /api/pi/sessions/search?q={query}
|
||||
Authorization: Bearer {token}
|
||||
|
||||
Response: { results: SessionMeta[] }
|
||||
```
|
||||
|
||||
### List Models
|
||||
```http
|
||||
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
|
||||
|
||||
```typescript
|
||||
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`
|
||||
|
||||
```typescript
|
||||
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
|
||||
|
||||
```typescript
|
||||
// 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
|
||||
|
||||
```typescript
|
||||
// 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
|
||||
|
||||
```typescript
|
||||
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
|
||||
|
||||
```typescript
|
||||
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
|
||||
|
||||
```typescript
|
||||
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 `pi` CLI 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
|
||||
|
||||
1. Add type to `types.ts`:
|
||||
```typescript
|
||||
export type ServerMessage =
|
||||
| { type: 'new-type'; data: string }
|
||||
| ...;
|
||||
```
|
||||
|
||||
2. Handle in `websocket.ts`:
|
||||
```typescript
|
||||
if (event.type === 'new-type') {
|
||||
const msg: ServerMessage = { type: 'new-type', data: event.data };
|
||||
ws.send(JSON.stringify(msg));
|
||||
}
|
||||
```
|
||||
|
||||
3. Update Pi bridge if needed (`pi-bridge.ts`)
|
||||
|
||||
### Adding a New REST Endpoint
|
||||
|
||||
1. Add route in `rest.ts`:
|
||||
```typescript
|
||||
piRestRouter.get('/pi/new-endpoint', async (ctx: Context) => {
|
||||
const email = ctx.get('email');
|
||||
// Implementation
|
||||
return ctx.json({ result: '...' });
|
||||
});
|
||||
```
|
||||
|
||||
2. Wire into `hono.ts`:
|
||||
```typescript
|
||||
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
|
||||
|
||||
- [x] Delete old harness code (`src/servers/api/claude`, etc.)
|
||||
- [x] Remove old routes from `hono.ts` and `server.tsx`
|
||||
- [x] Update frontend to use new WebSocket protocol
|
||||
- [x] Migrate old session storage to new format (manual or script)
|
||||
- [x] Update user settings to reference only 'pi' provider
|
||||
|
||||
---
|
||||
|
||||
## Testing
|
||||
|
||||
### Manual Testing
|
||||
|
||||
```bash
|
||||
# 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
|
||||
|
||||
1. **Streaming Buffer Optimization**: Batch small deltas to reduce WebSocket overhead
|
||||
2. **Session Compression**: Gzip old messages to save disk space
|
||||
3. **Model Auto-Detection**: Dynamically discover available models from Pi CLI
|
||||
4. **Cost Tracking Dashboard**: Aggregate cost data across sessions
|
||||
5. **Session Export**: Export conversations to Markdown or JSON
|
||||
6. **Real-time Collaboration**: Multiple users in same session
|
||||
7. **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:
|
||||
- `ClientMessage`
|
||||
- `ServerMessage`
|
||||
- `Message`
|
||||
- `SessionMeta`
|
||||
- `UserSession`
|
||||
- `PiEvent`
|
||||
- `ModelInfo`
|
||||
- `MessageCost`
|
||||
|
||||
### 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.
|
||||
@@ -0,0 +1,57 @@
|
||||
/**
|
||||
* Structured logging utility for Pi harness
|
||||
*/
|
||||
|
||||
export type LogLevel = 'DEBUG' | 'INFO' | 'WARN' | 'ERROR';
|
||||
|
||||
type LogContext = {
|
||||
sessionId?: string;
|
||||
email?: string;
|
||||
model?: string;
|
||||
requestId?: string;
|
||||
[key: string]: unknown;
|
||||
};
|
||||
|
||||
const LOG_COLORS = {
|
||||
DEBUG: '\x1b[36m', // Cyan
|
||||
INFO: '\x1b[32m', // Green
|
||||
WARN: '\x1b[33m', // Yellow
|
||||
ERROR: '\x1b[31m', // Red
|
||||
RESET: '\x1b[0m',
|
||||
};
|
||||
|
||||
function formatTimestamp(): string {
|
||||
return new Date().toISOString();
|
||||
}
|
||||
|
||||
function formatContext(context?: LogContext): string {
|
||||
if (!context || Object.keys(context).length === 0) return '';
|
||||
return ' ' + JSON.stringify(context);
|
||||
}
|
||||
|
||||
function log(level: LogLevel, message: string, context?: LogContext) {
|
||||
const timestamp = formatTimestamp();
|
||||
const color = LOG_COLORS[level];
|
||||
const reset = LOG_COLORS.RESET;
|
||||
const contextStr = formatContext(context);
|
||||
|
||||
console.log(`${color}[${timestamp}] [Pi] [${level}]${reset} ${message}${contextStr}`);
|
||||
}
|
||||
|
||||
export const logger = {
|
||||
debug(message: string, context?: LogContext) {
|
||||
log('DEBUG', message, context);
|
||||
},
|
||||
|
||||
info(message: string, context?: LogContext) {
|
||||
log('INFO', message, context);
|
||||
},
|
||||
|
||||
warn(message: string, context?: LogContext) {
|
||||
log('WARN', message, context);
|
||||
},
|
||||
|
||||
error(message: string, context?: LogContext) {
|
||||
log('ERROR', message, context);
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,149 @@
|
||||
import type { Subprocess } from "bun";
|
||||
import type { PiEvent, MessageCost } from "./types";
|
||||
import { logger } from "./logger";
|
||||
|
||||
export async function spawnPi(
|
||||
cwd: string,
|
||||
model: string,
|
||||
env?: Record<string, string>
|
||||
): Promise<Subprocess> {
|
||||
const piProcess = Bun.spawn(
|
||||
[
|
||||
"pi",
|
||||
"--mode",
|
||||
"rpc",
|
||||
"--no-extensions",
|
||||
"--no-skills",
|
||||
"--model",
|
||||
model,
|
||||
],
|
||||
{
|
||||
cwd,
|
||||
env: {
|
||||
...process.env,
|
||||
...env,
|
||||
},
|
||||
stdin: "pipe",
|
||||
stdout: "pipe",
|
||||
stderr: "inherit",
|
||||
}
|
||||
);
|
||||
|
||||
return piProcess;
|
||||
}
|
||||
|
||||
export function sendPrompt(
|
||||
process: Subprocess,
|
||||
prompt: string,
|
||||
requestId: string
|
||||
): void {
|
||||
const request = {
|
||||
jsonrpc: "2.0",
|
||||
method: "chat",
|
||||
params: {
|
||||
prompt,
|
||||
},
|
||||
id: requestId,
|
||||
};
|
||||
|
||||
const writer = (process.stdin as any).getWriter();
|
||||
writer.write(
|
||||
new TextEncoder().encode(JSON.stringify(request) + "\n")
|
||||
);
|
||||
writer.releaseLock();
|
||||
}
|
||||
|
||||
export function abort(
|
||||
process: Subprocess,
|
||||
requestId: string
|
||||
): void {
|
||||
const request = {
|
||||
jsonrpc: "2.0",
|
||||
method: "abort",
|
||||
params: {},
|
||||
id: requestId,
|
||||
};
|
||||
|
||||
const writer = (process.stdin as any).getWriter();
|
||||
writer.write(
|
||||
new TextEncoder().encode(JSON.stringify(request) + "\n")
|
||||
);
|
||||
writer.releaseLock();
|
||||
}
|
||||
|
||||
export async function* readEvents(
|
||||
process: Subprocess
|
||||
): AsyncGenerator<PiEvent> {
|
||||
if (!process.stdout) {
|
||||
throw new Error("Pi process stdout not available");
|
||||
}
|
||||
|
||||
const reader = (process.stdout as any).getReader();
|
||||
const decoder = new TextDecoder();
|
||||
let buffer = "";
|
||||
|
||||
try {
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
|
||||
if (done) {
|
||||
break;
|
||||
}
|
||||
|
||||
buffer += decoder.decode(value, { stream: true });
|
||||
const lines = buffer.split("\n");
|
||||
|
||||
buffer = lines.pop() || "";
|
||||
|
||||
for (const line of lines) {
|
||||
if (!line.trim()) continue;
|
||||
|
||||
try {
|
||||
const event = JSON.parse(line);
|
||||
|
||||
if (event.method === "text") {
|
||||
yield { type: "text", text: event.params.text };
|
||||
} else if (event.method === "delta") {
|
||||
yield { type: "delta", text: event.params.text };
|
||||
} else if (event.method === "tool:start") {
|
||||
yield {
|
||||
type: "tool:start",
|
||||
toolCallId: event.params.toolCallId,
|
||||
toolName: event.params.toolName,
|
||||
toolInput: event.params.toolInput,
|
||||
};
|
||||
} else if (event.method === "tool:result") {
|
||||
yield {
|
||||
type: "tool:result",
|
||||
toolCallId: event.params.toolCallId,
|
||||
output: event.params.output,
|
||||
isError: event.params.isError || false,
|
||||
};
|
||||
} else if (event.method === "result") {
|
||||
const cost: MessageCost = {
|
||||
inputTokens: event.params.cost?.inputTokens || 0,
|
||||
outputTokens: event.params.cost?.outputTokens || 0,
|
||||
totalUSD: event.params.cost?.totalUSD || 0,
|
||||
};
|
||||
yield { type: "result", cost };
|
||||
} else if (event.method === "error") {
|
||||
yield {
|
||||
type: "error",
|
||||
message: event.params.message || "Unknown error",
|
||||
};
|
||||
} else if (event.method === "stopped") {
|
||||
yield { type: "stopped" };
|
||||
}
|
||||
} catch (err) {
|
||||
logger.error("Failed to parse Pi event", { line, error: String(err) });
|
||||
}
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
reader.releaseLock();
|
||||
}
|
||||
}
|
||||
|
||||
export function killPi(process: Subprocess): void {
|
||||
process.kill();
|
||||
}
|
||||
@@ -0,0 +1,501 @@
|
||||
import type { Context } from 'hono';
|
||||
import { createRouter } from '../../create-router';
|
||||
import * as storage from './storage';
|
||||
import type { ModelInfo } from './types';
|
||||
import { logger } from './logger';
|
||||
|
||||
/**
|
||||
* REST API Endpoints — Session management and model info
|
||||
*/
|
||||
|
||||
export const piRestRouter = createRouter();
|
||||
|
||||
/**
|
||||
* GET /api/pi/models
|
||||
* List available models
|
||||
*/
|
||||
piRestRouter.get('/pi/models', async (ctx: Context) => {
|
||||
// TODO: Implement dynamic model discovery
|
||||
// For now, return hardcoded models
|
||||
const models: ModelInfo[] = [
|
||||
{
|
||||
id: 'gpt-4o',
|
||||
name: 'GPT-4o',
|
||||
provider: 'openai',
|
||||
contextWindow: 128000,
|
||||
maxTokens: 4096,
|
||||
},
|
||||
{
|
||||
id: 'claude-opus-4-5',
|
||||
name: 'Claude Opus 4.5',
|
||||
provider: 'anthropic',
|
||||
contextWindow: 200000,
|
||||
maxTokens: 4096,
|
||||
},
|
||||
{
|
||||
id: 'big-pickle',
|
||||
name: 'Big Pickle',
|
||||
provider: 'opencode-zen',
|
||||
contextWindow: 128000,
|
||||
maxTokens: 4096,
|
||||
},
|
||||
];
|
||||
|
||||
return ctx.json({ models });
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /api/pi/sessions
|
||||
* List all sessions for the current user
|
||||
*/
|
||||
piRestRouter.post('/pi/sessions', async (ctx: Context) => {
|
||||
const user = ctx.get('user');
|
||||
if (!user) {
|
||||
return ctx.json({ error: 'Unauthorized' }, 401);
|
||||
}
|
||||
|
||||
// TODO: Implement proper user home directory resolution
|
||||
const userHome = `/home/${user.email.split('@')[0]}`;
|
||||
|
||||
try {
|
||||
const sessions = await storage.listUserSessions(userHome);
|
||||
return ctx.json({ sessions });
|
||||
} catch (err) {
|
||||
logger.error('Failed to list sessions', { email: ctx.get('email'), error: String(err) });
|
||||
return ctx.json({ error: 'Failed to list sessions' }, 500);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /api/pi/sessions/:sessionId
|
||||
* Get session detail with full message history
|
||||
*/
|
||||
piRestRouter.get('/pi/sessions/:sessionId', async (ctx: Context) => {
|
||||
const user = ctx.get('user');
|
||||
if (!user) {
|
||||
return ctx.json({ error: 'Unauthorized' }, 401);
|
||||
}
|
||||
|
||||
const sessionId = ctx.req.param('sessionId');
|
||||
if (!sessionId) {
|
||||
return ctx.json({ error: 'Session ID required' }, 400);
|
||||
}
|
||||
|
||||
const userHome = `/home/${user.email.split('@')[0]}`;
|
||||
|
||||
try {
|
||||
// Try loading from root first
|
||||
let meta, messages;
|
||||
try {
|
||||
({ meta, messages } = await storage.loadSession(userHome, sessionId));
|
||||
} catch {
|
||||
// Not in root, search in groups
|
||||
const groups = await storage.listGroups(userHome);
|
||||
let found = false;
|
||||
|
||||
for (const group of groups) {
|
||||
try {
|
||||
({ meta, messages } = await storage.loadSession(userHome, sessionId, group.slug));
|
||||
found = true;
|
||||
break;
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
if (!found) {
|
||||
throw new Error('Session not found');
|
||||
}
|
||||
}
|
||||
|
||||
return ctx.json({
|
||||
session: {
|
||||
...meta,
|
||||
messages,
|
||||
},
|
||||
});
|
||||
} catch (err) {
|
||||
logger.error('Failed to get session', { sessionId, email: ctx.get('email'), error: String(err) });
|
||||
return ctx.json({ error: 'Session not found' }, 404);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* PATCH /api/pi/sessions/:sessionId
|
||||
* Update session metadata (e.g., rename)
|
||||
*/
|
||||
piRestRouter.patch('/pi/sessions/:sessionId', async (ctx: Context) => {
|
||||
const user = ctx.get('user');
|
||||
if (!user) {
|
||||
return ctx.json({ error: 'Unauthorized' }, 401);
|
||||
}
|
||||
|
||||
const sessionId = ctx.req.param('sessionId');
|
||||
if (!sessionId) {
|
||||
return ctx.json({ error: 'Session ID required' }, 400);
|
||||
}
|
||||
|
||||
const body = await ctx.req.json();
|
||||
if (!body.title || typeof body.title !== 'string') {
|
||||
return ctx.json({ error: 'Title is required and must be a string' }, 400);
|
||||
}
|
||||
|
||||
const userHome = `/home/${user.email.split('@')[0]}`;
|
||||
|
||||
try {
|
||||
// Find the session (root or in group)
|
||||
let groupSlug: string | null = null;
|
||||
|
||||
try {
|
||||
const { meta } = await storage.loadSession(userHome, sessionId);
|
||||
groupSlug = meta.groupSlug || null;
|
||||
} catch {
|
||||
// Not in root, search groups
|
||||
const groups = await storage.listGroups(userHome);
|
||||
for (const group of groups) {
|
||||
try {
|
||||
await storage.loadSession(userHome, sessionId, group.slug);
|
||||
groupSlug = group.slug;
|
||||
break;
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const updatedMeta = await storage.updateSessionMeta(userHome, sessionId, {
|
||||
title: body.title,
|
||||
}, groupSlug);
|
||||
|
||||
return ctx.json({
|
||||
success: true,
|
||||
session: updatedMeta,
|
||||
});
|
||||
} catch (err) {
|
||||
logger.error('Failed to update session', { sessionId, email: ctx.get('email'), error: String(err) });
|
||||
return ctx.json({ error: 'Failed to update session' }, 500);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* DELETE /api/pi/sessions/:sessionId
|
||||
* Delete a session
|
||||
*/
|
||||
piRestRouter.delete('/pi/sessions/:sessionId', async (ctx: Context) => {
|
||||
const user = ctx.get('user');
|
||||
if (!user) {
|
||||
return ctx.json({ error: 'Unauthorized' }, 401);
|
||||
}
|
||||
|
||||
const sessionId = ctx.req.param('sessionId');
|
||||
if (!sessionId) {
|
||||
return ctx.json({ error: 'Session ID required' }, 400);
|
||||
}
|
||||
|
||||
const userHome = `/home/${user.email.split('@')[0]}`;
|
||||
|
||||
try {
|
||||
// Find the session (root or in group)
|
||||
let groupSlug: string | null = null;
|
||||
|
||||
try {
|
||||
const { meta } = await storage.loadSession(userHome, sessionId);
|
||||
groupSlug = meta.groupSlug || null;
|
||||
} catch {
|
||||
// Not in root, search groups
|
||||
const groups = await storage.listGroups(userHome);
|
||||
for (const group of groups) {
|
||||
try {
|
||||
const { meta } = await storage.loadSession(userHome, sessionId, group.slug);
|
||||
groupSlug = meta.groupSlug || null;
|
||||
break;
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await storage.deleteSession(userHome, sessionId, groupSlug);
|
||||
|
||||
// Update group session count if in a group
|
||||
if (groupSlug) {
|
||||
try {
|
||||
const group = await storage.loadGroup(userHome, groupSlug);
|
||||
group.sessionCount = Math.max(0, group.sessionCount - 1);
|
||||
group.updatedAt = Date.now();
|
||||
await storage.saveGroup(userHome, group);
|
||||
} catch {
|
||||
// Group might not exist anymore
|
||||
}
|
||||
}
|
||||
|
||||
return ctx.json({ success: true });
|
||||
} catch (err) {
|
||||
logger.error('Failed to delete session', { sessionId, email: ctx.get('email'), error: String(err) });
|
||||
return ctx.json({ error: 'Failed to delete session' }, 500);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /api/pi/sessions/search
|
||||
* Search sessions by query
|
||||
*/
|
||||
piRestRouter.get('/pi/sessions/search', async (ctx: Context) => {
|
||||
const user = ctx.get('user');
|
||||
if (!user) {
|
||||
return ctx.json({ error: 'Unauthorized' }, 401);
|
||||
}
|
||||
|
||||
const query = ctx.req.query('q');
|
||||
if (!query) {
|
||||
return ctx.json({ error: 'Query parameter required' }, 400);
|
||||
}
|
||||
|
||||
const userHome = `/home/${user.email.split('@')[0]}`;
|
||||
|
||||
try {
|
||||
const results = await storage.searchSessions(userHome, query);
|
||||
return ctx.json({ results });
|
||||
} catch (err) {
|
||||
logger.error('Failed to search sessions', { query, email: ctx.get('email'), error: String(err) });
|
||||
return ctx.json({ error: 'Failed to search sessions' }, 500);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /api/pi/groups
|
||||
* Create a new group
|
||||
*/
|
||||
piRestRouter.post('/pi/groups', async (ctx: Context) => {
|
||||
const user = ctx.get('user');
|
||||
if (!user) {
|
||||
return ctx.json({ error: 'Unauthorized' }, 401);
|
||||
}
|
||||
|
||||
const body = await ctx.req.json();
|
||||
|
||||
if (!body.name || typeof body.name !== 'string') {
|
||||
return ctx.json({ error: 'Name is required and must be a string' }, 400);
|
||||
}
|
||||
|
||||
if (!body.slug || typeof body.slug !== 'string') {
|
||||
return ctx.json({ error: 'Slug is required and must be a string' }, 400);
|
||||
}
|
||||
|
||||
const userHome = `/home/${user.email.split('@')[0]}`;
|
||||
|
||||
try {
|
||||
// Check if group already exists
|
||||
const exists = await storage.groupExists(userHome, body.slug);
|
||||
if (exists) {
|
||||
return ctx.json({ error: 'Group with this slug already exists' }, 409);
|
||||
}
|
||||
|
||||
const groupMeta = {
|
||||
name: body.name,
|
||||
slug: body.slug,
|
||||
description: body.description || '',
|
||||
createdAt: Date.now(),
|
||||
updatedAt: Date.now(),
|
||||
sessionCount: 0,
|
||||
};
|
||||
|
||||
await storage.saveGroup(userHome, groupMeta);
|
||||
|
||||
// Move sessions to group if provided
|
||||
if (body.sessionIds && Array.isArray(body.sessionIds)) {
|
||||
for (const sessionId of body.sessionIds) {
|
||||
try {
|
||||
// Find session (check both root and other groups)
|
||||
let sessionMeta;
|
||||
try {
|
||||
const { meta } = await storage.loadSession(userHome, sessionId);
|
||||
sessionMeta = meta;
|
||||
} catch {
|
||||
// Session might be in another group - skip
|
||||
continue;
|
||||
}
|
||||
|
||||
await storage.moveSession(userHome, sessionId, sessionMeta.groupSlug || null, body.slug);
|
||||
groupMeta.sessionCount++;
|
||||
} catch (err) {
|
||||
logger.error('Failed to move session to group', { sessionId, groupSlug: body.slug, error: String(err) });
|
||||
}
|
||||
}
|
||||
|
||||
// Update group with final session count
|
||||
await storage.saveGroup(userHome, groupMeta);
|
||||
}
|
||||
|
||||
return ctx.json({
|
||||
success: true,
|
||||
group: groupMeta,
|
||||
});
|
||||
} catch (err) {
|
||||
logger.error('Failed to create group', { slug: body.slug, email: ctx.get('email'), error: String(err) });
|
||||
return ctx.json({ error: 'Failed to create group' }, 500);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /api/pi/groups
|
||||
* List all groups
|
||||
*/
|
||||
piRestRouter.get('/pi/groups', async (ctx: Context) => {
|
||||
const user = ctx.get('user');
|
||||
if (!user) {
|
||||
return ctx.json({ error: 'Unauthorized' }, 401);
|
||||
}
|
||||
|
||||
const userHome = `/home/${user.email.split('@')[0]}`;
|
||||
|
||||
try {
|
||||
const groups = await storage.listGroups(userHome);
|
||||
return ctx.json({ groups });
|
||||
} catch (err) {
|
||||
logger.error('Failed to list groups', { email: ctx.get('email'), error: String(err) });
|
||||
return ctx.json({ error: 'Failed to list groups' }, 500);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* PATCH /api/pi/groups/:groupSlug
|
||||
* Update group metadata
|
||||
*/
|
||||
piRestRouter.patch('/pi/groups/:groupSlug', async (ctx: Context) => {
|
||||
const user = ctx.get('user');
|
||||
if (!user) {
|
||||
return ctx.json({ error: 'Unauthorized' }, 401);
|
||||
}
|
||||
|
||||
const groupSlug = ctx.req.param('groupSlug');
|
||||
if (!groupSlug) {
|
||||
return ctx.json({ error: 'Group slug required' }, 400);
|
||||
}
|
||||
|
||||
const body = await ctx.req.json();
|
||||
|
||||
const updates: any = {};
|
||||
if (body.name && typeof body.name === 'string') {
|
||||
updates.name = body.name;
|
||||
}
|
||||
if (body.description !== undefined) {
|
||||
updates.description = body.description;
|
||||
}
|
||||
|
||||
if (Object.keys(updates).length === 0) {
|
||||
return ctx.json({ error: 'No valid updates provided' }, 400);
|
||||
}
|
||||
|
||||
const userHome = `/home/${user.email.split('@')[0]}`;
|
||||
|
||||
try {
|
||||
const updatedGroup = await storage.updateGroupMeta(userHome, groupSlug, updates);
|
||||
return ctx.json({
|
||||
success: true,
|
||||
group: updatedGroup,
|
||||
});
|
||||
} catch (err) {
|
||||
logger.error('Failed to update group', { groupSlug, email: ctx.get('email'), error: String(err) });
|
||||
return ctx.json({ error: 'Failed to update group' }, 500);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* DELETE /api/pi/groups/:groupSlug
|
||||
* Delete a group (moves sessions to root)
|
||||
*/
|
||||
piRestRouter.delete('/pi/groups/:groupSlug', async (ctx: Context) => {
|
||||
const user = ctx.get('user');
|
||||
if (!user) {
|
||||
return ctx.json({ error: 'Unauthorized' }, 401);
|
||||
}
|
||||
|
||||
const groupSlug = ctx.req.param('groupSlug');
|
||||
if (!groupSlug) {
|
||||
return ctx.json({ error: 'Group slug required' }, 400);
|
||||
}
|
||||
|
||||
const userHome = `/home/${user.email.split('@')[0]}`;
|
||||
|
||||
try {
|
||||
await storage.deleteGroup(userHome, groupSlug);
|
||||
return ctx.json({ success: true });
|
||||
} catch (err) {
|
||||
logger.error('Failed to delete group', { groupSlug, email: ctx.get('email'), error: String(err) });
|
||||
return ctx.json({ error: 'Failed to delete group' }, 500);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /api/pi/sessions/:sessionId/move
|
||||
* Move session to/from a group
|
||||
*/
|
||||
piRestRouter.post('/pi/sessions/:sessionId/move', async (ctx: Context) => {
|
||||
const user = ctx.get('user');
|
||||
if (!user) {
|
||||
return ctx.json({ error: 'Unauthorized' }, 401);
|
||||
}
|
||||
|
||||
const sessionId = ctx.req.param('sessionId');
|
||||
if (!sessionId) {
|
||||
return ctx.json({ error: 'Session ID required' }, 400);
|
||||
}
|
||||
|
||||
const body = await ctx.req.json();
|
||||
const toGroupSlug = body.groupSlug === null ? null : body.groupSlug;
|
||||
|
||||
if (toGroupSlug !== null && typeof toGroupSlug !== 'string') {
|
||||
return ctx.json({ error: 'groupSlug must be a string or null' }, 400);
|
||||
}
|
||||
|
||||
const userHome = `/home/${user.email.split('@')[0]}`;
|
||||
|
||||
try {
|
||||
// Find the session in root or any group
|
||||
let fromGroupSlug: string | null = null;
|
||||
let sessionFound = false;
|
||||
|
||||
// Try root level first
|
||||
try {
|
||||
await storage.loadSession(userHome, sessionId);
|
||||
fromGroupSlug = null;
|
||||
sessionFound = true;
|
||||
} catch {
|
||||
// Not in root, check groups
|
||||
const groups = await storage.listGroups(userHome);
|
||||
for (const group of groups) {
|
||||
try {
|
||||
await storage.loadSession(userHome, sessionId, group.slug);
|
||||
fromGroupSlug = group.slug;
|
||||
sessionFound = true;
|
||||
break;
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!sessionFound) {
|
||||
return ctx.json({ error: 'Session not found' }, 404);
|
||||
}
|
||||
|
||||
// Validate target group exists
|
||||
if (toGroupSlug !== null) {
|
||||
const groupExists = await storage.groupExists(userHome, toGroupSlug);
|
||||
if (!groupExists) {
|
||||
return ctx.json({ error: 'Target group does not exist' }, 404);
|
||||
}
|
||||
}
|
||||
|
||||
const updatedMeta = await storage.moveSession(userHome, sessionId, fromGroupSlug, toGroupSlug);
|
||||
|
||||
return ctx.json({
|
||||
success: true,
|
||||
session: updatedMeta,
|
||||
});
|
||||
} catch (err) {
|
||||
logger.error('Failed to move session', { sessionId, toGroupSlug, email: ctx.get('email'), error: String(err) });
|
||||
return ctx.json({ error: 'Failed to move session' }, 500);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,145 @@
|
||||
import type { UserSession } from "./types";
|
||||
import * as piBridge from "./pi-bridge";
|
||||
import { logger } from "./logger";
|
||||
|
||||
class SessionManager {
|
||||
private sessions = new Map<string, UserSession>();
|
||||
private userSessions = new Map<string, string[]>();
|
||||
|
||||
getOrCreate(
|
||||
sessionId: string,
|
||||
email: string,
|
||||
cwd: string,
|
||||
model: string,
|
||||
groupSlug?: string | null
|
||||
): UserSession {
|
||||
let session = this.sessions.get(sessionId);
|
||||
|
||||
if (!session) {
|
||||
session = {
|
||||
sessionId,
|
||||
email,
|
||||
cwd,
|
||||
model,
|
||||
piProcess: null,
|
||||
ws: null,
|
||||
lastActivity: Date.now(),
|
||||
idleTimer: null,
|
||||
streamBuffer: "",
|
||||
isGenerating: false,
|
||||
systemContextSent: false,
|
||||
messages: [],
|
||||
meta: {
|
||||
id: sessionId,
|
||||
title: "",
|
||||
model,
|
||||
cwd,
|
||||
groupSlug: groupSlug || null,
|
||||
createdAt: Date.now(),
|
||||
updatedAt: Date.now(),
|
||||
messageCount: 0,
|
||||
cost: {
|
||||
inputTokens: 0,
|
||||
outputTokens: 0,
|
||||
totalUSD: 0,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
this.sessions.set(sessionId, session);
|
||||
|
||||
const userSessionIds = this.userSessions.get(email) || [];
|
||||
userSessionIds.push(sessionId);
|
||||
this.userSessions.set(email, userSessionIds);
|
||||
}
|
||||
|
||||
session.lastActivity = Date.now();
|
||||
return session;
|
||||
}
|
||||
|
||||
getSession(sessionId: string): UserSession | null {
|
||||
return this.sessions.get(sessionId) || null;
|
||||
}
|
||||
|
||||
getUserSessions(email: string): UserSession[] {
|
||||
const sessionIds = this.userSessions.get(email) || [];
|
||||
return sessionIds
|
||||
.map((id) => this.sessions.get(id))
|
||||
.filter((s): s is UserSession => s !== undefined);
|
||||
}
|
||||
|
||||
deleteSession(sessionId: string): void {
|
||||
const session = this.sessions.get(sessionId);
|
||||
if (!session) return;
|
||||
|
||||
if (session.idleTimer) {
|
||||
clearTimeout(session.idleTimer);
|
||||
}
|
||||
|
||||
if (session.piProcess) {
|
||||
session.piProcess.kill();
|
||||
}
|
||||
|
||||
this.sessions.delete(sessionId);
|
||||
|
||||
const userSessionIds = this.userSessions.get(session.email);
|
||||
if (userSessionIds) {
|
||||
const filtered = userSessionIds.filter(
|
||||
(id) => id !== sessionId
|
||||
);
|
||||
if (filtered.length > 0) {
|
||||
this.userSessions.set(session.email, filtered);
|
||||
} else {
|
||||
this.userSessions.delete(session.email);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
attachWs(sessionId: string, ws: any): void {
|
||||
const session = this.sessions.get(sessionId);
|
||||
if (session) {
|
||||
session.ws = ws;
|
||||
session.lastActivity = Date.now();
|
||||
|
||||
if (session.idleTimer) {
|
||||
clearTimeout(session.idleTimer);
|
||||
session.idleTimer = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
detachWs(sessionId: string): void {
|
||||
const session = this.sessions.get(sessionId);
|
||||
if (session) {
|
||||
session.ws = null;
|
||||
session.lastActivity = Date.now();
|
||||
}
|
||||
}
|
||||
|
||||
setIdleTimeout(sessionId: string, timeoutMs: number): void {
|
||||
const session = this.sessions.get(sessionId);
|
||||
if (!session) return;
|
||||
|
||||
if (session.idleTimer) {
|
||||
clearTimeout(session.idleTimer);
|
||||
}
|
||||
|
||||
session.idleTimer = setTimeout(() => {
|
||||
logger.info('Session idle timeout reached, cleaning up', { sessionId, timeoutMs });
|
||||
this.deleteSession(sessionId);
|
||||
}, timeoutMs);
|
||||
}
|
||||
|
||||
updateActivity(sessionId: string): void {
|
||||
const session = this.sessions.get(sessionId);
|
||||
if (session) {
|
||||
session.lastActivity = Date.now();
|
||||
}
|
||||
}
|
||||
|
||||
getAllSessions(): UserSession[] {
|
||||
return Array.from(this.sessions.values());
|
||||
}
|
||||
}
|
||||
|
||||
export const sessionManager = new SessionManager();
|
||||
@@ -0,0 +1,460 @@
|
||||
import * as fs from "fs/promises";
|
||||
import * as path from "path";
|
||||
import type { SessionMeta, Message, GroupMeta } from "./types";
|
||||
|
||||
const PI_SESSIONS_DIR = ".pi-sessions";
|
||||
const GROUP_PREFIX = "@";
|
||||
const GROUP_META_FILE = ".group-meta.json";
|
||||
|
||||
function getSessionDir(cwd: string, sessionId: string, groupSlug?: string | null): string {
|
||||
if (groupSlug) {
|
||||
return path.join(cwd, PI_SESSIONS_DIR, `${GROUP_PREFIX}${groupSlug}`, sessionId);
|
||||
}
|
||||
return path.join(cwd, PI_SESSIONS_DIR, sessionId);
|
||||
}
|
||||
|
||||
function getMetaPath(cwd: string, sessionId: string, groupSlug?: string | null): string {
|
||||
return path.join(getSessionDir(cwd, sessionId, groupSlug), "meta.json");
|
||||
}
|
||||
|
||||
function getMessagesPath(cwd: string, sessionId: string, groupSlug?: string | null): string {
|
||||
return path.join(getSessionDir(cwd, sessionId, groupSlug), "messages.json");
|
||||
}
|
||||
|
||||
function getGroupDir(cwd: string, groupSlug: string): string {
|
||||
return path.join(cwd, PI_SESSIONS_DIR, `${GROUP_PREFIX}${groupSlug}`);
|
||||
}
|
||||
|
||||
function getGroupMetaPath(cwd: string, groupSlug: string): string {
|
||||
return path.join(getGroupDir(cwd, groupSlug), GROUP_META_FILE);
|
||||
}
|
||||
|
||||
async function isGroupDirectory(dirPath: string): Promise<boolean> {
|
||||
try {
|
||||
const groupMetaPath = path.join(dirPath, GROUP_META_FILE);
|
||||
await fs.access(groupMetaPath);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export async function saveSession(
|
||||
cwd: string,
|
||||
sessionId: string,
|
||||
meta: SessionMeta,
|
||||
messages: Message[]
|
||||
): Promise<void> {
|
||||
const sessionDir = getSessionDir(cwd, sessionId, meta.groupSlug);
|
||||
await fs.mkdir(sessionDir, { recursive: true });
|
||||
|
||||
const metaPath = getMetaPath(cwd, sessionId, meta.groupSlug);
|
||||
const messagesPath = getMessagesPath(cwd, sessionId, meta.groupSlug);
|
||||
|
||||
await fs.writeFile(metaPath, JSON.stringify(meta, null, 2));
|
||||
await fs.writeFile(
|
||||
messagesPath,
|
||||
JSON.stringify({ messages }, null, 2)
|
||||
);
|
||||
}
|
||||
|
||||
export async function loadSession(
|
||||
cwd: string,
|
||||
sessionId: string,
|
||||
groupSlug?: string | null
|
||||
): Promise<{ meta: SessionMeta; messages: Message[] }> {
|
||||
const metaPath = getMetaPath(cwd, sessionId, groupSlug);
|
||||
const messagesPath = getMessagesPath(cwd, sessionId, groupSlug);
|
||||
|
||||
const metaContent = await fs.readFile(metaPath, "utf-8");
|
||||
const messagesContent = await fs.readFile(messagesPath, "utf-8");
|
||||
|
||||
const meta: SessionMeta = JSON.parse(metaContent);
|
||||
const { messages } = JSON.parse(messagesContent) as {
|
||||
messages: Message[];
|
||||
};
|
||||
|
||||
return { meta, messages };
|
||||
}
|
||||
|
||||
export async function sessionExists(
|
||||
cwd: string,
|
||||
sessionId: string,
|
||||
groupSlug?: string | null
|
||||
): Promise<boolean> {
|
||||
try {
|
||||
const sessionDir = getSessionDir(cwd, sessionId, groupSlug);
|
||||
await fs.access(sessionDir);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export async function updateSessionMeta(
|
||||
cwd: string,
|
||||
sessionId: string,
|
||||
updates: Partial<SessionMeta>,
|
||||
groupSlug?: string | null
|
||||
): Promise<SessionMeta> {
|
||||
const { meta, messages } = await loadSession(cwd, sessionId, groupSlug);
|
||||
|
||||
const updatedMeta: SessionMeta = {
|
||||
...meta,
|
||||
...updates,
|
||||
updatedAt: Date.now(),
|
||||
};
|
||||
|
||||
await saveSession(cwd, sessionId, updatedMeta, messages);
|
||||
return updatedMeta;
|
||||
}
|
||||
|
||||
export async function deleteSession(
|
||||
cwd: string,
|
||||
sessionId: string,
|
||||
groupSlug?: string | null
|
||||
): Promise<void> {
|
||||
const sessionDir = getSessionDir(cwd, sessionId, groupSlug);
|
||||
await fs.rm(sessionDir, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
export async function listUserSessions(
|
||||
baseCwd: string
|
||||
): Promise<SessionMeta[]> {
|
||||
const sessionsDir = path.join(baseCwd, PI_SESSIONS_DIR);
|
||||
|
||||
try {
|
||||
await fs.access(sessionsDir);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
|
||||
const entries = await fs.readdir(sessionsDir);
|
||||
const sessions: SessionMeta[] = [];
|
||||
|
||||
for (const entry of entries) {
|
||||
const entryPath = path.join(sessionsDir, entry);
|
||||
const stats = await fs.stat(entryPath);
|
||||
|
||||
if (!stats.isDirectory()) continue;
|
||||
|
||||
// Check if it's a group (starts with @)
|
||||
if (entry.startsWith(GROUP_PREFIX)) {
|
||||
const groupSlug = entry.slice(GROUP_PREFIX.length);
|
||||
const groupSessions = await fs.readdir(entryPath);
|
||||
|
||||
for (const sessionId of groupSessions) {
|
||||
if (sessionId === GROUP_META_FILE) continue;
|
||||
|
||||
try {
|
||||
const metaPath = getMetaPath(baseCwd, sessionId, groupSlug);
|
||||
const metaContent = await fs.readFile(metaPath, "utf-8");
|
||||
const meta: SessionMeta = JSON.parse(metaContent);
|
||||
sessions.push(meta);
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Regular ungrouped session
|
||||
try {
|
||||
const metaPath = getMetaPath(baseCwd, entry);
|
||||
const metaContent = await fs.readFile(metaPath, "utf-8");
|
||||
const meta: SessionMeta = JSON.parse(metaContent);
|
||||
sessions.push(meta);
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
sessions.sort((a, b) => b.updatedAt - a.updatedAt);
|
||||
return sessions;
|
||||
}
|
||||
|
||||
export async function searchSessions(
|
||||
baseCwd: string,
|
||||
query: string
|
||||
): Promise<
|
||||
Array<SessionMeta & { preview?: string; relevance?: number }>
|
||||
> {
|
||||
const sessionsDir = path.join(baseCwd, PI_SESSIONS_DIR);
|
||||
|
||||
try {
|
||||
await fs.access(sessionsDir);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
|
||||
const entries = await fs.readdir(sessionsDir);
|
||||
const results: Array<
|
||||
SessionMeta & { preview?: string; relevance?: number }
|
||||
> = [];
|
||||
const lowerQuery = query.toLowerCase();
|
||||
|
||||
for (const entry of entries) {
|
||||
const entryPath = path.join(sessionsDir, entry);
|
||||
const stats = await fs.stat(entryPath);
|
||||
|
||||
if (!stats.isDirectory()) continue;
|
||||
|
||||
// Check if it's a group
|
||||
if (entry.startsWith(GROUP_PREFIX)) {
|
||||
const groupSlug = entry.slice(GROUP_PREFIX.length);
|
||||
|
||||
// Search in group metadata
|
||||
try {
|
||||
const groupMetaPath = getGroupMetaPath(baseCwd, groupSlug);
|
||||
const groupMetaContent = await fs.readFile(groupMetaPath, "utf-8");
|
||||
const groupMeta: GroupMeta = JSON.parse(groupMetaContent);
|
||||
|
||||
let groupRelevance = 0;
|
||||
if (groupMeta.name.toLowerCase().includes(lowerQuery)) {
|
||||
groupRelevance += 2.0;
|
||||
}
|
||||
if (groupMeta.description?.toLowerCase().includes(lowerQuery)) {
|
||||
groupRelevance += 1.5;
|
||||
}
|
||||
|
||||
// Search sessions in group
|
||||
const groupSessions = await fs.readdir(entryPath);
|
||||
for (const sessionId of groupSessions) {
|
||||
if (sessionId === GROUP_META_FILE) continue;
|
||||
|
||||
try {
|
||||
const { meta, messages } = await loadSession(baseCwd, sessionId, groupSlug);
|
||||
let relevance = groupRelevance;
|
||||
let preview = "";
|
||||
|
||||
if (meta.title.toLowerCase().includes(lowerQuery)) {
|
||||
relevance += 1.0;
|
||||
preview = meta.title;
|
||||
}
|
||||
|
||||
for (const msg of messages) {
|
||||
if (msg.text && msg.text.toLowerCase().includes(lowerQuery)) {
|
||||
relevance += 0.5;
|
||||
if (!preview) {
|
||||
const index = msg.text.toLowerCase().indexOf(lowerQuery);
|
||||
const start = Math.max(0, index - 50);
|
||||
const end = Math.min(msg.text.length, index + query.length + 50);
|
||||
preview = "..." + msg.text.slice(start, end) + "...";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (relevance > 0) {
|
||||
results.push({ ...meta, preview, relevance });
|
||||
}
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
} else {
|
||||
// Regular ungrouped session
|
||||
try {
|
||||
const { meta, messages } = await loadSession(baseCwd, entry);
|
||||
let relevance = 0;
|
||||
let preview = "";
|
||||
|
||||
if (meta.title.toLowerCase().includes(lowerQuery)) {
|
||||
relevance += 1.0;
|
||||
preview = meta.title;
|
||||
}
|
||||
|
||||
for (const msg of messages) {
|
||||
if (msg.text && msg.text.toLowerCase().includes(lowerQuery)) {
|
||||
relevance += 0.5;
|
||||
if (!preview) {
|
||||
const index = msg.text.toLowerCase().indexOf(lowerQuery);
|
||||
const start = Math.max(0, index - 50);
|
||||
const end = Math.min(msg.text.length, index + query.length + 50);
|
||||
preview = "..." + msg.text.slice(start, end) + "...";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (relevance > 0) {
|
||||
results.push({ ...meta, preview, relevance });
|
||||
}
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
results.sort((a, b) => (b.relevance || 0) - (a.relevance || 0));
|
||||
return results;
|
||||
}
|
||||
|
||||
/**
|
||||
* Group Management Functions
|
||||
*/
|
||||
|
||||
export async function saveGroup(
|
||||
cwd: string,
|
||||
groupMeta: GroupMeta
|
||||
): Promise<void> {
|
||||
const groupDir = getGroupDir(cwd, groupMeta.slug);
|
||||
await fs.mkdir(groupDir, { recursive: true });
|
||||
|
||||
const groupMetaPath = getGroupMetaPath(cwd, groupMeta.slug);
|
||||
await fs.writeFile(groupMetaPath, JSON.stringify(groupMeta, null, 2));
|
||||
}
|
||||
|
||||
export async function loadGroup(
|
||||
cwd: string,
|
||||
groupSlug: string
|
||||
): Promise<GroupMeta> {
|
||||
const groupMetaPath = getGroupMetaPath(cwd, groupSlug);
|
||||
const groupMetaContent = await fs.readFile(groupMetaPath, "utf-8");
|
||||
return JSON.parse(groupMetaContent) as GroupMeta;
|
||||
}
|
||||
|
||||
export async function groupExists(
|
||||
cwd: string,
|
||||
groupSlug: string
|
||||
): Promise<boolean> {
|
||||
try {
|
||||
const groupMetaPath = getGroupMetaPath(cwd, groupSlug);
|
||||
await fs.access(groupMetaPath);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export async function listGroups(
|
||||
baseCwd: string
|
||||
): Promise<GroupMeta[]> {
|
||||
const sessionsDir = path.join(baseCwd, PI_SESSIONS_DIR);
|
||||
|
||||
try {
|
||||
await fs.access(sessionsDir);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
|
||||
const entries = await fs.readdir(sessionsDir);
|
||||
const groups: GroupMeta[] = [];
|
||||
|
||||
for (const entry of entries) {
|
||||
if (!entry.startsWith(GROUP_PREFIX)) continue;
|
||||
|
||||
const groupSlug = entry.slice(GROUP_PREFIX.length);
|
||||
try {
|
||||
const groupMeta = await loadGroup(baseCwd, groupSlug);
|
||||
groups.push(groupMeta);
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
groups.sort((a, b) => b.updatedAt - a.updatedAt);
|
||||
return groups;
|
||||
}
|
||||
|
||||
export async function updateGroupMeta(
|
||||
cwd: string,
|
||||
groupSlug: string,
|
||||
updates: Partial<GroupMeta>
|
||||
): Promise<GroupMeta> {
|
||||
const groupMeta = await loadGroup(cwd, groupSlug);
|
||||
|
||||
const updatedGroupMeta: GroupMeta = {
|
||||
...groupMeta,
|
||||
...updates,
|
||||
slug: groupMeta.slug, // Prevent slug changes
|
||||
updatedAt: Date.now(),
|
||||
};
|
||||
|
||||
await saveGroup(cwd, updatedGroupMeta);
|
||||
return updatedGroupMeta;
|
||||
}
|
||||
|
||||
export async function deleteGroup(
|
||||
cwd: string,
|
||||
groupSlug: string
|
||||
): Promise<void> {
|
||||
const groupDir = getGroupDir(cwd, groupSlug);
|
||||
|
||||
// Move all sessions in the group to root level
|
||||
try {
|
||||
const entries = await fs.readdir(groupDir);
|
||||
const sessionsDir = path.join(cwd, PI_SESSIONS_DIR);
|
||||
|
||||
for (const entry of entries) {
|
||||
if (entry === GROUP_META_FILE) continue;
|
||||
|
||||
const sessionDir = path.join(groupDir, entry);
|
||||
const destDir = path.join(sessionsDir, entry);
|
||||
|
||||
// Update session meta to remove groupSlug
|
||||
try {
|
||||
const metaPath = path.join(sessionDir, "meta.json");
|
||||
const metaContent = await fs.readFile(metaPath, "utf-8");
|
||||
const meta: SessionMeta = JSON.parse(metaContent);
|
||||
meta.groupSlug = null;
|
||||
await fs.writeFile(metaPath, JSON.stringify(meta, null, 2));
|
||||
} catch {
|
||||
// Continue even if meta update fails
|
||||
}
|
||||
|
||||
// Move session directory
|
||||
await fs.rename(sessionDir, destDir);
|
||||
}
|
||||
} catch (err) {
|
||||
// Continue to delete group even if moving fails
|
||||
}
|
||||
|
||||
// Delete the group directory
|
||||
await fs.rm(groupDir, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
export async function moveSession(
|
||||
cwd: string,
|
||||
sessionId: string,
|
||||
fromGroupSlug: string | null,
|
||||
toGroupSlug: string | null
|
||||
): Promise<SessionMeta> {
|
||||
// Load the session
|
||||
const { meta, messages } = await loadSession(cwd, sessionId, fromGroupSlug);
|
||||
|
||||
// Update groupSlug
|
||||
meta.groupSlug = toGroupSlug;
|
||||
meta.updatedAt = Date.now();
|
||||
|
||||
// Save to new location
|
||||
await saveSession(cwd, sessionId, meta, messages);
|
||||
|
||||
// Delete from old location
|
||||
await deleteSession(cwd, sessionId, fromGroupSlug);
|
||||
|
||||
// Update session count in groups
|
||||
if (fromGroupSlug) {
|
||||
try {
|
||||
const fromGroup = await loadGroup(cwd, fromGroupSlug);
|
||||
fromGroup.sessionCount = Math.max(0, fromGroup.sessionCount - 1);
|
||||
fromGroup.updatedAt = Date.now();
|
||||
await saveGroup(cwd, fromGroup);
|
||||
} catch {
|
||||
// Group might not exist
|
||||
}
|
||||
}
|
||||
|
||||
if (toGroupSlug) {
|
||||
try {
|
||||
const toGroup = await loadGroup(cwd, toGroupSlug);
|
||||
toGroup.sessionCount += 1;
|
||||
toGroup.updatedAt = Date.now();
|
||||
await saveGroup(cwd, toGroup);
|
||||
} catch {
|
||||
// Group might not exist
|
||||
}
|
||||
}
|
||||
|
||||
return meta;
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
export type Message = {
|
||||
id: string;
|
||||
timestamp: number;
|
||||
role: "user" | "assistant" | "tool";
|
||||
text?: string;
|
||||
model?: string;
|
||||
cost?: MessageCost;
|
||||
toolCallId?: string;
|
||||
toolName?: string;
|
||||
toolInput?: Record<string, unknown>;
|
||||
output?: string;
|
||||
isError?: boolean;
|
||||
};
|
||||
|
||||
export type MessageCost = {
|
||||
inputTokens: number;
|
||||
outputTokens: number;
|
||||
totalUSD: number;
|
||||
};
|
||||
|
||||
export type SessionMeta = {
|
||||
id: string;
|
||||
title: string;
|
||||
model: string;
|
||||
cwd: string;
|
||||
createdAt: number;
|
||||
updatedAt: number;
|
||||
messageCount: number;
|
||||
cost: MessageCost;
|
||||
groupSlug?: string | null;
|
||||
};
|
||||
|
||||
export type GroupMeta = {
|
||||
name: string;
|
||||
slug: string;
|
||||
description?: string;
|
||||
createdAt: number;
|
||||
updatedAt: number;
|
||||
sessionCount: number;
|
||||
};
|
||||
|
||||
export type ClientMessage =
|
||||
| {
|
||||
type: "chat";
|
||||
prompt: string;
|
||||
sessionId?: string;
|
||||
model?: string;
|
||||
cwd?: string;
|
||||
groupSlug?: string;
|
||||
attachmentIds?: string[];
|
||||
}
|
||||
| {
|
||||
type: "resume";
|
||||
sessionId: string;
|
||||
}
|
||||
| {
|
||||
type: "stop";
|
||||
};
|
||||
|
||||
export type ServerMessage =
|
||||
| {
|
||||
type: "session:init";
|
||||
sessionId: string;
|
||||
model: string;
|
||||
cwd: string;
|
||||
}
|
||||
| {
|
||||
type: "assistant:text";
|
||||
text: string;
|
||||
}
|
||||
| {
|
||||
type: "assistant:delta";
|
||||
text: string;
|
||||
}
|
||||
| {
|
||||
type: "tool:start";
|
||||
toolCallId: string;
|
||||
toolName: string;
|
||||
toolInput: Record<string, unknown>;
|
||||
}
|
||||
| {
|
||||
type: "tool:result";
|
||||
toolCallId: string;
|
||||
output: string;
|
||||
isError: boolean;
|
||||
}
|
||||
| {
|
||||
type: "result";
|
||||
sessionId: string;
|
||||
cost: MessageCost;
|
||||
}
|
||||
| {
|
||||
type: "sync:messages";
|
||||
sessionId: string;
|
||||
messages: Message[];
|
||||
isGenerating: boolean;
|
||||
streamingText: string;
|
||||
}
|
||||
| {
|
||||
type: "error";
|
||||
message: string;
|
||||
errorCode?: string;
|
||||
}
|
||||
| {
|
||||
type: "stopped";
|
||||
};
|
||||
|
||||
export type PiEvent =
|
||||
| { type: "text"; text: string }
|
||||
| { type: "delta"; text: string }
|
||||
| {
|
||||
type: "tool:start";
|
||||
toolCallId: string;
|
||||
toolName: string;
|
||||
toolInput: Record<string, unknown>;
|
||||
}
|
||||
| {
|
||||
type: "tool:result";
|
||||
toolCallId: string;
|
||||
output: string;
|
||||
isError: boolean;
|
||||
}
|
||||
| {
|
||||
type: "result";
|
||||
cost: MessageCost;
|
||||
}
|
||||
| { type: "error"; message: string }
|
||||
| { type: "stopped" };
|
||||
|
||||
export type UserSession = {
|
||||
sessionId: string;
|
||||
email: string;
|
||||
cwd: string;
|
||||
model: string;
|
||||
piProcess: any | null;
|
||||
ws: any | null;
|
||||
lastActivity: number;
|
||||
idleTimer: Timer | null;
|
||||
streamBuffer: string;
|
||||
isGenerating: boolean;
|
||||
systemContextSent: boolean;
|
||||
messages: Message[];
|
||||
meta: SessionMeta;
|
||||
};
|
||||
|
||||
export type ModelInfo = {
|
||||
id: string;
|
||||
name: string;
|
||||
provider: string;
|
||||
contextWindow: number;
|
||||
maxTokens: number;
|
||||
};
|
||||
@@ -0,0 +1,402 @@
|
||||
import type { ServerWebSocket } from 'bun';
|
||||
import { randomUUID } from 'crypto';
|
||||
import type { ClientMessage, ServerMessage, Message } from './types';
|
||||
import { sessionManager } from './session-manager';
|
||||
import * as storage from './storage';
|
||||
import * as piBridge from './pi-bridge';
|
||||
import { getHomeDir } from '../../../servers/data-path';
|
||||
import { logger } from './logger';
|
||||
|
||||
/**
|
||||
* WebSocket Handler — Pi chat session lifecycle
|
||||
*/
|
||||
|
||||
type WSData = {
|
||||
userId: number;
|
||||
email: string;
|
||||
role: string;
|
||||
provider: string;
|
||||
};
|
||||
|
||||
const IDLE_TIMEOUT_MS = 60 * 60 * 1000; // 1 hour
|
||||
|
||||
// Track which session is attached to which WebSocket
|
||||
const wsToSessionMap = new WeakMap<any, string>();
|
||||
|
||||
/**
|
||||
* Build system prompt with conversation history
|
||||
*/
|
||||
function buildSystemPrompt(messages: Message[], homeDir: string, skills: string): string {
|
||||
const history = messages
|
||||
.map((msg) => {
|
||||
if (msg.role === 'user') return `user: ${msg.text}`;
|
||||
if (msg.role === 'assistant') return `assistant: ${msg.text}`;
|
||||
if (msg.role === 'tool') return `tool(${msg.toolName}): ${msg.output}`;
|
||||
return '';
|
||||
})
|
||||
.filter(Boolean)
|
||||
.join('\n');
|
||||
|
||||
return `
|
||||
<system>
|
||||
User home directory: ${homeDir}
|
||||
${skills}
|
||||
|
||||
Below is the conversation history from this session:
|
||||
<conversation_history>
|
||||
${history}
|
||||
</conversation_history>
|
||||
</system>
|
||||
`;
|
||||
}
|
||||
|
||||
/**
|
||||
* WebSocket open handler
|
||||
*/
|
||||
export async function open(ws: ServerWebSocket<WSData>): Promise<void> {
|
||||
logger.info('WebSocket connection opened', { email: ws.data.email });
|
||||
}
|
||||
|
||||
/**
|
||||
* WebSocket message handler
|
||||
*/
|
||||
export function message(ws: ServerWebSocket<WSData>, raw: string | Buffer): void {
|
||||
const data = typeof raw === 'string' ? raw : raw.toString();
|
||||
|
||||
(async () => {
|
||||
try {
|
||||
const clientMsg = JSON.parse(data) as ClientMessage;
|
||||
|
||||
if (clientMsg.type === 'chat') {
|
||||
await handleChat(ws, clientMsg);
|
||||
} else if (clientMsg.type === 'resume') {
|
||||
await handleResume(ws, clientMsg);
|
||||
} else if (clientMsg.type === 'stop') {
|
||||
await handleStop(ws);
|
||||
}
|
||||
} catch (err) {
|
||||
logger.error('Error handling WebSocket message', { email: ws.data.email, error: String(err) });
|
||||
const errorMsg: ServerMessage = {
|
||||
type: 'error',
|
||||
message: 'Failed to process message',
|
||||
};
|
||||
ws.send(JSON.stringify(errorMsg));
|
||||
}
|
||||
})();
|
||||
}
|
||||
|
||||
/**
|
||||
* WebSocket close handler
|
||||
*/
|
||||
export function close(ws: ServerWebSocket<WSData>): void {
|
||||
logger.info('WebSocket connection closed', { email: ws.data.email });
|
||||
|
||||
const sessionId = wsToSessionMap.get(ws);
|
||||
if (sessionId) {
|
||||
sessionManager.detachWs(sessionId);
|
||||
sessionManager.setIdleTimeout(sessionId, IDLE_TIMEOUT_MS);
|
||||
logger.info('Started idle timeout for session', { sessionId, timeoutMs: IDLE_TIMEOUT_MS });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle chat message
|
||||
*/
|
||||
async function handleChat(
|
||||
ws: ServerWebSocket<WSData>,
|
||||
msg: { prompt: string; sessionId?: string; model?: string; cwd?: string; groupSlug?: string; attachmentIds?: string[] }
|
||||
): Promise<void> {
|
||||
const { email } = ws.data;
|
||||
const sessionId = msg.sessionId || randomUUID();
|
||||
const model = msg.model || 'gpt-4o'; // Default model
|
||||
const cwd = msg.cwd || getHomeDir(email);
|
||||
const groupSlug = msg.groupSlug || null;
|
||||
|
||||
// Get or create session
|
||||
const session = sessionManager.getOrCreate(sessionId, email, cwd, model, groupSlug);
|
||||
sessionManager.attachWs(sessionId, ws);
|
||||
wsToSessionMap.set(ws as any, sessionId);
|
||||
|
||||
// Send session init
|
||||
const initMsg: ServerMessage = {
|
||||
type: 'session:init',
|
||||
sessionId,
|
||||
model,
|
||||
cwd,
|
||||
};
|
||||
ws.send(JSON.stringify(initMsg));
|
||||
|
||||
// Spawn Pi process if not already running
|
||||
if (!session.piProcess) {
|
||||
try {
|
||||
session.piProcess = await piBridge.spawnPi(cwd, model);
|
||||
logger.info('Spawned Pi process for session', { sessionId, model, cwd });
|
||||
} catch (err) {
|
||||
logger.error('Failed to spawn Pi process', { sessionId, model, error: String(err) });
|
||||
const errorMsg: ServerMessage = {
|
||||
type: 'error',
|
||||
message: 'Failed to start Pi process',
|
||||
};
|
||||
ws.send(JSON.stringify(errorMsg));
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Add user message to session
|
||||
const userMsg: Message = {
|
||||
id: randomUUID(),
|
||||
timestamp: Date.now(),
|
||||
role: 'user',
|
||||
text: msg.prompt,
|
||||
};
|
||||
session.messages.push(userMsg);
|
||||
session.meta.messageCount += 1;
|
||||
session.meta.updatedAt = Date.now();
|
||||
|
||||
// Initialize title from first message
|
||||
if (!session.meta.title) {
|
||||
session.meta.title = msg.prompt.slice(0, 100);
|
||||
}
|
||||
|
||||
// Send prompt to Pi
|
||||
const requestId = randomUUID();
|
||||
session.isGenerating = true;
|
||||
piBridge.sendPrompt(session.piProcess, msg.prompt, requestId);
|
||||
|
||||
// Track assistant message and cost
|
||||
let assistantText = '';
|
||||
let assistantMsgId = randomUUID();
|
||||
|
||||
// Stream responses from Pi
|
||||
try {
|
||||
for await (const event of piBridge.readEvents(session.piProcess)) {
|
||||
if (event.type === 'text') {
|
||||
const textMsg: ServerMessage = {
|
||||
type: 'assistant:text',
|
||||
text: event.text,
|
||||
};
|
||||
ws.send(JSON.stringify(textMsg));
|
||||
assistantText = event.text;
|
||||
} else if (event.type === 'delta') {
|
||||
const deltaMsg: ServerMessage = {
|
||||
type: 'assistant:delta',
|
||||
text: event.text,
|
||||
};
|
||||
ws.send(JSON.stringify(deltaMsg));
|
||||
session.streamBuffer += event.text;
|
||||
} else if (event.type === 'tool:start') {
|
||||
const toolStartMsg: ServerMessage = {
|
||||
type: 'tool:start',
|
||||
toolCallId: event.toolCallId,
|
||||
toolName: event.toolName,
|
||||
toolInput: event.toolInput,
|
||||
};
|
||||
ws.send(JSON.stringify(toolStartMsg));
|
||||
} else if (event.type === 'tool:result') {
|
||||
const toolResultMsg: ServerMessage = {
|
||||
type: 'tool:result',
|
||||
toolCallId: event.toolCallId,
|
||||
output: event.output,
|
||||
isError: event.isError,
|
||||
};
|
||||
ws.send(JSON.stringify(toolResultMsg));
|
||||
|
||||
// Add tool message to history
|
||||
const toolMsg: Message = {
|
||||
id: randomUUID(),
|
||||
timestamp: Date.now(),
|
||||
role: 'tool',
|
||||
toolCallId: event.toolCallId,
|
||||
toolName: event.toolCallId.split(':')[0] || 'unknown',
|
||||
output: event.output,
|
||||
isError: event.isError,
|
||||
};
|
||||
session.messages.push(toolMsg);
|
||||
session.meta.messageCount += 1;
|
||||
} else if (event.type === 'result') {
|
||||
const resultMsg: ServerMessage = {
|
||||
type: 'result',
|
||||
sessionId,
|
||||
cost: event.cost,
|
||||
};
|
||||
ws.send(JSON.stringify(resultMsg));
|
||||
session.isGenerating = false;
|
||||
|
||||
// Add assistant message with cost
|
||||
const assistantMsg: Message = {
|
||||
id: assistantMsgId,
|
||||
timestamp: Date.now(),
|
||||
role: 'assistant',
|
||||
text: assistantText || session.streamBuffer,
|
||||
model,
|
||||
cost: event.cost,
|
||||
};
|
||||
session.messages.push(assistantMsg);
|
||||
session.meta.messageCount += 1;
|
||||
session.meta.cost.inputTokens += event.cost.inputTokens;
|
||||
session.meta.cost.outputTokens += event.cost.outputTokens;
|
||||
session.meta.cost.totalUSD += event.cost.totalUSD;
|
||||
session.meta.updatedAt = Date.now();
|
||||
|
||||
// Save session to disk
|
||||
try {
|
||||
await storage.saveSession(cwd, sessionId, session.meta, session.messages);
|
||||
logger.info('Session saved to disk', { sessionId, messageCount: session.messages.length });
|
||||
} catch (err) {
|
||||
logger.error('Failed to save session', { sessionId, error: String(err) });
|
||||
}
|
||||
|
||||
// Clear streaming buffer
|
||||
session.streamBuffer = '';
|
||||
} else if (event.type === 'error') {
|
||||
const errorMsg: ServerMessage = {
|
||||
type: 'error',
|
||||
message: event.message,
|
||||
};
|
||||
ws.send(JSON.stringify(errorMsg));
|
||||
session.isGenerating = false;
|
||||
} else if (event.type === 'stopped') {
|
||||
session.isGenerating = false;
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
logger.error('Error streaming from Pi', { sessionId, error: String(err) });
|
||||
const errorMsg: ServerMessage = {
|
||||
type: 'error',
|
||||
message: 'Stream error',
|
||||
};
|
||||
ws.send(JSON.stringify(errorMsg));
|
||||
session.isGenerating = false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle resume message
|
||||
*/
|
||||
async function handleResume(
|
||||
ws: ServerWebSocket<WSData>,
|
||||
msg: { sessionId: string }
|
||||
): Promise<void> {
|
||||
const { email } = ws.data;
|
||||
const { sessionId } = msg;
|
||||
|
||||
try {
|
||||
// First, check if session exists in memory
|
||||
let session = sessionManager.getSession(sessionId);
|
||||
|
||||
if (!session) {
|
||||
// Load from disk
|
||||
// We need to try finding it - iterate through potential cwds
|
||||
const homeDir = getHomeDir(email);
|
||||
|
||||
try {
|
||||
const { meta, messages } = await storage.loadSession(homeDir, sessionId);
|
||||
|
||||
// Recreate session in memory
|
||||
session = sessionManager.getOrCreate(
|
||||
sessionId,
|
||||
email,
|
||||
meta.cwd,
|
||||
meta.model
|
||||
);
|
||||
session.messages = messages;
|
||||
session.meta = meta;
|
||||
|
||||
logger.info('Loaded session from disk', { sessionId, messageCount: messages.length });
|
||||
} catch (err) {
|
||||
logger.error('Failed to load session from disk', { sessionId, error: String(err) });
|
||||
const errorMsg: ServerMessage = {
|
||||
type: 'error',
|
||||
message: 'Session not found',
|
||||
errorCode: 'SESSION_NOT_FOUND',
|
||||
};
|
||||
ws.send(JSON.stringify(errorMsg));
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Attach WebSocket to session
|
||||
sessionManager.attachWs(sessionId, ws);
|
||||
wsToSessionMap.set(ws as any, sessionId);
|
||||
|
||||
// Send session init
|
||||
const initMsg: ServerMessage = {
|
||||
type: 'session:init',
|
||||
sessionId,
|
||||
model: session.model,
|
||||
cwd: session.cwd,
|
||||
};
|
||||
ws.send(JSON.stringify(initMsg));
|
||||
|
||||
// Spawn fresh Pi process
|
||||
if (!session.piProcess) {
|
||||
try {
|
||||
session.piProcess = await piBridge.spawnPi(session.cwd, session.model);
|
||||
logger.info('Spawned fresh Pi process for resumed session', { sessionId, model: session.model });
|
||||
} catch (err) {
|
||||
logger.error('Failed to spawn Pi process for resume', { sessionId, error: String(err) });
|
||||
const errorMsg: ServerMessage = {
|
||||
type: 'error',
|
||||
message: 'Failed to start Pi process',
|
||||
};
|
||||
ws.send(JSON.stringify(errorMsg));
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Send full sync with history
|
||||
const syncMsg: ServerMessage = {
|
||||
type: 'sync:messages',
|
||||
sessionId,
|
||||
messages: session.messages,
|
||||
isGenerating: session.isGenerating,
|
||||
streamingText: session.streamBuffer,
|
||||
};
|
||||
ws.send(JSON.stringify(syncMsg));
|
||||
|
||||
logger.info('Session resumed successfully', { sessionId, messageCount: session.messages.length });
|
||||
} catch (err) {
|
||||
logger.error('Unexpected error in handleResume', { sessionId, error: String(err) });
|
||||
const errorMsg: ServerMessage = {
|
||||
type: 'error',
|
||||
message: 'Failed to resume session',
|
||||
};
|
||||
ws.send(JSON.stringify(errorMsg));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle stop message
|
||||
*/
|
||||
async function handleStop(ws: ServerWebSocket<WSData>): Promise<void> {
|
||||
const sessionId = wsToSessionMap.get(ws);
|
||||
|
||||
if (sessionId) {
|
||||
const session = sessionManager.getSession(sessionId);
|
||||
|
||||
if (session && session.piProcess) {
|
||||
try {
|
||||
// Send abort to Pi process
|
||||
const requestId = randomUUID();
|
||||
piBridge.abort(session.piProcess, requestId);
|
||||
|
||||
logger.info('Sent abort to Pi process', { sessionId });
|
||||
session.isGenerating = false;
|
||||
} catch (err) {
|
||||
logger.error('Failed to abort Pi process', { sessionId, error: String(err) });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const stoppedMsg: ServerMessage = {
|
||||
type: 'stopped',
|
||||
};
|
||||
ws.send(JSON.stringify(stoppedMsg));
|
||||
}
|
||||
|
||||
export const piWebsocket = {
|
||||
open,
|
||||
message,
|
||||
close,
|
||||
drain() {},
|
||||
};
|
||||
Reference in New Issue
Block a user