agent-orchestrator/ui-electrobun/src/mainview/agent-store.svelte.ts
Hibryda 252fca70df feat(electrobun): file management — CodeMirror editor, PDF viewer, CSV table, real file I/O
- CodeEditor: CodeMirror 6 with Catppuccin theme, 15+ languages, Ctrl+S save,
  dirty tracking, save-on-blur
- PdfViewer: pdfjs-dist canvas rendering, zoom 0.5-3x, HiDPI, lazy page load
- CsvTable: RFC 4180 parser, delimiter auto-detect, sortable columns, sticky header
- FileBrowser: real filesystem via files.list/read/write RPC, lazy dir loading,
  file type routing (code→editor, pdf→viewer, csv→table, images→display)
- 10MB size gate, binary detection, base64 encoding for non-text files
2026-03-22 01:36:02 +01:00

542 lines
17 KiB
TypeScript

/**
* 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<string, string>;
}
// ── Env var validation (Fix #14) ─────────────────────────────────────────────
const BLOCKED_ENV_PREFIXES = ['CLAUDE', 'CODEX', 'OLLAMA', 'ANTHROPIC_'];
function validateExtraEnv(env: Record<string, string> | undefined): Record<string, string> | undefined {
if (!env) return undefined;
const clean: Record<string, string> = {};
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<string, string>();
// Map sessionId -> reactive session state
let sessions = $state<Record<string, AgentSession>>({});
// Grace period timers for cleanup after done/error
const cleanupTimers = new Map<string, ReturnType<typeof setTimeout>>();
// Debounce timer for message persistence
const msgPersistTimers = new Map<string, ReturnType<typeof setTimeout>>();
// ── 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<string, unknown> | 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<string, unknown> | 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<string, unknown> | 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<string, unknown> | 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<boolean> {
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();