/** * Agent session store — manages per-project agent state and RPC communication. * * Listens for agent.message, agent.status, agent.cost events from Bun process. * Exposes reactive Svelte 5 rune state per project. */ import { appRpc } from './rpc.ts'; // ── Types ──────────────────────────────────────────────────────────────────── export type AgentStatus = 'idle' | 'running' | 'done' | 'error'; export type MsgRole = 'user' | 'assistant' | 'tool-call' | 'tool-result' | 'thinking' | 'system'; export interface AgentMessage { id: string; role: MsgRole; content: string; toolName?: string; toolInput?: string; toolPath?: string; timestamp: number; } export interface AgentSession { sessionId: string; projectId: string; provider: string; status: AgentStatus; messages: AgentMessage[]; costUsd: number; inputTokens: number; outputTokens: number; model: string; error?: string; } interface StartOptions { cwd?: string; model?: string; systemPrompt?: string; maxTurns?: number; permissionMode?: string; claudeConfigDir?: string; extraEnv?: Record; } // ── Env var validation (Fix #14) ───────────────────────────────────────────── const BLOCKED_ENV_PREFIXES = ['CLAUDE', 'CODEX', 'OLLAMA', 'ANTHROPIC_']; function validateExtraEnv(env: Record | undefined): Record | undefined { if (!env) return undefined; const clean: Record = {}; for (const [key, value] of Object.entries(env)) { const blocked = BLOCKED_ENV_PREFIXES.some(p => key.startsWith(p)); if (blocked) { console.warn(`[agent-store] Rejected extraEnv key "${key}" — provider-prefixed keys are not allowed`); continue; } clean[key] = value; } return Object.keys(clean).length > 0 ? clean : undefined; } // ── Internal state ─────────────────────────────────────────────────────────── // Map projectId -> sessionId for lookup const projectSessionMap = new Map(); // Map sessionId -> reactive session state let sessions = $state>({}); // Grace period timers for cleanup after done/error const cleanupTimers = new Map>(); // Debounce timer for message persistence const msgPersistTimers = new Map>(); // ── Session persistence helpers ───────────────────────────────────────────── function persistSession(session: AgentSession): void { appRpc.request['session.save']({ projectId: session.projectId, sessionId: session.sessionId, provider: session.provider, status: session.status, costUsd: session.costUsd, inputTokens: session.inputTokens, outputTokens: session.outputTokens, model: session.model, error: session.error, createdAt: session.messages[0]?.timestamp ?? Date.now(), updatedAt: Date.now(), }).catch((err: unknown) => { console.error('[session.save] persist error:', err); }); } function persistMessages(session: AgentSession): void { // Debounce: batch message saves every 2 seconds const existing = msgPersistTimers.get(session.sessionId); if (existing) clearTimeout(existing); const timer = setTimeout(() => { msgPersistTimers.delete(session.sessionId); const msgs = session.messages.map((m) => ({ sessionId: session.sessionId, msgId: m.id, role: m.role, content: m.content, toolName: m.toolName, toolInput: m.toolInput, timestamp: m.timestamp, })); if (msgs.length === 0) return; appRpc.request['session.messages.save']({ messages: msgs }).catch((err: unknown) => { console.error('[session.messages.save] persist error:', err); }); }, 2000); msgPersistTimers.set(session.sessionId, timer); } // ── RPC event listeners (registered once) ──────────────────────────────────── let listenersRegistered = false; function ensureListeners() { if (listenersRegistered) return; listenersRegistered = true; // agent.message — raw messages from sidecar, converted to display format appRpc.addMessageListener('agent.message', (payload: { sessionId: string; messages: Array<{ id: string; type: string; parentId?: string; content: unknown; timestamp: number; }>; }) => { const session = sessions[payload.sessionId]; if (!session) return; const converted: AgentMessage[] = []; for (const raw of payload.messages) { const msg = convertRawMessage(raw); if (msg) converted.push(msg); } if (converted.length > 0) { session.messages = [...session.messages, ...converted]; persistMessages(session); } }); // agent.status — session status changes appRpc.addMessageListener('agent.status', (payload: { sessionId: string; status: string; error?: string; }) => { const session = sessions[payload.sessionId]; if (!session) return; session.status = normalizeStatus(payload.status); if (payload.error) session.error = payload.error; // Persist on every status change persistSession(session); // Schedule cleanup after done/error (Fix #2) if (session.status === 'done' || session.status === 'error') { // Flush any pending message persistence immediately const pendingTimer = msgPersistTimers.get(session.sessionId); if (pendingTimer) { clearTimeout(pendingTimer); msgPersistTimers.delete(session.sessionId); } persistMessages(session); scheduleCleanup(session.sessionId, session.projectId); } }); // agent.cost — token/cost updates appRpc.addMessageListener('agent.cost', (payload: { sessionId: string; costUsd: number; inputTokens: number; outputTokens: number; }) => { const session = sessions[payload.sessionId]; if (!session) return; session.costUsd = payload.costUsd; session.inputTokens = payload.inputTokens; session.outputTokens = payload.outputTokens; }); } // ── Cleanup scheduling (Fix #2) ────────────────────────────────────────────── const CLEANUP_GRACE_MS = 60_000; // 60 seconds after done/error function scheduleCleanup(sessionId: string, projectId: string) { // Cancel any existing timer for this session const existing = cleanupTimers.get(sessionId); if (existing) clearTimeout(existing); const timer = setTimeout(() => { cleanupTimers.delete(sessionId); // Only clean up if session is still in done/error state const session = sessions[sessionId]; if (session && (session.status === 'done' || session.status === 'error')) { // Keep session data (messages, cost) but remove from projectSessionMap // so starting a new session on this project works cleanly const currentMapped = projectSessionMap.get(projectId); if (currentMapped === sessionId) { projectSessionMap.delete(projectId); } } }, CLEANUP_GRACE_MS); cleanupTimers.set(sessionId, timer); } // ── Message conversion ─────────────────────────────────────────────────────── function convertRawMessage(raw: { id: string; type: string; parentId?: string; content: unknown; timestamp: number; }): AgentMessage | null { const c = raw.content as Record | undefined; switch (raw.type) { case 'text': return { id: raw.id, role: 'assistant', content: String(c?.text ?? ''), timestamp: raw.timestamp, }; case 'thinking': return { id: raw.id, role: 'thinking', content: String(c?.text ?? ''), timestamp: raw.timestamp, }; case 'tool_call': { const name = String(c?.name ?? 'Tool'); const input = c?.input as Record | undefined; const path = extractToolPath(name, input); return { id: raw.id, role: 'tool-call', content: formatToolInput(name, input), toolName: name, toolInput: JSON.stringify(input, null, 2), toolPath: path, timestamp: raw.timestamp, }; } case 'tool_result': { const output = c?.output; const text = typeof output === 'string' ? output : JSON.stringify(output, null, 2); return { id: raw.id, role: 'tool-result', content: truncateOutput(text, 500), timestamp: raw.timestamp, }; } case 'init': { const model = String(c?.model ?? ''); const sid = String(c?.sessionId ?? ''); for (const s of Object.values(sessions)) { if (s.sessionId === raw.id || (sid && s.sessionId.includes(sid.slice(0, 8)))) { if (model) s.model = model; } } return { id: raw.id, role: 'system', content: `Session initialized${model ? ` (${model})` : ''}`, timestamp: raw.timestamp, }; } case 'error': return { id: raw.id, role: 'system', content: `Error: ${String(c?.message ?? 'Unknown error')}`, timestamp: raw.timestamp, }; case 'cost': case 'status': case 'compaction': case 'unknown': return null; default: return null; } } function extractToolPath(name: string, input: Record | undefined): string | undefined { if (!input) return undefined; if (typeof input.file_path === 'string') return input.file_path; if (typeof input.path === 'string') return input.path; if (name === 'Bash' && typeof input.command === 'string') { return input.command.length > 80 ? input.command.slice(0, 80) + '...' : input.command; } return undefined; } function formatToolInput(name: string, input: Record | undefined): string { if (!input) return ''; if (name === 'Bash' && typeof input.command === 'string') return input.command; if (typeof input.file_path === 'string') return input.file_path; return JSON.stringify(input, null, 2); } function truncateOutput(text: string, maxLines: number): string { const lines = text.split('\n'); if (lines.length <= maxLines) return text; return lines.slice(0, maxLines).join('\n') + `\n... (${lines.length - maxLines} more lines)`; } function normalizeStatus(status: string): AgentStatus { if (status === 'running' || status === 'idle' || status === 'done' || status === 'error') { return status; } return 'idle'; } // ── Public API ─────────────────────────────────────────────────────────────── /** Start an agent session for a project (Fix #5: reads permission_mode + system_prompt from settings). */ export async function startAgent( projectId: string, provider: string, prompt: string, options: StartOptions = {}, ): Promise<{ ok: boolean; error?: string }> { ensureListeners(); // If there's an existing done/error session for this project, clear it first clearSession(projectId); const sessionId = `${projectId}-${Date.now()}`; // Read settings defaults if not explicitly provided (Fix #5) let permissionMode = options.permissionMode; let systemPrompt = options.systemPrompt; try { const { settings } = await appRpc.request['settings.getAll']({}); if (!permissionMode && settings['permission_mode']) { permissionMode = settings['permission_mode']; } if (!systemPrompt && settings['system_prompt_template']) { systemPrompt = settings['system_prompt_template']; } } catch { /* use provided or defaults */ } // Create reactive session state sessions[sessionId] = { sessionId, projectId, provider, status: 'running', messages: [{ id: `${sessionId}-user-0`, role: 'user', content: prompt, timestamp: Date.now(), }], costUsd: 0, inputTokens: 0, outputTokens: 0, model: options.model ?? 'claude-opus-4-5', }; projectSessionMap.set(projectId, sessionId); const result = await appRpc.request['agent.start']({ sessionId, provider: provider as 'claude' | 'codex' | 'ollama', prompt, cwd: options.cwd, model: options.model, systemPrompt: systemPrompt, maxTurns: options.maxTurns, permissionMode: permissionMode, claudeConfigDir: options.claudeConfigDir, extraEnv: validateExtraEnv(options.extraEnv), }); if (!result.ok) { sessions[sessionId].status = 'error'; sessions[sessionId].error = result.error; } return result; } /** Stop a running agent session for a project. */ export async function stopAgent(projectId: string): Promise<{ ok: boolean; error?: string }> { const sessionId = projectSessionMap.get(projectId); if (!sessionId) return { ok: false, error: 'No session for project' }; const result = await appRpc.request['agent.stop']({ sessionId }); if (result.ok) { const session = sessions[sessionId]; if (session) session.status = 'done'; } return result; } /** Send a follow-up prompt to a running session. */ export async function sendPrompt(projectId: string, prompt: string): Promise<{ ok: boolean; error?: string }> { const sessionId = projectSessionMap.get(projectId); if (!sessionId) return { ok: false, error: 'No session for project' }; const session = sessions[sessionId]; if (!session) return { ok: false, error: 'Session not found' }; // Add user message immediately session.messages = [...session.messages, { id: `${sessionId}-user-${Date.now()}`, role: 'user', content: prompt, timestamp: Date.now(), }]; session.status = 'running'; return appRpc.request['agent.prompt']({ sessionId, prompt }); } /** Get the current session for a project (reactive). */ export function getSession(projectId: string): AgentSession | undefined { const sessionId = projectSessionMap.get(projectId); if (!sessionId) return undefined; return sessions[sessionId]; } /** Check if a project has an active session. */ export function hasSession(projectId: string): boolean { return projectSessionMap.has(projectId); } /** * Clear a done/error session for a project (Fix #2). * Removes from projectSessionMap so a new session can start. * Keeps session data in sessions map for history access. */ export function clearSession(projectId: string): void { const sessionId = projectSessionMap.get(projectId); if (!sessionId) return; const session = sessions[sessionId]; if (session && (session.status === 'done' || session.status === 'error')) { projectSessionMap.delete(projectId); // Cancel any pending cleanup timer const timer = cleanupTimers.get(sessionId); if (timer) { clearTimeout(timer); cleanupTimers.delete(sessionId); } } } /** * Load the last session for a project from SQLite (for restart recovery). * Restores session state + messages into the reactive store. * Only restores done/error sessions (running sessions are gone after restart). */ export async function loadLastSession(projectId: string): Promise { ensureListeners(); try { const { session } = await appRpc.request['session.load']({ projectId }); if (!session) return false; // Only restore completed sessions (running sessions can't be resumed) if (session.status !== 'done' && session.status !== 'error') return false; // Load messages for this session const { messages: storedMsgs } = await appRpc.request['session.messages.load']({ sessionId: session.sessionId, }); const restoredMessages: AgentMessage[] = storedMsgs.map((m: { msgId: string; role: string; content: string; toolName?: string; toolInput?: string; timestamp: number; }) => ({ id: m.msgId, role: m.role as MsgRole, content: m.content, toolName: m.toolName, toolInput: m.toolInput, timestamp: m.timestamp, })); sessions[session.sessionId] = { sessionId: session.sessionId, projectId: session.projectId, provider: session.provider, status: normalizeStatus(session.status), messages: restoredMessages, costUsd: session.costUsd, inputTokens: session.inputTokens, outputTokens: session.outputTokens, model: session.model, error: session.error, }; projectSessionMap.set(projectId, session.sessionId); return true; } catch (err) { console.error('[loadLastSession] error:', err); return false; } } /** Initialize listeners on module load. */ ensureListeners();