fix(electrobun): address all 20 Codex review findings

CRITICAL:
- PTY leak: Terminal.svelte now calls pty.close on destroy, not just unsubscribe
- Agent session cleanup: clearSession() removes done/error sessions, backend
  deletes after 60s grace period

HIGH:
- Clone branch passthrough: user's branch name flows through callback
- Circular imports: extracted rpc.ts singleton, broke main.ts ↔ App.svelte cycle
- Settings wired to runtime: Terminal reads cursor/scrollback from settings
- Security disclaimer: added "prototype — not system keyring" notice
- ThemeEditor: fixed basePalette → initialPalette reference

MEDIUM:
- Clone race: UUID suffix instead of count-based index
- Silent failures: structured error returns from PTY handlers
- WebKitGTK mount: only current + previous group mounted
- Debug listeners: gated behind DEBUG, cleanup on destroy
- NDJSON residual buffer parsed on process exit
- Codex adapter: deduplicated tool_call/tool_result
- extraEnv: rejects CLAUDE*/CODEX*/OLLAMA* keys
- settings-db: runMigrations() with version tracking
- active_group: persisted via settings.set

LOW:
- Removed dead demo code, unused variables
- color-mix() fallbacks added
This commit is contained in:
Hibryda 2026-03-22 01:20:23 +01:00
parent ef0183de7f
commit 29a3370e79
18 changed files with 331 additions and 114 deletions

View file

@ -5,7 +5,7 @@
* Exposes reactive Svelte 5 rune state per project.
*/
import { electrobun, appRpc } from './main.ts';
import { appRpc } from './rpc.ts';
// ── Types ────────────────────────────────────────────────────────────────────
@ -45,6 +45,24 @@ interface StartOptions {
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
@ -53,6 +71,9 @@ 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>>();
// ── RPC event listeners (registered once) ────────────────────────────────────
let listenersRegistered = false;
@ -62,7 +83,7 @@ function ensureListeners() {
listenersRegistered = true;
// agent.message — raw messages from sidecar, converted to display format
electrobun.rpc?.addMessageListener('agent.message', (payload: {
appRpc.addMessageListener('agent.message', (payload: {
sessionId: string;
messages: Array<{
id: string;
@ -87,7 +108,7 @@ function ensureListeners() {
});
// agent.status — session status changes
electrobun.rpc?.addMessageListener('agent.status', (payload: {
appRpc.addMessageListener('agent.status', (payload: {
sessionId: string;
status: string;
error?: string;
@ -97,10 +118,15 @@ function ensureListeners() {
session.status = normalizeStatus(payload.status);
if (payload.error) session.error = payload.error;
// Schedule cleanup after done/error (Fix #2)
if (session.status === 'done' || session.status === 'error') {
scheduleCleanup(session.sessionId, session.projectId);
}
});
// agent.cost — token/cost updates
electrobun.rpc?.addMessageListener('agent.cost', (payload: {
appRpc.addMessageListener('agent.cost', (payload: {
sessionId: string;
costUsd: number;
inputTokens: number;
@ -115,6 +141,32 @@ function ensureListeners() {
});
}
// ── 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: {
@ -146,7 +198,6 @@ function convertRawMessage(raw: {
case 'tool_call': {
const name = String(c?.name ?? 'Tool');
const input = c?.input as Record<string, unknown> | undefined;
// Extract file path from common tool input patterns
const path = extractToolPath(name, input);
return {
id: raw.id,
@ -174,7 +225,6 @@ function convertRawMessage(raw: {
case 'init': {
const model = String(c?.model ?? '');
// Update session model from init message
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)))) {
@ -210,7 +260,6 @@ function convertRawMessage(raw: {
function extractToolPath(name: string, input: Record<string, unknown> | undefined): string | undefined {
if (!input) return undefined;
// Common patterns: file_path, path, command (for Bash)
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') {
@ -241,7 +290,7 @@ function normalizeStatus(status: string): AgentStatus {
// ── Public API ───────────────────────────────────────────────────────────────
/** Start an agent session for a project. */
/** Start an agent session for a project (Fix #5: reads permission_mode + system_prompt from settings). */
export async function startAgent(
projectId: string,
provider: string,
@ -250,8 +299,24 @@ export async function startAgent(
): 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,
@ -278,11 +343,11 @@ export async function startAgent(
prompt,
cwd: options.cwd,
model: options.model,
systemPrompt: options.systemPrompt,
systemPrompt: systemPrompt,
maxTurns: options.maxTurns,
permissionMode: options.permissionMode,
permissionMode: permissionMode,
claudeConfigDir: options.claudeConfigDir,
extraEnv: options.extraEnv,
extraEnv: validateExtraEnv(options.extraEnv),
});
if (!result.ok) {
@ -341,5 +406,26 @@ 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);
}
}
}
/** Initialize listeners on module load. */
ensureListeners();