diff --git a/src/servers/api/pi/pi-bridge.ts b/src/servers/api/pi/pi-bridge.ts index 6cd1023f..b0c2d66d 100644 --- a/src/servers/api/pi/pi-bridge.ts +++ b/src/servers/api/pi/pi-bridge.ts @@ -195,6 +195,10 @@ type SandboxOptions = { homeDir: string; }; +type SpawnPiOptions = { + sessionFile?: string; +}; + export async function spawnPi( cwd: string, model: string, @@ -202,6 +206,7 @@ export async function spawnPi( email: string, onEvent: PiEventHandler, sandbox?: SandboxOptions, + options?: SpawnPiOptions, ): Promise { let proc: Subprocess; @@ -234,6 +239,7 @@ export async function spawnPi( ...resourceSkillFlags, ]; if (model) piArgs.push('--model', model); + if (options?.sessionFile) piArgs.push('--session', options.sessionFile); const resourcesEnv = buildResourcesEnv(); @@ -284,6 +290,7 @@ export async function spawnPi( const args = ['pi', '--mode', 'rpc', '--no-skills', '--no-prompt-templates', '--no-themes', ...skillFlags, ...extensionFlags, ...resourceSkillFlags]; if (model) args.push('--model', model); + if (options?.sessionFile) args.push('--session', options.sessionFile); if (!existsSync(cwd)) { mkdirSync(cwd, { recursive: true }); diff --git a/src/servers/api/pi/rest.ts b/src/servers/api/pi/rest.ts index ab0eee4e..2a95537c 100644 --- a/src/servers/api/pi/rest.ts +++ b/src/servers/api/pi/rest.ts @@ -235,6 +235,39 @@ piRestRouter.delete('/pi/sessions/:sessionId', async (ctx: Context) => { } }); +/** + * DELETE /api/pi/sessions + * Delete all sessions, optionally filtered by context + */ +piRestRouter.delete('/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 contextFilter = body.context ? { context: body.context as string, contextId: body.contextId as string | undefined } : undefined; + const userHome = getHomeDir(user.email); + + try { + const sessions = await storage.listUserSessions(userHome, contextFilter); + let deleted = 0; + for (const session of sessions) { + try { + await storage.deleteSession(userHome, session.id, session.groupSlug); + deleted++; + } catch { + // Skip sessions that fail to delete + } + } + logger.info('Bulk deleted sessions', { email: user.email, deleted, total: sessions.length, context: contextFilter?.context }); + return ctx.json({ success: true, deleted }); + } catch (err) { + logger.error('Failed to bulk delete sessions', { email: user.email, error: String(err) }); + return ctx.json({ error: 'Failed to delete sessions' }, 500); + } +}); + /** * GET /api/pi/sessions/search * Search sessions by query diff --git a/src/servers/api/pi/storage.ts b/src/servers/api/pi/storage.ts index 2db735d5..cff473ad 100644 --- a/src/servers/api/pi/storage.ts +++ b/src/servers/api/pi/storage.ts @@ -129,7 +129,7 @@ export function messagesToJnlEntries(messages: Message[], meta: SessionMeta): Jn id, parentId: prevId, timestamp: new Date(msg.timestamp).toISOString(), - message: { role: 'user', content: msg.text ?? '' }, + message: { role: 'user', content: [{ type: 'text' as const, text: msg.text ?? '' }] }, }; entries.push(entry); prevId = id; @@ -281,11 +281,16 @@ export function jnlEntriesToMessages(entries: JnlEntry[]): ParsedSession { const ts = new Date(entry.timestamp).getTime(); if (msgEntry.message.role === 'user') { + const rawContent = msgEntry.message.content; + const text = + typeof rawContent === 'string' + ? rawContent + : (rawContent as Array).map((c) => c.text).join('\n'); messages.push({ id: entry.id, timestamp: ts, role: 'user', - text: msgEntry.message.content as string, + text, }); } else if (msgEntry.message.role === 'assistant') { const contentBlocks = msgEntry.message.content as Array; @@ -380,6 +385,15 @@ function metaToIndexEntry(meta: SessionMeta, relFile: string): SessionIndexEntry }; } +// ── Path resolution ───────────────────────────────────────────────────── + +export async function getSessionFilePath(baseCwd: string, sessionId: string): Promise { + const index = await loadIndex(baseCwd); + const entry = index[sessionId]; + if (!entry) return null; + return path.join(getSessionsDir(baseCwd), entry.file); +} + // ── Session CRUD ─────────────────────────────────────────────────────── export async function saveSession( diff --git a/src/servers/api/pi/types.ts b/src/servers/api/pi/types.ts index a21d12dd..939a92ad 100644 --- a/src/servers/api/pi/types.ts +++ b/src/servers/api/pi/types.ts @@ -201,7 +201,7 @@ export type JnlUserMessage = JnlEntryBase & { type: 'message'; message: { role: 'user'; - content: string; + content: string | Array; }; }; diff --git a/src/servers/api/pi/websocket.ts b/src/servers/api/pi/websocket.ts index ede3ee31..011707c6 100644 --- a/src/servers/api/pi/websocket.ts +++ b/src/servers/api/pi/websocket.ts @@ -286,8 +286,37 @@ async function handleChat( if (!session.piProcess) { try { const onEvent = createEventHandler(sessionId, model, cwd, homeDir); - session.piProcess = await piBridge.spawnPi(cwd, model, userId, email, onEvent, sandboxed ? { userId, username, email, homeDir } : undefined); - logger.info('Spawned Pi process for session', { sessionId, model, cwd, sandboxed }); + const sandbox = sandboxed ? { userId, username, email, homeDir } : undefined; + + // If session has history, save to disk and pass --session for context replay + let spawnOptions: { sessionFile?: string } | undefined; + if (session.messages.length > 0) { + await storage.saveSession(homeDir, sessionId, session.meta, session.messages); + const hostPath = await storage.getSessionFilePath(homeDir, sessionId); + if (hostPath) { + if (sandboxed) { + const containerHome = `/home/${username}`; + const sessionsPrefix = join(homeDir, '.pi', 'agent', 'sessions'); + const relativePart = hostPath.slice(sessionsPrefix.length); + spawnOptions = { sessionFile: `${containerHome}/.pi/agent/sessions${relativePart}` }; + } else { + spawnOptions = { sessionFile: hostPath }; + } + } + } + + session.piProcess = await piBridge.spawnPi(cwd, model, userId, email, onEvent, sandbox, spawnOptions); + + // Null out piProcess when the process dies so next message triggers respawn + const proc = session.piProcess; + proc.exited.then(() => { + if (session.piProcess === proc) { + session.piProcess = null; + logger.info('Pi process exited, nulled reference', { sessionId }); + } + }); + + logger.info('Spawned Pi process for session', { sessionId, model, cwd, sandboxed, hasSessionFile: !!spawnOptions?.sessionFile }); } catch (err) { logger.error('Failed to spawn Pi process', { sessionId, model, error: String(err) }); sendToClient(ws, { type: 'error', message: 'Failed to start Pi process' }); @@ -361,8 +390,36 @@ async function handleResume( const homeDir = getHomeDir(email); const sandbox = session.sandboxed && session.userId ? { userId: session.userId, username: ws.data.username, email, homeDir } : undefined; const onEvent = createEventHandler(sessionId, session.model, session.cwd, homeDir); - session.piProcess = await piBridge.spawnPi(session.cwd, session.model, session.userId!, email, onEvent, sandbox); - logger.info('Spawned fresh Pi process for resumed session', { sessionId, model: session.model, sandboxed: session.sandboxed }); + + // If session has history, save to disk and pass --session for context replay + let spawnOptions: { sessionFile?: string } | undefined; + if (session.messages.length > 0) { + await storage.saveSession(homeDir, sessionId, session.meta, session.messages); + const hostPath = await storage.getSessionFilePath(homeDir, sessionId); + if (hostPath) { + if (sandbox) { + const containerHome = `/home/${ws.data.username}`; + const sessionsPrefix = join(homeDir, '.pi', 'agent', 'sessions'); + const relativePart = hostPath.slice(sessionsPrefix.length); + spawnOptions = { sessionFile: `${containerHome}/.pi/agent/sessions${relativePart}` }; + } else { + spawnOptions = { sessionFile: hostPath }; + } + } + } + + session.piProcess = await piBridge.spawnPi(session.cwd, session.model, session.userId!, email, onEvent, sandbox, spawnOptions); + + // Null out piProcess when the process dies so next message triggers respawn + const proc = session.piProcess; + proc.exited.then(() => { + if (session.piProcess === proc) { + session.piProcess = null; + logger.info('Pi process exited, nulled reference', { sessionId }); + } + }); + + logger.info('Spawned fresh Pi process for resumed session', { sessionId, model: session.model, sandboxed: session.sandboxed, hasSessionFile: !!spawnOptions?.sessionFile }); } catch (err) { logger.error('Failed to spawn Pi process for resume', { sessionId, error: String(err) }); sendToClient(ws, { type: 'error', message: 'Failed to start Pi process' }); diff --git a/src/servers/api/terminal/websocket.ts b/src/servers/api/terminal/websocket.ts index 60426800..a2511a36 100644 --- a/src/servers/api/terminal/websocket.ts +++ b/src/servers/api/terminal/websocket.ts @@ -194,7 +194,7 @@ const startDockerSidecar = async (port: number, homeDir: string, userId: number, '-v', `${getUserToolsDir(email)}:/officer/user/tools:ro`, '-v', `${PI_CONFIG_DIR}:/officer/pi-config:ro`, '-v', `${PI_CONFIG_DIR}:${containerHome}/.pi/agent`, - '-v', `${ensureDir(join(DATA_PATH, email, 'pi-sessions'))}:${containerHome}/.pi/agent/sessions`, + '-v', `${ensureDir(join(getHomeDir(email), '.pi', 'agent', 'sessions'))}:${containerHome}/.pi/agent/sessions`, '-v', `${join(DATA_PATH, '.generated')}:/officer/generated:ro`, ...googleMounts, '-v', `${join(DATA_PATH, email, 'integrations')}:/officer/user/integrations:ro`, diff --git a/src/workspaces/officerdev/src/apps/Chat/ChatHeader.tsx b/src/workspaces/officerdev/src/apps/Chat/ChatHeader.tsx index 03778552..88b4f017 100644 --- a/src/workspaces/officerdev/src/apps/Chat/ChatHeader.tsx +++ b/src/workspaces/officerdev/src/apps/Chat/ChatHeader.tsx @@ -1,5 +1,5 @@ import { useEffect, useRef, useState } from 'react'; -import { MessageSquare, History, Plus } from 'lucide-react'; +import { MessageSquare, History, Plus, Trash2 } from 'lucide-react'; import { Popover, PopoverTrigger, PopoverContent } from '@/components/ui/popover'; import { useWorkspace } from '../../components/Workspace/WorkspaceContext'; import { useChatSessions } from 'state/useChatSessions'; @@ -29,7 +29,7 @@ export const ChatHeader = () => { : workspaceId && !workspaceId.startsWith('screens/') ? { context: 'workspace' as const, contextId: workspaceId } : undefined; - const { sessions } = useChatSessions(contextFilter); + const { sessions, clearSessions } = useChatSessions(contextFilter); const [selection, setSelection] = usePanelChannel('chat:panel-session', null); const [activeSessionId] = usePanelChannel('chat:active-session', null); const [open, setOpen] = useState(false); @@ -69,14 +69,29 @@ export const ChatHeader = () => {
Sessions - +
+ {sessions.length > 0 && ( + + )} + +
{/* Show active session at top if not yet in fetched list */} diff --git a/src/workspaces/officerdev/src/apps/Chat/ChatPanelWrapper.tsx b/src/workspaces/officerdev/src/apps/Chat/ChatPanelWrapper.tsx index 757e24e1..87f6ef77 100644 --- a/src/workspaces/officerdev/src/apps/Chat/ChatPanelWrapper.tsx +++ b/src/workspaces/officerdev/src/apps/Chat/ChatPanelWrapper.tsx @@ -9,6 +9,39 @@ type ChatSessionSelection = { model?: string | null; }; +type ChatPanelInnerProps = { + sessionId?: string; + model?: string; + scoped: boolean; + sandboxed: boolean; + cwdParam?: { root?: string; path: string }; + promptPrefix?: string; + chatContext: Record; + setActiveSession: (id: string | null) => void; +}; + +const ChatPanelInner = ({ sessionId, model, scoped, sandboxed, cwdParam, promptPrefix, chatContext, setActiveSession }: ChatPanelInnerProps) => { + const chat = usePiChat(sessionId, model, { replaceUrl: false, projectScoped: scoped, ...chatContext }); + + useEffect(() => { + setActiveSession(chat.sessionId); + }, [chat.sessionId]); + + return ( + + ); +}; + export const ChatPanelWrapper = () => { const { workspaceId, cwd, root, promptPrefix } = useWorkspace(); const scoped = cwd !== '~'; @@ -31,24 +64,17 @@ export const ChatPanelWrapper = () => { const sessionId = selection?.sessionId ?? undefined; const model = selection?.model ?? undefined; - const chat = usePiChat(sessionId, model, { replaceUrl: false, projectScoped: scoped, ...chatContext }); - - useEffect(() => { - setActiveSession(chat.sessionId); - }, [chat.sessionId]); - return ( - ); }; diff --git a/src/workspaces/state/src/useChatSessions.ts b/src/workspaces/state/src/useChatSessions.ts index e7eaec2b..b28c80b1 100644 --- a/src/workspaces/state/src/useChatSessions.ts +++ b/src/workspaces/state/src/useChatSessions.ts @@ -46,6 +46,14 @@ export function useChatSessions(filter?: ChatSessionsFilter) { ); } + async function clearSessions() { + await client.delete('/pi/sessions', { + ...(filter?.context ? { context: filter.context } : {}), + ...(filter?.contextId ? { contextId: filter.contextId } : {}), + }); + queryClient.setQueryData(['PI_SESSIONS', filter?.context, filter?.contextId], []); + } + function searchSessions(query: string) { return client.get<{ results: SessionEntry[] }>(`/pi/sessions/search?q=${encodeURIComponent(query)}`); } @@ -60,6 +68,7 @@ export function useChatSessions(filter?: ChatSessionsFilter) { saveMessages, renameSession, deleteSession, + clearSessions, searchSessions, invalidate, };