fix: Pi harness - dynamic models, correct RPC protocol, proper event handling

Backend:
- /api/pi/models now calls 'pi --list-models' with stored API keys
- pi-bridge.ts: callback-based event handling (matches pi-monorepo)
- pi-bridge.ts: correct RPC format (type: 'prompt' not jsonrpc)
- pi-bridge.ts: pass API keys to Pi process env
- websocket.ts: event handler runs in background, no blocking
- rest.ts: fix user home path (getHomeDir instead of hardcoded)

Frontend:
- Fix /api/ double prefix in useChatSessions, useChatGroups, useModels
- Add PROVIDER_DISPLAY mapping in SystemSettings.tsx
- Provider tabs show friendly names (e.g., 'OpenCode Zen')

UI (from previous session):
- Grouped session list with collapsible folders
- CreateGroupDialog, GroupContextMenu, SessionContextMenu components
This commit is contained in:
2026-02-20 22:55:00 +00:00
parent ca497f9eff
commit 68c7973281
11 changed files with 978 additions and 457 deletions
+69 -37
View File
@@ -1,6 +1,8 @@
import type { Context } from 'hono';
import { createRouter } from '../../create-router';
import * as storage from './storage';
import { readApiKeys } from '../server-settings/pi-mono';
import { getHomeDir } from '../../data-path';
import type { ModelInfo } from './types';
import { logger } from './logger';
@@ -12,36 +14,66 @@ export const piRestRouter = createRouter();
/**
* GET /api/pi/models
* List available models
* List available models by running `pi --list-models` with stored API keys
*/
piRestRouter.get('/pi/models', async (ctx: Context) => {
// TODO: Implement dynamic model discovery
// For now, return hardcoded models
const models: ModelInfo[] = [
{
id: 'gpt-4o',
name: 'GPT-4o',
provider: 'openai',
contextWindow: 128000,
maxTokens: 4096,
},
{
id: 'claude-opus-4-5',
name: 'Claude Opus 4.5',
provider: 'anthropic',
contextWindow: 200000,
maxTokens: 4096,
},
{
id: 'big-pickle',
name: 'Big Pickle',
provider: 'opencode-zen',
contextWindow: 128000,
maxTokens: 4096,
},
];
try {
const storedKeys = await readApiKeys();
const proc = Bun.spawn(['pi', '--list-models'], {
stdout: 'pipe',
stderr: 'pipe',
env: { ...process.env, ...storedKeys },
});
return ctx.json({ models });
const output = await new Response(proc.stdout).text();
await proc.exited;
if (proc.exitCode !== 0) {
logger.error('pi --list-models failed', { exitCode: proc.exitCode });
return ctx.json({ models: [] });
}
// Parse the whitespace-separated table output:
// provider model context max-out thinking images
// anthropic claude-sonnet-4-6 200K 128K yes yes
const lines = output.trim().split('\n').filter(Boolean);
const models: ModelInfo[] = [];
// Skip header line (first line)
for (let i = 1; i < lines.length; i++) {
const cols = lines[i]!.trim().split(/\s+/);
if (cols.length < 2) continue;
const [provider, model, context, maxOut] = cols;
// Parse context window (e.g., "200K" -> 200000)
const parseSize = (s?: string): number => {
if (!s) return 128000;
const match = s.match(/^(\d+)([KMG])?$/i);
if (!match) return 128000;
const num = parseInt(match[1]!, 10);
const unit = (match[2] ?? '').toUpperCase();
if (unit === 'K') return num * 1000;
if (unit === 'M') return num * 1000000;
if (unit === 'G') return num * 1000000000;
return num;
};
models.push({
id: `${provider}/${model}`,
name: model!,
provider: provider!,
contextWindow: parseSize(context),
maxTokens: parseSize(maxOut),
});
}
return ctx.json({ models });
} catch (err) {
logger.error('Failed to list models', { error: String(err) });
return ctx.json({ models: [] });
}
});
/**
@@ -55,7 +87,7 @@ piRestRouter.post('/pi/sessions', async (ctx: Context) => {
}
// TODO: Implement proper user home directory resolution
const userHome = `/home/${user.email.split('@')[0]}`;
const userHome = getHomeDir(user.email);
try {
const sessions = await storage.listUserSessions(userHome);
@@ -81,7 +113,7 @@ piRestRouter.get('/pi/sessions/:sessionId', async (ctx: Context) => {
return ctx.json({ error: 'Session ID required' }, 400);
}
const userHome = `/home/${user.email.split('@')[0]}`;
const userHome = getHomeDir(user.email);
try {
// Try loading from root first
@@ -140,7 +172,7 @@ piRestRouter.patch('/pi/sessions/:sessionId', async (ctx: Context) => {
return ctx.json({ error: 'Title is required and must be a string' }, 400);
}
const userHome = `/home/${user.email.split('@')[0]}`;
const userHome = getHomeDir(user.email);
try {
// Find the session (root or in group)
@@ -192,7 +224,7 @@ piRestRouter.delete('/pi/sessions/:sessionId', async (ctx: Context) => {
return ctx.json({ error: 'Session ID required' }, 400);
}
const userHome = `/home/${user.email.split('@')[0]}`;
const userHome = getHomeDir(user.email);
try {
// Find the session (root or in group)
@@ -251,7 +283,7 @@ piRestRouter.get('/pi/sessions/search', async (ctx: Context) => {
return ctx.json({ error: 'Query parameter required' }, 400);
}
const userHome = `/home/${user.email.split('@')[0]}`;
const userHome = getHomeDir(user.email);
try {
const results = await storage.searchSessions(userHome, query);
@@ -282,7 +314,7 @@ piRestRouter.post('/pi/groups', async (ctx: Context) => {
return ctx.json({ error: 'Slug is required and must be a string' }, 400);
}
const userHome = `/home/${user.email.split('@')[0]}`;
const userHome = getHomeDir(user.email);
try {
// Check if group already exists
@@ -347,7 +379,7 @@ piRestRouter.get('/pi/groups', async (ctx: Context) => {
return ctx.json({ error: 'Unauthorized' }, 401);
}
const userHome = `/home/${user.email.split('@')[0]}`;
const userHome = getHomeDir(user.email);
try {
const groups = await storage.listGroups(userHome);
@@ -387,7 +419,7 @@ piRestRouter.patch('/pi/groups/:groupSlug', async (ctx: Context) => {
return ctx.json({ error: 'No valid updates provided' }, 400);
}
const userHome = `/home/${user.email.split('@')[0]}`;
const userHome = getHomeDir(user.email);
try {
const updatedGroup = await storage.updateGroupMeta(userHome, groupSlug, updates);
@@ -416,7 +448,7 @@ piRestRouter.delete('/pi/groups/:groupSlug', async (ctx: Context) => {
return ctx.json({ error: 'Group slug required' }, 400);
}
const userHome = `/home/${user.email.split('@')[0]}`;
const userHome = getHomeDir(user.email);
try {
await storage.deleteGroup(userHome, groupSlug);
@@ -449,7 +481,7 @@ piRestRouter.post('/pi/sessions/:sessionId/move', async (ctx: Context) => {
return ctx.json({ error: 'groupSlug must be a string or null' }, 400);
}
const userHome = `/home/${user.email.split('@')[0]}`;
const userHome = getHomeDir(user.email);
try {
// Find the session in root or any group