- 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
36 KiB
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)
- Phase 1: Setup & Cleanup — Complete
- Phase 2: Core Infrastructure — Complete
- Phase 3: WebSocket Handler — Complete
- Phase 4: REST Endpoints — Complete
- Phase 5: Integration — Complete
- Phase 6: Cleanup & Polish — Complete
- Phase 6.1: Session Grouping — Complete
Frontend (Complete)
- Phase 7.1: Type Alignment — Complete ✅
- Phase 7.2: Unified Pi Hook (
usePi.ts) — Complete ✅ - Phase 7.3: Unified Models Hook — Complete ✅
- Phase 7.4: Session Management Migration — Complete ✅
- 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)
- Phase 9.1: Frontend Legacy Cleanup — Complete ✅
- Phase 9.2: Backend Final Cleanup — Complete ✅
Table of Contents
- Decisions Made
- Cleanup Phase
- New Architecture
- Wire Protocol
- File Structure
- Implementation Steps
- Phase 6.1: Session Grouping
- Phase 7: Frontend Migration
- Phase 8: UI Enhancements
- Phase 9: Final Cleanup
- 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
cwdwith 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/wsroute/api/harness/opencode/wsroute/api/harness/pi-mono/wsroute- Remove handlers Map and provider routing logic
- Keep only
/api/pi/chat/wsfor new harness
References to remove from src/servers/hono.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
// 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
// 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:
{
"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:
{
"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
- Delete all three legacy harness directories
- Remove all references from
src/server.tsxandsrc/servers/hono.ts - Create new directory structure:
src/servers/api/pi/
Phase 2: Core Infrastructure
- Create
src/servers/api/pi/types.ts— message types - Create
src/servers/api/pi/storage.ts— disk I/O utilities - Create
src/servers/api/pi/pi-bridge.ts— Pi RPC communication - Create
src/servers/api/pi/session-manager.ts— in-memory session tracking
Phase 3: WebSocket Handler
- Create
src/servers/api/pi/websocket.ts— WebSocket bridge - Implement session lifecycle management
- Implement Pi process spawning/cleanup
- Implement message streaming and history injection
Phase 4: REST Endpoints
- Create
src/servers/api/pi/rest.ts— all REST handlers - Implement session listing, retrieval, update (rename), deletion
- Implement search functionality
- Implement model listing
Phase 5: Integration
- Wire WebSocket into
src/server.tsx - Wire REST routes into
src/servers/hono.ts - Update
src/servers/api/chat-types.tsif needed - Test all endpoints
Phase 6: Cleanup & Polish
- Remove old type definitions not needed
- Clean up any remaining references
- Add logging/debugging
- Document any edge cases
Phase 6.1: Session Grouping
- Add group types and metadata structures
- Update storage layer to support group directories
- Implement group management REST endpoints
- Update existing session endpoints to handle groups
- 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):
{
"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:
{
"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:
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:
// 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)— Usesmeta.groupSlugloadSession(cwd, sessionId, groupSlug?)— Can specify group locationsessionExists(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 groupsPATCH /api/pi/sessions/:sessionId— Updates session in correct locationDELETE /api/pi/sessions/:sessionId— Deletes and updates group countGET /api/pi/sessions/search?q=query— Includes group names/descriptions
WebSocket Integration
Creating Sessions with Groups:
// 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:
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):
- Chat List UI: Display sessions grouped by
groupSlug - Group Management: Add UI for creating/editing/deleting groups
- Drag & Drop: Implement moving sessions between groups
- Search Enhancement: Show group names in search results
- 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
- Create group with empty sessionIds array
- Create group with initial sessions
- List all groups sorted by updatedAt
- Update group name and description
- Delete group (sessions move to root)
- Move session from root to group
- Move session from group to group
- Move session from group to root (ungroup)
- Session counts update correctly on move/delete
- Search includes group names and descriptions
- WebSocket session creation with groupSlug
- All existing endpoints work with grouped sessions
- Group slug validation (prevents duplicate groups)
- 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/modelsuseOpenCodeModels()→/opencode/modelsusePiMonoModels()→/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,LegacyServerMessageuseOpenCode.ts→LegacyChatMessage,LegacyServerMessageusePiMono.ts→LegacyChatMessage,LegacyServerMessage
- Updated session management:
useChatSessions.ts→LegacySessionEntry,LegacyChatMessage
- Updated UI components:
InputArea.tsx,Settings.tsx,TaskLogs/index.tsxMessageBubble.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:
// 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:
// 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:
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:
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:
// 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:
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:
// 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
usePihook 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
groupSlugfield - 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()fromuseModels.tsuseOpenCodeModels()fromuseModels.tsusePiMonoModels()fromuseModels.tsuseVisibleClaudeModels()fromuseModels.tsuseVisibleOpenCodeModels()fromuseModels.tsuseVisiblePiMonoModels()fromuseModels.ts
Files to Delete:
useClaude.ts(replaced byusePi.ts)OpenCodeModelPicker.tsx(if provider-specific)
Code to Update:
- Remove
providerparameter 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.tsif 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:
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)
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)
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
- Open: Empty handler (connection established)
- 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
- If
- 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:
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:deltainstreamBuffer - Flush to state on
assistant:textor 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
resultevents
Ready to implement? Confirm and start Phase 1.