import type { Context } from 'hono'; import { createRouter } from '../../create-router'; import * as storage from './storage'; import { readApiKeys, readLocalProviders } from '../server-settings/pi-mono'; import { listPiModels } from './list-models'; import { getHomeDir } from '../../data-path'; import { resolveBaseCwd } from './websocket'; import { logger } from './logger'; /** * REST API Endpoints — Session management and model info */ export const piRestRouter = createRouter(); /** * GET /api/pi/models * List available models via `pi --list-models` (with stored API keys + PI_CODING_AGENT_DIR). * Local provider friendly names are resolved from stored local-providers config. */ piRestRouter.get('/pi/models', async (ctx: Context) => { try { const storedKeys = await readApiKeys(); const models = await listPiModels(storedKeys); // Build providerNames map for officer-local-* providers const providerNames: Record = {}; const localProviders = await readLocalProviders(); for (const lp of localProviders) { providerNames[`officer-local-${lp.id}`] = lp.name; } return ctx.json({ models, providerNames, hostHome: process.env.HOME ?? '' }); } catch (err) { logger.error('Failed to list models', { error: String(err) }); return ctx.json({ models: [], providerNames: {} }); } }); /** * POST /api/pi/sessions * List all sessions for the current user. * Optionally filter by cwd/cwdRoot to show only project-scoped sessions. */ piRestRouter.post('/pi/sessions', async (ctx: Context) => { const user = ctx.get('user'); if (!user) { return ctx.json({ error: 'Unauthorized' }, 401); } const body = await ctx.req.json().catch(() => ({})); const userHome = getHomeDir(user.email); const filterCwd = body.cwd ? resolveBaseCwd(user.email, body.cwdRoot, body.cwd) : null; try { let sessions = await storage.listUserSessions(userHome); if (filterCwd) { sessions = sessions.filter((s) => s.cwd === filterCwd); } return ctx.json({ sessions }); } catch (err) { logger.error('Failed to list sessions', { email: ctx.get('email'), error: String(err) }); return ctx.json({ error: 'Failed to list sessions' }, 500); } }); /** * GET /api/pi/sessions/:sessionId * Get session detail with full message history */ piRestRouter.get('/pi/sessions/:sessionId', async (ctx: Context) => { const user = ctx.get('user'); if (!user) { return ctx.json({ error: 'Unauthorized' }, 401); } const sessionId = ctx.req.param('sessionId'); if (!sessionId) { return ctx.json({ error: 'Session ID required' }, 400); } const userHome = getHomeDir(user.email); try { // Try loading from root first let meta, messages; try { ({ meta, messages } = await storage.loadSession(userHome, sessionId)); } catch { // Not in root, search in groups const groups = await storage.listGroups(userHome); let found = false; for (const group of groups) { try { ({ meta, messages } = await storage.loadSession(userHome, sessionId, group.slug)); found = true; break; } catch { continue; } } if (!found) { throw new Error('Session not found'); } } return ctx.json({ session: { ...meta, messages, }, }); } catch { // Session not found - this is expected for new sessions, don't log as error return ctx.json({ error: 'Session not found' }, 404); } }); /** * PATCH /api/pi/sessions/:sessionId * Update session metadata (e.g., rename) */ piRestRouter.patch('/pi/sessions/:sessionId', async (ctx: Context) => { const user = ctx.get('user'); if (!user) { return ctx.json({ error: 'Unauthorized' }, 401); } const sessionId = ctx.req.param('sessionId'); if (!sessionId) { return ctx.json({ error: 'Session ID required' }, 400); } const body = await ctx.req.json(); if (!body.title || typeof body.title !== 'string') { return ctx.json({ error: 'Title is required and must be a string' }, 400); } const userHome = getHomeDir(user.email); try { // Find the session (root or in group) let groupSlug: string | null = null; try { const { meta } = await storage.loadSession(userHome, sessionId); groupSlug = meta.groupSlug || null; } catch { // Not in root, search groups const groups = await storage.listGroups(userHome); for (const group of groups) { try { await storage.loadSession(userHome, sessionId, group.slug); groupSlug = group.slug; break; } catch { continue; } } } const updatedMeta = await storage.updateSessionMeta(userHome, sessionId, { title: body.title, }, groupSlug); return ctx.json({ success: true, session: updatedMeta, }); } catch (err) { logger.error('Failed to update session', { sessionId, email: ctx.get('email'), error: String(err) }); return ctx.json({ error: 'Failed to update session' }, 500); } }); /** * DELETE /api/pi/sessions/:sessionId * Delete a session */ piRestRouter.delete('/pi/sessions/:sessionId', async (ctx: Context) => { const user = ctx.get('user'); if (!user) { return ctx.json({ error: 'Unauthorized' }, 401); } const sessionId = ctx.req.param('sessionId'); if (!sessionId) { return ctx.json({ error: 'Session ID required' }, 400); } const userHome = getHomeDir(user.email); try { // Find the session (root or in group) let groupSlug: string | null = null; try { const { meta } = await storage.loadSession(userHome, sessionId); groupSlug = meta.groupSlug || null; } catch { // Not in root, search groups const groups = await storage.listGroups(userHome); for (const group of groups) { try { const { meta } = await storage.loadSession(userHome, sessionId, group.slug); groupSlug = meta.groupSlug || null; break; } catch { continue; } } } await storage.deleteSession(userHome, sessionId, groupSlug); // Update group session count if in a group if (groupSlug) { try { const group = await storage.loadGroup(userHome, groupSlug); group.sessionCount = Math.max(0, group.sessionCount - 1); group.updatedAt = Date.now(); await storage.saveGroup(userHome, group); } catch { // Group might not exist anymore } } return ctx.json({ success: true }); } catch (err) { logger.error('Failed to delete session', { sessionId, email: ctx.get('email'), error: String(err) }); return ctx.json({ error: 'Failed to delete session' }, 500); } }); /** * GET /api/pi/sessions/search * Search sessions by query */ piRestRouter.get('/pi/sessions/search', async (ctx: Context) => { const user = ctx.get('user'); if (!user) { return ctx.json({ error: 'Unauthorized' }, 401); } const query = ctx.req.query('q'); if (!query) { return ctx.json({ error: 'Query parameter required' }, 400); } const userHome = getHomeDir(user.email); try { const results = await storage.searchSessions(userHome, query); return ctx.json({ results }); } catch (err) { logger.error('Failed to search sessions', { query, email: ctx.get('email'), error: String(err) }); return ctx.json({ error: 'Failed to search sessions' }, 500); } }); /** * POST /api/pi/groups * Create a new group */ piRestRouter.post('/pi/groups', async (ctx: Context) => { const user = ctx.get('user'); if (!user) { return ctx.json({ error: 'Unauthorized' }, 401); } const body = await ctx.req.json(); if (!body.name || typeof body.name !== 'string') { return ctx.json({ error: 'Name is required and must be a string' }, 400); } if (!body.slug || typeof body.slug !== 'string') { return ctx.json({ error: 'Slug is required and must be a string' }, 400); } const userHome = getHomeDir(user.email); try { // Check if group already exists const exists = await storage.groupExists(userHome, body.slug); if (exists) { return ctx.json({ error: 'Group with this slug already exists' }, 409); } const groupMeta = { name: body.name, slug: body.slug, description: body.description || '', createdAt: Date.now(), updatedAt: Date.now(), sessionCount: 0, }; await storage.saveGroup(userHome, groupMeta); // Move sessions to group if provided if (body.sessionIds && Array.isArray(body.sessionIds)) { for (const sessionId of body.sessionIds) { try { // Find session (check both root and other groups) let sessionMeta; try { const { meta } = await storage.loadSession(userHome, sessionId); sessionMeta = meta; } catch { // Session might be in another group - skip continue; } await storage.moveSession(userHome, sessionId, sessionMeta.groupSlug || null, body.slug); groupMeta.sessionCount++; } catch (err) { logger.error('Failed to move session to group', { sessionId, groupSlug: body.slug, error: String(err) }); } } // Update group with final session count await storage.saveGroup(userHome, groupMeta); } return ctx.json({ success: true, group: groupMeta, }); } catch (err) { logger.error('Failed to create group', { slug: body.slug, email: ctx.get('email'), error: String(err) }); return ctx.json({ error: 'Failed to create group' }, 500); } }); /** * GET /api/pi/groups * List all groups */ piRestRouter.get('/pi/groups', async (ctx: Context) => { const user = ctx.get('user'); if (!user) { return ctx.json({ error: 'Unauthorized' }, 401); } const userHome = getHomeDir(user.email); try { const groups = await storage.listGroups(userHome); return ctx.json({ groups }); } catch (err) { logger.error('Failed to list groups', { email: ctx.get('email'), error: String(err) }); return ctx.json({ error: 'Failed to list groups' }, 500); } }); /** * PATCH /api/pi/groups/:groupSlug * Update group metadata */ piRestRouter.patch('/pi/groups/:groupSlug', async (ctx: Context) => { const user = ctx.get('user'); if (!user) { return ctx.json({ error: 'Unauthorized' }, 401); } const groupSlug = ctx.req.param('groupSlug'); if (!groupSlug) { return ctx.json({ error: 'Group slug required' }, 400); } const body = await ctx.req.json(); const updates: Record = {}; if (body.name && typeof body.name === 'string') { updates.name = body.name; } if (body.description !== undefined) { updates.description = body.description; } if (Object.keys(updates).length === 0) { return ctx.json({ error: 'No valid updates provided' }, 400); } const userHome = getHomeDir(user.email); try { const updatedGroup = await storage.updateGroupMeta(userHome, groupSlug, updates); return ctx.json({ success: true, group: updatedGroup, }); } catch (err) { logger.error('Failed to update group', { groupSlug, email: ctx.get('email'), error: String(err) }); return ctx.json({ error: 'Failed to update group' }, 500); } }); /** * DELETE /api/pi/groups/:groupSlug * Delete a group (moves sessions to root) */ piRestRouter.delete('/pi/groups/:groupSlug', async (ctx: Context) => { const user = ctx.get('user'); if (!user) { return ctx.json({ error: 'Unauthorized' }, 401); } const groupSlug = ctx.req.param('groupSlug'); if (!groupSlug) { return ctx.json({ error: 'Group slug required' }, 400); } const userHome = getHomeDir(user.email); try { await storage.deleteGroup(userHome, groupSlug); return ctx.json({ success: true }); } catch (err) { logger.error('Failed to delete group', { groupSlug, email: ctx.get('email'), error: String(err) }); return ctx.json({ error: 'Failed to delete group' }, 500); } }); /** * POST /api/pi/sessions/:sessionId/move * Move session to/from a group */ piRestRouter.post('/pi/sessions/:sessionId/move', async (ctx: Context) => { const user = ctx.get('user'); if (!user) { return ctx.json({ error: 'Unauthorized' }, 401); } const sessionId = ctx.req.param('sessionId'); if (!sessionId) { return ctx.json({ error: 'Session ID required' }, 400); } const body = await ctx.req.json(); const toGroupSlug = body.groupSlug === null ? null : body.groupSlug; if (toGroupSlug !== null && typeof toGroupSlug !== 'string') { return ctx.json({ error: 'groupSlug must be a string or null' }, 400); } const userHome = getHomeDir(user.email); try { // Find the session in root or any group let fromGroupSlug: string | null = null; let sessionFound = false; // Try root level first try { await storage.loadSession(userHome, sessionId); fromGroupSlug = null; sessionFound = true; } catch { // Not in root, check groups const groups = await storage.listGroups(userHome); for (const group of groups) { try { await storage.loadSession(userHome, sessionId, group.slug); fromGroupSlug = group.slug; sessionFound = true; break; } catch { continue; } } } if (!sessionFound) { return ctx.json({ error: 'Session not found' }, 404); } // Validate target group exists if (toGroupSlug !== null) { const groupExists = await storage.groupExists(userHome, toGroupSlug); if (!groupExists) { return ctx.json({ error: 'Target group does not exist' }, 404); } } const updatedMeta = await storage.moveSession(userHome, sessionId, fromGroupSlug, toGroupSlug); return ctx.json({ success: true, session: updatedMeta, }); } catch (err) { logger.error('Failed to move session', { sessionId, toGroupSlug, email: ctx.get('email'), error: String(err) }); return ctx.json({ error: 'Failed to move session' }, 500); } });