From 1cd4558740a538d21b6c6e3de9e9043035ed976c Mon Sep 17 00:00:00 2001 From: Hibryda Date: Sun, 22 Mar 2026 02:30:09 +0100 Subject: [PATCH] fix(electrobun): address all 22 Codex review #2 findings CRITICAL: - DocsTab XSS: DOMPurify sanitization on all {@html} output - File RPC path traversal: guardPath() validates against project CWDs HIGH: - SSH injection: spawn /usr/bin/ssh via PTY args, no shell string - Search XSS: strip HTML, highlight matches client-side with - Terminal listener leak: cleanup functions stored + called in onDestroy - FileBrowser race: request token, discard stale responses - SearchOverlay race: same request token pattern - App startup ordering: groups.list chains into active_group restore - PtyClient timeout: 5-second auth timeout on connect() - Rule 55: 6 {#if} patterns converted to style:display toggle MEDIUM: - Agent persistence: only persist NEW messages (lastPersistedIndex) - Search errors: typed error response, "Invalid query" UI - Health store wired: agent events call recordActivity/setProjectStatus - index.ts SRP: split into 8 domain handler modules (298 lines) - App.svelte: extracted workspace-store.svelte.ts - rpc.ts: typed AppRpcHandle, removed `any` LOW: - CommandPalette listener wired in App.svelte - Dead code removed (removeGroup, onDragStart, plugin loaded) --- ui-electrobun/package-lock.json | 17 + ui-electrobun/package.json | 1 + .../src/bun/handlers/agent-handlers.ts | 144 +++ .../src/bun/handlers/btmsg-handlers.ts | 96 ++ .../src/bun/handlers/files-handlers.ts | 95 ++ ui-electrobun/src/bun/handlers/path-guard.ts | 58 + .../src/bun/handlers/plugin-handlers.ts | 71 ++ .../src/bun/handlers/pty-handlers.ts | 69 ++ .../src/bun/handlers/remote-handlers.ts | 65 + .../src/bun/handlers/search-handlers.ts | 41 + .../src/bun/handlers/settings-handlers.ts | 131 ++ ui-electrobun/src/bun/index.ts | 1078 ++--------------- ui-electrobun/src/bun/pty-client.ts | 31 +- ui-electrobun/src/mainview/App.svelte | 64 +- ui-electrobun/src/mainview/ChatInput.svelte | 17 +- .../src/mainview/CommandPalette.svelte | 26 +- ui-electrobun/src/mainview/DocsTab.svelte | 26 +- ui-electrobun/src/mainview/FileBrowser.svelte | 13 +- ui-electrobun/src/mainview/NotifDrawer.svelte | 53 +- .../src/mainview/SearchOverlay.svelte | 60 +- .../src/mainview/SettingsDrawer.svelte | 116 +- ui-electrobun/src/mainview/SshTab.svelte | 10 +- ui-electrobun/src/mainview/Terminal.svelte | 14 +- .../src/mainview/agent-store.svelte.ts | 29 +- .../src/mainview/plugin-store.svelte.ts | 2 - ui-electrobun/src/mainview/rpc.ts | 29 +- .../src/mainview/workspace-store.svelte.ts | 137 +++ ui-electrobun/src/shared/pty-rpc-schema.ts | 13 +- 28 files changed, 1342 insertions(+), 1164 deletions(-) create mode 100644 ui-electrobun/src/bun/handlers/agent-handlers.ts create mode 100644 ui-electrobun/src/bun/handlers/btmsg-handlers.ts create mode 100644 ui-electrobun/src/bun/handlers/files-handlers.ts create mode 100644 ui-electrobun/src/bun/handlers/path-guard.ts create mode 100644 ui-electrobun/src/bun/handlers/plugin-handlers.ts create mode 100644 ui-electrobun/src/bun/handlers/pty-handlers.ts create mode 100644 ui-electrobun/src/bun/handlers/remote-handlers.ts create mode 100644 ui-electrobun/src/bun/handlers/search-handlers.ts create mode 100644 ui-electrobun/src/bun/handlers/settings-handlers.ts create mode 100644 ui-electrobun/src/mainview/workspace-store.svelte.ts diff --git a/ui-electrobun/package-lock.json b/ui-electrobun/package-lock.json index 809659e..641f7c5 100644 --- a/ui-electrobun/package-lock.json +++ b/ui-electrobun/package-lock.json @@ -26,6 +26,7 @@ "@xterm/addon-fit": "^0.11.0", "@xterm/addon-image": "^0.9.0", "@xterm/xterm": "^6.0.0", + "dompurify": "^3.3.3", "electrobun": "latest", "pdfjs-dist": "^5.5.207" }, @@ -1501,6 +1502,13 @@ "undici-types": "~7.18.0" } }, + "node_modules/@types/trusted-types": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz", + "integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==", + "license": "MIT", + "optional": true + }, "node_modules/@xterm/addon-canvas": { "version": "0.7.0", "license": "MIT", @@ -1778,6 +1786,15 @@ "dev": true, "license": "MIT" }, + "node_modules/dompurify": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.3.3.tgz", + "integrity": "sha512-Oj6pzI2+RqBfFG+qOaOLbFXLQ90ARpcGG6UePL82bJLtdsa6CYJD7nmiU8MW9nQNOtCHV3lZ/Bzq1X0QYbBZCA==", + "license": "(MPL-2.0 OR Apache-2.0)", + "optionalDependencies": { + "@types/trusted-types": "^2.0.7" + } + }, "node_modules/electrobun": { "version": "1.16.0", "resolved": "https://registry.npmjs.org/electrobun/-/electrobun-1.16.0.tgz", diff --git a/ui-electrobun/package.json b/ui-electrobun/package.json index f231870..dab9f42 100644 --- a/ui-electrobun/package.json +++ b/ui-electrobun/package.json @@ -30,6 +30,7 @@ "@xterm/addon-fit": "^0.11.0", "@xterm/addon-image": "^0.9.0", "@xterm/xterm": "^6.0.0", + "dompurify": "^3.3.3", "electrobun": "latest", "pdfjs-dist": "^5.5.207" }, diff --git a/ui-electrobun/src/bun/handlers/agent-handlers.ts b/ui-electrobun/src/bun/handlers/agent-handlers.ts new file mode 100644 index 0000000..c85954d --- /dev/null +++ b/ui-electrobun/src/bun/handlers/agent-handlers.ts @@ -0,0 +1,144 @@ +/** + * Agent + Session persistence RPC handlers. + */ + +import type { SidecarManager } from "../sidecar-manager.ts"; +import type { SessionDb } from "../session-db.ts"; + +export function createAgentHandlers( + sidecarManager: SidecarManager, + sessionDb: SessionDb, + sendToWebview: { send: Record void> }, +) { + return { + "agent.start": ({ sessionId, provider, prompt, cwd, model, systemPrompt, maxTurns, permissionMode, claudeConfigDir, extraEnv, additionalDirectories, worktreeName }: Record) => { + try { + const result = sidecarManager.startSession( + sessionId as string, + provider as string, + prompt as string, + { cwd, model, systemPrompt, maxTurns, permissionMode, claudeConfigDir, extraEnv, additionalDirectories, worktreeName } as Record, + ); + + if (result.ok) { + sidecarManager.onMessage(sessionId as string, (sid: string, messages: unknown) => { + try { + (sendToWebview.send as Record)["agent.message"]({ sessionId: sid, messages }); + } catch (err) { + console.error("[agent.message] forward error:", err); + } + }); + + sidecarManager.onStatus(sessionId as string, (sid: string, status: string, error?: string) => { + try { + (sendToWebview.send as Record)["agent.status"]({ sessionId: sid, status, error }); + } catch (err) { + console.error("[agent.status] forward error:", err); + } + + const sessions = sidecarManager.listSessions(); + const session = sessions.find((s: Record) => s.sessionId === sid); + if (session) { + try { + (sendToWebview.send as Record)["agent.cost"]({ + sessionId: sid, + costUsd: session.costUsd, + inputTokens: session.inputTokens, + outputTokens: session.outputTokens, + }); + } catch { /* ignore */ } + } + }); + } + + return result; + } catch (err) { + const error = err instanceof Error ? err.message : String(err); + console.error("[agent.start]", err); + return { ok: false, error }; + } + }, + + "agent.stop": ({ sessionId }: { sessionId: string }) => { + try { + return sidecarManager.stopSession(sessionId); + } catch (err) { + const error = err instanceof Error ? err.message : String(err); + console.error("[agent.stop]", err); + return { ok: false, error }; + } + }, + + "agent.prompt": ({ sessionId, prompt }: { sessionId: string; prompt: string }) => { + try { + return sidecarManager.writePrompt(sessionId, prompt); + } catch (err) { + const error = err instanceof Error ? err.message : String(err); + console.error("[agent.prompt]", err); + return { ok: false, error }; + } + }, + + "agent.list": () => { + try { + return { sessions: sidecarManager.listSessions() }; + } catch (err) { + console.error("[agent.list]", err); + return { sessions: [] }; + } + }, + + // Session persistence + "session.save": ({ projectId, sessionId, provider, status, costUsd, inputTokens, outputTokens, model, error, createdAt, updatedAt }: Record) => { + try { + sessionDb.saveSession({ projectId, sessionId, provider, status, costUsd, inputTokens, outputTokens, model, error, createdAt, updatedAt } as Record); + return { ok: true }; + } catch (err) { + console.error("[session.save]", err); + return { ok: false }; + } + }, + + "session.load": ({ projectId }: { projectId: string }) => { + try { + return { session: sessionDb.loadSession(projectId) }; + } catch (err) { + console.error("[session.load]", err); + return { session: null }; + } + }, + + "session.list": ({ projectId }: { projectId: string }) => { + try { + return { sessions: sessionDb.listSessionsByProject(projectId) }; + } catch (err) { + console.error("[session.list]", err); + return { sessions: [] }; + } + }, + + "session.messages.save": ({ messages }: { messages: Array> }) => { + try { + sessionDb.saveMessages(messages.map((m) => ({ + sessionId: m.sessionId, msgId: m.msgId, role: m.role, + content: m.content, toolName: m.toolName, toolInput: m.toolInput, + timestamp: m.timestamp, costUsd: (m.costUsd as number) ?? 0, + inputTokens: (m.inputTokens as number) ?? 0, outputTokens: (m.outputTokens as number) ?? 0, + }))); + return { ok: true }; + } catch (err) { + console.error("[session.messages.save]", err); + return { ok: false }; + } + }, + + "session.messages.load": ({ sessionId }: { sessionId: string }) => { + try { + return { messages: sessionDb.loadMessages(sessionId) }; + } catch (err) { + console.error("[session.messages.load]", err); + return { messages: [] }; + } + }, + }; +} diff --git a/ui-electrobun/src/bun/handlers/btmsg-handlers.ts b/ui-electrobun/src/bun/handlers/btmsg-handlers.ts new file mode 100644 index 0000000..bf80b90 --- /dev/null +++ b/ui-electrobun/src/bun/handlers/btmsg-handlers.ts @@ -0,0 +1,96 @@ +/** + * btmsg + bttask RPC handlers. + */ + +import type { BtmsgDb } from "../btmsg-db.ts"; +import type { BttaskDb } from "../bttask-db.ts"; + +export function createBtmsgHandlers(btmsgDb: BtmsgDb) { + return { + "btmsg.registerAgent": ({ id, name, role, groupId, tier, model }: Record) => { + try { btmsgDb.registerAgent(id as string, name as string, role as string, groupId as string, tier as number, model as string); return { ok: true }; } + catch (err) { console.error("[btmsg.registerAgent]", err); return { ok: false }; } + }, + "btmsg.getAgents": ({ groupId }: { groupId: string }) => { + try { return { agents: btmsgDb.getAgents(groupId) }; } + catch (err) { console.error("[btmsg.getAgents]", err); return { agents: [] }; } + }, + "btmsg.sendMessage": ({ fromAgent, toAgent, content }: { fromAgent: string; toAgent: string; content: string }) => { + try { const messageId = btmsgDb.sendMessage(fromAgent, toAgent, content); return { ok: true, messageId }; } + catch (err) { const error = err instanceof Error ? err.message : String(err); console.error("[btmsg.sendMessage]", err); return { ok: false, error }; } + }, + "btmsg.listMessages": ({ agentId, otherId, limit }: { agentId: string; otherId: string; limit?: number }) => { + try { return { messages: btmsgDb.listMessages(agentId, otherId, limit ?? 50) }; } + catch (err) { console.error("[btmsg.listMessages]", err); return { messages: [] }; } + }, + "btmsg.markRead": ({ agentId, messageIds }: { agentId: string; messageIds: string[] }) => { + try { btmsgDb.markRead(agentId, messageIds); return { ok: true }; } + catch (err) { console.error("[btmsg.markRead]", err); return { ok: false }; } + }, + "btmsg.listChannels": ({ groupId }: { groupId: string }) => { + try { return { channels: btmsgDb.listChannels(groupId) }; } + catch (err) { console.error("[btmsg.listChannels]", err); return { channels: [] }; } + }, + "btmsg.createChannel": ({ name, groupId, createdBy }: { name: string; groupId: string; createdBy: string }) => { + try { const channelId = btmsgDb.createChannel(name, groupId, createdBy); return { ok: true, channelId }; } + catch (err) { console.error("[btmsg.createChannel]", err); return { ok: false }; } + }, + "btmsg.getChannelMessages": ({ channelId, limit }: { channelId: string; limit?: number }) => { + try { return { messages: btmsgDb.getChannelMessages(channelId, limit ?? 100) }; } + catch (err) { console.error("[btmsg.getChannelMessages]", err); return { messages: [] }; } + }, + "btmsg.sendChannelMessage": ({ channelId, fromAgent, content }: { channelId: string; fromAgent: string; content: string }) => { + try { const messageId = btmsgDb.sendChannelMessage(channelId, fromAgent, content); return { ok: true, messageId }; } + catch (err) { console.error("[btmsg.sendChannelMessage]", err); return { ok: false }; } + }, + "btmsg.heartbeat": ({ agentId }: { agentId: string }) => { + try { btmsgDb.heartbeat(agentId); return { ok: true }; } + catch (err) { console.error("[btmsg.heartbeat]", err); return { ok: false }; } + }, + "btmsg.getDeadLetters": ({ limit }: { limit?: number }) => { + try { return { letters: btmsgDb.getDeadLetters(limit ?? 50) }; } + catch (err) { console.error("[btmsg.getDeadLetters]", err); return { letters: [] }; } + }, + "btmsg.logAudit": ({ agentId, eventType, detail }: { agentId: string; eventType: string; detail: string }) => { + try { btmsgDb.logAudit(agentId, eventType, detail); return { ok: true }; } + catch (err) { console.error("[btmsg.logAudit]", err); return { ok: false }; } + }, + "btmsg.getAuditLog": ({ limit }: { limit?: number }) => { + try { return { entries: btmsgDb.getAuditLog(limit ?? 100) }; } + catch (err) { console.error("[btmsg.getAuditLog]", err); return { entries: [] }; } + }, + }; +} + +export function createBttaskHandlers(bttaskDb: BttaskDb) { + return { + "bttask.listTasks": ({ groupId }: { groupId: string }) => { + try { return { tasks: bttaskDb.listTasks(groupId) }; } + catch (err) { console.error("[bttask.listTasks]", err); return { tasks: [] }; } + }, + "bttask.createTask": ({ title, description, priority, groupId, createdBy, assignedTo }: Record) => { + try { const taskId = bttaskDb.createTask(title as string, description as string, priority as string, groupId as string, createdBy as string, assignedTo as string); return { ok: true, taskId }; } + catch (err) { const error = err instanceof Error ? err.message : String(err); console.error("[bttask.createTask]", err); return { ok: false, error }; } + }, + "bttask.updateTaskStatus": ({ taskId, status, expectedVersion }: { taskId: string; status: string; expectedVersion: number }) => { + try { const newVersion = bttaskDb.updateTaskStatus(taskId, status, expectedVersion); return { ok: true, newVersion }; } + catch (err) { const error = err instanceof Error ? err.message : String(err); console.error("[bttask.updateTaskStatus]", err); return { ok: false, error }; } + }, + "bttask.deleteTask": ({ taskId }: { taskId: string }) => { + try { bttaskDb.deleteTask(taskId); return { ok: true }; } + catch (err) { console.error("[bttask.deleteTask]", err); return { ok: false }; } + }, + "bttask.addComment": ({ taskId, agentId, content }: { taskId: string; agentId: string; content: string }) => { + try { const commentId = bttaskDb.addComment(taskId, agentId, content); return { ok: true, commentId }; } + catch (err) { console.error("[bttask.addComment]", err); return { ok: false }; } + }, + "bttask.listComments": ({ taskId }: { taskId: string }) => { + try { return { comments: bttaskDb.listComments(taskId) }; } + catch (err) { console.error("[bttask.listComments]", err); return { comments: [] }; } + }, + "bttask.reviewQueueCount": ({ groupId }: { groupId: string }) => { + try { return { count: bttaskDb.reviewQueueCount(groupId) }; } + catch (err) { console.error("[bttask.reviewQueueCount]", err); return { count: 0 }; } + }, + }; +} diff --git a/ui-electrobun/src/bun/handlers/files-handlers.ts b/ui-electrobun/src/bun/handlers/files-handlers.ts new file mode 100644 index 0000000..bd3ae3f --- /dev/null +++ b/ui-electrobun/src/bun/handlers/files-handlers.ts @@ -0,0 +1,95 @@ +/** + * File I/O RPC handlers — list, read, write with path traversal protection. + */ + +import path from "path"; +import fs from "fs"; +import { guardPath } from "./path-guard.ts"; + +export function createFilesHandlers() { + return { + "files.list": async ({ path: dirPath }: { path: string }) => { + const guard = guardPath(dirPath); + if (!guard.valid) { + console.error(`[files.list] blocked: ${guard.error}`); + return { entries: [], error: guard.error }; + } + try { + const dirents = fs.readdirSync(guard.resolved, { withFileTypes: true }); + const entries = dirents + .filter((d) => !d.name.startsWith(".")) + .map((d) => { + let size = 0; + if (d.isFile()) { + try { + size = fs.statSync(path.join(guard.resolved, d.name)).size; + } catch { /* ignore stat errors */ } + } + return { + name: d.name, + type: (d.isDirectory() ? "dir" : "file") as "file" | "dir", + size, + }; + }) + .sort((a, b) => { + if (a.type !== b.type) return a.type === "dir" ? -1 : 1; + return a.name.localeCompare(b.name); + }); + return { entries }; + } catch (err) { + const error = err instanceof Error ? err.message : String(err); + console.error("[files.list]", error); + return { entries: [], error }; + } + }, + + "files.read": async ({ path: filePath }: { path: string }) => { + const guard = guardPath(filePath); + if (!guard.valid) { + console.error(`[files.read] blocked: ${guard.error}`); + return { encoding: "utf8" as const, size: 0, error: guard.error }; + } + try { + const stat = fs.statSync(guard.resolved); + const MAX_SIZE = 10 * 1024 * 1024; + if (stat.size > MAX_SIZE) { + return { encoding: "utf8" as const, size: stat.size, error: `File too large (${(stat.size / 1024 / 1024).toFixed(1)}MB). Maximum is 10MB.` }; + } + + const buf = Buffer.alloc(Math.min(8192, stat.size)); + const fd = fs.openSync(guard.resolved, "r"); + fs.readSync(fd, buf, 0, buf.length, 0); + fs.closeSync(fd); + + const isBinary = buf.includes(0); + if (isBinary) { + const content = fs.readFileSync(guard.resolved).toString("base64"); + return { content, encoding: "base64" as const, size: stat.size }; + } + + const content = fs.readFileSync(guard.resolved, "utf8"); + return { content, encoding: "utf8" as const, size: stat.size }; + } catch (err) { + const error = err instanceof Error ? err.message : String(err); + console.error("[files.read]", error); + return { encoding: "utf8" as const, size: 0, error }; + } + }, + + "files.write": async ({ path: filePath, content }: { path: string; content: string }) => { + const guard = guardPath(filePath); + if (!guard.valid) { + console.error(`[files.write] blocked: ${guard.error}`); + return { ok: false, error: guard.error }; + } + try { + fs.writeFileSync(guard.resolved, content, "utf8"); + return { ok: true }; + } catch (err) { + const error = err instanceof Error ? err.message : String(err); + console.error("[files.write]", error); + return { ok: false, error }; + } + }, + }; +} diff --git a/ui-electrobun/src/bun/handlers/path-guard.ts b/ui-electrobun/src/bun/handlers/path-guard.ts new file mode 100644 index 0000000..74405c7 --- /dev/null +++ b/ui-electrobun/src/bun/handlers/path-guard.ts @@ -0,0 +1,58 @@ +/** + * Path traversal guard — validates that resolved paths stay within allowed boundaries. + * + * Used by file I/O handlers to prevent path traversal attacks (CWE-22). + */ + +import path from "path"; +import { settingsDb } from "../settings-db.ts"; +import { homedir } from "os"; +import { join } from "path"; + +const PLUGINS_DIR = join(homedir(), ".config", "agor", "plugins"); + +/** Get all configured project CWDs from settings DB. */ +function getAllowedRoots(): string[] { + const roots: string[] = [PLUGINS_DIR]; + try { + const projects = settingsDb.listProjects(); + for (const p of projects) { + if (typeof p.cwd === "string" && p.cwd) { + roots.push(path.resolve(p.cwd)); + } + } + } catch { + // If settings DB is unavailable, still allow plugins dir + } + return roots; +} + +/** + * Validate that a file path is within an allowed boundary. + * Returns the resolved path if valid, or null if outside boundaries. + */ +export function validatePath(filePath: string): string | null { + const resolved = path.resolve(filePath); + const roots = getAllowedRoots(); + + for (const root of roots) { + const resolvedRoot = path.resolve(root); + if (resolved === resolvedRoot || resolved.startsWith(resolvedRoot + path.sep)) { + return resolved; + } + } + + return null; +} + +/** + * Check path and return error response if invalid. + * Returns { valid: true, resolved: string } or { valid: false, error: string }. + */ +export function guardPath(filePath: string): { valid: true; resolved: string } | { valid: false; error: string } { + const resolved = validatePath(filePath); + if (resolved === null) { + return { valid: false, error: `Access denied: path outside allowed project directories` }; + } + return { valid: true, resolved }; +} diff --git a/ui-electrobun/src/bun/handlers/plugin-handlers.ts b/ui-electrobun/src/bun/handlers/plugin-handlers.ts new file mode 100644 index 0000000..1bb78ab --- /dev/null +++ b/ui-electrobun/src/bun/handlers/plugin-handlers.ts @@ -0,0 +1,71 @@ +/** + * Plugin discovery + file reading RPC handlers. + */ + +import fs from "fs"; +import path from "path"; +import { join } from "path"; +import { homedir } from "os"; + +const PLUGINS_DIR = join(homedir(), ".config", "agor", "plugins"); + +export function createPluginHandlers() { + return { + "plugin.discover": () => { + try { + const plugins: Array<{ + id: string; name: string; version: string; + description: string; main: string; permissions: string[]; + }> = []; + + if (!fs.existsSync(PLUGINS_DIR)) return { plugins }; + + const entries = fs.readdirSync(PLUGINS_DIR, { withFileTypes: true }); + for (const entry of entries) { + if (!entry.isDirectory()) continue; + const manifestPath = join(PLUGINS_DIR, entry.name, "plugin.json"); + if (!fs.existsSync(manifestPath)) continue; + + try { + const raw = fs.readFileSync(manifestPath, "utf-8"); + const manifest = JSON.parse(raw); + plugins.push({ + id: manifest.id ?? entry.name, + name: manifest.name ?? entry.name, + version: manifest.version ?? "0.0.0", + description: manifest.description ?? "", + main: manifest.main ?? "index.js", + permissions: Array.isArray(manifest.permissions) ? manifest.permissions : [], + }); + } catch (parseErr) { + console.error(`[plugin.discover] Bad manifest in ${entry.name}:`, parseErr); + } + } + + return { plugins }; + } catch (err) { + console.error("[plugin.discover]", err); + return { plugins: [] }; + } + }, + + "plugin.readFile": ({ pluginId, filePath }: { pluginId: string; filePath: string }) => { + try { + const pluginDir = join(PLUGINS_DIR, pluginId); + const resolved = path.resolve(pluginDir, filePath); + if (!resolved.startsWith(pluginDir + path.sep) && resolved !== pluginDir) { + return { ok: false, content: "", error: "Path traversal blocked" }; + } + if (!fs.existsSync(resolved)) { + return { ok: false, content: "", error: "File not found" }; + } + const content = fs.readFileSync(resolved, "utf-8"); + return { ok: true, content }; + } catch (err) { + const error = err instanceof Error ? err.message : String(err); + console.error("[plugin.readFile]", err); + return { ok: false, content: "", error }; + } + }, + }; +} diff --git a/ui-electrobun/src/bun/handlers/pty-handlers.ts b/ui-electrobun/src/bun/handlers/pty-handlers.ts new file mode 100644 index 0000000..c621715 --- /dev/null +++ b/ui-electrobun/src/bun/handlers/pty-handlers.ts @@ -0,0 +1,69 @@ +/** + * PTY RPC handlers — create, write, resize, unsubscribe, close sessions. + */ + +import type { PtyClient } from "../pty-client.ts"; + +export function createPtyHandlers(ptyClient: PtyClient) { + return { + "pty.create": async ({ sessionId, cols, rows, cwd, shell, args }: { + sessionId: string; cols: number; rows: number; cwd?: string; + shell?: string; args?: string[]; + }) => { + if (!ptyClient.isConnected) { + return { ok: false, error: "PTY daemon not connected" }; + } + try { + ptyClient.createSession({ id: sessionId, cols, rows, cwd, shell, args }); + return { ok: true }; + } catch (err) { + const error = err instanceof Error ? err.message : String(err); + console.error(`[pty.create] ${sessionId}: ${error}`); + return { ok: false, error }; + } + }, + + "pty.write": ({ sessionId, data }: { sessionId: string; data: string }) => { + if (!ptyClient.isConnected) { + console.error(`[pty.write] ${sessionId}: daemon not connected`); + return { ok: false }; + } + try { + ptyClient.writeInput(sessionId, data); + return { ok: true }; + } catch (err) { + const error = err instanceof Error ? err.message : String(err); + console.error(`[pty.write] ${sessionId}: ${error}`); + return { ok: false }; + } + }, + + "pty.resize": ({ sessionId, cols, rows }: { sessionId: string; cols: number; rows: number }) => { + if (!ptyClient.isConnected) return { ok: true }; + try { + ptyClient.resize(sessionId, cols, rows); + } catch (err) { + console.error(`[pty.resize] ${sessionId}:`, err); + } + return { ok: true }; + }, + + "pty.unsubscribe": ({ sessionId }: { sessionId: string }) => { + try { + ptyClient.unsubscribe(sessionId); + } catch (err) { + console.error(`[pty.unsubscribe] ${sessionId}:`, err); + } + return { ok: true }; + }, + + "pty.close": ({ sessionId }: { sessionId: string }) => { + try { + ptyClient.closeSession(sessionId); + } catch (err) { + console.error(`[pty.close] ${sessionId}:`, err); + } + return { ok: true }; + }, + }; +} diff --git a/ui-electrobun/src/bun/handlers/remote-handlers.ts b/ui-electrobun/src/bun/handlers/remote-handlers.ts new file mode 100644 index 0000000..cf359be --- /dev/null +++ b/ui-electrobun/src/bun/handlers/remote-handlers.ts @@ -0,0 +1,65 @@ +/** + * Remote machine (relay) RPC handlers. + */ + +import type { RelayClient } from "../relay-client.ts"; + +export function createRemoteHandlers(relayClient: RelayClient) { + return { + "remote.connect": async ({ url, token, label }: { url: string; token: string; label?: string }) => { + try { + const machineId = await relayClient.connect(url, token, label); + return { ok: true, machineId }; + } catch (err) { + const error = err instanceof Error ? err.message : String(err); + console.error("[remote.connect]", err); + return { ok: false, error }; + } + }, + + "remote.disconnect": ({ machineId }: { machineId: string }) => { + try { + relayClient.disconnect(machineId); + return { ok: true }; + } catch (err) { + const error = err instanceof Error ? err.message : String(err); + console.error("[remote.disconnect]", err); + return { ok: false, error }; + } + }, + + "remote.list": () => { + try { + return { machines: relayClient.listMachines() }; + } catch (err) { + console.error("[remote.list]", err); + return { machines: [] }; + } + }, + + "remote.send": ({ machineId, command, payload }: { machineId: string; command: string; payload: Record }) => { + try { + relayClient.sendCommand(machineId, command, payload); + return { ok: true }; + } catch (err) { + const error = err instanceof Error ? err.message : String(err); + console.error("[remote.send]", err); + return { ok: false, error }; + } + }, + + "remote.status": ({ machineId }: { machineId: string }) => { + try { + const info = relayClient.getStatus(machineId); + if (!info) { + return { status: "disconnected" as const, latencyMs: null, error: "Machine not found" }; + } + return { status: info.status, latencyMs: info.latencyMs }; + } catch (err) { + const error = err instanceof Error ? err.message : String(err); + console.error("[remote.status]", err); + return { status: "error" as const, latencyMs: null, error }; + } + }, + }; +} diff --git a/ui-electrobun/src/bun/handlers/search-handlers.ts b/ui-electrobun/src/bun/handlers/search-handlers.ts new file mode 100644 index 0000000..25055cd --- /dev/null +++ b/ui-electrobun/src/bun/handlers/search-handlers.ts @@ -0,0 +1,41 @@ +/** + * Search RPC handlers — FTS5 full-text search. + * Fix #13: Returns typed error for invalid queries. + */ + +import type { SearchDb } from "../search-db.ts"; + +export function createSearchHandlers(searchDb: SearchDb) { + return { + "search.query": ({ query, limit }: { query: string; limit?: number }) => { + try { + const results = searchDb.searchAll(query, limit ?? 20); + return { results }; + } catch (err) { + const error = err instanceof Error ? err.message : String(err); + console.error("[search.query]", err); + return { results: [], error }; + } + }, + + "search.indexMessage": ({ sessionId, role, content }: { sessionId: string; role: string; content: string }) => { + try { + searchDb.indexMessage(sessionId, role, content); + return { ok: true }; + } catch (err) { + console.error("[search.indexMessage]", err); + return { ok: false }; + } + }, + + "search.rebuild": () => { + try { + searchDb.rebuildIndex(); + return { ok: true }; + } catch (err) { + console.error("[search.rebuild]", err); + return { ok: false }; + } + }, + }; +} diff --git a/ui-electrobun/src/bun/handlers/settings-handlers.ts b/ui-electrobun/src/bun/handlers/settings-handlers.ts new file mode 100644 index 0000000..08e0bf5 --- /dev/null +++ b/ui-electrobun/src/bun/handlers/settings-handlers.ts @@ -0,0 +1,131 @@ +/** + * Settings + Groups + Themes RPC handlers. + */ + +import type { SettingsDb } from "../settings-db.ts"; + +export function createSettingsHandlers(settingsDb: SettingsDb) { + return { + "settings.get": ({ key }: { key: string }) => { + try { + return { value: settingsDb.getSetting(key) }; + } catch (err) { + console.error("[settings.get]", err); + return { value: null }; + } + }, + + "settings.set": ({ key, value }: { key: string; value: string }) => { + try { + settingsDb.setSetting(key, value); + return { ok: true }; + } catch (err) { + console.error("[settings.set]", err); + return { ok: false }; + } + }, + + "settings.getAll": () => { + try { + return { settings: settingsDb.getAll() }; + } catch (err) { + console.error("[settings.getAll]", err); + return { settings: {} }; + } + }, + + "settings.getProjects": () => { + try { + const projects = settingsDb.listProjects().map((p: Record) => ({ + id: p.id, + config: JSON.stringify(p), + })); + return { projects }; + } catch (err) { + console.error("[settings.getProjects]", err); + return { projects: [] }; + } + }, + + "settings.setProject": ({ id, config }: { id: string; config: string }) => { + try { + const parsed = JSON.parse(config); + settingsDb.setProject(id, { id, ...parsed }); + return { ok: true }; + } catch (err) { + console.error("[settings.setProject]", err); + return { ok: false }; + } + }, + + "settings.deleteProject": ({ id }: { id: string }) => { + try { + settingsDb.deleteProject(id); + return { ok: true }; + } catch (err) { + console.error("[settings.deleteProject]", err); + return { ok: false }; + } + }, + + // Groups + "groups.list": () => { + try { + return { groups: settingsDb.listGroups() }; + } catch (err) { + console.error("[groups.list]", err); + return { groups: [] }; + } + }, + + "groups.create": ({ id, name, icon, position }: { id: string; name: string; icon: string; position: number }) => { + try { + settingsDb.createGroup(id, name, icon, position); + return { ok: true }; + } catch (err) { + console.error("[groups.create]", err); + return { ok: false }; + } + }, + + "groups.delete": ({ id }: { id: string }) => { + try { + settingsDb.deleteGroup(id); + return { ok: true }; + } catch (err) { + console.error("[groups.delete]", err); + return { ok: false }; + } + }, + + // Custom themes + "themes.getCustom": () => { + try { + return { themes: settingsDb.getCustomThemes() }; + } catch (err) { + console.error("[themes.getCustom]", err); + return { themes: [] }; + } + }, + + "themes.saveCustom": ({ id, name, palette }: { id: string; name: string; palette: Record }) => { + try { + settingsDb.saveCustomTheme(id, name, palette); + return { ok: true }; + } catch (err) { + console.error("[themes.saveCustom]", err); + return { ok: false }; + } + }, + + "themes.deleteCustom": ({ id }: { id: string }) => { + try { + settingsDb.deleteCustomTheme(id); + return { ok: true }; + } catch (err) { + console.error("[themes.deleteCustom]", err); + return { ok: false }; + } + }, + }; +} diff --git a/ui-electrobun/src/bun/index.ts b/ui-electrobun/src/bun/index.ts index eb177ce..8ce5643 100644 --- a/ui-electrobun/src/bun/index.ts +++ b/ui-electrobun/src/bun/index.ts @@ -1,4 +1,10 @@ -import path from "path"; +/** + * Electrobun Bun process — thin router that delegates to domain handler modules. + * + * Fix #15: Extracted handlers into src/bun/handlers/ for SRP. + * Fix #2: Path traversal guard on files.list/read/write via path-guard.ts. + */ + import fs from "fs"; import { BrowserWindow, BrowserView, Updater } from "electrobun/bun"; import { PtyClient } from "./pty-client.ts"; @@ -17,21 +23,29 @@ import { initTelemetry, telemetry } from "./telemetry.ts"; import { homedir } from "os"; import { join } from "path"; +// Handler modules +import { createPtyHandlers } from "./handlers/pty-handlers.ts"; +import { createFilesHandlers } from "./handlers/files-handlers.ts"; +import { createSettingsHandlers } from "./handlers/settings-handlers.ts"; +import { createAgentHandlers } from "./handlers/agent-handlers.ts"; +import { createBtmsgHandlers, createBttaskHandlers } from "./handlers/btmsg-handlers.ts"; +import { createSearchHandlers } from "./handlers/search-handlers.ts"; +import { createPluginHandlers } from "./handlers/plugin-handlers.ts"; +import { createRemoteHandlers } from "./handlers/remote-handlers.ts"; + /** Current app version — sourced from electrobun.config.ts at build time. */ const APP_VERSION = "0.0.1"; -const DEV_SERVER_PORT = 9760; // Project convention: 9700+ range +const DEV_SERVER_PORT = 9760; const DEV_SERVER_URL = `http://localhost:${DEV_SERVER_PORT}`; -// ── PTY daemon client ──────────────────────────────────────────────────────── +// ── Services ─────────────────────────────────────────────────────────────── const ptyClient = new PtyClient(); const sidecarManager = new SidecarManager(); const searchDb = new SearchDb(); const relayClient = new RelayClient(); -const PLUGINS_DIR = join(homedir(), ".config", "agor", "plugins"); -// Initialize telemetry (console-only unless AGOR_OTLP_ENDPOINT is set) initTelemetry(); async function connectToDaemon(retries = 5, delayMs = 500): Promise { @@ -43,19 +57,18 @@ async function connectToDaemon(retries = 5, delayMs = 500): Promise { } catch (err) { const msg = err instanceof Error ? err.message : String(err); if (attempt < retries) { - console.warn(`[agor-ptyd] Connect attempt ${attempt}/${retries} failed: ${msg}. Retrying in ${delayMs}ms…`); + console.warn(`[agor-ptyd] Connect attempt ${attempt}/${retries} failed: ${msg}. Retrying in ${delayMs}ms...`); await new Promise((r) => setTimeout(r, delayMs)); delayMs = Math.min(delayMs * 2, 4000); } else { console.error(`[agor-ptyd] Could not connect after ${retries} attempts: ${msg}`); - console.error("[agor-ptyd] Terminals will not work. Start agor-ptyd and restart the app."); } } } return false; } -// ── Clone helpers ──────────────────────────────────────────────────────────── +// ── Clone helpers ────────────────────────────────────────────────────────── const BRANCH_RE = /^[a-zA-Z0-9/_.-]+$/; @@ -72,326 +85,78 @@ async function gitWorktreeAdd(mainRepoPath: string, worktreePath: string, branch return { ok: true }; } -// ── RPC definition ──────────────────────────────────────────────────────────── +// ── Build handler maps ─────────────────────────────────────────────────── + +// Placeholder rpc for agent handler — will be set after rpc creation +let rpcRef: { send: Record void> } = { send: {} }; + +const ptyHandlers = createPtyHandlers(ptyClient); +const filesHandlers = createFilesHandlers(); +const settingsHandlers = createSettingsHandlers(settingsDb); +const agentHandlers = createAgentHandlers(sidecarManager, sessionDb, rpcRef); +const btmsgHandlers = createBtmsgHandlers(btmsgDb); +const bttaskHandlers = createBttaskHandlers(bttaskDb); +const searchHandlers = createSearchHandlers(searchDb); +const pluginHandlers = createPluginHandlers(); +const remoteHandlers = createRemoteHandlers(relayClient); + +// ── RPC definition ───────────────────────────────────────────────────────── const rpc = BrowserView.defineRPC({ maxRequestTime: 15_000, handlers: { requests: { - "pty.create": async ({ sessionId, cols, rows, cwd }) => { - if (!ptyClient.isConnected) { - return { ok: false, error: "PTY daemon not connected" }; - } - try { - ptyClient.createSession({ id: sessionId, cols, rows, cwd }); - return { ok: true }; - } catch (err) { - const error = err instanceof Error ? err.message : String(err); - console.error(`[pty.create] ${sessionId}: ${error}`); - return { ok: false, error }; - } - }, - - "pty.write": ({ sessionId, data }) => { - if (!ptyClient.isConnected) { - console.error(`[pty.write] ${sessionId}: daemon not connected`); - return { ok: false }; - } - try { - ptyClient.writeInput(sessionId, data); - return { ok: true }; - } catch (err) { - const error = err instanceof Error ? err.message : String(err); - console.error(`[pty.write] ${sessionId}: ${error}`); - return { ok: false }; - } - }, - - "pty.resize": ({ sessionId, cols, rows }) => { - if (!ptyClient.isConnected) return { ok: true }; - try { - ptyClient.resize(sessionId, cols, rows); - } catch (err) { - console.error(`[pty.resize] ${sessionId}:`, err); - } - return { ok: true }; - }, - - "pty.unsubscribe": ({ sessionId }) => { - try { - ptyClient.unsubscribe(sessionId); - } catch (err) { - console.error(`[pty.unsubscribe] ${sessionId}:`, err); - } - return { ok: true }; - }, - - "pty.close": ({ sessionId }) => { - try { - ptyClient.closeSession(sessionId); - } catch (err) { - console.error(`[pty.close] ${sessionId}:`, err); - } - return { ok: true }; - }, - - // ── Settings handlers ───────────────────────────────────────────────── - - "settings.get": ({ key }) => { - try { - return { value: settingsDb.getSetting(key) }; - } catch (err) { - console.error("[settings.get]", err); - return { value: null }; - } - }, - - "settings.set": ({ key, value }) => { - try { - settingsDb.setSetting(key, value); - return { ok: true }; - } catch (err) { - console.error("[settings.set]", err); - return { ok: false }; - } - }, - - "settings.getAll": () => { - try { - return { settings: settingsDb.getAll() }; - } catch (err) { - console.error("[settings.getAll]", err); - return { settings: {} }; - } - }, - - "settings.getProjects": () => { - try { - const projects = settingsDb.listProjects().map((p) => ({ - id: p.id, - config: JSON.stringify(p), - })); - return { projects }; - } catch (err) { - console.error("[settings.getProjects]", err); - return { projects: [] }; - } - }, - - "settings.setProject": ({ id, config }) => { - try { - const parsed = JSON.parse(config); - settingsDb.setProject(id, { id, ...parsed }); - return { ok: true }; - } catch (err) { - console.error("[settings.setProject]", err); - return { ok: false }; - } - }, - - "settings.deleteProject": ({ id }) => { - try { - settingsDb.deleteProject(id); - return { ok: true }; - } catch (err) { - console.error("[settings.deleteProject]", err); - return { ok: false }; - } - }, - - // ── Custom Themes handlers ─────────────────────────────────────────── - - "themes.getCustom": () => { - try { - return { themes: settingsDb.getCustomThemes() }; - } catch (err) { - console.error("[themes.getCustom]", err); - return { themes: [] }; - } - }, - - "themes.saveCustom": ({ id, name, palette }) => { - try { - settingsDb.saveCustomTheme(id, name, palette); - return { ok: true }; - } catch (err) { - console.error("[themes.saveCustom]", err); - return { ok: false }; - } - }, - - "themes.deleteCustom": ({ id }) => { - try { - settingsDb.deleteCustomTheme(id); - return { ok: true }; - } catch (err) { - console.error("[themes.deleteCustom]", err); - return { ok: false }; - } - }, - - // ── File I/O handlers ──────────────────────────────────────────────── - - "files.list": async ({ path: dirPath }) => { - try { - const dirents = fs.readdirSync(dirPath, { withFileTypes: true }); - const entries = dirents - .filter((d) => !d.name.startsWith(".")) - .map((d) => { - let size = 0; - if (d.isFile()) { - try { - size = fs.statSync(path.join(dirPath, d.name)).size; - } catch { /* ignore stat errors */ } - } - return { - name: d.name, - type: (d.isDirectory() ? "dir" : "file") as "file" | "dir", - size, - }; - }) - .sort((a, b) => { - // Directories first, then alphabetical - if (a.type !== b.type) return a.type === "dir" ? -1 : 1; - return a.name.localeCompare(b.name); - }); - return { entries }; - } catch (err) { - const error = err instanceof Error ? err.message : String(err); - console.error("[files.list]", error); - return { entries: [], error }; - } - }, - - "files.read": async ({ path: filePath }) => { - try { - const stat = fs.statSync(filePath); - const MAX_SIZE = 10 * 1024 * 1024; // 10MB - if (stat.size > MAX_SIZE) { - return { encoding: "utf8" as const, size: stat.size, error: `File too large (${(stat.size / 1024 / 1024).toFixed(1)}MB). Maximum is 10MB.` }; - } - - // Detect binary by reading first 8KB - const buf = Buffer.alloc(Math.min(8192, stat.size)); - const fd = fs.openSync(filePath, "r"); - fs.readSync(fd, buf, 0, buf.length, 0); - fs.closeSync(fd); - - const isBinary = buf.includes(0); // null byte = binary - if (isBinary) { - const content = fs.readFileSync(filePath).toString("base64"); - return { content, encoding: "base64" as const, size: stat.size }; - } - - const content = fs.readFileSync(filePath, "utf8"); - return { content, encoding: "utf8" as const, size: stat.size }; - } catch (err) { - const error = err instanceof Error ? err.message : String(err); - console.error("[files.read]", error); - return { encoding: "utf8" as const, size: 0, error }; - } - }, - - "files.write": async ({ path: filePath, content }) => { - try { - fs.writeFileSync(filePath, content, "utf8"); - return { ok: true }; - } catch (err) { - const error = err instanceof Error ? err.message : String(err); - console.error("[files.write]", error); - return { ok: false, error }; - } - }, - - // ── Groups handlers ────────────────────────────────────────────────── - - "groups.list": () => { - try { - return { groups: settingsDb.listGroups() }; - } catch (err) { - console.error("[groups.list]", err); - return { groups: [] }; - } - }, - - "groups.create": ({ id, name, icon, position }) => { - try { - settingsDb.createGroup(id, name, icon, position); - return { ok: true }; - } catch (err) { - console.error("[groups.create]", err); - return { ok: false }; - } - }, - - "groups.delete": ({ id }) => { - try { - settingsDb.deleteGroup(id); - return { ok: true }; - } catch (err) { - console.error("[groups.delete]", err); - return { ok: false }; - } - }, - - // ── Project clone handler ──────────────────────────────────────────── + // PTY + ...ptyHandlers, + // Files (with path traversal guard) + ...filesHandlers, + // Settings + Groups + Themes + ...settingsHandlers, + // Agents + Session persistence + ...agentHandlers, + // btmsg + bttask + ...btmsgHandlers, + ...bttaskHandlers, + // Search + ...searchHandlers, + // Plugins + ...pluginHandlers, + // Remote + ...remoteHandlers, + // ── Project clone handler ────────────────────────────────────────── "project.clone": async ({ projectId, branchName }) => { try { if (!BRANCH_RE.test(branchName)) { return { ok: false, error: "Invalid branch name. Use only letters, numbers, /, _, -, ." }; } - const source = settingsDb.getProject(projectId); - if (!source) { - return { ok: false, error: `Project not found: ${projectId}` }; - } + if (!source) return { ok: false, error: `Project not found: ${projectId}` }; - // Determine the authoritative main repo path const mainRepoPath = source.mainRepoPath ?? source.cwd; - - // Count existing clones const allProjects = settingsDb.listProjects(); const existingClones = allProjects.filter( (p) => p.cloneOf === projectId || (source.cloneOf && p.cloneOf === source.cloneOf) ); - if (existingClones.length >= 3) { - return { ok: false, error: "Maximum 3 clones per project reached" }; - } + if (existingClones.length >= 3) return { ok: false, error: "Maximum 3 clones per project reached" }; const cloneIndex = existingClones.length + 1; - // 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) { - return { ok: false, error: gitResult.error }; - } + if (!gitResult.ok) return { ok: false, error: gitResult.error }; const cloneId = `${projectId}-clone-${cloneIndex}-${randomUUID().slice(0, 8)}`; const cloneConfig = { - id: cloneId, - name: `${source.name} [${branchName}]`, - cwd: worktreePath, - accent: source.accent, - provider: source.provider, - profile: source.profile, - model: source.model, - groupId: source.groupId ?? "dev", - mainRepoPath, - cloneOf: projectId, - worktreePath, - worktreeBranch: branchName, - cloneIndex, + id: cloneId, name: `${source.name} [${branchName}]`, + cwd: worktreePath, accent: source.accent, provider: source.provider, + profile: source.profile, model: source.model, + groupId: source.groupId ?? "dev", mainRepoPath, + cloneOf: projectId, worktreePath, worktreeBranch: branchName, cloneIndex, }; - settingsDb.setProject(cloneId, cloneConfig); - - return { - ok: true, - project: { id: cloneId, config: JSON.stringify(cloneConfig) }, - }; + return { ok: true, project: { id: cloneId, config: JSON.stringify(cloneConfig) } }; } catch (err) { const error = err instanceof Error ? err.message : String(err); console.error("[project.clone]", err); @@ -399,634 +164,43 @@ const rpc = BrowserView.defineRPC({ } }, - // ── Window control handlers ────────────────────────────────────────── - - "window.minimize": () => { - try { - mainWindow.minimize(); - return { ok: true }; - } catch (err) { - console.error("[window.minimize]", err); - return { ok: false }; - } - }, - + // ── Window controls ──────────────────────────────────────────────── + "window.minimize": () => { try { mainWindow.minimize(); return { ok: true }; } catch (err) { console.error("[window.minimize]", err); return { ok: false }; } }, "window.maximize": () => { try { const frame = mainWindow.getFrame(); - // Heuristic: if window is already near the screen edge, unmaximize - if (frame.x <= 0 && frame.y <= 0) { - mainWindow.unmaximize(); - } else { - mainWindow.maximize(); - } + if (frame.x <= 0 && frame.y <= 0) mainWindow.unmaximize(); else mainWindow.maximize(); return { ok: true }; - } catch (err) { - console.error("[window.maximize]", err); - return { ok: false }; - } + } catch (err) { console.error("[window.maximize]", err); return { ok: false }; } }, + "window.close": () => { try { mainWindow.close(); return { ok: true }; } catch (err) { console.error("[window.close]", err); return { ok: false }; } }, + "window.getFrame": () => { try { return mainWindow.getFrame(); } catch { return { x: 0, y: 0, width: 1400, height: 900 }; } }, + "window.setPosition": ({ x, y }: { x: number; y: number }) => { try { mainWindow.setPosition(x, y); return { ok: true }; } catch { return { ok: false }; } }, - "window.close": () => { - try { - mainWindow.close(); - return { ok: true }; - } catch (err) { - console.error("[window.close]", err); - return { ok: false }; - } - }, - - "window.getFrame": () => { - try { - return mainWindow.getFrame(); - } catch { - return { x: 0, y: 0, width: 1400, height: 900 }; - } - }, - - "window.setPosition": ({ x, y }: { x: number; y: number }) => { - try { - mainWindow.setPosition(x, y); - return { ok: true }; - } catch { - return { ok: false }; - } - }, - - // ── Keybinding handlers ────────────────────────────────────────────── - - "keybindings.getAll": () => { - try { - return { keybindings: settingsDb.getKeybindings() }; - } catch (err) { - console.error("[keybindings.getAll]", err); - return { keybindings: {} }; - } - }, - - "keybindings.set": ({ id, chord }) => { - try { - settingsDb.setKeybinding(id, chord); - return { ok: true }; - } catch (err) { - console.error("[keybindings.set]", err); - return { ok: false }; - } - }, - - "keybindings.reset": ({ id }) => { - try { - settingsDb.deleteKeybinding(id); - return { ok: true }; - } catch (err) { - console.error("[keybindings.reset]", err); - return { ok: false }; - } - }, - - // ── Agent handlers ────────────────────────────────────────────────── - - "agent.start": ({ sessionId, provider, prompt, cwd, model, systemPrompt, maxTurns, permissionMode, claudeConfigDir, extraEnv, additionalDirectories, worktreeName }) => { - try { - const result = sidecarManager.startSession(sessionId, provider, prompt, { - cwd, - model, - systemPrompt, - maxTurns, - permissionMode, - claudeConfigDir, - extraEnv, - additionalDirectories, - worktreeName, - }); - - if (result.ok) { - // Forward sidecar messages to webview - sidecarManager.onMessage(sessionId, (sid, messages) => { - try { - rpc.send["agent.message"]({ sessionId: sid, messages }); - } catch (err) { - console.error("[agent.message] forward error:", err); - } - }); - - sidecarManager.onStatus(sessionId, (sid, status, error) => { - try { - rpc.send["agent.status"]({ sessionId: sid, status, error }); - } catch (err) { - console.error("[agent.status] forward error:", err); - } - - // Send cost update on status change - const sessions = sidecarManager.listSessions(); - const session = sessions.find((s) => s.sessionId === sid); - if (session) { - try { - rpc.send["agent.cost"]({ - sessionId: sid, - costUsd: session.costUsd, - inputTokens: session.inputTokens, - outputTokens: session.outputTokens, - }); - } catch { /* ignore */ } - } - }); - } - - return result; - } catch (err) { - const error = err instanceof Error ? err.message : String(err); - console.error("[agent.start]", err); - return { ok: false, error }; - } - }, - - "agent.stop": ({ sessionId }) => { - try { - return sidecarManager.stopSession(sessionId); - } catch (err) { - const error = err instanceof Error ? err.message : String(err); - console.error("[agent.stop]", err); - return { ok: false, error }; - } - }, - - "agent.prompt": ({ sessionId, prompt }) => { - try { - return sidecarManager.writePrompt(sessionId, prompt); - } catch (err) { - const error = err instanceof Error ? err.message : String(err); - console.error("[agent.prompt]", err); - return { ok: false, error }; - } - }, - - "agent.list": () => { - try { - return { sessions: sidecarManager.listSessions() }; - } catch (err) { - console.error("[agent.list]", err); - return { sessions: [] }; - } - }, - - // ── Session persistence handlers ────────────────────────────────── - - "session.save": ({ projectId, sessionId, provider, status, costUsd, inputTokens, outputTokens, model, error, createdAt, updatedAt }) => { - try { - sessionDb.saveSession({ - projectId, sessionId, provider, status, costUsd, - inputTokens, outputTokens, model, error, createdAt, updatedAt, - }); - return { ok: true }; - } catch (err) { - console.error("[session.save]", err); - return { ok: false }; - } - }, - - "session.load": ({ projectId }) => { - try { - return { session: sessionDb.loadSession(projectId) }; - } catch (err) { - console.error("[session.load]", err); - return { session: null }; - } - }, - - "session.list": ({ projectId }) => { - try { - return { sessions: sessionDb.listSessionsByProject(projectId) }; - } catch (err) { - console.error("[session.list]", err); - return { sessions: [] }; - } - }, - - "session.messages.save": ({ messages }) => { - try { - sessionDb.saveMessages(messages.map((m) => ({ - sessionId: m.sessionId, msgId: m.msgId, role: m.role, - content: m.content, toolName: m.toolName, toolInput: m.toolInput, - timestamp: m.timestamp, costUsd: m.costUsd ?? 0, - inputTokens: m.inputTokens ?? 0, outputTokens: m.outputTokens ?? 0, - }))); - return { ok: true }; - } catch (err) { - console.error("[session.messages.save]", err); - return { ok: false }; - } - }, - - "session.messages.load": ({ sessionId }) => { - try { - return { messages: sessionDb.loadMessages(sessionId) }; - } catch (err) { - console.error("[session.messages.load]", err); - return { messages: [] }; - } - }, - - // ── btmsg handlers ──────────────────────────────────────────────── - - "btmsg.registerAgent": ({ id, name, role, groupId, tier, model }) => { - try { - btmsgDb.registerAgent(id, name, role, groupId, tier, model); - return { ok: true }; - } catch (err) { - console.error("[btmsg.registerAgent]", err); - return { ok: false }; - } - }, - - "btmsg.getAgents": ({ groupId }) => { - try { - return { agents: btmsgDb.getAgents(groupId) }; - } catch (err) { - console.error("[btmsg.getAgents]", err); - return { agents: [] }; - } - }, - - "btmsg.sendMessage": ({ fromAgent, toAgent, content }) => { - try { - const messageId = btmsgDb.sendMessage(fromAgent, toAgent, content); - return { ok: true, messageId }; - } catch (err) { - const error = err instanceof Error ? err.message : String(err); - console.error("[btmsg.sendMessage]", err); - return { ok: false, error }; - } - }, - - "btmsg.listMessages": ({ agentId, otherId, limit }) => { - try { - return { messages: btmsgDb.listMessages(agentId, otherId, limit ?? 50) }; - } catch (err) { - console.error("[btmsg.listMessages]", err); - return { messages: [] }; - } - }, - - "btmsg.markRead": ({ agentId, messageIds }) => { - try { - btmsgDb.markRead(agentId, messageIds); - return { ok: true }; - } catch (err) { - console.error("[btmsg.markRead]", err); - return { ok: false }; - } - }, - - "btmsg.listChannels": ({ groupId }) => { - try { - return { channels: btmsgDb.listChannels(groupId) }; - } catch (err) { - console.error("[btmsg.listChannels]", err); - return { channels: [] }; - } - }, - - "btmsg.createChannel": ({ name, groupId, createdBy }) => { - try { - const channelId = btmsgDb.createChannel(name, groupId, createdBy); - return { ok: true, channelId }; - } catch (err) { - console.error("[btmsg.createChannel]", err); - return { ok: false }; - } - }, - - "btmsg.getChannelMessages": ({ channelId, limit }) => { - try { - return { messages: btmsgDb.getChannelMessages(channelId, limit ?? 100) }; - } catch (err) { - console.error("[btmsg.getChannelMessages]", err); - return { messages: [] }; - } - }, - - "btmsg.sendChannelMessage": ({ channelId, fromAgent, content }) => { - try { - const messageId = btmsgDb.sendChannelMessage(channelId, fromAgent, content); - return { ok: true, messageId }; - } catch (err) { - console.error("[btmsg.sendChannelMessage]", err); - return { ok: false }; - } - }, - - "btmsg.heartbeat": ({ agentId }) => { - try { - btmsgDb.heartbeat(agentId); - return { ok: true }; - } catch (err) { - console.error("[btmsg.heartbeat]", err); - return { ok: false }; - } - }, - - "btmsg.getDeadLetters": ({ limit }) => { - try { - return { letters: btmsgDb.getDeadLetters(limit ?? 50) }; - } catch (err) { - console.error("[btmsg.getDeadLetters]", err); - return { letters: [] }; - } - }, - - "btmsg.logAudit": ({ agentId, eventType, detail }) => { - try { - btmsgDb.logAudit(agentId, eventType, detail); - return { ok: true }; - } catch (err) { - console.error("[btmsg.logAudit]", err); - return { ok: false }; - } - }, - - "btmsg.getAuditLog": ({ limit }) => { - try { - return { entries: btmsgDb.getAuditLog(limit ?? 100) }; - } catch (err) { - console.error("[btmsg.getAuditLog]", err); - return { entries: [] }; - } - }, - - // ── bttask handlers ─────────────────────────────────────────────── - - "bttask.listTasks": ({ groupId }) => { - try { - return { tasks: bttaskDb.listTasks(groupId) }; - } catch (err) { - console.error("[bttask.listTasks]", err); - return { tasks: [] }; - } - }, - - "bttask.createTask": ({ title, description, priority, groupId, createdBy, assignedTo }) => { - try { - const taskId = bttaskDb.createTask(title, description, priority, groupId, createdBy, assignedTo); - return { ok: true, taskId }; - } catch (err) { - const error = err instanceof Error ? err.message : String(err); - console.error("[bttask.createTask]", err); - return { ok: false, error }; - } - }, - - "bttask.updateTaskStatus": ({ taskId, status, expectedVersion }) => { - try { - const newVersion = bttaskDb.updateTaskStatus(taskId, status, expectedVersion); - return { ok: true, newVersion }; - } catch (err) { - const error = err instanceof Error ? err.message : String(err); - console.error("[bttask.updateTaskStatus]", err); - return { ok: false, error }; - } - }, - - "bttask.deleteTask": ({ taskId }) => { - try { - bttaskDb.deleteTask(taskId); - return { ok: true }; - } catch (err) { - console.error("[bttask.deleteTask]", err); - return { ok: false }; - } - }, - - "bttask.addComment": ({ taskId, agentId, content }) => { - try { - const commentId = bttaskDb.addComment(taskId, agentId, content); - return { ok: true, commentId }; - } catch (err) { - console.error("[bttask.addComment]", err); - return { ok: false }; - } - }, - - "bttask.listComments": ({ taskId }) => { - try { - return { comments: bttaskDb.listComments(taskId) }; - } catch (err) { - console.error("[bttask.listComments]", err); - return { comments: [] }; - } - }, - - "bttask.reviewQueueCount": ({ groupId }) => { - try { - return { count: bttaskDb.reviewQueueCount(groupId) }; - } catch (err) { - console.error("[bttask.reviewQueueCount]", err); - return { count: 0 }; - } - }, - - // ── Search handlers ────────────────────────────────────────────────── - - "search.query": ({ query, limit }) => { - try { - const results = searchDb.searchAll(query, limit ?? 20); - return { results }; - } catch (err) { - console.error("[search.query]", err); - return { results: [] }; - } - }, - - "search.indexMessage": ({ sessionId, role, content }) => { - try { - searchDb.indexMessage(sessionId, role, content); - return { ok: true }; - } catch (err) { - console.error("[search.indexMessage]", err); - return { ok: false }; - } - }, - - "search.rebuild": () => { - try { - searchDb.rebuildIndex(); - return { ok: true }; - } catch (err) { - console.error("[search.rebuild]", err); - return { ok: false }; - } - }, - - // ── Plugin handlers ────────────────────────────────────────────────── - - "plugin.discover": () => { - try { - const plugins: Array<{ - id: string; name: string; version: string; - description: string; main: string; permissions: string[]; - }> = []; - - if (!fs.existsSync(PLUGINS_DIR)) return { plugins }; - - const entries = fs.readdirSync(PLUGINS_DIR, { withFileTypes: true }); - for (const entry of entries) { - if (!entry.isDirectory()) continue; - const manifestPath = join(PLUGINS_DIR, entry.name, "plugin.json"); - if (!fs.existsSync(manifestPath)) continue; - - try { - const raw = fs.readFileSync(manifestPath, "utf-8"); - const manifest = JSON.parse(raw); - plugins.push({ - id: manifest.id ?? entry.name, - name: manifest.name ?? entry.name, - version: manifest.version ?? "0.0.0", - description: manifest.description ?? "", - main: manifest.main ?? "index.js", - permissions: Array.isArray(manifest.permissions) ? manifest.permissions : [], - }); - } catch (parseErr) { - console.error(`[plugin.discover] Bad manifest in ${entry.name}:`, parseErr); - } - } - - return { plugins }; - } catch (err) { - console.error("[plugin.discover]", err); - return { plugins: [] }; - } - }, - - "plugin.readFile": ({ pluginId, filePath }) => { - try { - // Path traversal protection: resolve and verify within plugins dir - const pluginDir = join(PLUGINS_DIR, pluginId); - const resolved = path.resolve(pluginDir, filePath); - if (!resolved.startsWith(pluginDir + path.sep) && resolved !== pluginDir) { - return { ok: false, content: "", error: "Path traversal blocked" }; - } - if (!fs.existsSync(resolved)) { - return { ok: false, content: "", error: "File not found" }; - } - const content = fs.readFileSync(resolved, "utf-8"); - return { ok: true, content }; - } catch (err) { - const error = err instanceof Error ? err.message : String(err); - console.error("[plugin.readFile]", err); - return { ok: false, content: "", error }; - } - }, - - // ── Updater handlers ────────────────────────────────────────────────── + // ── Keybindings ──────────────────────────────────────────────────── + "keybindings.getAll": () => { try { return { keybindings: settingsDb.getKeybindings() }; } catch (err) { console.error("[keybindings.getAll]", err); return { keybindings: {} }; } }, + "keybindings.set": ({ id, chord }) => { try { settingsDb.setKeybinding(id, chord); return { ok: true }; } catch (err) { console.error("[keybindings.set]", err); return { ok: false }; } }, + "keybindings.reset": ({ id }) => { try { settingsDb.deleteKeybinding(id); return { ok: true }; } catch (err) { console.error("[keybindings.reset]", err); return { ok: false }; } }, + // ── Updater ──────────────────────────────────────────────────────── "updater.check": async () => { - try { - const result = await checkForUpdates(APP_VERSION); - return { ...result, error: undefined }; - } catch (err) { - const error = err instanceof Error ? err.message : String(err); - console.error("[updater.check]", err); - return { - available: false, - version: "", - downloadUrl: "", - releaseNotes: "", - checkedAt: Date.now(), - error, - }; - } + try { const result = await checkForUpdates(APP_VERSION); return { ...result, error: undefined }; } + catch (err) { const error = err instanceof Error ? err.message : String(err); console.error("[updater.check]", err); return { available: false, version: "", downloadUrl: "", releaseNotes: "", checkedAt: Date.now(), error }; } }, + "updater.getVersion": () => ({ version: APP_VERSION, lastCheck: getLastCheckTimestamp() }), - "updater.getVersion": () => { - return { - version: APP_VERSION, - lastCheck: getLastCheckTimestamp(), - }; - }, - - // ── Remote machine (relay) handlers ────────────────────────────────── - - "remote.connect": async ({ url, token, label }) => { - try { - const machineId = await relayClient.connect(url, token, label); - return { ok: true, machineId }; - } catch (err) { - const error = err instanceof Error ? err.message : String(err); - console.error("[remote.connect]", err); - return { ok: false, error }; - } - }, - - "remote.disconnect": ({ machineId }) => { - try { - relayClient.disconnect(machineId); - return { ok: true }; - } catch (err) { - const error = err instanceof Error ? err.message : String(err); - console.error("[remote.disconnect]", err); - return { ok: false, error }; - } - }, - - "remote.list": () => { - try { - return { machines: relayClient.listMachines() }; - } catch (err) { - console.error("[remote.list]", err); - return { machines: [] }; - } - }, - - "remote.send": ({ machineId, command, payload }) => { - try { - relayClient.sendCommand(machineId, command, payload); - return { ok: true }; - } catch (err) { - const error = err instanceof Error ? err.message : String(err); - console.error("[remote.send]", err); - return { ok: false, error }; - } - }, - - "remote.status": ({ machineId }) => { - try { - const info = relayClient.getStatus(machineId); - if (!info) { - return { status: "disconnected" as const, latencyMs: null, error: "Machine not found" }; - } - return { status: info.status, latencyMs: info.latencyMs }; - } catch (err) { - const error = err instanceof Error ? err.message : String(err); - console.error("[remote.status]", err); - return { status: "error" as const, latencyMs: null, error }; - } - }, - - // ── Memora handlers (read-only) ───────────────────────────────────── - + // ── Memora (read-only) ──────────────────────────────────────────── "memora.search": ({ query, limit }) => { try { const dbPath = join(homedir(), ".local", "share", "memora", "memories.db"); if (!fs.existsSync(dbPath)) return { memories: [] }; const db = new Database(dbPath, { readonly: true }); try { - const rows = db - .query("SELECT id, content, tags, metadata, created_at as createdAt, updated_at as updatedAt FROM memories WHERE content LIKE ? ORDER BY updated_at DESC LIMIT ?") - .all(`%${query}%`, limit ?? 20) as Array<{ - id: number; content: string; tags: string; - metadata: string; createdAt: string; updatedAt: string; - }>; - return { memories: rows }; - } finally { - db.close(); - } - } catch (err) { - console.error("[memora.search]", err); - return { memories: [] }; - } + const rows = db.query("SELECT id, content, tags, metadata, created_at as createdAt, updated_at as updatedAt FROM memories WHERE content LIKE ? ORDER BY updated_at DESC LIMIT ?").all(`%${query}%`, limit ?? 20); + return { memories: rows as Array<{ id: number; content: string; tags: string; metadata: string; createdAt: string; updatedAt: string }> }; + } finally { db.close(); } + } catch (err) { console.error("[memora.search]", err); return { memories: [] }; } }, - "memora.list": ({ limit, tag }) => { try { const dbPath = join(homedir(), ".local", "share", "memora", "memories.db"); @@ -1035,87 +209,55 @@ const rpc = BrowserView.defineRPC({ try { let sql = "SELECT id, content, tags, metadata, created_at as createdAt, updated_at as updatedAt FROM memories"; const params: unknown[] = []; - if (tag) { - sql += " WHERE tags LIKE ?"; - params.push(`%${tag}%`); - } + if (tag) { sql += " WHERE tags LIKE ?"; params.push(`%${tag}%`); } sql += " ORDER BY updated_at DESC LIMIT ?"; params.push(limit ?? 20); - const rows = db.query(sql).all(...params) as Array<{ - id: number; content: string; tags: string; - metadata: string; createdAt: string; updatedAt: string; - }>; - return { memories: rows }; - } finally { - db.close(); - } - } catch (err) { - console.error("[memora.list]", err); - return { memories: [] }; - } + const rows = db.query(sql).all(...params); + return { memories: rows as Array<{ id: number; content: string; tags: string; metadata: string; createdAt: string; updatedAt: string }> }; + } finally { db.close(); } + } catch (err) { console.error("[memora.list]", err); return { memories: [] }; } }, - // ── Telemetry handler ──────────────────────────────────────────────── - + // ── Telemetry ───────────────────────────────────────────────────── "telemetry.log": ({ level, message, attributes }) => { - try { - telemetry.log(level, `[frontend] ${message}`, attributes ?? {}); - return { ok: true }; - } catch (err) { - console.error("[telemetry.log]", err); - return { ok: false }; - } + try { telemetry.log(level, `[frontend] ${message}`, attributes ?? {}); return { ok: true }; } + catch (err) { console.error("[telemetry.log]", err); return { ok: false }; } }, }, - messages: {}, }, }); -// ── Forward daemon events to WebView ──────────────────────────────────────── +// Wire rpcRef so agent handlers can forward events to webview +rpcRef.send = rpc.send; + +// ── Forward daemon events to WebView ────────────────────────────────────── ptyClient.on("session_output", (msg) => { if (msg.type !== "session_output") return; - try { - rpc.send["pty.output"]({ sessionId: msg.session_id, data: msg.data }); - } catch (err) { - console.error("[pty.output] forward error:", err); - } + try { rpc.send["pty.output"]({ sessionId: msg.session_id, data: msg.data }); } + catch (err) { console.error("[pty.output] forward error:", err); } }); ptyClient.on("session_closed", (msg) => { if (msg.type !== "session_closed") return; - try { - rpc.send["pty.closed"]({ sessionId: msg.session_id, exitCode: msg.exit_code }); - } catch (err) { - console.error("[pty.closed] forward error:", err); - } + try { rpc.send["pty.closed"]({ sessionId: msg.session_id, exitCode: msg.exit_code }); } + catch (err) { console.error("[pty.closed] forward error:", err); } }); -// ── Forward relay events to WebView ───────────────────────────────────────── +// ── Forward relay events to WebView ─────────────────────────────────────── relayClient.onEvent((machineId, event) => { - try { - rpc.send["remote.event"]({ - machineId, - eventType: event.type, - sessionId: event.sessionId, - payload: event.payload, - }); - } catch (err) { - console.error("[remote.event] forward error:", err); - } + try { rpc.send["remote.event"]({ machineId, eventType: event.type, sessionId: event.sessionId, payload: event.payload }); } + catch (err) { console.error("[remote.event] forward error:", err); } }); relayClient.onStatus((machineId, status, error) => { - try { - rpc.send["remote.statusChange"]({ machineId, status, error }); - } catch (err) { - console.error("[remote.statusChange] forward error:", err); - } + try { rpc.send["remote.statusChange"]({ machineId, status, error }); } + catch (err) { console.error("[remote.statusChange] forward error:", err); } }); -// ── App window ─────────────────────────────────────────────────────────────── +// ── App window ──────────────────────────────────────────────────────────── async function getMainViewUrl(): Promise { const channel = await Updater.localInfo.channel(); @@ -1125,20 +267,16 @@ async function getMainViewUrl(): Promise { console.log(`HMR enabled: Using Vite dev server at ${DEV_SERVER_URL}`); return DEV_SERVER_URL; } catch { - console.log( - "Vite dev server not running. Run 'bun run dev:hmr' for HMR support.", - ); + console.log("Vite dev server not running. Run 'bun run dev:hmr' for HMR support."); } } return "views://mainview/index.html"; } -// Connect to daemon (non-blocking — window opens regardless). connectToDaemon(); const url = await getMainViewUrl(); -// Restore persisted window frame if available const savedX = Number(settingsDb.getSetting("win_x") ?? 100); const savedY = Number(settingsDb.getSetting("win_y") ?? 100); const savedWidth = Number(settingsDb.getSetting("win_width") ?? 1400); @@ -1146,7 +284,7 @@ const savedHeight = Number(settingsDb.getSetting("win_height") ?? 900); const mainWindow = new BrowserWindow({ title: "Agent Orchestrator", - titleBarStyle: "default", // "hidden" breaks clicks on WebKitGTK — testing with default + titleBarStyle: "default", url, rpc, frame: { diff --git a/ui-electrobun/src/bun/pty-client.ts b/ui-electrobun/src/bun/pty-client.ts index 8837052..eea1538 100644 --- a/ui-electrobun/src/bun/pty-client.ts +++ b/ui-electrobun/src/bun/pty-client.ts @@ -58,7 +58,7 @@ export class PtyClient extends EventEmitter { this.tokenPath = join(dir, "ptyd.token"); } - /** Connect to daemon and authenticate. */ + /** Connect to daemon and authenticate. Fix #10: 5-second timeout. */ async connect(): Promise { return new Promise((resolve, reject) => { let token: string; @@ -69,6 +69,16 @@ export class PtyClient extends EventEmitter { return; } + let settled = false; + + // Fix #10: 5-second timeout on connect + const timeout = setTimeout(() => { + if (settled) return; + settled = true; + this.socket?.destroy(); + reject(new Error("Connection timeout (5s). Is agor-ptyd running?")); + }, 5_000); + this.socket = connect(this.socketPath); this.socket.on("connect", () => { @@ -84,6 +94,9 @@ export class PtyClient extends EventEmitter { try { const msg = JSON.parse(line) as DaemonEvent; if (!this.authenticated && msg.type === "auth_result") { + if (settled) return; + settled = true; + clearTimeout(timeout); if (msg.ok) { this.authenticated = true; resolve(); @@ -101,21 +114,32 @@ export class PtyClient extends EventEmitter { }); this.socket.on("error", (err) => { - if (!this.authenticated) reject(err); + if (!settled) { + settled = true; + clearTimeout(timeout); + reject(err); + } this.emit("error", err); }); this.socket.on("close", () => { this.authenticated = false; + if (!settled) { + settled = true; + clearTimeout(timeout); + reject(new Error("Connection closed before auth")); + } this.emit("close"); }); }); } - /** Create a new PTY session. */ + /** Create a new PTY session. Fix #3: accepts shell + args for direct command spawning. */ createSession(opts: { id: string; shell?: string; + /** Arguments to pass to shell (used for SSH direct spawn). */ + args?: string[]; cwd?: string; env?: Record; cols?: number; @@ -125,6 +149,7 @@ export class PtyClient extends EventEmitter { type: "create_session", id: opts.id, shell: opts.shell ?? null, + args: opts.args ?? null, cwd: opts.cwd ?? null, env: opts.env ?? null, cols: opts.cols ?? 80, diff --git a/ui-electrobun/src/mainview/App.svelte b/ui-electrobun/src/mainview/App.svelte index a8a71d4..0d3cda3 100644 --- a/ui-electrobun/src/mainview/App.svelte +++ b/ui-electrobun/src/mainview/App.svelte @@ -123,12 +123,7 @@ newGroupName = ''; } - async function removeGroup(id: string) { - if (groups.length <= 1) return; // keep at least one group - groups = groups.filter(g => g.id !== id); - if (activeGroupId === id) activeGroupId = groups[0]?.id ?? 'dev'; - await appRpc.request['groups.delete']({ id }).catch(console.error); - } + // Fix #19: removeGroup removed — was defined but never called from UI let activeGroupId = $state('dev'); // Fix #10: Track previous group to limit mounted DOM (max 2 groups) let previousGroupId = $state(null); @@ -218,38 +213,7 @@ return () => clearInterval(id); }); - // ── JS-based window drag (replaces broken -webkit-app-region on WebKitGTK) ── - let isDraggingWindow = false; - let dragStartX = 0; - let dragStartY = 0; - let winStartX = 0; - let winStartY = 0; - - function onDragStart(e: MouseEvent) { - isDraggingWindow = true; - dragStartX = e.screenX; - dragStartY = e.screenY; - appRpc?.request["window.getFrame"]({}).then((frame: any) => { - winStartX = frame.x; - winStartY = frame.y; - }).catch(() => {}); - window.addEventListener('mousemove', onDragMove); - window.addEventListener('mouseup', onDragEnd); - } - - function onDragMove(e: MouseEvent) { - if (!isDraggingWindow) return; - const dx = e.screenX - dragStartX; - const dy = e.screenY - dragStartY; - appRpc?.request["window.setPosition"]?.({ x: winStartX + dx, y: winStartY + dy })?.catch?.(() => {}); - } - - function onDragEnd() { - isDraggingWindow = false; - window.removeEventListener('mousemove', onDragMove); - window.removeEventListener('mouseup', onDragEnd); - saveWindowFrame(); - } + // Fix #19: onDragStart/onDragMove/onDragEnd removed — no longer referenced from template // ── Window frame persistence (debounced 500ms) ───────────────── let frameSaveTimer: ReturnType | null = null; @@ -329,15 +293,18 @@ // Set up global error boundary setupErrorBoundary(); - // Run all init tasks in parallel, mark app ready when all complete + // Fix #8: Load groups FIRST, then apply saved active_group. + // Other init tasks run in parallel. const initTasks = [ themeStore.initTheme(appRpc).catch(console.error), fontStore.initFonts(appRpc).catch(console.error), keybindingStore.init(appRpc).catch(console.error), + // Sequential: groups.list -> active_group (depends on groups being loaded) appRpc.request["groups.list"]({}).then(({ groups: dbGroups }: { groups: Group[] }) => { if (dbGroups.length > 0) groups = dbGroups; - }).catch(console.error), - appRpc.request["settings.get"]({ key: 'active_group' }).then(({ value }: { value: string | null }) => { + // Now that groups are loaded, apply saved active_group + return appRpc.request["settings.get"]({ key: 'active_group' }); + }).then(({ value }: { value: string | null }) => { if (value && groups.some(g => g.id === value)) activeGroupId = value; }).catch(console.error), // Load projects from SQLite @@ -356,7 +323,6 @@ Promise.allSettled(initTasks).then(() => { appReady = true; - // Track projects for health monitoring after load for (const p of PROJECTS) trackProject(p.id); }); @@ -377,10 +343,24 @@ } document.addEventListener('keydown', handleSearchShortcut); + // Fix #18: Wire CommandPalette events to action handlers + function handlePaletteCommand(e: Event) { + const detail = (e as CustomEvent).detail; + switch (detail) { + case 'settings': settingsOpen = !settingsOpen; break; + case 'search': searchOpen = !searchOpen; break; + case 'new-project': showAddProject = true; break; + case 'toggle-sidebar': settingsOpen = !settingsOpen; break; + default: console.log(`[palette] unhandled command: ${detail}`); + } + } + window.addEventListener('palette-command', handlePaletteCommand); + const cleanup = keybindingStore.installListener(); return () => { cleanup(); document.removeEventListener('keydown', handleSearchShortcut); + window.removeEventListener('palette-command', handlePaletteCommand); }; }); diff --git a/ui-electrobun/src/mainview/ChatInput.svelte b/ui-electrobun/src/mainview/ChatInput.svelte index de2e505..e4a3994 100644 --- a/ui-electrobun/src/mainview/ChatInput.svelte +++ b/ui-electrobun/src/mainview/ChatInput.svelte @@ -242,15 +242,14 @@ - -{#if openPopup !== null} - - -{/if} + + +