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:
parent
ef0183de7f
commit
29a3370e79
18 changed files with 331 additions and 114 deletions
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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}`);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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()}`);
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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, {
|
||||
|
|
|
|||
34
ui-electrobun/src/mainview/rpc.ts
Normal file
34
ui-electrobun/src/mainview/rpc.ts
Normal 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];
|
||||
},
|
||||
});
|
||||
|
|
@ -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';
|
||||
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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); }
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue