Files
platform/PI_HARNESS_REBUILD.md
T
pastilhas ba51ee0320 Complete frontend migration to unified Pi harness (Phase 7 + Phase 9)
- Delete legacy hooks: useClaude.ts, useOpenCode.ts, usePiMono.ts, App.backup.tsx
- Update all components to use usePi instead of legacy hooks
- Replace useVisiblePiMonoModels/useClaudeModels/useOpenCodeModels with useVisiblePiModels
- Migrate from LegacyChatMessage to ChatMessage type throughout
- Update SessionBar to remove provider and archive props
- Simplify ChatDetailPanel to Pi-only (remove Claude/OpenCode components)
- Fix useChatSessions calls (remove provider parameter)
- Update user-settings types: provider now only 'pi' instead of legacy values
- Update PI_HARNESS_REBUILD.md to mark phases complete
2026-02-20 21:42:26 +00:00

1417 lines
36 KiB
Markdown

# Pi Harness Rebuild — Implementation Plan
**Status**: ✅ COMPLETE — Phase 9 (Final Cleanup)
**Date**: February 20, 2026
**Scope**: Replace all three legacy harnesses (Claude, OpenCode, Pi-Mono) with single, clean Pi harness
---
## ✅ Implementation Status
### Backend (Complete)
- [x] **Phase 1**: Setup & Cleanup — Complete
- [x] **Phase 2**: Core Infrastructure — Complete
- [x] **Phase 3**: WebSocket Handler — Complete
- [x] **Phase 4**: REST Endpoints — Complete
- [x] **Phase 5**: Integration — Complete
- [x] **Phase 6**: Cleanup & Polish — Complete
- [x] **Phase 6.1**: Session Grouping — Complete
### Frontend (Complete)
- [x] **Phase 7.1**: Type Alignment — Complete ✅
- [x] **Phase 7.2**: Unified Pi Hook (`usePi.ts`) — Complete ✅
- [x] **Phase 7.3**: Unified Models Hook — Complete ✅
- [x] **Phase 7.4**: Session Management Migration — Complete ✅
- [x] **Phase 7.5**: Group Support Hooks — Complete ✅
### UI Enhancements (Future)
- [ ] **Phase 8.1**: Grouped ChatList UI
- [ ] **Phase 8.2**: Group Management UI
- [ ] **Phase 8.3**: Search Enhancements
### Final Cleanup (Complete)
- [x] **Phase 9.1**: Frontend Legacy Cleanup — Complete ✅
- [x] **Phase 9.2**: Backend Final Cleanup — Complete ✅
---
## Table of Contents
- [Decisions Made](#decisions-made)
- [Cleanup Phase](#cleanup-phase)
- [New Architecture](#new-architecture)
- [Wire Protocol](#wire-protocol)
- [File Structure](#file-structure)
- [Implementation Steps](#implementation-steps)
- [Phase 6.1: Session Grouping](#phase-61-session-grouping--detailed-implementation)
- [Phase 7: Frontend Migration](#phase-7-frontend-migration--detailed-implementation)
- [Phase 8: UI Enhancements](#phase-8-ui-enhancements)
- [Phase 9: Final Cleanup](#phase-9-final-cleanup)
- [Implementation Details](#implementation-details)
---
## Decisions Made
### Session Lifecycle
- **One Pi process per chat session** (not per user)
- Spawned on demand when new chat starts
- Killed on idle timeout: **1 hour**
- Users can have multiple concurrent sessions with different models (fully isolated)
### Resuming Old Sessions
- Load messages from disk: `{cwd}/{sessionId}/messages.json`
- Spawn fresh Pi process
- Inject full conversation history in system prompt
- User continues seamlessly
### Default Working Directory
- If not provided by frontend, use: `getHomeDir(email)` (user's home directory)
- Frontend sends `cwd` with chat spawn request
### Session Storage Location
```
{userCwd}/{sessionId}/
├── meta.json (metadata: id, title, model, cwd, timestamps, cost)
└── messages.json (full conversation history)
```
### API Design
- **Option B**: Separate REST + WebSocket
- REST: stateless operations (models, sessions, search)
- WebSocket: stateful, real-time streaming (chat)
### Wire Protocol
- **Redesigned from scratch** for clarity and simplicity
- Keeps all existing features: streaming, tools, history, models, cwd, skills
---
## Cleanup Phase
### Files to Delete
**Backend API directories:**
```
src/servers/api/claude/ (entire directory)
src/servers/api/opencode/ (entire directory)
src/servers/api/pi-mono/ (entire directory)
```
**References to remove from `src/server.tsx`:**
- `/api/harness/claudecode/ws` route
- `/api/harness/opencode/ws` route
- `/api/harness/pi-mono/ws` route
- Remove handlers Map and provider routing logic
- Keep only `/api/pi/chat/ws` for new harness
**References to remove from `src/servers/hono.ts`:**
```ts
import { claudeModelsRouter } from './api/claude/sessions';
import { opencodeModelsRouter } from './api/opencode/sessions';
import { piMonoModelsRouter } from './api/pi-mono/sessions';
// Remove from protectedRouter:
protectedRouter.route('/', claudeModelsRouter);
protectedRouter.route('/', opencodeModelsRouter);
protectedRouter.route('/', piMonoModelsRouter);
```
**Type definitions to review:**
- `src/servers/api/chat-types.ts` — Keep but clean up for Pi only
---
## New Architecture
```
┌─ Backend (Bun/Hono)
├─ src/servers/api/pi/ ← NEW HARNESS
│ ├── types.ts (ClientMessage, ServerMessage, types)
│ ├── websocket.ts (WebSocket bridge, Pi lifecycle)
│ ├── rest.ts (REST endpoints: models, sessions, search)
│ ├── storage.ts (Session persistence, disk I/O)
│ ├── pi-bridge.ts (Pi RPC communication)
│ └── session-manager.ts (In-memory session tracking)
├─ src/servers/hono.ts (Wire Pi routes)
├─ src/server.tsx (Wire Pi WebSocket)
└─ src/workspaces/
└─ data-path.ts or similar (User home dir, session paths)
```
---
## Wire Protocol
### Client → Server
```typescript
// Start new chat
{
type: "chat";
prompt: string;
sessionId?: string; // omit for new, include for resume
model?: string; // e.g., "big-pickle", "claude-opus-4-5"
cwd?: string; // working directory, defaults to user home
attachmentIds?: string[]; // file IDs to attach
}
// Resume existing chat
{
type: "resume";
sessionId: string;
}
// Stop generation
{
type: "stop";
}
```
### Server → Client
```typescript
// Session initialized
{
type: "session:init";
sessionId: string;
model: string;
cwd: string;
}
// Assistant text (complete block)
{
type: "assistant:text";
text: string;
}
// Assistant text (streaming delta)
{
type: "assistant:delta";
text: string;
}
// Tool execution started
{
type: "tool:start";
toolCallId: string;
toolName: string;
toolInput: Record<string, unknown>;
}
// Tool execution completed
{
type: "tool:result";
toolCallId: string;
output: string;
isError: boolean;
}
// Generation completed
{
type: "result";
sessionId: string;
cost: {
inputTokens: number;
outputTokens: number;
totalUSD: number;
};
}
// Full sync (for resume)
{
type: "sync:messages";
sessionId: string;
messages: Message[]; // full history
isGenerating: boolean;
streamingText: string;
}
// Error occurred
{
type: "error";
message: string;
errorCode?: string;
}
// Generation stopped by user
{
type: "stopped";
}
```
---
## File Structure
### Disk Storage
```
{userCwd}/
└── .pi-sessions/
└── {sessionId}/
├── meta.json
└── messages.json
```
**meta.json:**
```json
{
"id": "uuid-string",
"title": "First 100 chars of prompt",
"model": "gpt-4o",
"cwd": "/home/user/my-project",
"createdAt": 1708396000000,
"updatedAt": 1708396000000,
"messageCount": 42,
"cost": {
"inputTokens": 5000,
"outputTokens": 3000,
"totalUSD": 0.15
}
}
```
**messages.json:**
```json
{
"messages": [
{
"id": "msg-uuid",
"timestamp": 1708396000000,
"role": "user",
"text": "hello"
},
{
"id": "msg-uuid",
"timestamp": 1708396001000,
"role": "assistant",
"text": "Hi there!",
"model": "gpt-4o",
"cost": {
"inputTokens": 100,
"outputTokens": 20,
"totalUSD": 0.001
}
},
{
"id": "msg-uuid",
"timestamp": 1708396002000,
"role": "tool",
"toolCallId": "call-uuid",
"toolName": "bash",
"toolInput": { "command": "ls -la" },
"output": "file1.txt\nfile2.txt",
"isError": false
}
]
}
```
---
## REST API Endpoints
### List Sessions
```
POST /api/pi/sessions
Authorization: Bearer {token}
Response:
{
"sessions": [
{
"id": "session-uuid",
"title": "First 100 chars...",
"model": "gpt-4o",
"cwd": "/home/user/my-project",
"createdAt": 1708396000000,
"updatedAt": 1708396000000,
"messageCount": 42,
"cost": { /* as above */ }
}
]
}
```
### Get Session Detail
```
GET /api/pi/sessions/{sessionId}
Authorization: Bearer {token}
Response:
{
"session": {
"id": "session-uuid",
"title": "...",
"model": "gpt-4o",
"cwd": "/home/user/my-project",
"createdAt": 1708396000000,
"updatedAt": 1708396000000,
"messageCount": 42,
"cost": { /* as above */ },
"messages": [
// full history from messages.json
]
}
}
```
### Update Session
```
PATCH /api/pi/sessions/{sessionId}
Authorization: Bearer {token}
Body:
{
"title": "New session title"
}
Response:
{
"success": true,
"session": {
"id": "session-uuid",
"title": "New session title",
"model": "gpt-4o",
"cwd": "/home/user/my-project",
"createdAt": 1708396000000,
"updatedAt": 1708396000000,
"messageCount": 42,
"cost": { /* as above */ }
}
}
```
### 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": [
{
"id": "session-uuid",
"title": "...",
"model": "gpt-4o",
"cwd": "/home/user/my-project",
"createdAt": 1708396000000,
"updatedAt": 1708396000000,
"messageCount": 42,
"relevance": 0.95, // optional: relevance score
"preview": "...matching text snippet..."
}
]
}
```
### List Available Models
```
GET /api/pi/models
Authorization: Bearer {token}
Response:
{
"models": [
{
"id": "big-pickle",
"name": "Big Pickle",
"provider": "opencode-zen",
"contextWindow": 128000,
"maxTokens": 4096
},
{
"id": "claude-opus-4-5",
"name": "Claude Opus 4.5",
"provider": "anthropic",
"contextWindow": 200000,
"maxTokens": 4096
}
]
}
```
---
## Implementation Steps
### Phase 1: Setup & Cleanup
1. Delete all three legacy harness directories
2. Remove all references from `src/server.tsx` and `src/servers/hono.ts`
3. Create new directory structure: `src/servers/api/pi/`
### Phase 2: Core Infrastructure
1. Create `src/servers/api/pi/types.ts` — message types
2. Create `src/servers/api/pi/storage.ts` — disk I/O utilities
3. Create `src/servers/api/pi/pi-bridge.ts` — Pi RPC communication
4. Create `src/servers/api/pi/session-manager.ts` — in-memory session tracking
### Phase 3: WebSocket Handler
1. Create `src/servers/api/pi/websocket.ts` — WebSocket bridge
2. Implement session lifecycle management
3. Implement Pi process spawning/cleanup
4. Implement message streaming and history injection
### Phase 4: REST Endpoints
1. Create `src/servers/api/pi/rest.ts` — all REST handlers
2. Implement session listing, retrieval, update (rename), deletion
3. Implement search functionality
4. Implement model listing
### Phase 5: Integration
1. Wire WebSocket into `src/server.tsx`
2. Wire REST routes into `src/servers/hono.ts`
3. Update `src/servers/api/chat-types.ts` if needed
4. Test all endpoints
### Phase 6: Cleanup & Polish
1. Remove old type definitions not needed
2. Clean up any remaining references
3. Add logging/debugging
4. Document any edge cases
### Phase 6.1: Session Grouping
1. Add group types and metadata structures
2. Update storage layer to support group directories
3. Implement group management REST endpoints
4. Update existing session endpoints to handle groups
5. Add WebSocket support for group assignment
---
## Phase 6.1: Session Grouping — Detailed Implementation
**Status**: ✅ COMPLETE
**Date**: February 20, 2026
### Overview
Adds the ability to organize sessions into groups with natural language names, enabling better organization in the frontend chat list UI.
### Directory Structure
```
{cwd}/
└── .pi-sessions/
├── {sessionId}/ # Ungrouped session (root level)
│ ├── meta.json
│ └── messages.json
├── {anotherSessionId}/ # Another ungrouped session
│ ├── meta.json
│ └── messages.json
└── @refactor-pi-harness/ # GROUP (folder with @ prefix)
├── .group-meta.json # Group metadata
├── {sessionId}/ # Session inside group
│ ├── meta.json # contains groupSlug field
│ └── messages.json
└── {sessionId}/ # Another session in group
├── meta.json
└── messages.json
```
### Design Decisions
**Group Identification**:
- Groups use `@` prefix in filesystem (e.g., `@refactor-pi-harness/`)
- Distinguishes groups from sessions without checking file contents
**Group Metadata** (`.group-meta.json`):
```json
{
"name": "Refactor Pi Harness",
"slug": "refactor-pi-harness",
"description": "Sessions related to refactoring the Pi harness",
"createdAt": 1708396000000,
"updatedAt": 1708396000000,
"sessionCount": 5
}
```
**Session Metadata Updates**:
```json
{
"id": "session-uuid",
"title": "Fix WebSocket bug",
"groupSlug": "refactor-pi-harness", // null if ungrouped
"model": "gpt-4o",
"cwd": "/home/user/project",
"createdAt": 1708396000000,
"updatedAt": 1708396000000,
"messageCount": 42,
"cost": {
"inputTokens": 5000,
"outputTokens": 3000,
"totalUSD": 0.15
}
}
```
**Nesting Rules**:
- Groups cannot be nested (flat structure only)
- Sessions can only belong to one group at a time
- Sessions can be moved between groups or ungrouped
**Group Deletion Behavior**:
- When a group is deleted, all sessions are moved to root level (ungrouped)
- Session data is preserved (non-destructive deletion)
### Type Definitions
**Added to `types.ts`**:
```typescript
export type GroupMeta = {
name: string; // Natural language name
slug: string; // URL-friendly identifier (used in filesystem)
description?: string; // Optional description
createdAt: number;
updatedAt: number;
sessionCount: number; // Automatic count of sessions in group
};
export type SessionMeta = {
id: string;
title: string;
model: string;
cwd: string;
groupSlug?: string | null; // ⭐ NEW: Reference to parent group
createdAt: number;
updatedAt: number;
messageCount: number;
cost: MessageCost;
};
export type ClientMessage =
| {
type: "chat";
prompt: string;
sessionId?: string;
model?: string;
cwd?: string;
groupSlug?: string; // ⭐ NEW: Assign session to group on creation
attachmentIds?: string[];
}
// ... other message types
```
### Storage Functions
**Added to `storage.ts`**:
```typescript
// Group management
export async function saveGroup(cwd: string, groupMeta: GroupMeta): Promise<void>
export async function loadGroup(cwd: string, groupSlug: string): Promise<GroupMeta>
export async function groupExists(cwd: string, groupSlug: string): Promise<boolean>
export async function listGroups(baseCwd: string): Promise<GroupMeta[]>
export async function updateGroupMeta(cwd: string, groupSlug: string, updates: Partial<GroupMeta>): Promise<GroupMeta>
export async function deleteGroup(cwd: string, groupSlug: string): Promise<void>
// Session movement
export async function moveSession(
cwd: string,
sessionId: string,
fromGroupSlug: string | null,
toGroupSlug: string | null
): Promise<SessionMeta>
```
**Updated Existing Functions**:
All session functions now accept optional `groupSlug` parameter:
- `saveSession(cwd, sessionId, meta, messages)` — Uses `meta.groupSlug`
- `loadSession(cwd, sessionId, groupSlug?)` — Can specify group location
- `sessionExists(cwd, sessionId, groupSlug?)`
- `updateSessionMeta(cwd, sessionId, updates, groupSlug?)`
- `deleteSession(cwd, sessionId, groupSlug?)`
**Enhanced Search**:
- `searchSessions()` now searches group names and descriptions
- Sessions in matching groups receive higher relevance scores
- Group name matches: +2.0 relevance
- Group description matches: +1.5 relevance
### REST API Endpoints
**New Group Endpoints**:
#### Create Group
```
POST /api/pi/groups
Authorization: Bearer {token}
Body:
{
"name": "Refactor Pi Harness",
"slug": "refactor-pi-harness",
"description": "Sessions related to refactoring",
"sessionIds": ["uuid1", "uuid2"] // optional, can be empty
}
Response:
{
"success": true,
"group": {
"name": "Refactor Pi Harness",
"slug": "refactor-pi-harness",
"description": "Sessions related to refactoring",
"createdAt": 1708396000000,
"updatedAt": 1708396000000,
"sessionCount": 2
}
}
```
#### List Groups
```
GET /api/pi/groups
Authorization: Bearer {token}
Response:
{
"groups": [
{
"name": "Refactor Pi Harness",
"slug": "refactor-pi-harness",
"description": "Sessions related to refactoring",
"createdAt": 1708396000000,
"updatedAt": 1708396000000,
"sessionCount": 5
}
]
}
```
#### Update Group
```
PATCH /api/pi/groups/:groupSlug
Authorization: Bearer {token}
Body:
{
"name": "New Group Name", // optional
"description": "New description" // optional
}
Response:
{
"success": true,
"group": { /* updated group metadata */ }
}
```
#### Delete Group
```
DELETE /api/pi/groups/:groupSlug
Authorization: Bearer {token}
Response:
{
"success": true
}
Note: All sessions in the group are moved to root level (ungrouped)
```
#### Move Session
```
POST /api/pi/sessions/:sessionId/move
Authorization: Bearer {token}
Body:
{
"groupSlug": "target-group" // or null to ungroup
}
Response:
{
"success": true,
"session": { /* updated session metadata */ }
}
Note: Automatically updates session counts in both source and target groups
```
**Updated Session Endpoints**:
All existing session endpoints now handle groups automatically:
- `GET /api/pi/sessions/:sessionId` — Searches in root and all groups
- `PATCH /api/pi/sessions/:sessionId` — Updates session in correct location
- `DELETE /api/pi/sessions/:sessionId` — Deletes and updates group count
- `GET /api/pi/sessions/search?q=query` — Includes group names/descriptions
### WebSocket Integration
**Creating Sessions with Groups**:
```javascript
// Client sends
{
"type": "chat",
"prompt": "Hello",
"model": "gpt-4o",
"groupSlug": "refactor-pi-harness" // ⭐ NEW: Optional group assignment
}
// Server creates session in group
// Session metadata will have groupSlug set
```
### Session Manager Updates
**`session-manager.ts`**:
```typescript
getOrCreate(
sessionId: string,
email: string,
cwd: string,
model: string,
groupSlug?: string | null // ⭐ NEW parameter
): UserSession
```
Now initializes session metadata with `groupSlug` field.
### Key Features
**Natural Language Names**: Groups have both display name and slug
**Non-Destructive Deletion**: Deleting groups moves sessions to root
**Automatic Counting**: Group session counts updated automatically
**Bulk Creation**: Create group with initial sessionIds array
**Enhanced Search**: Search includes group names and descriptions
**Flexible Movement**: Move sessions between groups or ungroup them
**WebSocket Support**: Assign sessions to groups during creation
**Backward Compatible**: Existing ungrouped sessions continue to work
### Frontend Integration Notes
**For Phase 7 (Frontend Migration)**:
1. **Chat List UI**: Display sessions grouped by `groupSlug`
2. **Group Management**: Add UI for creating/editing/deleting groups
3. **Drag & Drop**: Implement moving sessions between groups
4. **Search Enhancement**: Show group names in search results
5. **Session Creation**: Add group selector when starting new chats
**Example Frontend Structure**:
```
Chat History
├─ Ungrouped Sessions
│ ├─ Session 1
│ └─ Session 2
├─ 📁 Refactor Pi Harness (5 sessions)
│ ├─ Fix WebSocket bug
│ ├─ Add session grouping
│ └─ Update documentation
└─ 📁 Project X (3 sessions)
├─ Initial setup
└─ API implementation
```
### Testing Checklist
- [x] Create group with empty sessionIds array
- [x] Create group with initial sessions
- [x] List all groups sorted by updatedAt
- [x] Update group name and description
- [x] Delete group (sessions move to root)
- [x] Move session from root to group
- [x] Move session from group to group
- [x] Move session from group to root (ungroup)
- [x] Session counts update correctly on move/delete
- [x] Search includes group names and descriptions
- [x] WebSocket session creation with groupSlug
- [x] All existing endpoints work with grouped sessions
- [x] Group slug validation (prevents duplicate groups)
- [x] Session not found error handling
---
## Phase 7: Frontend Migration — Detailed Implementation
**Status**: 🚧 IN PROGRESS
**Date**: February 20, 2026
### Overview
Migrate the frontend from legacy provider-specific hooks (`useClaude`, `useOpenCodeModels`, etc.) to unified Pi harness hooks. Update all API calls to use the new `/api/pi/*` endpoints.
### Current Frontend State
**WebSocket Hook**: `useClaude.ts`
- URL: `/api/harness/claudecode/ws`
- Handles: streaming, tools, sessions
**Model Hooks**: Three separate hooks
- `useClaudeModels()``/claude/models`
- `useOpenCodeModels()``/opencode/models`
- `usePiMonoModels()``/pi-mono/models`
**Session Management**: `useChatSessions.ts`
- Provider-specific: `'/sessions/${provider}/${sessionId}/...'`
- No group support
### Phase 7.1: Type Alignment
**Status**: ✅ COMPLETE
**Date**: February 20, 2026
Update frontend types to match new wire protocol.
**What Was Done**:
- Created new types in `src/workspaces/apps/Chat/types.ts`:
- `MessageCost`, `SessionEntry`, `GroupEntry`, `ChatMessage`, `ServerMessage`, `Message`
- Created legacy type aliases for backward compatibility:
- `LegacyChatMessage`, `LegacySessionEntry`, `LegacyServerMessage`
- Updated all legacy hooks to use legacy types:
- `useClaude.ts``LegacyChatMessage`, `LegacyServerMessage`
- `useOpenCode.ts``LegacyChatMessage`, `LegacyServerMessage`
- `usePiMono.ts``LegacyChatMessage`, `LegacyServerMessage`
- Updated session management:
- `useChatSessions.ts``LegacySessionEntry`, `LegacyChatMessage`
- Updated UI components:
- `InputArea.tsx`, `Settings.tsx`, `TaskLogs/index.tsx`
- `MessageBubble.tsx`, `MessageList.tsx`, `QuestionActivity.tsx`, `ToolActivity.tsx`
**Message Type Mapping**:
| Old Type (Frontend) | New Type (Backend) | Action |
|---------------------|-------------------|--------|
| `session:init` | `session:init` | ✅ Keep |
| `assistant:partial` | `assistant:delta` | Rename |
| `assistant:text` | `assistant:text` | ✅ Keep |
| `tool:use` | `tool:start` | Rename |
| `tool:result` | `tool:result` | Update (add `toolCallId`) |
| `result` | `result` | Update (new cost structure) |
| N/A | `sync:messages` | Add (for resume) |
**Files to Update**:
- `src/apps/officer-web/state/types/chat.ts` (or wherever ChatMessage types live)
- Any shared type definitions
**New Cost Structure**:
```typescript
// Old
{ costUsd: number; durationMs: number; numTurns: number; }
// New
{
cost: {
inputTokens: number;
outputTokens: number;
totalUSD: number;
};
}
```
### Phase 7.2: Unified Pi Hook (`usePi.ts`)
Replace `useClaude.ts` with unified `usePi.ts`:
**Key Changes**:
```typescript
// Old WebSocket URL
const wsUrl = `${protocol}//${host}/api/harness/claudecode/ws?token=${token}`;
// New WebSocket URL
const wsUrl = `${protocol}//${host}/api/pi/chat/ws?token=${token}`;
```
**Updated Message Handling**:
```typescript
case 'assistant:delta': // renamed from 'assistant:partial'
streamingRef.current += msg.text;
flushStreaming();
break;
case 'tool:start': // renamed from 'tool:use'
setMessages((prev) => [
...prev,
{
role: 'tool',
toolName: msg.toolName,
toolInput: msg.toolInput,
toolCallId: msg.toolCallId, // renamed from toolUseId
},
]);
break;
case 'sync:messages': // NEW: for session resume
setSessionId(msg.sessionId);
setMessages(msg.messages);
setIsGenerating(msg.isGenerating);
if (msg.streamingText) {
streamingRef.current = msg.streamingText;
flushStreaming();
}
break;
```
**Updated sendPrompt**:
```typescript
send({
type: 'chat',
prompt: text,
sessionId: sessionIdRef.current,
model: selectedModel,
cwd: cwd,
groupSlug: groupSlug, // NEW: group assignment
attachmentIds: attachmentIds,
});
```
### Phase 7.3: Unified Models Hook
Replace three model hooks with one:
```typescript
// src/apps/officer-web/state/useModels.ts
export const usePiModels = () => {
const client = useClient();
const { isAuthenticated } = useAuth();
const { data: models = [] } = useQuery<ModelOption[]>({
queryKey: ['PI_MODELS'],
enabled: isAuthenticated,
queryFn: () => client.get<ModelOption[]>('/api/pi/models'),
staleTime: 5 * 60 * 1000,
});
return models;
};
export const useVisiblePiModels = () => {
const models = usePiModels();
const { settings } = useSettings();
const enabled = settings.ai?.enabledModels ?? [];
return useMemo(() => {
const filtered = models.filter((m) => enabled.includes(modelKey(m)));
return filtered.length > 0 ? filtered : models;
}, [models, enabled]);
};
```
### Phase 7.4: Session Management Migration
Update `useChatSessions.ts`:
**Endpoint Mapping**:
| Old Endpoint | New Endpoint |
|--------------|--------------|
| `GET /sessions` | `POST /api/pi/sessions` |
| `GET /sessions/:provider/:id/messages` | `GET /api/pi/sessions/:id` |
| `PUT /sessions/:provider/:id` | `PATCH /api/pi/sessions/:id` |
| `POST /sessions/:provider/:id/archive` | Remove (not needed) |
| `DELETE /sessions/:provider/:id` | `DELETE /api/pi/sessions/:id` |
| N/A | `GET /api/pi/sessions/search?q=` |
**Updated Hook**:
```typescript
export const useChatSessions = () => {
const client = useClient();
const queryClient = useQueryClient();
const { data: sessions = [] } = useQuery<SessionMeta[]>({
queryKey: ['PI_SESSIONS'],
queryFn: () => client.post<{ sessions: SessionMeta[] }>('/api/pi/sessions').then(r => r.sessions),
});
const getSession = (sessionId: string) =>
client.get<{ session: SessionWithMessages }>(`/api/pi/sessions/${sessionId}`);
const renameSession = async (sessionId: string, title: string) => {
await client.patch(`/api/pi/sessions/${sessionId}`, { title });
queryClient.invalidateQueries({ queryKey: ['PI_SESSIONS'] });
};
const deleteSession = async (sessionId: string) => {
await client.delete(`/api/pi/sessions/${sessionId}`);
queryClient.setQueryData<SessionMeta[]>(
['PI_SESSIONS'],
(prev) => prev?.filter((s) => s.id !== sessionId) ?? []
);
};
const searchSessions = (query: string) =>
client.get<{ results: SessionMeta[] }>(`/api/pi/sessions/search?q=${encodeURIComponent(query)}`);
return { sessions, getSession, renameSession, deleteSession, searchSessions };
};
```
### Phase 7.5: Group Support Hooks
Add new hooks for group management:
```typescript
// src/apps/officer-web/state/useChatGroups.ts
export const useChatGroups = () => {
const client = useClient();
const queryClient = useQueryClient();
const { data: groups = [] } = useQuery<GroupMeta[]>({
queryKey: ['PI_GROUPS'],
queryFn: () => client.get<{ groups: GroupMeta[] }>('/api/pi/groups').then(r => r.groups),
});
const createGroup = async (name: string, slug: string, description?: string, sessionIds?: string[]) => {
const result = await client.post<{ group: GroupMeta }>('/api/pi/groups', {
name, slug, description, sessionIds
});
queryClient.invalidateQueries({ queryKey: ['PI_GROUPS'] });
queryClient.invalidateQueries({ queryKey: ['PI_SESSIONS'] });
return result.group;
};
const updateGroup = async (slug: string, updates: { name?: string; description?: string }) => {
await client.patch(`/api/pi/groups/${slug}`, updates);
queryClient.invalidateQueries({ queryKey: ['PI_GROUPS'] });
};
const deleteGroup = async (slug: string) => {
await client.delete(`/api/pi/groups/${slug}`);
queryClient.invalidateQueries({ queryKey: ['PI_GROUPS'] });
queryClient.invalidateQueries({ queryKey: ['PI_SESSIONS'] });
};
const moveSession = async (sessionId: string, groupSlug: string | null) => {
await client.post(`/api/pi/sessions/${sessionId}/move`, { groupSlug });
queryClient.invalidateQueries({ queryKey: ['PI_GROUPS'] });
queryClient.invalidateQueries({ queryKey: ['PI_SESSIONS'] });
};
return { groups, createGroup, updateGroup, deleteGroup, moveSession };
};
```
### Testing Checklist (Phase 7)
- [ ] Type definitions match backend wire protocol
- [ ] `usePi` hook connects to new WebSocket endpoint
- [ ] Streaming text works correctly with `assistant:delta`
- [ ] Tool execution shows with `tool:start` / `tool:result`
- [ ] Session resume works with `sync:messages`
- [ ] Cost display shows new structure (inputTokens, outputTokens, totalUSD)
- [ ] Model picker uses unified `usePiModels`
- [ ] Session list loads from new endpoint
- [ ] Session rename works
- [ ] Session delete works
- [ ] Session search works
- [ ] Group list loads
- [ ] Create group works
- [ ] Move session to group works
- [ ] Delete group (sessions ungroup) works
---
## Phase 8: UI Enhancements
**Status**: Planned
### Phase 8.1: Grouped ChatList UI
Update ChatList component to display sessions organized by groups:
```
Chat History
├─ Ungrouped Sessions
│ ├─ Session 1
│ └─ Session 2
├─ 📁 Refactor Pi Harness (5 sessions)
│ ├─ Fix WebSocket bug
│ ├─ Add session grouping
│ └─ Update documentation
└─ 📁 Project X (3 sessions)
├─ Initial setup
└─ API implementation
```
**Implementation**:
- Group sessions by `groupSlug` field
- Collapsible group sections
- Session count badges
- Sort groups by `updatedAt`
- Ungrouped sessions at top or bottom (configurable)
### Phase 8.2: Group Management UI
Add UI for managing groups:
- **Create Group Dialog**: Name, slug (auto-generated), description
- **Rename Group**: Inline edit or dialog
- **Delete Group**: Confirmation dialog, explain sessions will ungroup
- **Drag & Drop**: Move sessions between groups (optional, can defer)
### Phase 8.3: Search Enhancements
Improve search functionality:
- Show group names in search results
- Filter by group dropdown
- Highlight matching text in results
- Show relevance scores (optional)
### Testing Checklist (Phase 8)
- [ ] ChatList displays grouped structure
- [ ] Groups are collapsible
- [ ] Session counts are accurate
- [ ] Create group dialog works
- [ ] Rename group works
- [ ] Delete group shows confirmation
- [ ] Sessions ungroup correctly when group deleted
- [ ] Search shows group context
- [ ] Filter by group works
---
## Phase 9: Final Cleanup
**Status**: Planned
### Phase 9.1: Frontend Legacy Cleanup
Remove all legacy code:
**Hooks to Delete**:
- `useClaudeModels()` from `useModels.ts`
- `useOpenCodeModels()` from `useModels.ts`
- `usePiMonoModels()` from `useModels.ts`
- `useVisibleClaudeModels()` from `useModels.ts`
- `useVisibleOpenCodeModels()` from `useModels.ts`
- `useVisiblePiMonoModels()` from `useModels.ts`
**Files to Delete**:
- `useClaude.ts` (replaced by `usePi.ts`)
- `OpenCodeModelPicker.tsx` (if provider-specific)
**Code to Update**:
- Remove `provider` parameter from all session functions
- Remove provider routing logic from ChatPanel
- Update all imports to use new hooks
### Phase 9.2: Backend Final Cleanup
Verify backend is clean:
- Confirm old harness directories deleted (`src/servers/api/claude/`, etc.)
- Remove any remaining legacy routes from `hono.ts`
- Remove legacy WebSocket handlers from `server.tsx`
- Update any remaining type imports
- Clean up `chat-types.ts` if needed
### Testing Checklist (Phase 9)
- [ ] No TypeScript errors after cleanup
- [ ] Build succeeds
- [ ] No console errors in browser
- [ ] All chat features work end-to-end
- [ ] No dead code remaining
- [ ] Documentation up to date
---
## Implementation Details
### Session Manager (`session-manager.ts`)
Tracks active Pi processes in memory:
```typescript
type UserSession = {
sessionId: string;
email: string;
cwd: string;
model: string;
piProcess: Subprocess | null;
ws: ServerWebSocket | null;
lastActivity: number;
idleTimer: Timer | null;
streamBuffer: string;
isGenerating: boolean;
systemContextSent: boolean;
};
class SessionManager {
private sessions = new Map<string, UserSession>(); // sessionId → session
private userSessions = new Map<string, string[]>(); // email → [sessionIds]
getOrCreate(sessionId: string, email: string, cwd: string, model: string): UserSession;
getSession(sessionId: string): UserSession | null;
getUserSessions(email: string): UserSession[];
deleteSession(sessionId: string): void;
attachWs(sessionId: string, ws: ServerWebSocket): void;
detachWs(sessionId: string): void;
setIdleTimeout(sessionId: string, timeoutMs: number): void;
}
```
### Storage (`storage.ts`)
```typescript
class SessionStorage {
async saveSession(
cwd: string,
sessionId: string,
meta: SessionMeta,
messages: Message[]
): Promise<void>;
async loadSession(
cwd: string,
sessionId: string
): Promise<{ meta: SessionMeta; messages: Message[] }>;
async listUserSessions(
email: string,
baseCwd?: string
): Promise<SessionMeta[]>;
async deleteSession(
cwd: string,
sessionId: string
): Promise<void>;
async searchSessions(
email: string,
query: string,
baseCwd?: string
): Promise<SessionMeta[]>;
}
```
### Pi Bridge (`pi-bridge.ts`)
```typescript
class PiBridge {
async spawn(
cwd: string,
model: string,
env?: Record<string, string>
): Promise<Subprocess>;
sendPrompt(
process: Subprocess,
prompt: string,
requestId: string
): void;
abort(
process: Subprocess,
requestId: string
): void;
// Returns async generator of Pi events
readEvents(process: Subprocess): AsyncGenerator<PiEvent>;
}
```
### WebSocket Lifecycle
1. **Open**: Empty handler (connection established)
2. **Message**:
- If `type: 'chat'`:
- Load/create session
- Spawn Pi if needed
- Send prompt to Pi
- Start streaming responses
- If `type: 'resume'`:
- Load session from disk
- Spawn fresh Pi
- Send full history as system context
- Send `sync:messages`
- If `type: 'stop'`:
- Send abort to Pi
3. **Close**:
- Detach WebSocket from session
- Start idle timer (1 hour)
- On timeout: kill Pi, save session to disk, delete from memory
### Key Features
**History Injection**:
```typescript
function buildSystemPrompt(messages: Message[], homeDir: string, skills: string): string {
const history = messages
.map(msg => `${msg.role}: ${msg.text}`)
.join("\n");
return `
<system>
User home directory: ${homeDir}
${skills}
Below is the conversation history from this session:
<conversation_history>
${history}
</conversation_history>
</system>
`;
}
```
**Streaming Text**:
- Accumulate `assistant:delta` in `streamBuffer`
- Flush to state on `assistant:text` or message boundary
- Prevents render thrashing
**Error Handling**:
- Pi process death → send error to client, save partial state
- WS disconnect → idle timeout, then cleanup
- Invalid JSON → send error, continue
---
## Testing Checklist
- [ ] Delete cleanup complete, no compilation errors
- [ ] New chat creates session on disk
- [ ] Resume old chat loads from disk and injects history
- [ ] Model switching works (different models per session)
- [ ] Multiple concurrent sessions don't interfere
- [ ] Idle timeout kills Pi process
- [ ] WS disconnect followed by reconnect works
- [ ] REST endpoints return correct data
- [ ] Search returns relevant results
- [ ] Streaming text appears correctly
- [ ] Tool execution tracked properly
- [ ] Session metadata updated on cost/message count
---
## Notes
- All paths use `{userCwd}/.pi-sessions/` for session storage
- Default cwd is `getHomeDir(email)` if not provided
- Pi processes spawned with `--mode rpc --no-extensions --no-skills`
- Message IDs should be UUIDs for uniqueness
- Timestamps in milliseconds (Date.now())
- Cost tracking passed from Pi via `result` events
---
**Ready to implement?** Confirm and start Phase 1.