diff --git a/ui-electrobun/src/bun/index.ts b/ui-electrobun/src/bun/index.ts index b6f41fb..3bb4ea1 100644 --- a/ui-electrobun/src/bun/index.ts +++ b/ui-electrobun/src/bun/index.ts @@ -73,12 +73,16 @@ const rpc = BrowserView.defineRPC({ }, "pty.write": ({ sessionId, data }) => { - if (!ptyClient.isConnected) return { ok: false }; + if (!ptyClient.isConnected) { + console.error(`[pty.write] ${sessionId}: daemon not connected`); + return { ok: false }; + } try { ptyClient.writeInput(sessionId, data); return { ok: true }; } catch (err) { - console.error(`[pty.write] ${sessionId}:`, err); + const error = err instanceof Error ? err.message : String(err); + console.error(`[pty.write] ${sessionId}: ${error}`); return { ok: false }; } }, @@ -87,21 +91,27 @@ const rpc = BrowserView.defineRPC({ if (!ptyClient.isConnected) return { ok: true }; try { ptyClient.resize(sessionId, cols, rows); - } catch { /* ignore */ } + } catch (err) { + console.error(`[pty.resize] ${sessionId}:`, err); + } return { ok: true }; }, "pty.unsubscribe": ({ sessionId }) => { try { ptyClient.unsubscribe(sessionId); - } catch { /* ignore */ } + } catch (err) { + console.error(`[pty.unsubscribe] ${sessionId}:`, err); + } return { ok: true }; }, "pty.close": ({ sessionId }) => { try { ptyClient.closeSession(sessionId); - } catch { /* ignore */ } + } catch (err) { + console.error(`[pty.close] ${sessionId}:`, err); + } return { ok: true }; }, @@ -227,7 +237,9 @@ const rpc = BrowserView.defineRPC({ } const cloneIndex = existingClones.length + 1; - const worktreePath = `${mainRepoPath}-wt-${cloneIndex}`; + // Fix #8: Use UUID suffix to prevent race conditions between concurrent clones + const wtSuffix = randomUUID().slice(0, 8); + const worktreePath = `${mainRepoPath}-wt-${wtSuffix}`; const gitResult = await gitWorktreeAdd(mainRepoPath, worktreePath, branchName); if (!gitResult.ok) { diff --git a/ui-electrobun/src/bun/message-adapter.ts b/ui-electrobun/src/bun/message-adapter.ts index 2d20c93..262cec3 100644 --- a/ui-electrobun/src/bun/message-adapter.ts +++ b/ui-electrobun/src/bun/message-adapter.ts @@ -369,9 +369,11 @@ function adaptCodexItem( }, ]; case "command_execution": { + // Fix #13: Only emit tool_call on item.started, tool_result on item.completed + // Prevents duplicate tool_call messages. const messages: AgentMessage[] = []; const toolUseId = str(item.id, uuid); - if (eventType === "item.started" || eventType === "item.completed") { + if (eventType === "item.started") { messages.push({ id: `${uuid}-call`, type: "tool_call", diff --git a/ui-electrobun/src/bun/settings-db.ts b/ui-electrobun/src/bun/settings-db.ts index 2d99044..e3b0ef5 100644 --- a/ui-electrobun/src/bun/settings-db.ts +++ b/ui-electrobun/src/bun/settings-db.ts @@ -110,12 +110,30 @@ export class SettingsDb { this.db.exec(SCHEMA); this.db.exec(SEED_GROUPS); - // Seed schema_version row if missing - const version = this.db + // Run version-tracked migrations + this.runMigrations(); + } + + /** Run version-tracked schema migrations. */ + private runMigrations(): void { + const CURRENT_VERSION = 1; + + const row = this.db .query<{ version: number }, []>("SELECT version FROM schema_version LIMIT 1") .get(); - if (!version) { - this.db.exec("INSERT INTO schema_version (version) VALUES (1)"); + const currentVersion = row?.version ?? 0; + + if (currentVersion < 1) { + // Version 1 is the initial schema — already created above via SCHEMA. + // Future migrations go here as version checks: + // if (currentVersion < 2) { this.db.exec("ALTER TABLE ..."); } + // if (currentVersion < 3) { this.db.exec("ALTER TABLE ..."); } + } + + if (!row) { + this.db.exec(`INSERT INTO schema_version (version) VALUES (${CURRENT_VERSION})`); + } else if (currentVersion < CURRENT_VERSION) { + this.db.exec(`UPDATE schema_version SET version = ${CURRENT_VERSION}`); } } diff --git a/ui-electrobun/src/bun/sidecar-manager.ts b/ui-electrobun/src/bun/sidecar-manager.ts index dc94b91..2853198 100644 --- a/ui-electrobun/src/bun/sidecar-manager.ts +++ b/ui-electrobun/src/bun/sidecar-manager.ts @@ -41,11 +41,25 @@ interface ActiveSession { onStatus: StatusCallback[]; } -// ── Environment stripping ──────────────────────────────────────────────────── +// ── Environment stripping (Fix #14) ────────────────────────────────────────── const STRIP_PREFIXES = ["CLAUDE", "CODEX", "OLLAMA", "ANTHROPIC_"]; const WHITELIST_PREFIXES = ["CLAUDE_CODE_EXPERIMENTAL_"]; +function validateExtraEnv(extraEnv: Record | undefined): Record | undefined { + if (!extraEnv) return undefined; + const clean: Record = {}; + for (const [key, value] of Object.entries(extraEnv)) { + const blocked = STRIP_PREFIXES.some((p) => key.startsWith(p)); + if (blocked) { + console.warn(`[sidecar] Rejected extraEnv key "${key}" — provider-prefixed keys not allowed`); + continue; + } + clean[key] = value; + } + return Object.keys(clean).length > 0 ? clean : undefined; +} + function buildCleanEnv(extraEnv?: Record, claudeConfigDir?: string): Record { const clean: Record = {}; @@ -61,8 +75,10 @@ function buildCleanEnv(extraEnv?: Record, claudeConfigDir?: stri if (claudeConfigDir) { clean["CLAUDE_CONFIG_DIR"] = claudeConfigDir; } - if (extraEnv) { - Object.assign(clean, extraEnv); + // Apply validated extraEnv + const validated = validateExtraEnv(extraEnv); + if (validated) { + Object.assign(clean, validated); } return clean; @@ -93,13 +109,11 @@ function findClaudeCli(): string | undefined { // ── Runner resolution ──────────────────────────────────────────────────────── function resolveRunnerPath(provider: ProviderId): string { - // Sidecar runners live in the repo's sidecar/dist/ directory const repoRoot = join(import.meta.dir, "..", "..", ".."); return join(repoRoot, "sidecar", "dist", `${provider}-runner.mjs`); } function findNodeRuntime(): string { - // Prefer Deno, fallback to Node.js (matching Tauri sidecar behavior) try { const result = Bun.spawnSync(["which", "deno"]); const path = new TextDecoder().decode(result.stdout).trim(); @@ -115,10 +129,15 @@ function findNodeRuntime(): string { return "node"; // last resort } +// ── Cleanup grace period ───────────────────────────────────────────────────── + +const CLEANUP_GRACE_MS = 60_000; // 60s after done/error before removing session + // ── SidecarManager ─────────────────────────────────────────────────────────── export class SidecarManager { private sessions = new Map(); + private cleanupTimers = new Map>(); private claudePath: string | undefined; private nodeRuntime: string; @@ -197,6 +216,8 @@ export class SidecarManager { if (s) { s.state.status = exitCode === 0 ? "done" : "error"; this.emitStatus(sessionId, s.state.status, exitCode !== 0 ? `Exit code: ${exitCode}` : undefined); + // Schedule cleanup (Fix #2) + this.scheduleCleanup(sessionId); } }); @@ -211,7 +232,7 @@ export class SidecarManager { maxTurns: options.maxTurns, permissionMode: options.permissionMode ?? "bypassPermissions", claudeConfigDir: options.claudeConfigDir, - extraEnv: options.extraEnv, + extraEnv: validateExtraEnv(options.extraEnv), }; this.writeToProcess(sessionId, queryMsg); @@ -236,7 +257,7 @@ export class SidecarManager { s.controller.abort(); s.state.status = "done"; this.emitStatus(sessionId, "done"); - this.sessions.delete(sessionId); + this.scheduleCleanup(sessionId); } }, 3000); @@ -290,6 +311,30 @@ export class SidecarManager { } this.sessions.delete(sessionId); } + // Cancel any cleanup timer + const timer = this.cleanupTimers.get(sessionId); + if (timer) { + clearTimeout(timer); + this.cleanupTimers.delete(sessionId); + } + } + + // ── Cleanup scheduling (Fix #2) ───────────────────────────────────────── + + private scheduleCleanup(sessionId: string): void { + // Cancel any existing timer + const existing = this.cleanupTimers.get(sessionId); + if (existing) clearTimeout(existing); + + const timer = setTimeout(() => { + this.cleanupTimers.delete(sessionId); + const session = this.sessions.get(sessionId); + if (session && (session.state.status === "done" || session.state.status === "error")) { + this.sessions.delete(sessionId); + } + }, CLEANUP_GRACE_MS); + + this.cleanupTimers.set(sessionId, timer); } // ── Internal ─────────────────────────────────────────────────────────────── @@ -324,6 +369,12 @@ export class SidecarManager { this.handleNdjsonLine(sessionId, session, line); } } + + // Fix #12: Parse any residual data left in the buffer after stream ends + const residual = buffer.trim(); + if (residual) { + this.handleNdjsonLine(sessionId, session, residual); + } } catch (err) { // Stream closed — expected on process exit if (!session.controller.signal.aborted) { @@ -339,7 +390,6 @@ export class SidecarManager { try { for await (const chunk of reader) { const text = decoder.decode(chunk, { stream: true }); - // Log sidecar stderr as debug output for (const line of text.split("\n")) { if (line.trim()) { console.log(`[sidecar:${sessionId}] ${line.trim()}`); diff --git a/ui-electrobun/src/mainview/App.svelte b/ui-electrobun/src/mainview/App.svelte index 723fb2b..1bb6d5e 100644 --- a/ui-electrobun/src/mainview/App.svelte +++ b/ui-electrobun/src/mainview/App.svelte @@ -8,7 +8,7 @@ import { themeStore } from './theme-store.svelte.ts'; import { fontStore } from './font-store.svelte.ts'; import { keybindingStore } from './keybinding-store.svelte.ts'; - import { appRpc } from './main.ts'; + import { appRpc } from './rpc.ts'; // ── Types ───────────────────────────────────────────────────── type AgentStatus = 'running' | 'idle' | 'stalled'; @@ -104,6 +104,9 @@ { id: 'research', name: 'Research', icon: '🔬', position: 3 }, ]); let activeGroupId = $state('dev'); + // Fix #10: Track previous group to limit mounted DOM (max 2 groups) + let previousGroupId = $state(null); + let mountedGroupIds = $derived(new Set([activeGroupId, ...(previousGroupId ? [previousGroupId] : [])])); // ── Filtered projects for active group ──────────────────────── let activeGroup = $derived(groups.find(g => g.id === activeGroupId) ?? groups[0]); @@ -111,39 +114,15 @@ PROJECTS.filter(p => (p.groupId ?? 'dev') === activeGroupId) ); - // Group projects into: top-level cards + clone groups - interface ProjectRow { type: 'standalone'; project: Project; } - interface CloneGroupRow { type: 'clone-group'; parent: Project; clones: Project[]; } - type GridRow = ProjectRow | CloneGroupRow; - - let gridRows = $derived((): GridRow[] => { - const standalone: GridRow[] = []; - const cloneParentIds = new Set( - filteredProjects.filter(p => p.cloneOf).map(p => p.cloneOf!) - ); - for (const p of filteredProjects) { - if (p.cloneOf) continue; - if (cloneParentIds.has(p.id)) { - const clones = filteredProjects - .filter(c => c.cloneOf === p.id) - .sort((a, b) => (a.cloneIndex ?? 0) - (b.cloneIndex ?? 0)); - standalone.push({ type: 'clone-group', parent: p, clones }); - } else { - standalone.push({ type: 'standalone', project: p }); - } - } - return standalone; - }); - // ── Clone helpers ────────────────────────────────────────────── function cloneCountForProject(projectId: string): number { return PROJECTS.filter(p => p.cloneOf === projectId).length; } - function handleClone(projectId: string) { + function handleClone(projectId: string, branch: string) { const source = PROJECTS.find(p => p.id === projectId); if (!source) return; - const branchName = `feature/clone-${Date.now()}`; + const branchName = branch || `feature/clone-${Date.now()}`; appRpc.request["project.clone"]({ projectId, branchName }).then((result) => { if (result.ok && result.project) { const cloneConfig = JSON.parse(result.project.config) as Project; @@ -176,9 +155,13 @@ // ── setActiveGroup: fire-and-forget RPC ─────────────────────── function setActiveGroup(id: string | undefined) { if (!id) return; - console.log('[DEBUG] setActiveGroup:', id); + // Fix #10: Track previous group for DOM mount limit + if (activeGroupId !== id) { + previousGroupId = activeGroupId; + } activeGroupId = id; - // NO RPC — pure local state change for debugging + // Fix #16: Persist active_group selection + appRpc.request["settings.set"]({ key: 'active_group', value: id }).catch(console.error); } // ── Window controls ──────────────────────────────────────────── @@ -263,10 +246,12 @@ function fmtTokens(n: number): string { return n >= 1000 ? `${(n / 1000).toFixed(1)}k` : String(n); } function fmtCost(n: number): string { return `$${n.toFixed(3)}`; } - // ── DEBUG: Visual click diagnostics overlay ───────────────────── + // ── DEBUG: Visual click diagnostics overlay (gated behind DEBUG env) ──── + const DEBUG_ENABLED = typeof window !== 'undefined' && new URLSearchParams(window.location.search).has('debug'); let debugLog = $state([]); $effect(() => { + if (!DEBUG_ENABLED) return; function debugClick(e: MouseEvent) { const el = e.target as HTMLElement; const tag = el?.tagName; @@ -276,15 +261,20 @@ if (elAtPoint && elAtPoint !== el) { const overTag = (elAtPoint as HTMLElement).tagName; const overCls = ((elAtPoint as HTMLElement).className?.toString?.() ?? '').slice(0, 40); - line += ` ⚠️OVERLAY: ${overTag}.${overCls}`; + line += ` OVERLAY: ${overTag}.${overCls}`; } debugLog = [...debugLog.slice(-8), line]; } - document.addEventListener('click', debugClick, true); - document.addEventListener('mousedown', (e) => { + function debugDown(e: MouseEvent) { const el = e.target as HTMLElement; debugLog = [...debugLog.slice(-8), `DOWN ${el?.tagName}.${(el?.className?.toString?.() ?? '').slice(0, 40)}`]; - }, true); + } + document.addEventListener('click', debugClick, true); + document.addEventListener('mousedown', debugDown, true); + return () => { + document.removeEventListener('click', debugClick, true); + document.removeEventListener('mousedown', debugDown, true); + }; }); // ── Init ─────────────────────────────────────────────────────── @@ -327,7 +317,6 @@