diff --git a/ui-electrobun/src/mainview/health-store.svelte.ts b/ui-electrobun/src/mainview/health-store.svelte.ts index 5327b7d..9de7687 100644 --- a/ui-electrobun/src/mainview/health-store.svelte.ts +++ b/ui-electrobun/src/mainview/health-store.svelte.ts @@ -32,18 +32,36 @@ const DEFAULT_CONTEXT_LIMIT = 200_000; // ── Internal state ─────────────────────────────────────────────────────────── +// Feature 10: Per-tool tracking entry +interface ToolEntry { + startTime: number; + count: number; +} + +// Feature 10: Tool duration histogram entry +interface ToolHistogramEntry { + toolName: string; + totalMs: number; + count: number; +} + interface ProjectTracker { projectId: string; lastActivityTs: number; lastToolName: string | null; // Fix #8 (Codex audit): Counter instead of boolean for concurrent tools toolsInFlight: number; + // Feature 10: Per-tool tracking map + activeToolMap: Map; costSnapshots: Array<[number, number]>; // [timestamp, costUsd] totalTokens: number; totalCost: number; status: 'inactive' | 'running' | 'idle' | 'done' | 'error'; } +// Feature 10: Global tool duration histogram (across all projects) +const toolDurationHistogram = new Map(); + let trackers = $state>(new Map()); let tickTs = $state(Date.now()); let tickInterval: ReturnType | null = null; diff --git a/ui-electrobun/src/mainview/plugin-host.ts b/ui-electrobun/src/mainview/plugin-host.ts index c4d56a1..878b78c 100644 --- a/ui-electrobun/src/mainview/plugin-host.ts +++ b/ui-electrobun/src/mainview/plugin-host.ts @@ -18,6 +18,12 @@ export interface PluginMeta { description: string; main: string; permissions: string[]; + /** Feature 9: Allowed network origins for fetch-like operations. */ + allowedOrigins?: string[]; + /** Feature 9: Max runtime in seconds (CPU time quota). Default 30. */ + maxRuntime?: number; + /** Feature 9: Max memory display hint (bytes, informational only). */ + maxMemory?: number; } interface LoadedPlugin { @@ -104,6 +110,27 @@ self.onmessage = function(e) { }; } + if (permissions.includes('network')) { + api.network = { + fetch(url, options) { + // Check against allowedOrigins + const allowedOrigins = msg.allowedOrigins || []; + if (allowedOrigins.length > 0) { + try { + const parsed = new URL(url); + const origin = parsed.origin; + if (!allowedOrigins.some(o => origin === o || parsed.hostname.endsWith(o))) { + return Promise.reject(new Error('Origin not in allowedOrigins: ' + origin)); + } + } catch (e) { + return Promise.reject(new Error('Invalid URL: ' + url)); + } + } + return _rpc('network.fetch', { url, options }); + }, + }; + } + if (permissions.includes('events')) { api.events = { on(event, callback) { @@ -173,13 +200,16 @@ export async function loadPlugin(meta: PluginMeta): Promise { } // Validate permissions - const validPerms = new Set(['palette', 'notifications', 'messages', 'events']); + const validPerms = new Set(['palette', 'notifications', 'messages', 'events', 'network']); for (const p of meta.permissions) { if (!validPerms.has(p)) { throw new Error(`Plugin '${meta.id}' requests unknown permission: ${p}`); } } + // Feature 9: Validate allowedOrigins + const maxRuntime = (meta.maxRuntime ?? 30) * 1000; // default 30s, convert to ms + // Read plugin code via RPC let code: string; try { @@ -253,11 +283,22 @@ export async function loadPlugin(meta: PluginMeta): Promise { type: 'init', code, permissions: meta.permissions, + allowedOrigins: meta.allowedOrigins ?? [], meta: { id: meta.id, name: meta.name, version: meta.version, description: meta.description }, }); }); + // Feature 9: maxRuntime — terminate Worker after timeout + let runtimeTimer: ReturnType | null = null; + if (maxRuntime > 0) { + runtimeTimer = setTimeout(() => { + console.warn(`Plugin '${meta.id}' exceeded maxRuntime (${maxRuntime}ms), terminating`); + unloadPlugin(meta.id); + }, maxRuntime); + } + const cleanup = () => { + if (runtimeTimer) clearTimeout(runtimeTimer); commandRemover?.(meta.id); for (const sub of eventSubscriptions) eventBus?.off(sub.event, sub.handler); eventSubscriptions.length = 0;