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 <mark>
- 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)
This commit is contained in:
Hibryda 2026-03-22 02:30:09 +01:00
parent 8e756d3523
commit 1cd4558740
28 changed files with 1342 additions and 1164 deletions

View file

@ -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<string | null>(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<typeof setTimeout> | 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);
};
});
</script>

View file

@ -242,15 +242,14 @@
</div>
</div>
<!-- Click outside overlay to close popup -->
{#if openPopup !== null}
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
class="popup-backdrop"
onclick={closePopup}
onkeydown={e => e.key === 'Escape' && closePopup()}
></div>
{/if}
<!-- Fix #11: display toggle for popup backdrop -->
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
class="popup-backdrop"
style:display={openPopup !== null ? 'block' : 'none'}
onclick={closePopup}
onkeydown={e => e.key === 'Escape' && closePopup()}
></div>
<style>
.chat-input-outer {

View file

@ -89,17 +89,18 @@
}
</script>
{#if open}
<!-- svelte-ignore a11y_no_noninteractive_element_interactions -->
<div
class="palette-backdrop"
onclick={handleBackdropClick}
onkeydown={handleKeydown}
role="dialog"
aria-modal="true"
aria-label="Command Palette"
tabindex="-1"
>
<!-- Fix #11: display toggle instead of {#if} -->
<!-- svelte-ignore a11y_no_noninteractive_element_interactions -->
<div
class="palette-backdrop"
style:display={open ? 'flex' : 'none'}
onclick={handleBackdropClick}
onkeydown={handleKeydown}
role="dialog"
aria-modal="true"
aria-label="Command Palette"
tabindex="-1"
>
<div class="palette-panel">
<div class="palette-input-row">
<svg class="palette-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true">
@ -155,8 +156,7 @@
{/if}
</ul>
</div>
</div>
{/if}
</div>
<style>
.palette-backdrop {

View file

@ -1,5 +1,6 @@
<script lang="ts">
import { onMount } from 'svelte';
import DOMPurify from 'dompurify';
import { appRpc } from './rpc.ts';
interface Props {
@ -19,6 +20,12 @@
let renderedHtml = $state('');
let loading = $state(false);
// Fix #1: Configure DOMPurify with safe tag whitelist
const PURIFY_CONFIG = {
ALLOWED_TAGS: ['h1', 'h2', 'h3', 'h4', 'p', 'a', 'strong', 'em', 'code', 'pre', 'ul', 'li', 'br'],
ALLOWED_ATTR: ['href', 'target', 'class'],
};
function expandHome(p: string): string {
if (p.startsWith('~/')) return p.replace('~', '/home/' + (typeof process !== 'undefined' ? process.env.USER : 'user'));
return p;
@ -44,19 +51,26 @@
const res = await appRpc.request['files.read']({ path: file.path });
if (res.error || !res.content) {
content = '';
renderedHtml = `<p class="doc-error">${res.error ?? 'Empty file'}</p>`;
renderedHtml = DOMPurify.sanitize(
`<p class="doc-error">${escapeHtml(res.error ?? 'Empty file')}</p>`,
PURIFY_CONFIG,
);
} else {
content = res.content;
renderedHtml = renderMarkdown(content);
renderedHtml = DOMPurify.sanitize(renderMarkdown(content), PURIFY_CONFIG);
}
} catch (err) {
console.error('[DocsTab] read error:', err);
renderedHtml = '<p class="doc-error">Failed to read file</p>';
renderedHtml = DOMPurify.sanitize('<p class="doc-error">Failed to read file</p>', PURIFY_CONFIG);
}
loading = false;
}
/** Simple markdown-to-HTML (no external dep). Handles headers, code blocks, bold, italic, links, lists. */
function escapeHtml(s: string): string {
return s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
}
/** Simple markdown-to-HTML. All output is sanitized by DOMPurify before rendering. */
function renderMarkdown(md: string): string {
let html = md
.replace(/&/g, '&amp;')
@ -64,8 +78,8 @@
.replace(/>/g, '&gt;');
// Code blocks
html = html.replace(/```(\w*)\n([\s\S]*?)```/g, (_m, lang, code) =>
`<pre class="doc-code"><code class="lang-${lang}">${code.trim()}</code></pre>`
html = html.replace(/```(\w*)\n([\s\S]*?)```/g, (_m, _lang, code) =>
`<pre class="doc-code"><code>${code.trim()}</code></pre>`
);
// Inline code
html = html.replace(/`([^`]+)`/g, '<code class="doc-inline-code">$1</code>');

View file

@ -30,6 +30,8 @@
let fileLoading = $state(false);
let isDirty = $state(false);
let editorContent = $state('');
// Fix #6: Request token to discard stale file load responses
let fileRequestToken = 0;
// Extension-based type detection
const CODE_EXTS = new Set([
@ -115,7 +117,7 @@
}
}
/** Select and load a file. */
/** Select and load a file. Fix #6: uses request token to discard stale responses. */
async function selectFile(filePath: string) {
if (selectedFile === filePath) return;
selectedFile = filePath;
@ -123,6 +125,7 @@
fileContent = null;
fileError = null;
fileLoading = true;
const token = ++fileRequestToken;
const type = detectFileType(filePath);
@ -136,6 +139,7 @@
if (type === 'image') {
try {
const result = await appRpc.request["files.read"]({ path: filePath });
if (token !== fileRequestToken) return;
if (result.error) {
fileError = result.error;
return;
@ -144,15 +148,17 @@
fileEncoding = result.encoding;
fileSize = result.size;
} catch (err) {
if (token !== fileRequestToken) return;
fileError = err instanceof Error ? err.message : String(err);
} finally {
fileLoading = false;
if (token === fileRequestToken) fileLoading = false;
}
return;
}
try {
const result = await appRpc.request["files.read"]({ path: filePath });
if (token !== fileRequestToken) return;
if (result.error) {
fileError = result.error;
return;
@ -162,9 +168,10 @@
fileSize = result.size;
editorContent = fileContent;
} catch (err) {
if (token !== fileRequestToken) return;
fileError = err instanceof Error ? err.message : String(err);
} finally {
fileLoading = false;
if (token === fileRequestToken) fileLoading = false;
}
}

View file

@ -16,35 +16,34 @@
let { open, notifications, onClear, onClose }: Props = $props();
</script>
{#if open}
<!-- Backdrop to close on outside click -->
<div class="notif-backdrop" role="presentation" onclick={onClose}></div>
<!-- Fix #11: display toggle instead of {#if} -->
<!-- Backdrop to close on outside click -->
<div class="notif-backdrop" style:display={open ? 'block' : 'none'} role="presentation" onclick={onClose}></div>
<div class="notif-drawer" role="complementary" aria-label="Notification history">
<div class="drawer-header">
<span class="drawer-title">Notifications</span>
<button class="clear-btn" onclick={onClear} aria-label="Clear all notifications">
Clear all
</button>
</div>
<div class="drawer-body">
{#each notifications as notif (notif.id)}
<div class="notif-item" class:success={notif.type === 'success'} class:warning={notif.type === 'warning'} class:error={notif.type === 'error'}>
<span class="notif-dot" class:success={notif.type === 'success'} class:warning={notif.type === 'warning'} class:error={notif.type === 'error'} aria-hidden="true"></span>
<div class="notif-content">
<span class="notif-text">{notif.message}</span>
<span class="notif-time">{notif.time}</span>
</div>
</div>
{/each}
{#if notifications.length === 0}
<div class="notif-empty">No notifications</div>
{/if}
</div>
<div class="notif-drawer" style:display={open ? 'flex' : 'none'} role="complementary" aria-label="Notification history">
<div class="drawer-header">
<span class="drawer-title">Notifications</span>
<button class="clear-btn" onclick={onClear} aria-label="Clear all notifications">
Clear all
</button>
</div>
{/if}
<div class="drawer-body">
{#each notifications as notif (notif.id)}
<div class="notif-item" class:success={notif.type === 'success'} class:warning={notif.type === 'warning'} class:error={notif.type === 'error'}>
<span class="notif-dot" class:success={notif.type === 'success'} class:warning={notif.type === 'warning'} class:error={notif.type === 'error'} aria-hidden="true"></span>
<div class="notif-content">
<span class="notif-text">{notif.message}</span>
<span class="notif-time">{notif.time}</span>
</div>
</div>
{/each}
{#if notifications.length === 0}
<div class="notif-empty">No notifications</div>
{/if}
</div>
</div>
<style>
.notif-backdrop {

View file

@ -22,9 +22,12 @@
let loading = $state(false);
let selectedIndex = $state(0);
let inputEl: HTMLInputElement | undefined = $state();
let searchError = $state<string | null>(null);
// Debounce timer
let debounceTimer: ReturnType<typeof setTimeout> | null = null;
// Fix #7: Request counter to discard stale results
let requestToken = 0;
// Group results by type
let grouped = $derived(() => {
@ -52,18 +55,30 @@
const q = query.trim();
if (!q) {
results = [];
searchError = null;
return;
}
loading = true;
searchError = null;
const token = ++requestToken;
try {
const res = await appRpc.request['search.query']({ query: q, limit: 20 });
results = res.results ?? [];
// Fix #7: Discard stale results if a newer request was issued
if (token !== requestToken) return;
if (res.error) {
searchError = res.error;
results = [];
} else {
results = res.results ?? [];
}
selectedIndex = 0;
} catch (err) {
if (token !== requestToken) return;
console.error('[search]', err);
results = [];
searchError = 'Search failed';
} finally {
loading = false;
if (token === requestToken) loading = false;
}
}
@ -95,6 +110,25 @@
}
}
/**
* Fix #4: Render snippet as plain text, highlight query matches client-side with <mark>.
* Strips any HTML from snippet (from FTS5 <b> tags), then highlights matches safely.
*/
function highlightQuery(snippet: string, q: string): string {
// Strip existing HTML tags (FTS5 returns <b>...</b>)
const plain = snippet.replace(/<[^>]*>/g, '');
// Escape HTML entities
const escaped = plain
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;');
if (!q.trim()) return escaped;
// Escape regex special chars in query
const safeQ = q.trim().replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
const re = new RegExp(`(${safeQ})`, 'gi');
return escaped.replace(re, '<mark>$1</mark>');
}
// Focus input when opened
$effect(() => {
if (open && inputEl) {
@ -108,11 +142,11 @@
});
</script>
{#if open}
<!-- Fix #11: display toggle instead of {#if} to avoid DOM add/remove during click events -->
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div class="overlay-backdrop" style:display={open ? 'flex' : 'none'} onclick={onClose} onkeydown={handleKeydown}>
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div class="overlay-backdrop" onclick={onClose} onkeydown={handleKeydown}>
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div class="overlay-panel" onclick={(e) => e.stopPropagation()} onkeydown={handleKeydown}>
<div class="overlay-panel" onclick={(e) => e.stopPropagation()} onkeydown={handleKeydown}>
<div class="search-input-wrap">
<svg class="search-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true">
<circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/>
@ -145,18 +179,19 @@
onmouseenter={() => selectedIndex = flatIdx}
>
<span class="result-title">{item.title}</span>
<span class="result-snippet">{@html item.snippet}</span>
<span class="result-snippet">{@html highlightQuery(item.snippet, query)}</span>
</button>
{/each}
</div>
{/each}
</div>
{:else if searchError}
<div class="no-results search-error">Invalid query: {searchError}</div>
{:else if query.trim() && !loading}
<div class="no-results">No results for "{query}"</div>
{/if}
</div>
</div>
{/if}
</div>
<style>
.overlay-backdrop {
@ -283,9 +318,14 @@
white-space: nowrap;
}
.result-snippet :global(b) {
.result-snippet :global(mark) {
color: var(--ctp-yellow);
font-weight: 600;
background: transparent;
}
.search-error {
color: var(--ctp-red);
}
.no-results {

View file

@ -47,70 +47,70 @@
}
</script>
{#if open}
<!-- Fix #11: display toggle instead of {#if} -->
<!-- svelte-ignore a11y_no_noninteractive_element_interactions -->
<div
class="drawer-backdrop"
style:display={open ? 'flex' : 'none'}
role="dialog"
aria-modal="true"
aria-label="Settings"
tabindex="-1"
onclick={handleBackdropClick}
onkeydown={handleKeydown}
>
<!-- svelte-ignore a11y_no_noninteractive_element_interactions -->
<div
class="drawer-backdrop"
role="dialog"
aria-modal="true"
aria-label="Settings"
tabindex="-1"
onclick={handleBackdropClick}
onkeydown={handleKeydown}
>
<!-- svelte-ignore a11y_no_noninteractive_element_interactions -->
<aside class="drawer-panel" onclick={e => e.stopPropagation()} onkeydown={e => e.stopPropagation()}>
<aside class="drawer-panel" onclick={e => e.stopPropagation()} onkeydown={e => e.stopPropagation()}>
<!-- Header -->
<header class="drawer-header">
<h2 class="drawer-title">Settings</h2>
<button class="drawer-close" onclick={onClose} aria-label="Close settings">×</button>
</header>
<!-- Header -->
<header class="drawer-header">
<h2 class="drawer-title">Settings</h2>
<button class="drawer-close" onclick={onClose} aria-label="Close settings">×</button>
</header>
<!-- Body: sidebar + content -->
<div class="drawer-body">
<!-- Category nav -->
<nav class="cat-nav" aria-label="Settings categories">
{#each CATEGORIES as cat}
<button
class="cat-btn"
class:active={activeCategory === cat.id}
onclick={() => activeCategory = cat.id}
aria-current={activeCategory === cat.id ? 'page' : undefined}
>
<span class="cat-icon" aria-hidden="true">{cat.icon}</span>
<span class="cat-label">{cat.label}</span>
</button>
{/each}
</nav>
<!-- Body: sidebar + content -->
<div class="drawer-body">
<!-- Category nav -->
<nav class="cat-nav" aria-label="Settings categories">
{#each CATEGORIES as cat}
<button
class="cat-btn"
class:active={activeCategory === cat.id}
onclick={() => activeCategory = cat.id}
aria-current={activeCategory === cat.id ? 'page' : undefined}
>
<span class="cat-icon" aria-hidden="true">{cat.icon}</span>
<span class="cat-label">{cat.label}</span>
</button>
{/each}
</nav>
<!-- Category content -->
<div class="cat-content">
{#if activeCategory === 'appearance'}
<AppearanceSettings />
{:else if activeCategory === 'agents'}
<AgentSettings />
{:else if activeCategory === 'security'}
<SecuritySettings />
{:else if activeCategory === 'projects'}
<ProjectSettings />
{:else if activeCategory === 'orchestration'}
<OrchestrationSettings />
{:else if activeCategory === 'machines'}
<RemoteMachinesSettings />
{:else if activeCategory === 'advanced'}
<AdvancedSettings />
{:else if activeCategory === 'keyboard'}
<KeyboardSettings />
{:else if activeCategory === 'marketplace'}
<MarketplaceTab />
{/if}
</div>
<!-- Category content -->
<div class="cat-content">
{#if activeCategory === 'appearance'}
<AppearanceSettings />
{:else if activeCategory === 'agents'}
<AgentSettings />
{:else if activeCategory === 'security'}
<SecuritySettings />
{:else if activeCategory === 'projects'}
<ProjectSettings />
{:else if activeCategory === 'orchestration'}
<OrchestrationSettings />
{:else if activeCategory === 'machines'}
<RemoteMachinesSettings />
{:else if activeCategory === 'advanced'}
<AdvancedSettings />
{:else if activeCategory === 'keyboard'}
<KeyboardSettings />
{:else if activeCategory === 'marketplace'}
<MarketplaceTab />
{/if}
</div>
</div>
</aside>
</div>
{/if}
</aside>
</div>
<style>
.drawer-backdrop {

View file

@ -86,7 +86,7 @@
}
function connectSsh(conn: SshConfig) {
// Spawn a PTY with ssh command
// Fix #3: Spawn ssh directly via PTY shell+args — no shell command injection
const sessionId = `ssh-${conn.id}-${Date.now()}`;
const args = ['-p', String(conn.port), `${conn.user}@${conn.host}`];
if (conn.keyPath) args.unshift('-i', conn.keyPath);
@ -95,13 +95,9 @@
sessionId,
cols: 120,
rows: 30,
shell: '/usr/bin/ssh',
args,
}).catch(console.error);
// Write the ssh command after a short delay to let the shell start
setTimeout(() => {
const cmd = `/usr/bin/ssh ${args.join(' ')}\n`;
appRpc.request['pty.write']({ sessionId, data: cmd }).catch(console.error);
}, 300);
}
onMount(async () => {

View file

@ -23,6 +23,8 @@
let unsubFont: (() => void) | null = null;
let ro: ResizeObserver | null = null;
let destroyed = false;
// Fix #5: Store listener cleanup functions to prevent leaks
let listenerCleanups: Array<() => void> = [];
/** Decode a base64 string from the daemon into a Uint8Array. */
function decodeBase64(b64: string): Uint8Array {
@ -126,6 +128,12 @@
};
appRpc.addMessageListener('pty.closed', closedHandler);
// Fix #5: Store cleanup functions for message listeners
listenerCleanups.push(
() => appRpc.removeMessageListener?.('pty.output', outputHandler),
() => appRpc.removeMessageListener?.('pty.closed', closedHandler),
);
// ── Send user input to daemon ──────────────────────────────────────────
term.onData((data: string) => {
@ -148,7 +156,11 @@
destroyed = true;
unsubFont?.();
ro?.disconnect();
// Fix #1: Close the PTY session (not just unsubscribe) to prevent session leak
// Fix #5: Clean up all message listeners to prevent leaks
for (const cleanup of listenerCleanups) {
try { cleanup(); } catch { /* ignore */ }
}
listenerCleanups = [];
appRpc.request['pty.close']({ sessionId }).catch(() => {});
term?.dispose();
});

View file

@ -6,6 +6,7 @@
*/
import { appRpc } from './rpc.ts';
import { recordActivity, recordToolDone, recordTokenSnapshot, setProjectStatus } from './health-store.svelte.ts';
// ── Types ────────────────────────────────────────────────────────────────────
@ -118,6 +119,8 @@ const cleanupTimers = new Map<string, ReturnType<typeof setTimeout>>();
// Debounce timer for message persistence
const msgPersistTimers = new Map<string, ReturnType<typeof setTimeout>>();
// Fix #12: Track last persisted index per session to avoid re-saving entire history
const lastPersistedIndex = new Map<string, number>();
// ── Session persistence helpers ─────────────────────────────────────────────
@ -146,7 +149,11 @@ function persistMessages(session: AgentSession): void {
const timer = setTimeout(() => {
msgPersistTimers.delete(session.sessionId);
const msgs = session.messages.map((m) => ({
// Fix #12: Only persist NEW messages (from lastPersistedIndex onward)
const startIdx = lastPersistedIndex.get(session.sessionId) ?? 0;
const newMsgs = session.messages.slice(startIdx);
if (newMsgs.length === 0) return;
const msgs = newMsgs.map((m) => ({
sessionId: session.sessionId,
msgId: m.id,
role: m.role,
@ -155,8 +162,9 @@ function persistMessages(session: AgentSession): void {
toolInput: m.toolInput,
timestamp: m.timestamp,
}));
if (msgs.length === 0) return;
appRpc.request['session.messages.save']({ messages: msgs }).catch((err: unknown) => {
appRpc.request['session.messages.save']({ messages: msgs }).then(() => {
lastPersistedIndex.set(session.sessionId, session.messages.length);
}).catch((err: unknown) => {
console.error('[session.messages.save] persist error:', err);
});
}, 2000);
@ -197,6 +205,16 @@ function ensureListeners() {
persistMessages(session);
// Reset stall timer on activity
resetStallTimer(payload.sessionId, session.projectId);
// Fix #14: Wire health store — record activity on every message batch
for (const msg of converted) {
if (msg.role === 'tool-call') {
recordActivity(session.projectId, msg.toolName);
} else if (msg.role === 'tool-result') {
recordToolDone(session.projectId);
} else {
recordActivity(session.projectId);
}
}
}
});
@ -212,6 +230,9 @@ function ensureListeners() {
session.status = normalizeStatus(payload.status);
if (payload.error) session.error = payload.error;
// Fix #14: Wire health store — update project status
setProjectStatus(session.projectId, session.status === 'done' ? 'done' : session.status === 'error' ? 'error' : session.status === 'running' ? 'running' : 'idle');
// Persist on every status change
persistSession(session);
@ -250,6 +271,8 @@ function ensureListeners() {
session.costUsd = payload.costUsd;
session.inputTokens = payload.inputTokens;
session.outputTokens = payload.outputTokens;
// Fix #14: Wire health store — record token/cost snapshot
recordTokenSnapshot(session.projectId, payload.inputTokens + payload.outputTokens, payload.costUsd);
});
}

View file

@ -11,7 +11,6 @@ import {
loadPlugin,
unloadPlugin,
unloadAllPlugins,
getLoadedPlugins,
setPluginRegistries,
type PluginMeta,
} from './plugin-host.ts';
@ -28,7 +27,6 @@ export interface PluginCommand {
let discovered = $state<PluginMeta[]>([]);
let commands = $state<PluginCommand[]>([]);
let loaded = $derived(getLoadedPlugins());
// ── Event bus (simple pub/sub) ───────────────────────────────────────────────

View file

@ -3,20 +3,31 @@
*
* main.ts creates the Electroview and RPC, then sets it here.
* All other modules import from this file instead of main.ts.
*
* Fix #17: Typed RPC interface instead of `any`.
*/
import type { PtyRPCSchema } from '../shared/pty-rpc-schema.ts';
import type { PtyRPCSchema, PtyRPCRequests, PtyRPCMessages } 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;
// ── Typed RPC interface ──────────────────────────────────────────────────────
let _rpc: ElectrobunRpc | null = null;
type RequestFn<K extends keyof PtyRPCRequests> = (params: PtyRPCRequests[K]['params']) => Promise<PtyRPCRequests[K]['response']>;
type MessagePayload<K extends keyof PtyRPCMessages> = PtyRPCMessages[K];
type MessageListener<K extends keyof PtyRPCMessages> = (payload: MessagePayload<K>) => void;
export interface AppRpcHandle {
request: { [K in keyof PtyRPCRequests]: RequestFn<K> };
addMessageListener: <K extends keyof PtyRPCMessages>(event: K, handler: MessageListener<K>) => void;
removeMessageListener?: <K extends keyof PtyRPCMessages>(event: K, handler: MessageListener<K>) => void;
}
// ── Internal holder ──────────────────────────────────────────────────────────
let _rpc: AppRpcHandle | null = null;
/** Called once from main.ts after Electroview.defineRPC(). */
export function setAppRpc(rpc: ElectrobunRpc): void {
export function setAppRpc(rpc: AppRpcHandle): void {
_rpc = rpc;
}
@ -24,7 +35,7 @@ export function setAppRpc(rpc: ElectrobunRpc): void {
* 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, {
export const appRpc: AppRpcHandle = new Proxy({} as AppRpcHandle, {
get(_target, prop) {
if (!_rpc) {
throw new Error(`[rpc] accessed before init — property "${String(prop)}"`);

View file

@ -0,0 +1,137 @@
/**
* Workspace store project/group CRUD extracted from App.svelte (Fix #16).
*
* Manages PROJECTS and groups state, persists via RPC.
* App.svelte imports and calls these methods instead of inline CRUD logic.
*/
import { appRpc } from './rpc.ts';
import { trackProject } from './health-store.svelte.ts';
// ── Types ─────────────────────────────────────────────────────────────────
type AgentStatus = 'running' | 'idle' | 'stalled';
export interface Project {
id: string;
name: string;
cwd: string;
accent: string;
status: AgentStatus;
costUsd: number;
tokens: number;
messages: Array<{ id: number; role: string; content: string }>;
provider?: string;
profile?: string;
model?: string;
contextPct?: number;
burnRate?: number;
groupId?: string;
cloneOf?: string;
worktreeBranch?: string;
mainRepoPath?: string;
cloneIndex?: number;
}
export interface Group {
id: string;
name: string;
icon: string;
position: number;
hasNew?: boolean;
}
// ── Accent colors ─────────────────────────────────────────────────────────
const ACCENTS = [
'var(--ctp-mauve)', 'var(--ctp-sapphire)', 'var(--ctp-teal)',
'var(--ctp-peach)', 'var(--ctp-pink)', 'var(--ctp-lavender)',
'var(--ctp-green)', 'var(--ctp-blue)', 'var(--ctp-flamingo)',
];
// ── State ─────────────────────────────────────────────────────────────────
let projects = $state<Project[]>([]);
let groups = $state<Group[]>([
{ id: 'dev', name: 'Development', icon: '1', position: 0 },
]);
let activeGroupId = $state('dev');
let previousGroupId = $state<string | null>(null);
// ── Derived ───────────────────────────────────────────────────────────────
export const mountedGroupIds = $derived(new Set([activeGroupId, ...(previousGroupId ? [previousGroupId] : [])]));
export const activeGroup = $derived(groups.find(g => g.id === activeGroupId) ?? groups[0]);
export const filteredProjects = $derived(projects.filter(p => (p.groupId ?? 'dev') === activeGroupId));
// ── Getters/setters for state ─────────────────────────────────────────────
export function getProjects(): Project[] { return projects; }
export function setProjects(p: Project[]): void { projects = p; }
export function getGroups(): Group[] { return groups; }
export function setGroups(g: Group[]): void { groups = g; }
export function getActiveGroupId(): string { return activeGroupId; }
export function setActiveGroup(id: string | undefined): void {
if (!id) return;
if (activeGroupId !== id) previousGroupId = activeGroupId;
activeGroupId = id;
appRpc.request["settings.set"]({ key: 'active_group', value: id }).catch(console.error);
}
// ── Project CRUD ──────────────────────────────────────────────────────────
export async function addProject(name: string, cwd: string): Promise<void> {
if (!name.trim() || !cwd.trim()) return;
const id = `p-${Date.now()}`;
const accent = ACCENTS[projects.length % ACCENTS.length];
const project: Project = {
id, name: name.trim(), cwd: cwd.trim(), accent,
status: 'idle', costUsd: 0, tokens: 0, messages: [],
provider: 'claude', groupId: activeGroupId,
};
projects = [...projects, project];
trackProject(id);
await appRpc.request['settings.setProject']({
id, config: JSON.stringify(project),
}).catch(console.error);
}
export async function deleteProject(projectId: string): Promise<void> {
projects = projects.filter(p => p.id !== projectId);
await appRpc.request['settings.deleteProject']({ id: projectId }).catch(console.error);
}
export function cloneCountForProject(projectId: string): number {
return projects.filter(p => p.cloneOf === projectId).length;
}
export function handleClone(projectId: string, branch: string): void {
const source = projects.find(p => p.id === projectId);
if (!source) return;
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;
projects = [...projects, { ...cloneConfig, status: 'idle', costUsd: 0, tokens: 0, messages: [] }];
} else {
console.error('[clone]', result.error);
}
}).catch(console.error);
}
// ── Group CRUD ────────────────────────────────────────────────────────────
export async function addGroup(name: string): Promise<void> {
if (!name.trim()) return;
const id = `grp-${Date.now()}`;
const position = groups.length;
const group: Group = { id, name: name.trim(), icon: String(position + 1), position };
groups = [...groups, group];
await appRpc.request['groups.create']({ id, name: name.trim(), icon: group.icon, position }).catch(console.error);
}
// ── Aggregates ────────────────────────────────────────────────────────────
export function getTotalCost(): number { return projects.reduce((s, p) => s + p.costUsd, 0); }
export function getTotalTokens(): number { return projects.reduce((s, p) => s + p.tokens, 0); }