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

@ -73,12 +73,16 @@ const rpc = BrowserView.defineRPC<PtyRPCSchema>({
},
"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<PtyRPCSchema>({
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<PtyRPCSchema>({
}
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) {

View file

@ -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",

View file

@ -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}`);
}
}

View file

@ -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<string, string> | undefined): Record<string, string> | undefined {
if (!extraEnv) return undefined;
const clean: Record<string, string> = {};
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<string, string>, claudeConfigDir?: string): Record<string, string> {
const clean: Record<string, string> = {};
@ -61,8 +75,10 @@ function buildCleanEnv(extraEnv?: Record<string, string>, 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<string, ActiveSession>();
private cleanupTimers = new Map<string, ReturnType<typeof setTimeout>>();
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()}`);

View file

@ -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<string | null>(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<string[]>([]);
$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 @@
<div
class="app-shell"
role="presentation"
onresize={saveWindowFrame}
>
<!-- Left sidebar icon rail -->
<aside class="sidebar" role="navigation" aria-label="Primary navigation">
@ -374,9 +363,10 @@
<main class="workspace">
<!-- Project grid -->
<div class="project-grid" role="list" aria-label="{activeGroup?.name ?? 'Projects'} projects">
<!-- ALL projects rendered always with display:none/flex toggling.
<!-- Fix #10: Only mount projects in active + previous group (max 2 groups).
WebKitGTK corrupts hit-test tree when DOM nodes are added/removed during click events. -->
{#each PROJECTS as project (project.id)}
{#if mountedGroupIds.has(project.groupId ?? 'dev')}
<div role="listitem" style:display={(project.groupId ?? 'dev') === activeGroupId ? 'flex' : 'none'}>
<ProjectCard
id={project.id}
@ -395,6 +385,7 @@
cloneOf={project.cloneOf}
/>
</div>
{/if}
{/each}
<!-- Empty group — always in DOM, visibility toggled -->
@ -486,12 +477,14 @@
<kbd class="palette-hint" title="Open command palette">Ctrl+K</kbd>
</footer>
<!-- DEBUG: visible click log -->
{#if DEBUG_ENABLED && debugLog.length > 0}
<!-- DEBUG: visible click log (enable with ?debug URL param) -->
<div style="position:fixed;bottom:0;left:0;right:0;background:#000;color:#0f0;font-family:monospace;font-size:10px;padding:4px 8px;z-index:9999;max-height:100px;overflow-y:auto;pointer-events:none;">
{#each debugLog as line}
<div>{line}</div>
{/each}
</div>
{/if}
<style>
:global(body) { overflow: hidden; }
@ -642,28 +635,6 @@
.project-grid::-webkit-scrollbar-track { background: transparent; }
.project-grid::-webkit-scrollbar-thumb { background: var(--ctp-surface1); border-radius: 0.25rem; }
.clone-group-row {
grid-column: 1 / -1;
display: flex;
flex-direction: row;
gap: 0;
align-items: stretch;
min-height: 0;
}
.clone-group-row :global(.project-card) { flex: 1; min-width: 0; }
.chain-icon {
flex-shrink: 0;
width: 1.5rem;
display: flex;
align-items: center;
justify-content: center;
color: var(--ctp-surface1);
}
.chain-icon svg { width: 1rem; height: 1rem; }
.empty-group {
grid-column: 1 / -1;
display: flex;

View file

@ -27,8 +27,8 @@
cloneOf?: string;
/** Max clones reached for this project. */
clonesAtMax?: boolean;
/** Callback when user requests cloning. */
onClone?: (projectId: string) => void;
/** Callback when user requests cloning (receives projectId and branch name). */
onClone?: (projectId: string, branch: string) => void;
}
let {
@ -78,7 +78,7 @@
cloneError = 'Use only letters, numbers, /, _, -, .';
return;
}
onClone?.(id);
onClone?.(id, cloneBranchName);
showCloneDialog = false;
}

View file

@ -4,7 +4,7 @@
import { CanvasAddon } from '@xterm/addon-canvas';
import { FitAddon } from '@xterm/addon-fit';
import { ImageAddon } from '@xterm/addon-image';
import { electrobun } from './main.ts';
import { appRpc } from './rpc.ts';
import { fontStore } from './font-store.svelte.ts';
import { themeStore } from './theme-store.svelte.ts';
import { getXtermTheme } from './themes.ts';
@ -22,6 +22,7 @@
let fitAddon: FitAddon;
let unsubFont: (() => void) | null = null;
let ro: ResizeObserver | null = null;
let destroyed = false;
/** Decode a base64 string from the daemon into a Uint8Array. */
function decodeBase64(b64: string): Uint8Array {
@ -60,20 +61,41 @@
term.open(termEl);
fitAddon.fit();
// ── Read cursor/scrollback settings ─────────────────────────────────
void (async () => {
try {
const { settings } = await appRpc.request['settings.getAll']({});
if (settings['cursor_style']) {
const style = settings['cursor_style'];
if (style === 'block' || style === 'underline' || style === 'bar') {
term.options.cursorStyle = style;
}
}
if (settings['cursor_blink'] === 'false') {
term.options.cursorBlink = false;
}
if (settings['scrollback']) {
const sb = parseInt(settings['scrollback'], 10);
if (!isNaN(sb) && sb >= 0) term.options.scrollback = sb;
}
} catch { /* non-critical — use defaults */ }
})();
// ── Subscribe to terminal font changes ─────────────────────────────────
unsubFont = fontStore.onTermFontChange((family: string, size: number) => {
term.options.fontFamily = family || 'JetBrains Mono, Fira Code, monospace';
term.options.fontSize = size;
fitAddon.fit();
electrobun.rpc?.request['pty.resize']({ sessionId, cols: term.cols, rows: term.rows }).catch(() => {});
appRpc.request['pty.resize']({ sessionId, cols: term.cols, rows: term.rows }).catch(() => {});
});
// ── Connect to PTY daemon (fire-and-forget from onMount) ───────────────
void (async () => {
const { cols, rows } = term;
const result = await electrobun.rpc?.request['pty.create']({ sessionId, cols, rows, cwd });
const result = await appRpc.request['pty.create']({ sessionId, cols, rows, cwd });
if (!result?.ok) {
term.writeln(`\x1b[31m[agor] Failed to connect to PTY daemon: ${result?.error ?? 'unknown error'}\x1b[0m`);
term.writeln('\x1b[33m[agor] Is agor-ptyd running? Start it with: agor-ptyd\x1b[0m');
@ -82,20 +104,22 @@
// ── Receive output from daemon ─────────────────────────────────────────
electrobun.rpc?.addMessageListener('pty.output', ({ sessionId: sid, data }: { sessionId: string; data: string }) => {
if (sid !== sessionId) return;
const outputHandler = ({ sessionId: sid, data }: { sessionId: string; data: string }) => {
if (destroyed || sid !== sessionId) return;
term.write(decodeBase64(data));
});
};
appRpc.addMessageListener('pty.output', outputHandler);
electrobun.rpc?.addMessageListener('pty.closed', ({ sessionId: sid, exitCode }: { sessionId: string; exitCode: number | null }) => {
if (sid !== sessionId) return;
const closedHandler = ({ sessionId: sid, exitCode }: { sessionId: string; exitCode: number | null }) => {
if (destroyed || sid !== sessionId) return;
term.writeln(`\r\n\x1b[90m[Process exited${exitCode !== null ? ` with code ${exitCode}` : ''}]\x1b[0m`);
});
};
appRpc.addMessageListener('pty.closed', closedHandler);
// ── Send user input to daemon ──────────────────────────────────────────
term.onData((data: string) => {
electrobun.rpc?.request['pty.write']({ sessionId, data }).catch((err: unknown) => {
appRpc.request['pty.write']({ sessionId, data }).catch((err: unknown) => {
console.error('[pty.write] error:', err);
});
});
@ -103,7 +127,7 @@
// ── Sync resize events to daemon ───────────────────────────────────────
term.onResize(({ cols: c, rows: r }) => {
electrobun.rpc?.request['pty.resize']({ sessionId, cols: c, rows: r }).catch(() => {});
appRpc.request['pty.resize']({ sessionId, cols: c, rows: r }).catch(() => {});
});
ro = new ResizeObserver(() => { fitAddon.fit(); });
@ -111,9 +135,11 @@
});
onDestroy(() => {
destroyed = true;
unsubFont?.();
ro?.disconnect();
electrobun.rpc?.request['pty.unsubscribe']({ sessionId }).catch(() => {});
// Fix #1: Close the PTY session (not just unsubscribe) to prevent session leak
appRpc.request['pty.close']({ sessionId }).catch(() => {});
term?.dispose();
});
</script>

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();

View file

@ -4,6 +4,7 @@ import App from "./App.svelte";
import { mount } from "svelte";
import { Electroview } from "electrobun/view";
import type { PtyRPCSchema } from "../shared/pty-rpc-schema.ts";
import { setAppRpc } from "./rpc.ts";
/**
* Set up Electroview RPC.
@ -31,9 +32,12 @@ const rpc = Electroview.defineRPC<PtyRPCSchema>({
},
});
// Register the RPC singleton so all modules can import from rpc.ts
setAppRpc(rpc);
export const electrobun = new Electroview({ rpc });
/** Exported for use by stores that need RPC access (theme-store, font-store). */
/** @deprecated Import from './rpc.ts' instead. */
export { rpc as appRpc };
const app = mount(App, {

View file

@ -0,0 +1,34 @@
/**
* RPC singleton breaks the circular import chain.
*
* main.ts creates the Electroview and RPC, then sets it here.
* All other modules import from this file instead of main.ts.
*/
import type { PtyRPCSchema } from '../shared/pty-rpc-schema.ts';
// Placeholder type — matches the shape Electroview.defineRPC returns.
// Uses `any` for the internal Electrobun RPC wrapper type since it is
// not exported from the electrobun package.
// eslint-disable-next-line @typescript-eslint/no-explicit-any
type ElectrobunRpc = any;
let _rpc: ElectrobunRpc | null = null;
/** Called once from main.ts after Electroview.defineRPC(). */
export function setAppRpc(rpc: ElectrobunRpc): void {
_rpc = rpc;
}
/**
* The app-wide RPC handle.
* Safe to call after main.ts has executed (Svelte components mount after).
*/
export const appRpc: ElectrobunRpc = new Proxy({} as ElectrobunRpc, {
get(_target, prop) {
if (!_rpc) {
throw new Error(`[rpc] accessed before init — property "${String(prop)}"`);
}
return (_rpc as Record<string | symbol, unknown>)[prop];
},
});

View file

@ -1,6 +1,6 @@
<script lang="ts">
import { onMount } from 'svelte';
import { appRpc } from '../main.ts';
import { appRpc } from '../rpc.ts';
type LogLevel = 'trace' | 'debug' | 'info' | 'warn' | 'error';

View file

@ -1,6 +1,6 @@
<script lang="ts">
import { onMount } from 'svelte';
import { appRpc } from '../main.ts';
import { appRpc } from '../rpc.ts';
import { PROVIDER_CAPABILITIES, type ProviderId } from '../provider-capabilities';
type PermMode = 'bypassPermissions' | 'default' | 'plan';

View file

@ -1,6 +1,6 @@
<script lang="ts">
import { onMount } from 'svelte';
import { appRpc } from '../main.ts';
import { appRpc } from '../rpc.ts';
import { THEMES, THEME_GROUPS, getPalette, type ThemeId, type ThemeMeta } from '../themes.ts';
import { themeStore } from '../theme-store.svelte.ts';
import { fontStore } from '../font-store.svelte.ts';

View file

@ -1,6 +1,6 @@
<script lang="ts">
import { onMount } from 'svelte';
import { appRpc } from '../main.ts';
import { appRpc } from '../rpc.ts';
interface CatalogPlugin {
id: string;

View file

@ -1,6 +1,6 @@
<script lang="ts">
import { onMount } from 'svelte';
import { appRpc } from '../main.ts';
import { appRpc } from '../rpc.ts';
type WakeStrategy = 'persistent' | 'on-demand' | 'smart';
type AnchorScale = 'small' | 'medium' | 'large' | 'full';

View file

@ -1,6 +1,6 @@
<script lang="ts">
import { onMount } from 'svelte';
import { appRpc } from '../main.ts';
import { appRpc } from '../rpc.ts';
import { PROVIDER_CAPABILITIES, type ProviderId } from '../provider-capabilities';
const ANCHOR_SCALES = ['small', 'medium', 'large', 'full'] as const;

View file

@ -1,6 +1,6 @@
<script lang="ts">
import { onMount } from 'svelte';
import { appRpc } from '../main.ts';
import { appRpc } from '../rpc.ts';
const KNOWN_KEYS: Record<string, string> = {
ANTHROPIC_API_KEY: 'Anthropic API Key',
@ -77,6 +77,11 @@
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div class="section" onclick={handleOutsideClick} onkeydown={e => e.key === 'Escape' && (keyDropOpen = false)}>
<div class="prototype-notice">
Prototype — secrets are stored locally in plain SQLite, not in the system keyring.
Do not store production credentials here.
</div>
<h3 class="sh">Keyring Status</h3>
<div class="keyring-status" class:ok={keyringAvailable} class:unavail={!keyringAvailable}>
<span class="ks-dot"></span>
@ -148,6 +153,15 @@
</div>
<style>
.prototype-notice {
padding: 0.5rem 0.625rem;
background: color-mix(in srgb, var(--ctp-peach) 12%, transparent);
border: 1px solid color-mix(in srgb, var(--ctp-peach) 30%, transparent);
border-radius: 0.25rem;
color: var(--ctp-peach);
font-size: 0.75rem;
line-height: 1.4;
}
.section { display: flex; flex-direction: column; gap: 0.5rem; }
.sh { margin: 0.375rem 0 0.125rem; font-size: 0.6875rem; font-weight: 700; text-transform: uppercase; letter-spacing: 0.06em; color: var(--ctp-overlay0); }

View file

@ -1,5 +1,5 @@
<script lang="ts">
import { appRpc } from '../main.ts';
import { appRpc } from '../rpc.ts';
import { themeStore } from '../theme-store.svelte.ts';
import { CSS_VAR_MAP, getPalette, applyCssVars, type ThemePalette, type ThemeId } from '../themes.ts';
@ -98,7 +98,7 @@
const parsed = JSON.parse(text);
if (parsed.name) themeName = parsed.name;
if (parsed.palette && typeof parsed.palette === 'object') {
palette = { ...basePalette };
palette = { ...initialPalette };
for (const [k, v] of Object.entries(parsed.palette)) {
if (k in palette && typeof v === 'string') {
(palette as unknown as Record<string, string>)[k] = v;