Files
platform/PI_HARNESS_REBUILD.md
T

36 KiB

Pi Harness Rebuild — Implementation Plan

Status: 🚧 IN PROGRESS — Phase 7 (Frontend Migration)
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 (In Progress)

  • Phase 7.1: Type Alignment — Complete
  • Phase 7.2: Unified Pi Hook (usePi.ts)
  • Phase 7.3: Unified Models Hook
  • Phase 7.4: Session Management Migration
  • Phase 7.5: Group Support Hooks

UI Enhancements (Planned)

  • Phase 8.1: Grouped ChatList UI
  • Phase 8.2: Group Management UI
  • Phase 8.3: Search Enhancements

Final Cleanup (Planned)

  • Phase 9.1: Frontend Legacy Cleanup
  • Phase 9.2: Backend Final Cleanup

Table of Contents


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:

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

  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):

{
  "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) — 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:

// 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):

  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

  • 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/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.tsLegacyChatMessage, LegacyServerMessage
    • useOpenCode.tsLegacyChatMessage, LegacyServerMessage
    • usePiMono.tsLegacyChatMessage, LegacyServerMessage
  • Updated session management:
    • useChatSessions.tsLegacySessionEntry, 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:

// 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
  • 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:

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

  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:

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.