diff --git a/CLAUDE-ENCORES.md b/CLAUDE-ENCORES.md new file mode 100644 index 000000000..3c04dcff1 --- /dev/null +++ b/CLAUDE-ENCORES.md @@ -0,0 +1,369 @@ +# CLAUDE-ENCORES.md + +Encore Features architecture and development guide. For the main guide, see [[CLAUDE.md]]. + +--- + +## Overview + +**Encore Features** are Maestro's extension system. The Encore tab in Settings presents a flat list of optional features — both built-in features (like Director's Notes) and installable encores (like Agent Status Exporter, Notification Webhook). Every item in the list is a peer: same card UI, same toggle pattern, same permission badges. + +Installable encores are sandboxed JavaScript modules that run in the main process. They can read process events, send webhooks, write files, and register UI surfaces — all scoped by a permission model. + +Encores are discovered from `userData/encores/` at startup. First-party encores ship bundled in `src/encores/` and are bootstrapped (copied) to userData on version mismatch. + +--- + +## Architecture + +``` +src/encores/ # Bundled first-party encore source + ├── agent-status-exporter/ # Exports agent status to JSON + └── notification-webhook/ # Sends webhooks on agent events + +src/main/ + ├── encore-loader.ts # Discovery, manifest validation, bootstrap + ├── encore-manager.ts # Lifecycle orchestration (singleton) + ├── encore-host.ts # API creation, activation, sandboxing + ├── encore-storage.ts # Per-encore file storage + ├── encore-ipc-bridge.ts # Main↔renderer encore communication + ├── ipc/handlers/encores.ts # IPC handlers for renderer + └── preload/encores.ts # Preload bridge (window.maestro.encores) + +src/shared/encore-types.ts # All encore type definitions +src/renderer/ + ├── components/EncoreManager.tsx # Encore UI (list, detail, settings) + ├── hooks/useEncoreRegistry.ts # React hook for encore state + └── global.d.ts # Encore IPC type declarations +``` + +### Lifecycle Flow + +``` +Bootstrap → Discover → Validate → Auto-enable (first-party) → Activate + ↓ + Encore receives EncoreAPI + (scoped by permissions) +``` + +1. **Bootstrap** (`bootstrapBundledEncores`): Copies `dist/encores/` → `userData/encores/` on version mismatch +2. **Discover** (`discoverEncores`): Reads each subdirectory's `manifest.json` + `README.md` +3. **Validate**: Schema validation of manifest fields, permission checking +4. **Auto-enable**: First-party encores activate unless user explicitly disabled them +5. **Activate** (`EncoreHost.activateEncore`): Loads module, creates scoped API, calls `activate(api)` + +--- + +## Encore Manifest + +Every encore requires a `manifest.json`: + +```json +{ + "id": "my-encore", + "name": "My Encore", + "version": "1.0.0", + "description": "What this encore does", + "author": "Author Name", + "firstParty": true, + "main": "index.js", + "permissions": ["process:read", "storage"], + "settings": [ + { "key": "outputPath", "type": "string", "label": "Output Path", "default": "" }, + { "key": "enabled", "type": "boolean", "label": "Feature Enabled", "default": true } + ], + "tags": ["monitoring", "automation"] +} +``` + +### Permission Model + +| Permission | Grants | Risk | +|------------|--------|------| +| `process:read` | Subscribe to agent data, exit, usage, tool events | Low | +| `process:write` | Kill/write to agent processes | High | +| `stats:read` | Query usage statistics database | Low | +| `settings:read` | Read encore-scoped settings | Low | +| `settings:write` | Read and write encore-scoped settings | Medium | +| `storage` | File I/O in encore's data directory | Medium | +| `notifications` | Show desktop notifications, play sounds | Low | +| `network` | HTTP requests (implicit, not enforced yet) | Medium | +| `middleware` | Reserved for v2 — intercept/transform data | High | + +Permissions are color-coded in the UI: green (read), yellow (write), red (middleware). + +--- + +## Encore API Surface + +Encores receive a scoped `EncoreAPI` object in their `activate(api)` call. Namespaces are only present when the encore has the required permission. + +### `api.maestro` (always available) + +```typescript +{ + version: string; // Maestro app version + platform: string; // 'darwin' | 'win32' | 'linux' + encoreId: string; // This encore's ID + encoreDir: string; // Absolute path to encore directory + dataDir: string; // Absolute path to encore's data/ directory +} +``` + +### `api.process` (requires `process:read`) + +```typescript +{ + getActiveProcesses(): Promise>; + onData(cb: (sessionId, data) => void): () => void; + onExit(cb: (sessionId, code) => void): () => void; + onUsage(cb: (sessionId, stats) => void): () => void; + onToolExecution(cb: (sessionId, tool) => void): () => void; + onThinkingChunk(cb: (sessionId, text) => void): () => void; +} +``` + +### `api.settings` (requires `settings:read` or `settings:write`) + +```typescript +{ + get(key: string): Promise; + set(key: string, value: unknown): Promise; // requires settings:write + getAll(): Promise>; +} +``` + +Settings are namespaced to `encore::` in electron-store. + +### `api.storage` (requires `storage`) + +```typescript +{ + read(filename: string): Promise; + write(filename: string, data: string): Promise; + list(): Promise; + delete(filename: string): Promise; +} +``` + +Files stored in `userData/encores//data/`. + +### `api.notifications` (requires `notifications`) + +```typescript +{ + show(title: string, body: string): Promise; + playSound(sound: string): Promise; +} +``` + +### `api.ipcBridge` (always available if EncoreIpcBridge is wired) + +```typescript +{ + onMessage(channel: string, handler: (...args) => unknown): () => void; + sendToRenderer(channel: string, ...args): void; +} +``` + +--- + +## Settings Persistence + +Encore settings flow through two paths: + +1. **Runtime API** (`api.settings.get/set`): Used by the encore code at runtime. Keys are stored as `encore::` in the main settings store via `EncoreHost.createSettingsAPI()`. + +2. **Renderer IPC** (`encores:settings:get/set`): Used by the EncoreManager UI. Calls through to `EncoreManager.getAllEncoreSettings()` / `setEncoreSetting()`. + +**Critical:** `EncoreManager.setSettingsStore(store)` must be called during initialization, or settings silently no-op (both methods have early returns when `settingsStore` is null). + +--- + +## Build Pipeline + +Encores in `src/encores/` are plain JavaScript (not TypeScript) and need to be copied to `dist/encores/` for the main process to find them at runtime. + +```bash +npm run build:encores # Copies src/encores/ → dist/encores/ +``` + +This runs as part of `build:main`, `dev:main`, and `dev:main:prod-data`. The Windows `start-dev.ps1` also includes it. + +**Why not TypeScript?** Encores are loaded via `require()` at runtime from userData. They must be self-contained `.js` files without a compile step. The manifest and README are JSON/Markdown. + +--- + +## Bootstrap and Deprecation + +`bootstrapBundledEncores()` in `encore-loader.ts`: + +1. Reads `dist/encores/` (or `resources/encores/` in production) +2. Removes any deprecated encore directories (hardcoded list: `['agent-dashboard']`) +3. For each bundled encore: + - If destination doesn't exist → copy (install) + - If version differs → overwrite (update) + - If version matches → skip (preserve user modifications) + +**To rename an encore:** Add the old ID to the `deprecatedEncores` array and bump the new encore's version. + +--- + +## IPC Handlers + +All encore IPC is registered in `src/main/ipc/handlers/encores.ts`: + +| Channel | Direction | Purpose | +|---------|-----------|---------| +| `encores:getAll` | Renderer → Main | Get all discovered encores | +| `encores:enable` | Renderer → Main | Enable/activate an encore | +| `encores:disable` | Renderer → Main | Disable/deactivate an encore | +| `encores:refresh` | Renderer → Main | Re-run discovery | +| `encores:getDir` | Renderer → Main | Get encores directory path | +| `encores:settings:get` | Renderer → Main | Get all settings for an encore | +| `encores:settings:set` | Renderer → Main | Set a single encore setting | +| `encores:bridge:invoke` | Renderer → Main | Call an encore's registered handler | + +Preload bridge: `window.maestro.encores.*` (see `src/main/preload/encores.ts`). + +--- + +## UI Integration + +### Encore Tab (Settings → Encore Features) + +The Encore tab renders a **flat list of feature cards**. Each card uses the shared `EncoreFeatureCard` component with a toggle switch, permission badges, and expandable settings content. The keyboard shortcut `Ctrl+Shift+X` opens the Encore tab directly. + +**Built-in features** (like Director's Notes) are gated by `EncoreFeatureFlags` — a boolean per feature in the settings store, defaulting to `false`. Toggling enables/disables the feature globally (shortcuts, menus, command palette). + +**Installable encores** appear as individual cards in the same flat list, one per encore. Each card shows: +- Name, version, author +- Description and permission badges (color-coded: green/read, yellow/write, red/middleware) +- Toggle switch to enable/disable the encore +- Expandable settings editor when enabled (via `EncoreSettings` component) + +The `EncoreFeatureCard` component (`src/renderer/components/Settings/EncoreFeatureCard.tsx`) is the shared wrapper. Children unmount when disabled, ensuring cleanup of effects. + +### Key UI Components + +| Component | File | Role | +|-----------|------|------| +| `EncoreFeatureCard` | `Settings/EncoreFeatureCard.tsx` | Shared toggle card with permission badges | +| `DirectorNotesSettings` | `Settings/DirectorNotesSettings.tsx` | Self-contained DN settings (provider, lookback) | +| `EncoreSettings` | `EncoreManager.tsx` | Per-encore settings editor (string, number, boolean, select) | +| `EncoreManager` | `EncoreManager.tsx` | Full encore list/detail view (used standalone via Modal, or embedded) | + +### Settings Editor + +`EncoreSettings` validates path-like keys (absolute path) and URL-like keys (valid URL). Text inputs save on blur with a "Saved" flash indicator. + +--- + +## Writing a New Encore + +### Minimal Encore + +``` +my-encore/ + ├── manifest.json + ├── index.js + └── README.md (optional, displayed in UI) +``` + +**manifest.json:** +```json +{ + "id": "my-encore", + "name": "My Encore", + "version": "1.0.0", + "description": "Does something useful", + "author": "You", + "main": "index.js", + "permissions": ["process:read"] +} +``` + +**index.js:** +```javascript +let unsubscribers = []; + +async function activate(api) { + const unsub = api.process.onExit((sessionId, code) => { + console.log(`[my-encore] Agent ${sessionId} exited with code ${code}`); + }); + unsubscribers.push(unsub); +} + +async function deactivate() { + for (const unsub of unsubscribers) unsub(); + unsubscribers = []; +} + +module.exports = { activate, deactivate }; +``` + +### First-Party Encore Checklist + +- [ ] Place in `src/encores//` +- [ ] Set `"firstParty": true` in manifest +- [ ] Write a README.md (shown in encore detail view) +- [ ] Add tests in `src/__tests__/main/encore-reference.test.ts` +- [ ] Bump version on any change (triggers bootstrap re-copy) +- [ ] Clean up timers/subscriptions in `deactivate()` + +--- + +## Bundled Encores + +### Agent Status Exporter (`agent-status-exporter`) + +Writes a `status.json` file with real-time agent state. Heartbeat every 10 seconds ensures the file stays fresh even when idle. + +**Permissions:** `process:read`, `storage`, `settings:read` +**Settings:** `outputPath` — custom absolute path for status.json (defaults to encore data dir) + +### Notification Webhook (`notification-webhook`) + +Sends HTTP POST webhooks on agent exit and error events. Includes agent name, type, exit code, and last ~1000 chars of output. + +**Permissions:** `process:read`, `settings:write`, `notifications`, `network` +**Settings:** `webhookUrl`, `notifyOnCompletion`, `notifyOnError` + +**IPv6 note:** The encore resolves `localhost` to `127.0.0.1` explicitly to avoid `ECONNREFUSED ::1` on Linux systems where Node prefers IPv6. + +--- + +## Common Gotchas + +1. **Settings not persisting**: Ensure `encoreManager.setSettingsStore(store)` is called in `index.ts` +2. **Encores not bootstrapping in dev**: `dist/encores/` must exist — run `npm run build:encores` or restart with `npm run dev` +3. **Stale encore in userData after rename**: Add old ID to `deprecatedEncores` array in `bootstrapBundledEncores()` +4. **Session ID format**: Process manager uses `{baseId}-ai-{tabId}`. Strip suffix with `/-ai-.+$|-terminal$|-batch-\d+$|-synopsis-\d+$/` to match against sessions store +5. **Shortcut opens wrong tab**: `openEncores` must use `openModal('settings', { tab: 'encore' })` directly, not `setSettingsModalOpen(true)` + +--- + +## Key Files + +| File | Purpose | +|------|---------| +| `src/main/encore-loader.ts` | Discovery, validation, bootstrap | +| `src/main/encore-manager.ts` | Lifecycle, settings, singleton | +| `src/main/encore-host.ts` | API creation, sandboxing, activation | +| `src/main/encore-storage.ts` | Per-encore file I/O | +| `src/main/encore-ipc-bridge.ts` | Main↔renderer communication | +| `src/main/ipc/handlers/encores.ts` | IPC handler registration | +| `src/main/preload/encores.ts` | Preload bridge | +| `src/shared/encore-types.ts` | All type definitions | +| `src/renderer/components/Settings/EncoreFeatureCard.tsx` | Shared toggle card for Encore tab | +| `src/renderer/components/Settings/DirectorNotesSettings.tsx` | DN settings panel | +| `src/renderer/components/EncoreManager.tsx` | Encore UI (list, detail, settings) | +| `src/renderer/hooks/useEncoreRegistry.ts` | React state management | +| `src/encores/` | Bundled first-party encores | +| `src/__tests__/main/encore-reference.test.ts` | Encore integration tests | diff --git a/CLAUDE.md b/CLAUDE.md index 52f41a6c4..4ceb63d66 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -16,6 +16,7 @@ This guide has been split into focused sub-documents for progressive disclosure: | [[CLAUDE-AGENTS.md]] | Supported agents and capabilities | | [[CLAUDE-SESSION.md]] | Session interface (agent data model) and code conventions | | [[CLAUDE-PLATFORM.md]] | Cross-platform concerns (Windows, Linux, macOS, SSH remote) | +| [[CLAUDE-ENCORES.md]] | Plugin system architecture, API surface, and development guide | | [AGENT_SUPPORT.md](AGENT_SUPPORT.md) | Detailed agent integration guide | --- @@ -125,7 +126,8 @@ src/ │ ├── agent-*.ts # Agent detection, capabilities, session storage │ ├── parsers/ # Per-agent output parsers + error patterns │ ├── storage/ # Per-agent session storage implementations -│ ├── ipc/handlers/ # IPC handler modules (stats, git, playbooks, etc.) +│ ├── plugin-*.ts # Plugin system (loader, manager, host, storage) +│ ├── ipc/handlers/ # IPC handler modules (stats, git, playbooks, plugins, etc.) │ └── utils/ # Utilities (execFile, ssh-spawn-wrapper, etc.) │ ├── renderer/ # React frontend (desktop) @@ -146,6 +148,8 @@ src/ │ ├── prompts/ # System prompts (editable .md files) │ +├── plugins/ # Bundled first-party plugins (JS, copied to dist/) +│ ├── shared/ # Shared types and utilities │ └── docs/ # Mintlify documentation (docs.runmaestro.ai) @@ -192,8 +196,11 @@ src/ | Spawn agent with SSH support | `src/main/utils/ssh-spawn-wrapper.ts` (required for SSH remote execution) | | Modify file preview tabs | `TabBar.tsx`, `FilePreview.tsx`, `MainPanel.tsx` (see ARCHITECTURE.md → File Preview Tab System) | | Add Director's Notes feature | `src/renderer/components/DirectorNotes/`, `src/main/ipc/handlers/director-notes.ts` | -| Add Encore Feature | `src/renderer/types/index.ts` (flag), `useSettings.ts` (state), `SettingsModal.tsx` (toggle UI), gate in `App.tsx` + keyboard handler | +| Add Encore Feature | `src/renderer/types/index.ts` (flag), `settingsStore.ts` (default), `Settings/EncoreFeatureCard.tsx` (card UI), `SettingsModal.tsx` (add card to list), gate in `App.tsx` + keyboard handler | | Modify history components | `src/renderer/components/History/` | +| Add/modify plugin | `src/encores/`, `src/main/plugin-*.ts` (see [[CLAUDE-ENCORES.md]]) | +| Add plugin IPC handler | `src/main/ipc/handlers/encores.ts`, `src/main/preload/encores.ts` | +| Add plugin permission | `src/shared/encore-types.ts`, `src/main/encore-host.ts` | --- diff --git a/docs/docs.json b/docs/docs.json index 1791f3524..907ea4f44 100644 --- a/docs/docs.json +++ b/docs/docs.json @@ -91,6 +91,7 @@ { "group": "Integrations", "pages": [ + "encores", "mcp-server" ], "icon": "plug" diff --git a/docs/encores.md b/docs/encores.md new file mode 100644 index 000000000..6a710c9e7 --- /dev/null +++ b/docs/encores.md @@ -0,0 +1,351 @@ +--- +title: Encores +description: Extend Maestro with custom encores — lightweight plugins that react to agent events, store data, and integrate with external services. +icon: puzzle-piece +--- + +Encores are Maestro's extension system. An encore is a self-contained JavaScript module that runs in the main process and reacts to agent lifecycle events, token usage, tool executions, and more. Encores are discovered at runtime from your user data directory — no app modifications required. + +## Core Concepts + +An encore consists of a directory containing: + +| File | Required | Purpose | +|------|----------|---------| +| `manifest.json` | Yes | Metadata, permissions, settings schema | +| `index.js` | Yes | Main entry point (Node.js module) | +| `README.md` | No | Documentation shown in the Encore Manager UI | + +Maestro scans the encores directory on startup. Each valid manifest is registered and shown in **Settings > Encores**. When a user toggles an encore on, Maestro calls its `activate()` function with a scoped API. When toggled off, `deactivate()` is called for cleanup. + +### Lifecycle + +``` +discovered → (user enables) → active + ↓ + (user disables or error) + ↓ + disabled / error +``` + +All encores start **disabled**. Enabled/disabled state persists across restarts. + +## Using Encores + +### Where Encores Live + +Encores are stored in your Maestro user data directory: + +| Platform | Path | +|----------|------| +| macOS | `~/Library/Application Support/maestro/encores/` | +| Linux | `~/.config/maestro/encores/` | +| Windows | `%APPDATA%\maestro\encores\` | + +### Installing an Encore + +1. Copy the encore folder into your encores directory +2. Open Maestro and go to **Settings > Encores** +3. Click **Refresh** if the encore doesn't appear +4. Toggle it on + +### Managing Encores + +Open **Settings > Encores** or press `Ctrl+Shift+X` to access the Encore Manager. From there you can: + +- Browse discovered encores and read their documentation +- Toggle encores on and off +- Configure per-encore settings (webhook URLs, output paths, etc.) + +### Bundled Encores + +Maestro ships with two first-party encores: + +**Agent Status Exporter** — Writes a `status.json` file with real-time metrics for all active agents (token usage, cost, tool executions, runtime). Updates on activity and every 10 seconds as a heartbeat. Useful for external dashboards or monitoring scripts. + +**Notification Webhook** — Sends HTTP POST requests when agents complete tasks or encounter errors. Configure a webhook URL and Maestro will POST JSON payloads with session ID, agent type, exit code, and recent output. + +## Building an Encore + +### Project Structure + +``` +my-encore/ +├── manifest.json +├── index.js +└── README.md (optional) +``` + +### Manifest + +The manifest declares your encore's identity, permissions, and configurable settings. + +```json +{ + "id": "my-encore", + "name": "My Encore", + "version": "1.0.0", + "description": "A short description of what this encore does.", + "author": "Your Name", + "main": "index.js", + "permissions": ["process:read", "storage"], + "settings": [ + { + "key": "outputPath", + "type": "string", + "label": "Output Path", + "default": "" + }, + { + "key": "enabled", + "type": "boolean", + "label": "Enable Feature", + "default": true + } + ], + "tags": ["monitoring", "automation"] +} +``` + +**Required fields:** + +| Field | Type | Rules | +|-------|------|-------| +| `id` | string | Lowercase alphanumeric and hyphens only (`/^[a-z0-9]+(?:-[a-z0-9]+)*$/`) | +| `name` | string | Display name | +| `version` | string | Semver version | +| `description` | string | Short description | +| `author` | string | Author name | +| `main` | string | Entry point file relative to encore directory | +| `permissions` | string[] | Array of permission strings (see below) | + +**Optional fields:** + +| Field | Type | Purpose | +|-------|------|---------| +| `authorLink` | string | URL to author's website | +| `minMaestroVersion` | string | Minimum Maestro version required | +| `renderer` | string | Renderer-process entry point (for split-architecture encores) | +| `settings` | array | Configurable settings rendered in the UI | +| `tags` | string[] | Searchable keywords | +| `ui` | object | UI surface registrations (right panel tabs, settings section) | + +### Entry Point + +Your `index.js` must export an `activate` function. An optional `deactivate` function is called on cleanup. + +```javascript +let api = null; + +async function activate(encoreApi) { + api = encoreApi; + + // Subscribe to agent events + api.process.onExit((sessionId, code) => { + console.log(`Agent ${sessionId} exited with code ${code}`); + }); + + // Read a setting + const outputPath = await api.settings.get('outputPath'); + + // Write to scoped storage + await api.storage.write('state.json', JSON.stringify({ started: Date.now() })); +} + +async function deactivate() { + // Clean up timers, subscriptions, etc. + api = null; +} + +module.exports = { activate, deactivate }; +``` + +Event subscriptions registered through the API are automatically cleaned up when the encore is deactivated — you don't need to manually unsubscribe. + +## Permissions + +Encores declare the permissions they need in their manifest. The API object passed to `activate()` only includes namespaces for granted permissions. + +| Permission | API Namespace | Description | +|-----------|---------------|-------------| +| `process:read` | `api.process` | Subscribe to agent output, usage stats, tool executions, exit events | +| `process:write` | `api.processControl` | Send input to agents, kill processes | +| `stats:read` | `api.stats` | Query aggregated usage statistics | +| `settings:read` | `api.settings` | Read encore-scoped settings | +| `settings:write` | `api.settings` | Read and write encore-scoped settings | +| `storage` | `api.storage` | Read/write files in the encore's data directory | +| `notifications` | `api.notifications` | Show desktop notifications, play sounds | +| `network` | — | Declares intent to make network requests | + +The `api.maestro` namespace is always available regardless of permissions. + +## API Reference + +### `api.maestro` — Metadata (always available) + +```typescript +api.maestro.version // Maestro app version (string) +api.maestro.platform // 'win32' | 'darwin' | 'linux' +api.maestro.encoreId // Your encore's ID from manifest +api.maestro.encoreDir // Absolute path to your encore directory +api.maestro.dataDir // Absolute path to your data directory +``` + +### `api.process` — Agent Events (`process:read`) + +```javascript +// List currently running agents +const agents = await api.process.getActiveProcesses(); +// Returns: [{ sessionId, toolType, pid, startTime, name }] + +// Subscribe to agent output +const unsub = api.process.onData((sessionId, data) => { }); + +// Subscribe to token/cost updates +api.process.onUsage((sessionId, stats) => { + // stats: { inputTokens, outputTokens, cacheReadTokens, contextWindow, totalCostUsd } +}); + +// Subscribe to tool executions +api.process.onToolExecution((sessionId, tool) => { + // tool: { toolName, state, timestamp } +}); + +// Subscribe to agent exits +api.process.onExit((sessionId, exitCode) => { }); + +// Subscribe to thinking/reasoning chunks +api.process.onThinkingChunk((sessionId, text) => { }); +``` + +All `on*` methods return an unsubscribe function. + +### `api.processControl` — Agent Control (`process:write`) + +```javascript +api.processControl.kill(sessionId); // Kill agent process +api.processControl.write(sessionId, data); // Send input to agent +``` + +### `api.stats` — Usage Statistics (`stats:read`) + +```javascript +const stats = await api.stats.getAggregation('7d'); // '24h', '7d', '30d', 'all' +api.stats.onStatsUpdate(() => { /* stats changed */ }); +``` + +### `api.settings` — Scoped Settings (`settings:read` / `settings:write`) + +Settings are automatically namespaced to your encore. When you call `api.settings.get('webhookUrl')`, Maestro reads `encore:my-encore:webhookUrl` from the store. + +```javascript +const value = await api.settings.get('key'); +await api.settings.set('key', value); // Requires settings:write +const all = await api.settings.getAll(); // { key: value, ... } +``` + +Settings declared in your manifest's `settings` array are automatically rendered in the Encore Manager UI with appropriate input controls. + +### `api.storage` — File Storage (`storage`) + +Files are stored in `userData/encores//data/`. Filenames are validated — path traversal (`..`), absolute paths, and null bytes are rejected. + +```javascript +await api.storage.write('output.json', jsonString); +const content = await api.storage.read('output.json'); // string | null +const files = await api.storage.list(); // string[] +await api.storage.delete('output.json'); +``` + +### `api.notifications` — Desktop Notifications (`notifications`) + +```javascript +await api.notifications.show('Title', 'Body text'); +await api.notifications.playSound('default'); +``` + +### `api.ipcBridge` — Renderer Communication + +For encores with a renderer component, the IPC bridge enables communication between main-process and renderer-process code. + +```javascript +// In main process (index.js): register a handler +api.ipcBridge.onMessage('getData', () => { + return { agents: Array.from(agents.values()) }; +}); + +// Send data to the renderer component +api.ipcBridge.sendToRenderer('update', { count: 42 }); +``` + +## Settings Schema + +The `settings` array in your manifest defines configurable options that Maestro renders automatically in the UI. + +```json +"settings": [ + { "key": "webhookUrl", "type": "string", "label": "Webhook URL", "default": "" }, + { "key": "verbose", "type": "boolean", "label": "Verbose Logging", "default": false }, + { "key": "interval", "type": "number", "label": "Poll Interval (ms)", "default": 5000 }, + { + "key": "format", + "type": "select", + "label": "Output Format", + "default": "json", + "options": [ + { "label": "JSON", "value": "json" }, + { "label": "CSV", "value": "csv" } + ] + } +] +``` + +Supported types: `string`, `boolean`, `number`, `select`. + +## Example: Minimal Encore + +A complete encore that logs agent exits to a file: + +**manifest.json** +```json +{ + "id": "exit-logger", + "name": "Exit Logger", + "version": "1.0.0", + "description": "Logs agent exit events to a file.", + "author": "You", + "main": "index.js", + "permissions": ["process:read", "storage"], + "tags": ["logging"] +} +``` + +**index.js** +```javascript +let api = null; + +async function activate(encoreApi) { + api = encoreApi; + + api.process.onExit(async (sessionId, code) => { + const existing = await api.storage.read('exits.log') || ''; + const line = `${new Date().toISOString()} session=${sessionId} code=${code}\n`; + await api.storage.write('exits.log', existing + line); + }); +} + +async function deactivate() { + api = null; +} + +module.exports = { activate, deactivate }; +``` + +Drop this folder into your encores directory, toggle it on in Settings, and every agent exit will be appended to `exits.log` in the encore's data directory. + +## Limitations + +- **No sandboxing (v1)** — Encores run in the same Node.js process as Maestro. The permission system scopes the API surface but does not prevent direct `require('fs')` calls. Only install encores you trust. +- **No hot-reload** — Code changes require an app restart. Toggling off and on re-runs `activate()` but Node.js caches `require()` results. +- **No marketplace** — Encores are installed manually by placing folders in the encores directory. There is no built-in install UI or package manager. +- **No renderer sandboxing** — Renderer components (if declared via `renderer` in manifest) run in an iframe but share the Electron context. diff --git a/docs/research/plugin-feasibility/README.md b/docs/research/plugin-feasibility/README.md new file mode 100644 index 000000000..0bc62ff4c --- /dev/null +++ b/docs/research/plugin-feasibility/README.md @@ -0,0 +1,259 @@ +--- +type: report +title: Plugin System Feasibility Summary +created: 2026-02-18 +tags: + - plugin + - architecture + - feasibility + - summary +related: + - "[[extension-points]]" + - "[[concept-agent-dashboard]]" + - "[[concept-ai-auditor]]" + - "[[concept-agent-guardrails]]" + - "[[concept-notifications]]" + - "[[concept-external-integration]]" +--- + +# Plugin System Feasibility Summary + +This document consolidates findings from the Phase 01 architectural feasibility study. It is the single source of truth for all subsequent plugin system phases. + +--- + +## Plugin Concept Feasibility Ratings + +Six plugin concepts were pressure-tested against Maestro's existing architecture. Each was evaluated for API availability, infrastructure gaps, and implementation complexity. + +| Concept | Rating | Renderer-Only? | Main-Process Needed? | Primary Blocker | +|---------|--------|----------------|---------------------|-----------------| +| [[concept-agent-dashboard\|Agent Dashboard Widget]] | **Trivial** | Yes | No | None — all APIs exist | +| [[concept-external-integration\|External Tool Integration]] | **Easy-to-Moderate** | N/A (web server) | Yes (route registration) | Core web server completeness (Gaps A–D) | +| [[concept-notifications\|Third-Party Notifications]] | **Moderate** | No | Yes (CORS-free HTTP) | Plugin IPC bridge + credential storage | +| [[concept-ai-auditor\|AI Auditor]] | **Moderate** | No | Yes (SQLite storage) | Plugin-scoped storage API (Gap #8) | +| [[concept-agent-guardrails\|Agent Guardrails]] | **Moderate-Hard** | No | Yes (process control) | Process control API + storage + latency constraints | + +### Key Observations + +1. **Only one concept (Dashboard) works renderer-only.** Every other concept needs a main-process component — for storage, HTTP dispatch, or process control. The plugin system must support main-process plugins from v1. + +2. **The hardest concept (Guardrails) is still feasible.** The recommended approach (Observer + Reactive Kill) requires zero core architecture changes. Middleware interception is deferred to v2. + +3. **External Integration is mostly a core web server problem, not a plugin problem.** The highest-value integrations (dashboards, log sync, CI triggers) need web server enhancements, not plugin infrastructure. + +4. **Event APIs are comprehensive.** All 11 ProcessManager events are accessible from both main process and renderer. No new event types are needed for v1. + +5. **Agent-specific data inconsistency is a persistent concern.** Tool execution state varies across agents (Claude Code: untyped input; Codex: structured; OpenCode: richest; Factory Droid: no events). Plugins must handle this variation defensively. + +--- + +## Identified Gaps + +### Infrastructure Gaps (Plugin System) + +These gaps must be addressed by the plugin system itself. They are ordered by dependency (earlier gaps unblock later ones). + +| # | Gap | Severity | Needed By | Complexity | +|---|-----|----------|-----------|------------| +| 6 | **Plugin manifest type** (permissions, entry points, API version, dependencies) | High | All plugins | Medium | +| 1 | **Main-process plugin listener registration API** | High | Auditor, Guardrails, Notifications | Medium | +| 2 | **Sandboxed/scoped renderer API surface** | High | All renderer plugins | Medium | +| 10 | **Plugin UI registration system** (panels, tabs, widgets) | High | Dashboard, any UI plugin | Medium | +| 8 | **Plugin-scoped storage API** (`userData/plugins//`) | Medium | Auditor, Guardrails, Notifications | Medium | +| 3 | **Runtime IPC handler registration** for plugins | Medium | External Integration | Medium | +| 5 | **Dynamic Right Panel tab registration** (refactor static `RightPanelTab` union) | Medium | Dashboard (v2) | Medium | +| 9 | **Middleware/interception layer** in ProcessManager event chain | High | Guardrails (v2 only) | High | +| 4 | **Reserved modal priority range** for plugins (e.g., 300–399) | Low | Dashboard (if modal) | Low | +| 7 | **Session-scoped event filtering** in process subscriptions | Low | Performance optimization | Low | + +### Core Enhancement Gaps (Not Plugin Infrastructure) + +These improve Maestro's core capabilities and benefit all consumers, not just plugins. + +| # | Gap | Severity | Needed By | Complexity | +|---|-----|----------|-----------|------------| +| A | **Tool execution WebSocket broadcast** | Medium | External Integration, web clients | Low | +| B | **Stats/analytics REST endpoints** | Medium | External Integration, dashboards | Low | +| C | **Session creation REST endpoint** | Low | CI/CD integration | Medium | +| D | **Auto Run trigger REST endpoint** | Low | CI/CD integration | Medium | +| 11 | **IPC-level batch lifecycle events** | Medium | Notifications (main-process), any main-process batch consumer | Medium | + +### Plugin-Specific Gap + +| # | Gap | Severity | Needed By | Complexity | +|---|-----|----------|-----------|------------| +| E | **Plugin route registration on web server** | Low | External Integration (v2) | Low (Fastify native prefix scoping) | + +--- + +## Gap Ranking by Implementation Difficulty + +### Tier 1: Low Complexity (Quick Wins) + +| Gap | Description | Effort | +|-----|-------------|--------| +| #4 | Reserved modal priority range | Convention only — define numeric range in docs | +| #7 | Session-scoped event filtering | Optional optimization, plugins can self-filter | +| Gap A | Tool execution WebSocket broadcast | Add broadcast call in `forwarding-listeners.ts` | +| Gap E | Plugin route registration | Fastify `register()` with prefix | + +### Tier 2: Medium Complexity (Core Plugin Infrastructure) + +| Gap | Description | Effort | +|-----|-------------|--------| +| #6 | Plugin manifest type | TypeScript interface + validation + loader | +| #8 | Plugin-scoped storage API | Directory creation + JSON read/write + optional SQLite | +| #1 | Main-process listener registration | Extension of `setupProcessListeners()` pattern | +| #2 | Sandboxed renderer API | Proxy wrapper around `window.maestro.*` with allowlists | +| #10 | Plugin UI registration | React component registry + mount points | +| #3 | Runtime IPC handler registration | Namespaced `ipcMain.handle()` for plugins | +| #5 | Dynamic Right Panel tab registration | Refactor `RightPanelTab` to use registry pattern | +| Gap B | Stats REST endpoints | Proxy to existing `stats-db.ts` queries | +| Gap C | Session creation endpoint | IPC-backed endpoint with renderer callback | +| Gap D | Auto Run trigger endpoint | IPC-backed endpoint | +| #11 | IPC batch lifecycle events | Add IPC emit calls to batch store transitions | + +### Tier 3: High Complexity (Deferred) + +| Gap | Description | Effort | +|-----|-------------|--------| +| #9 | Middleware/interception layer | EventEmitter wrapping or forwarding-listener refactor; risk to core stability | + +--- + +## Recommended Plugin System Scope + +### v1: Minimum Viable Plugin System + +Build the smallest surface area that enables the three simplest concepts: **Dashboard**, **Auditor**, and **Notifications**. + +#### v1 Infrastructure (Must Build) + +| Component | Gaps Addressed | Enables | +|-----------|---------------|---------| +| **Plugin manifest + loader** | #6 | All plugins — defines entry points, permissions, metadata | +| **Main-process plugin component** | #1 | Auditor, Guardrails, Notifications — listener registration on ProcessManager | +| **Renderer plugin sandbox** | #2 | All renderer plugins — scoped access to `window.maestro.*` subsets | +| **Plugin UI registration** | #10 | Dashboard, Auditor — mount React components in modal or panel slots | +| **Plugin-scoped storage** | #8 | Auditor, Notifications — persistent config and data in `userData/plugins//` | +| **Plugin IPC bridge** | #3 (partial) | Notifications — renderer ↔ main process communication for split-architecture plugins | + +#### v1 Core Enhancements (Should Build) + +| Enhancement | Gap | Rationale | +|-------------|-----|-----------| +| Tool execution WebSocket broadcast | A | Low effort, high value for web clients and External Integration | +| Stats REST endpoints | B | Low effort, enables external dashboards without plugin system | +| Reserved modal priority range | #4 | Convention-only, zero code | + +#### v1 Deferred + +| Item | Why Deferred | +|------|-------------| +| Dynamic Right Panel tabs (#5) | Dashboard works as floating modal; tab registration is a larger refactor | +| Middleware/interception layer (#9) | Guardrails v1 uses reactive kill (no middleware needed) | +| Session creation endpoint (C) | Stretch goal; not required by any v1 plugin | +| Auto Run trigger endpoint (D) | Stretch goal; not required by any v1 plugin | +| Plugin route registration (E) | External Integration v1 uses core web server enhancements | +| IPC batch lifecycle events (#11) | Notifications v1 uses renderer Zustand subscription + IPC forward | +| Session-scoped event filtering (#7) | Plugins self-filter; optimization can come later | + +### v2: Full Plugin Ecosystem + +After v1 validates the architecture with internal/first-party plugins: + +| Component | Gaps Addressed | Enables | +|-----------|---------------|---------| +| Dynamic Right Panel tabs | #5 | Dashboard as dockable tab, custom plugin tabs | +| Plugin route registration | E | External Integration plugins (Obsidian, Prometheus, Notion formatters) | +| Middleware/interception layer | #9 | Guardrails pre-execution blocking with user confirmation dialogs | +| IPC batch lifecycle events | #11 | Main-process observation of auto-run state without renderer bridge | +| Session creation + Auto Run endpoints | C, D | Full CI/CD integration via web server | +| Plugin marketplace | — | Community plugin discovery, install, and update flow (reuse Playbook Exchange infrastructure) | + +--- + +## Dependency Graph + +> **Updated post-Phase 01:** The phase numbering below reflects the revised plan where middleware (Gap #9) is deferred to v2. The original Phase 04 (Middleware & Event Interception) was replaced with Main-Process Plugin Activation & Storage. + +``` +Phase 02: Plugin Manifest + Loader (#6) + │ + ├── Phase 03: Plugin API Surface + Sandboxing (#2) + │ │ + │ └── Phase 04: Main-Process Activation + Storage (#1, #3, #8) + │ │ + │ └── Phase 05: Plugin UI Registration (#10, #5, #4) + │ │ + │ └── Phase 06: Reference Plugins + │ │ + │ ├── Agent Dashboard [TRIVIAL] — validates renderer-only plugins + │ │ + │ └── Notification Webhook [MODERATE] — validates main-process-only plugins + │ │ + │ └── Phase 07: Settings, Distribution, v2 Roadmap + │ │ + │ └── Phase 08: Documentation & Developer Guide + +v2 (deferred — documented in Phase 07): + ├── Middleware/Interception Layer (#9) — enables Guardrails pre-execution blocking + ├── Plugin Route Registration (Gap E) — enables External Tool Integration plugins + ├── IPC Batch Lifecycle Events (#11) — replaces renderer→main bridge workaround + ├── Third-Party Plugin Sandboxing — vm2/worker threads for untrusted plugins + └── Plugin Marketplace — community plugin discovery + distribution + +Independent (can proceed in parallel with any phase): + ├── Core Web Server Enhancements (Gaps A, B, C, D) + └── Reserved Modal Priority Range (#4, convention-only) +``` + +### Critical Path + +The minimum path to a working plugin: + +1. **Plugin manifest + loader** (Phase 02) → defines how plugins are discovered and initialized +2. **API surface + sandboxing** (Phase 03) → scoped API based on declared permissions +3. **Main-process activation + storage** (Phase 04) → enables main-process plugins, plugin-scoped data persistence, IPC bridge for split-architecture plugins +4. **UI registration** (Phase 05) → Right Panel tabs, Plugin Manager modal +5. **Reference plugins** (Phase 06) → Dashboard (renderer-only) + Notification Webhook (main-process-only) validate both architectures end-to-end + +Each phase builds on the previous one. The two reference plugins in Phase 06 are the first concrete validation that the system works. + +--- + +## Existing Infrastructure to Reuse + +| Existing System | Plugin System Reuse | +|----------------|-------------------| +| **Marketplace manifest** (`MarketplacePlaybook`) | Template for `PluginManifest` type (id, title, author, tags, path) | +| **Marketplace fetch + cache** | Plugin registry fetch, version checking, local cache | +| **Process listener pattern** (`setupProcessListeners`) | Plugin listener registration follows same `(processManager, deps)` signature | +| **Layer Stack** (modal priorities) | Plugin modals use reserved priority range | +| **Stats DB** (`better-sqlite3`) | Plugin-scoped SQLite databases reuse same dependency | +| **Fastify web server** | Plugin route registration via native `register()` with prefix | +| **Electron `safeStorage`** | Encrypt plugin credentials (webhook URLs, API keys) | +| **Group chat `GROUP_CHAT_PREFIX`** | Prior art for session-scoped event routing in plugins | + +--- + +## Risk Summary + +| Risk | Impact | Likelihood | Mitigation | +|------|--------|-----------|------------| +| Plugin crashes affect core Maestro | High | Medium | Isolate plugin code in try/catch; main-process plugins run in separate error boundary; Sentry captures plugin errors with plugin ID tag | +| Unsandboxed renderer plugins access all `window.maestro.*` APIs | High | Certain (until #2 resolved) | v1 ships with trusted/first-party plugins only; sandbox is v1 prerequisite for third-party plugins | +| Agent-specific tool execution data breaks plugin assumptions | Medium | Medium | Define normalized `ToolExecution` interface for plugins; adapter layer per agent type | +| Plugin storage grows unbounded | Low | Medium | Require retention policies in plugin manifest; provide `clearOldData()` utility (mirrors stats pattern) | +| Performance degradation from plugin event listeners | Medium | Low | Limit active plugin listener count; debounce guidance in plugin SDK docs; profile before optimizing (#7) | +| Guardrails kill signal arrives after tool execution completes | Medium | Certain (for single-action) | Document limitation; recommend pairing with agent-native deny rules; effective for multi-step chains | +| CORS blocks renderer-only plugins from external HTTP | High | Certain | Main-process component is required for outbound HTTP — documented in concept reports | + +--- + +## Conclusion + +Maestro's existing architecture provides a strong foundation for a plugin system. The ProcessManager event system, preload API surface, and web server cover the data access needs of all five plugin concepts. The primary work is building the **infrastructure layer** — manifest, loader, sandbox, storage, and UI registration — that turns these raw capabilities into a safe, structured extension API. + +The recommended approach is incremental: start with the Dashboard (validates renderer plugins), then Auditor/Notifications (validates main-process plugins), then Guardrails (validates enforcement plugins). Each step builds on the previous one and produces a usable plugin, avoiding the trap of building a large framework before validating it. diff --git a/docs/research/plugin-feasibility/concept-agent-dashboard.md b/docs/research/plugin-feasibility/concept-agent-dashboard.md new file mode 100644 index 000000000..9e5085fef --- /dev/null +++ b/docs/research/plugin-feasibility/concept-agent-dashboard.md @@ -0,0 +1,211 @@ +--- +type: research +title: "Plugin Concept: Agent Dashboard Widget" +created: 2026-02-18 +tags: + - plugin + - concept + - dashboard +related: + - "[[extension-points]]" + - "[[concept-ai-auditor]]" + - "[[concept-agent-guardrails]]" + - "[[concept-notifications]]" + - "[[concept-external-integration]]" +--- + +# Plugin Concept: Agent Dashboard Widget + +A real-time dashboard plugin that displays live metrics for all active agents: token usage, cost, context window utilization, tool executions, and process states. + +--- + +## API Mapping + +### Required APIs (All Verified) + +| API Call | Exists | Location | Purpose | +|----------|--------|----------|---------| +| `window.maestro.process.getActiveProcesses()` | Yes | `preload/process.ts:176` | Snapshot of all running processes (sessionId, toolType, pid, cwd, startTime) | +| `window.maestro.process.onUsage(cb)` | Yes | `preload/process.ts:407` | Real-time token/cost/context updates per agent response | +| `window.maestro.process.onData(cb)` | Yes | `preload/process.ts:184` | Raw agent output stream | +| `window.maestro.process.onToolExecution(cb)` | Yes | `preload/process.ts:236` | Tool call events (name, state, timestamp) | + +### Supplementary APIs (Useful, Not Required) + +| API Call | Purpose | +|----------|---------| +| `window.maestro.process.onExit(cb)` | Detect agent completion/crash | +| `window.maestro.process.onAgentError(cb)` | Error state monitoring | +| `window.maestro.process.onThinkingChunk(cb)` | Thinking activity indicator | +| `window.maestro.stats.getAggregation(range)` | Historical usage data (tokens by agent, by day) | +| `window.maestro.stats.onStatsUpdate(cb)` | Refresh trigger when stats DB changes | + +--- + +## Data Available + +### From `ActiveProcess` + +```typescript +interface ActiveProcess { + sessionId: string; // Agent identifier + toolType: string; // Agent type (claude-code, codex, etc.) + pid: number; // OS process ID + cwd: string; // Working directory + isTerminal: boolean; // Terminal vs AI process + isBatchMode: boolean; // Auto Run mode flag + startTime: number; // Epoch ms + command?: string; // Spawned command + args?: string[]; // Spawn args +} +``` + +### From `UsageStats` (per `onUsage` event) + +```typescript +interface UsageStats { + inputTokens: number; + outputTokens: number; + cacheReadInputTokens: number; + cacheCreationInputTokens: number; + totalCostUsd: number; + contextWindow: number; // Context window utilization (0-1 range or absolute) + reasoningTokens?: number; // Codex o3/o4-mini only +} +``` + +### From `ToolExecutionEvent` (per `onToolExecution` event) + +```typescript +interface ToolExecutionEvent { + toolName: string; // e.g., "Read", "Write", "Bash", "Glob" + state?: unknown; // Tool-specific state + timestamp: number; // Epoch ms +} +``` + +### From `StatsAggregation` (historical) + +```typescript +interface StatsAggregation { + totalQueries: number; + totalDuration: number; + avgDuration: number; + byAgent: Record; + bySource: { user: number; auto: number }; + byDay: Array<{ date: string; count: number; duration: number }>; +} +``` + +--- + +## Read-Only Assessment + +**Yes, this plugin can work as purely read-only.** Every API it needs is either a subscription (returns data, no side effects) or a read-only query. No write operations are required: + +- `getActiveProcesses()` — read-only snapshot +- `onUsage()` — passive event subscription +- `onData()` — passive event subscription +- `onToolExecution()` — passive event subscription +- `getAggregation()` — read-only database query +- `onStatsUpdate()` — passive notification + +The plugin never needs to call `spawn()`, `write()`, `kill()`, or any other mutating API. + +--- + +## UI Surface Options + +### Option A: Right Panel Tab + +**Pros:** +- Natural home — sits alongside Files, History, Auto Run tabs +- Always accessible without obscuring the main workspace +- Consistent with existing UI patterns + +**Cons:** +- `RightPanelTab` is a static union type (`'files' | 'history' | 'autorun'`) — requires type extension +- Tab rendering is hardcoded in `RightPanel.tsx` as a static array `['files', 'history', 'autorun'].map(...)` +- Content rendering uses conditional branches, not a registry +- Minimum width of 384px may constrain dashboard layout + +**Required changes (Gap #5, #10 from [[extension-points]]):** +1. Extend `RightPanelTab` to accept plugin-registered tab IDs (e.g., `plugin:${string}` pattern) +2. Refactor `RightPanel.tsx` tab rendering to use a registry instead of hardcoded array +3. Add a content rendering hook/slot for plugin-provided React components + +### Option B: Floating Modal/Panel + +**Pros:** +- No changes to `RightPanelTab` or `RightPanel.tsx` required +- Can be arbitrarily sized and positioned +- Layer Stack system already supports custom priorities +- Prior art: Usage Dashboard (priority 540), Process Monitor (priority 550) are similar concepts + +**Cons:** +- Obscures workspace when open +- Not "always visible" — user must open/close +- Adds yet another modal to the layer stack + +**Required changes:** +1. Reserve a modal priority for plugin panels (e.g., 300–399 range per Gap #4) +2. Plugin needs to register a keyboard shortcut or command palette entry to toggle visibility + +### Option C: Hybrid — Modal That Docks + +Start as a floating panel (quick to implement), graduate to a dockable Right Panel tab once the tab registration infrastructure (Gap #5, #10) is built. + +### Recommendation + +**Option B (floating modal) for v1, with Option A as a v2 upgrade.** Rationale: +- The floating modal approach requires zero changes to core RightPanel infrastructure +- Usage Dashboard and Process Monitor already prove this pattern works +- The Right Panel tab registration system (Gap #5, #10) is a larger infrastructure project that benefits all UI plugins, not just the dashboard — it should be built generically, not as a one-off for this concept + +--- + +## Feasibility Verdict + +### Rating: **Trivial** + +This is the simplest plugin concept of all. Every API it needs already exists and is accessible from the renderer process. No new infrastructure is required for a basic implementation. + +### Required New Infrastructure + +| Infrastructure | Needed For | Complexity | +|----------------|-----------|------------| +| Plugin manifest + loader | Loading the plugin | Medium (shared across all plugins) | +| Plugin UI registration | Mounting the React component | Medium (shared across all plugins) | +| Sandboxed API surface | Restricting to read-only APIs | Medium (shared across all plugins) | + +### Infrastructure NOT Required (Unlike Other Concepts) + +- No middleware/interception layer (it's purely observational) +- No plugin-scoped storage (it derives state from live events + stats queries) +- No main-process component (all APIs are available via preload bridge) +- No new IPC handlers (existing process and stats APIs suffice) + +### Implementation Sketch + +A minimal dashboard plugin would: + +1. On mount: call `getActiveProcesses()` for initial state +2. Subscribe to `onUsage()`, `onToolExecution()`, `onData()`, `onExit()`, `onAgentError()` +3. Maintain internal state: per-session token totals, tool execution counts, error counts +4. Render: agent cards with live metrics, cost ticker, context window bars +5. Optionally query `getAggregation('day')` for historical sparklines +6. On unmount: call all unsubscribe functions + +The only architectural decision is the UI surface (floating modal vs Right Panel tab), and the floating modal approach has zero core dependencies. + +--- + +## Risk Assessment + +| Risk | Likelihood | Impact | Mitigation | +|------|-----------|--------|------------| +| Event volume overwhelms plugin renderer | Low | Medium | Debounce/throttle updates, batch state changes | +| No session-scoped filtering (Gap #7) | Certain | Low | Plugin filters events by sessionId internally; trivial | +| `onUsage` reports deltas not cumulatives for some agents | Medium | Low | Plugin maintains running totals per session | +| Context window value semantics vary by agent | Medium | Low | Normalize per agent type; document expected ranges | diff --git a/docs/research/plugin-feasibility/concept-agent-guardrails.md b/docs/research/plugin-feasibility/concept-agent-guardrails.md new file mode 100644 index 000000000..925cb4bf8 --- /dev/null +++ b/docs/research/plugin-feasibility/concept-agent-guardrails.md @@ -0,0 +1,330 @@ +--- +type: research +title: "Plugin Concept: Agent Guardrails" +created: 2026-02-18 +tags: + - plugin + - concept + - guardrails +related: + - "[[extension-points]]" + - "[[concept-ai-auditor]]" + - "[[concept-agent-dashboard]]" + - "[[concept-notifications]]" + - "[[concept-external-integration]]" +--- + +# Plugin Concept: Agent Guardrails + +An active enforcement plugin that can intercept and block agent actions, enforce token budgets, and kill or pause agents that violate policy. Unlike the [[concept-ai-auditor|AI Auditor]], which is purely observational, Guardrails requires the ability to **intervene** in the event pipeline — making it the hardest plugin concept in this feasibility study. + +--- + +## Core Requirement: Interception, Not Just Observation + +The Auditor listens to events after they've fired. Guardrails must either: +- **Prevent** an event from reaching downstream listeners (true interception), or +- **React** to an event by killing/pausing the agent before damage completes (reactive kill) + +This distinction drives the entire architectural analysis below. + +--- + +## Architecture Analysis + +### ProcessManager Event Emission Chain + +From `src/main/process-manager/ProcessManager.ts`: + +``` +Agent stdout → StdoutHandler.handleData() + → StdoutHandler.processLine() → handleParsedEvent() + → this.emitter.emit('tool-execution', sessionId, toolExecution) + → this.emitter.emit('thinking-chunk', sessionId, text) + → bufferManager.emitDataBuffered(sessionId, data) + → DataBufferManager accumulates (max 8KB or 50ms) + → flushDataBuffer() → emitter.emit('data', sessionId, data) +``` + +Key insight: by the time `tool-execution` is emitted, the **agent has already decided** to use the tool. For Claude Code and similar agents, the tool use block indicates intent — the agent is waiting for tool output, meaning the tool execution may already be in progress. This limits the value of pre-forwarding interception for some agents, but not all. + +### ProcessManager.kill() and interrupt() Behavior + +```typescript +kill(sessionId: string): boolean // Flushes data buffer, sends SIGTERM, removes from map +interrupt(sessionId: string): boolean // Sends SIGINT, escalates to SIGTERM after 2000ms +``` + +Both are synchronous boolean returns. `kill()` is immediate and final. `interrupt()` gives the agent a chance to clean up but guarantees termination within 2 seconds. Neither is async — a guardrail plugin can invoke them without awaiting. + +No `pause()` API exists. The only intervention options are interrupt (graceful stop with 2s timeout) and kill (immediate). + +--- + +## Approach A: Event Middleware (Intercept Before Forwarding) + +### How It Would Work + +Insert a guard function between ProcessManager event emission and `forwarding-listeners.ts` forwarding to renderer: + +```typescript +// In forwarding-listeners.ts (current): +processManager.on('tool-execution', (sessionId, toolEvent) => { + safeSend('process:tool-execution', sessionId, toolEvent); +}); + +// With middleware (proposed): +processManager.on('tool-execution', async (sessionId, toolEvent) => { + const decision = await pluginGuard.evaluateToolExecution(sessionId, toolEvent); + if (decision === 'allow') { + safeSend('process:tool-execution', sessionId, toolEvent); + } else { + processManager.kill(sessionId); + safeSend('process:guardrail-blocked', sessionId, { + toolEvent, + reason: decision.reason, + }); + } +}); +``` + +### Insertion Point: `forwarding-listeners.ts` + +`src/main/process-listeners/forwarding-listeners.ts` is a thin forwarder — it registers five `processManager.on(...)` handlers that call `safeSend()`. This is the **cleanest insertion point** because: + +1. It sits between ProcessManager events and the renderer +2. It already depends on `ProcessListenerDependencies.safeSend` — adding a guard dependency is natural +3. No other listeners depend on it — blocking here doesn't break the main process event chain + +### Critical Limitation: EventEmitter Has No Cancellation + +Node.js `EventEmitter.on()` has no built-in way to cancel event propagation. All registered listeners fire in registration order, unconditionally. This means: + +- **A middleware at the forwarding layer cannot prevent other listeners from seeing the event.** The ProcessManager emits `tool-execution`, and all registered listeners (forwarding, stats, group-chat routing) all fire. The middleware can only prevent the `safeSend()` call, suppressing renderer notification. +- **Other main-process listeners (stats recording, group chat routing) would still process the event.** This is acceptable for most guardrail use cases — the goal is to stop the agent, not pretend the event never happened. + +### Would Modifying EventEmitter.emit() Break Tests? + +Wrapping `EventEmitter.emit()` to support cancellation is possible but **inadvisable**: + +1. ProcessManager is used throughout the codebase — any behavioral change to `emit()` risks breaking existing listeners +2. The existing test suite (verified via process-manager tests) relies on standard EventEmitter behavior +3. A custom `emit()` wrapper would be a non-standard pattern that surprises future developers + +**Verdict: Do not modify EventEmitter.emit().** The forwarding-listener insertion point is sufficient. + +### Alternative: StdoutHandler.handleParsedEvent() Hook + +A deeper insertion point exists at `StdoutHandler.handleParsedEvent()` — this is where tool-execution events are first created from parsed output. A hook here would fire **before** the event reaches any listener. However: + +- `StdoutHandler` is a private implementation detail of ProcessManager, not an extension point +- Refactoring it to accept plugin callbacks adds complexity to a hot path (every line of agent output) +- The benefit over forwarding-listener interception is marginal: both fire within the same event loop tick + +**Not recommended for v1.** Consider only if forwarding-level interception proves too late. + +--- + +## Approach B: Observer + Reactive Kill + +### How It Would Work + +The guardrail plugin subscribes to events using the same `processManager.on(...)` API as any other listener. When it detects a policy violation, it calls `processManager.kill(sessionId)` or `processManager.interrupt(sessionId)`. + +```typescript +processManager.on('tool-execution', (sessionId, toolExecution) => { + if (guardrailRules.isViolation(toolExecution)) { + processManager.interrupt(sessionId); // graceful stop + auditLog.record(sessionId, 'blocked', toolExecution); + } +}); + +processManager.on('usage', (sessionId, usageStats) => { + if (usageStats.totalCostUsd > budget.maxCostPerSession) { + processManager.kill(sessionId); // hard stop + auditLog.record(sessionId, 'budget-exceeded', usageStats); + } +}); +``` + +### Latency Analysis + +**Question: What's the latency between observing a tool-execution event and the kill taking effect?** + +1. `tool-execution` event emitted by StdoutHandler → guardrail listener fires (same event loop tick, ~0ms) +2. Guardrail evaluates rules (synchronous pattern matching, ~0ms) +3. `processManager.kill(sessionId)` called → sends SIGTERM to PTY/child process (synchronous, ~0ms) +4. OS delivers SIGTERM → agent process terminates (OS-dependent, typically <10ms) + +**Total latency: sub-millisecond from event to kill signal, <10ms to process termination.** + +However, the relevant question is: **what has the agent already done by the time the tool-execution event fires?** + +- **For Claude Code (PTY mode):** The tool-execution event fires when the agent *announces* it will use a tool. Claude Code's tools are executed by the Claude Code CLI itself, not by Maestro. By the time Maestro sees the tool-execution event, the CLI may have already begun executing (or completed) the tool. The kill signal stops future actions but **cannot undo the current tool execution**. +- **For Codex (stream-JSON mode):** Codex emits `status: 'running'` when a tool starts and `status: 'completed'` when it finishes. The guardrail can react to `running` before completion, but the tool is already executing. +- **For OpenCode:** Similar to Codex — the `running` event fires as execution begins. + +**Verdict: Reactive kill is fast enough to prevent *subsequent* tool executions but cannot guarantee prevention of the *current* tool execution.** This is acceptable for most guardrail use cases: stopping a chain of dangerous operations rather than preventing a single atomic action. + +### Is This Fast Enough to Prevent Damage? + +For multi-step destructive operations (e.g., `rm -rf` across multiple directories, a series of force-pushes), reactive kill is highly effective — the first violation triggers termination before the second action begins. + +For single atomic destructive operations (e.g., one `rm -rf /important-dir`), the damage is done by the time the event fires. The guardrail can only log the violation and prevent further harm. + +**Mitigation:** Pair the reactive guardrail with agent-native safety features (e.g., Claude Code's `--allowedTools` flag, per-project `.claude/settings.json` deny rules). Agent-native controls operate *before* tool execution; the plugin guardrail operates *after* the event. + +--- + +## Token Budget Enforcement + +### Data Available via `onUsage()` + +```typescript +interface UsageStats { + inputTokens: number; + outputTokens: number; + cacheReadInputTokens: number; + cacheCreationInputTokens: number; + totalCostUsd: number; // Cumulative USD cost + contextWindow: number; // Context window fill percentage (0-1) + reasoningTokens?: number; +} +``` + +`onUsage()` fires after each agent response. `totalCostUsd` is **cumulative per session** — no aggregation needed. + +### Implementation + +```typescript +processManager.on('usage', (sessionId, stats) => { + const budget = pluginConfig.getBudget(sessionId); + + // Hard cost cap + if (stats.totalCostUsd > budget.maxCostUsd) { + processManager.kill(sessionId); + notifyUser(`Agent killed: exceeded $${budget.maxCostUsd} budget`); + } + + // Token limit (input + output) + const totalTokens = stats.inputTokens + stats.outputTokens; + if (totalTokens > budget.maxTokens) { + processManager.interrupt(sessionId); + notifyUser(`Agent interrupted: exceeded ${budget.maxTokens} token limit`); + } + + // Context window warning (approaching limit) + if (stats.contextWindow > budget.contextWarningThreshold) { + notifyUser(`Warning: agent at ${Math.round(stats.contextWindow * 100)}% context window`); + } +}); +``` + +The `kill` API is already exposed to the renderer via `window.maestro.process.kill(sessionId)`, so a renderer-only plugin could also enforce budgets. However, a main-process plugin would have lower latency and could enforce budgets even if the renderer is unresponsive. + +--- + +## Group Chat Session Filtering + +### Prior Art: `GROUP_CHAT_PREFIX` Pattern + +From `src/main/process-listeners/types.ts`: + +```typescript +export const GROUP_CHAT_PREFIX = 'group-chat-'; +``` + +Every process listener uses a two-tier filter: +1. `sessionId.startsWith('group-chat-')` — O(1) skip for the common case +2. Regex matching (`REGEX_MODERATOR_SESSION`, `REGEX_BATCH_SESSION`, etc.) — only when needed + +A guardrail plugin should follow this pattern to: +- **Apply guardrails to user-facing agents** (standard session IDs) +- **Optionally apply to group chat participants** (session IDs matching `group-chat-*`) +- **Skip internal sessions** (`REGEX_BATCH_SESSION`, `REGEX_SYNOPSIS_SESSION`) — guardrails should not interfere with background operations + +The `patterns` object from `ProcessListenerDependencies` provides all necessary regexes. A guardrail plugin registered via `setupPluginListeners(processManager, deps)` would receive these through the same dependency injection pattern. + +--- + +## Recommended Approach + +### Approach B (Observer + Reactive Kill) for v1 + +**Rationale:** + +| Criterion | Approach A (Middleware) | Approach B (Observer + Kill) | +|-----------|------------------------|------------------------------| +| Core architecture changes | Requires modifying forwarding-listeners.ts | Zero changes to core — standard event listener | +| Risk to existing behavior | Medium — safeSend wrapping could affect event ordering | None — additive listener only | +| Interception granularity | Can suppress renderer notification | Can kill process but can't suppress events | +| Implementation complexity | Medium — async middleware in synchronous emit chain | Low — standard pattern matching + kill call | +| Latency to intervention | ~0ms (suppresses forward) | ~0ms (sends kill signal) | +| Effectiveness | Blocks UI display but agent still runs until killed | Kills agent; UI sees event + kill notification | +| Test impact | Must verify forwarding still works correctly | No impact on existing tests | + +**Approach B is recommended because:** + +1. **Zero core changes.** The guardrail registers as a standard listener — no modification to ProcessManager, forwarding-listeners, or EventEmitter behavior. This is critical for plugin system safety. +2. **Sufficient for real-world use.** The primary value of guardrails is stopping agents that are in a dangerous loop, not preventing a single atomic action. Reactive kill handles this well. +3. **Natural progression from Auditor.** The [[concept-ai-auditor|AI Auditor]] plugin establishes the event observation pattern. Guardrails adds `kill()`/`interrupt()` calls — a small delta, not a new architecture. +4. **Main-process component provides lowest latency.** A guardrail listener registered directly on ProcessManager fires in the same event loop tick as the event, with synchronous access to `kill()`. + +### Consider Approach A for v2 + +If users require pre-execution blocking (e.g., showing a confirmation dialog before a destructive tool runs), Approach A's forwarding-listener middleware becomes necessary. This would: +1. Hold the `safeSend` call pending user approval +2. The agent process is suspended (via `interrupt()`) while waiting +3. On approval, forward the event and resume; on denial, kill + +This requires async middleware in the forwarding pipeline, which is a more significant change. Defer until v1 usage validates demand. + +--- + +## Feasibility Verdict + +### Rating: **Moderate-Hard** + +The reactive guardrail (Approach B) is straightforward to implement with existing APIs. The difficulty comes from: (1) the inherent limitation that tool execution may complete before the kill signal arrives, (2) the need for a main-process plugin component for lowest-latency enforcement, and (3) the configuration complexity of defining useful guardrail rules. + +### Required New Infrastructure + +| Infrastructure | Needed For | Complexity | Shared? | +|----------------|-----------|------------|---------| +| Plugin manifest + loader | Loading the plugin | Medium | Yes — all plugins need this | +| Plugin UI registration | Mounting guardrail config panel and alert UI | Medium | Yes — all UI plugins need this | +| Sandboxed API surface | Restricting to read-only events + kill/interrupt | Medium | Yes — with **write** access to kill/interrupt APIs | +| **Main-process plugin listener registration** | Registering guardrail listener on ProcessManager | Medium | Yes — Auditor also benefits from this | +| **Plugin-scoped storage API** | Persisting guardrail rules and violation log | Medium | Yes — shared with Auditor (Gap #8 from [[extension-points]]) | +| **Process control API for plugins** | Exposing `kill()` and `interrupt()` to plugin code | Low | Partial — only enforcement plugins need write access to process control | + +### Infrastructure NOT Required (for v1) + +- No EventEmitter modification or custom emit wrapper +- No middleware/interception layer in forwarding-listeners +- No new event types (existing tool-execution, usage, exit are sufficient) +- No pause/resume API (kill and interrupt are sufficient) + +### Comparison to Other Concepts + +| Concept | Rating | Key Differentiator | +|---------|--------|-------------------| +| [[concept-agent-dashboard\|Agent Dashboard]] | Trivial | Purely read-only renderer component | +| [[concept-ai-auditor\|AI Auditor]] | Moderate | Read-only but needs storage | +| **Agent Guardrails** | **Moderate-Hard** | Needs process control (kill) + storage + session filtering | +| [[concept-notifications\|Notifications]] | TBD | Needs outbound HTTP | +| [[concept-external-integration\|External Integration]] | TBD | Needs web server routes | + +--- + +## Risk Assessment + +| Risk | Likelihood | Impact | Mitigation | +|------|-----------|--------|------------| +| Kill signal arrives after tool execution completes | Certain for single-action | Medium | Document limitation; recommend pairing with agent-native deny rules; effective for multi-step chains | +| False positive kills interrupt legitimate work | Medium | High | Conservative default rules; require explicit opt-in for kill-on-violation; warning mode before kill mode | +| Guardrail rules too complex for users to configure | Medium | Medium | Ship sensible defaults; provide rule presets (e.g., "no force pushes", "no recursive deletes", "cost cap") | +| Plugin kill() access is a security concern | Low | High | Restrict to approved plugins only; require explicit user consent for process-control permission | +| Token budget enforcement races with multiple rapid usage events | Low | Low | `totalCostUsd` is cumulative — each event has the latest total; no aggregation race possible | +| Group chat / batch sessions incorrectly subject to guardrails | Medium | Medium | Follow existing `GROUP_CHAT_PREFIX` + regex pattern for session filtering; skip internal sessions by default | +| Main-process plugin component adds crash risk | Low | High | Plugin code runs in try/catch; errors captured by Sentry; plugin crash should not crash ProcessManager | diff --git a/docs/research/plugin-feasibility/concept-ai-auditor.md b/docs/research/plugin-feasibility/concept-ai-auditor.md new file mode 100644 index 000000000..3a6e5b5df --- /dev/null +++ b/docs/research/plugin-feasibility/concept-ai-auditor.md @@ -0,0 +1,239 @@ +--- +type: research +title: "Plugin Concept: AI Auditor" +created: 2026-02-18 +tags: + - plugin + - concept + - auditor +related: + - "[[extension-points]]" + - "[[concept-agent-dashboard]]" + - "[[concept-agent-guardrails]]" + - "[[concept-notifications]]" + - "[[concept-external-integration]]" +--- + +# Plugin Concept: AI Auditor + +A passive monitoring plugin that logs all agent actions (tool executions, file operations, shell commands), flags risky operations in real time, and provides a searchable audit trail. Unlike the [[concept-agent-guardrails|Guardrails]] concept, the Auditor is strictly observational — it never blocks or kills agents. + +--- + +## Event Subscriptions Needed + +### Primary Events + +| Event | API | Data Available | Auditor Use | +|-------|-----|---------------|-------------| +| `process:tool-execution` | `window.maestro.process.onToolExecution(cb)` | `toolName`, `state` (status, input, output), `timestamp` | Core audit log: what tool was used, what arguments were passed, when | +| `process:data` | `window.maestro.process.onData(cb)` | `sessionId`, raw output string | Parse agent output for file paths, commands, and context when tool-execution data is insufficient | +| `process:exit` | `window.maestro.process.onExit(cb)` | `sessionId`, exit code | Session lifecycle boundary; flag non-zero exits as incidents | + +### Supplementary Events + +| Event | API | Auditor Use | +|-------|-----|-------------| +| `agent:error` | `window.maestro.process.onAgentError(cb)` | Log error events (type, message, recoverability, raw stderr) | +| `process:usage` | `window.maestro.process.onUsage(cb)` | Track cumulative token spend per session for budget alerting | +| `process:stderr` | `window.maestro.process.onStderr(cb)` | Capture error output that may indicate failed dangerous operations | +| `process:thinking-chunk` | `window.maestro.process.onThinkingChunk(cb)` | Optional: log reasoning traces for post-hoc analysis | + +All events are verified to exist in `src/main/preload/process.ts` and are exposed via `window.maestro.process.*`. + +--- + +## ToolExecution Data Model + +From `src/main/process-manager/types.ts`: + +```typescript +interface ToolExecution { + toolName: string; // e.g., "Read", "Write", "Bash", "Glob", "Edit" + state: unknown; // Agent-specific; see below + timestamp: number; // Epoch ms +} +``` + +### State Structure by Agent + +The `state` field varies by agent type, but follows common patterns: + +| Agent | State Fields | Notes | +|-------|-------------|-------| +| **Claude Code** | `{ status: 'running', input: unknown }` | Tool use blocks from mixed content; only emitted at start, not completion | +| **Codex** | `{ status: 'running' \| 'completed', input?: Record, output?: string }` | Dual events: running (with input) then completed (with output) | +| **OpenCode** | `{ status, input?, output?, title?, metadata?, time? }` | Richest state model; includes timing and metadata | +| **Factory Droid** | — | Does not currently emit tool-execution events | + +### Tool Names Observed + +Tool names come from the underlying agent's tool system. Common examples: +- **File operations**: `Read`, `Write`, `Edit`, `Glob` +- **Shell execution**: `Bash`, `bash`, `shell` +- **Search/navigation**: `Grep`, `Search`, `ListFiles` +- **Agent-specific**: varies by provider + +--- + +## Risky Operation Detection + +The auditor can flag risky operations by analyzing `toolName` and `state.input` from tool-execution events. This is a pattern-matching problem, not a data availability problem. + +### Detection Rules (Examples) + +| Risk Category | Detection Signal | Confidence | +|---------------|-----------------|------------| +| **Destructive file operations** | `toolName` = "Bash" + `state.input` matches `rm -rf`, `rm -r`, `git clean -f` | High — input args are available in state | +| **Force pushes** | `toolName` = "Bash" + `state.input` matches `git push --force`, `git push -f` | High | +| **Broad glob patterns** | `toolName` = "Bash" + `state.input` matches `find . -delete`, `rm *`, `rm -rf /` | High | +| **Credential access** | `toolName` = "Read"/"Edit" + `state.input` path matches `.env`, `credentials.*`, `*.pem` | Medium — depends on state containing file path | +| **Package modification** | `toolName` = "Bash" + `state.input` matches `npm install`, `pip install` | Medium — may be legitimate | +| **Database operations** | `toolName` = "Bash" + `state.input` matches `DROP TABLE`, `DELETE FROM`, `TRUNCATE` | High | +| **System modifications** | `toolName` = "Bash" + `state.input` matches `chmod`, `chown`, `sudo` | Medium | +| **Network operations** | `toolName` = "Bash" + `state.input` matches `curl`, `wget`, `ssh` | Low — often legitimate | + +### Detection Limitations + +1. **Claude Code's `state.input` is typed as `unknown`** — the input structure varies. File paths may be embedded differently than in Codex/OpenCode. +2. **Factory Droid does not emit tool-execution events** — the auditor has no visibility into its actions. This is a known gap. +3. **Raw `process:data` parsing is fragile** — agent output formats change between versions. Tool-execution events are the preferred detection path. +4. **No output/completion event from Claude Code** — only the "running" state is emitted, so the auditor cannot confirm whether an operation succeeded or failed via tool-execution alone. `process:data` can supplement. + +--- + +## Storage Needs + +### Requirements + +The auditor needs persistent storage for: +1. **Audit log entries** — timestamped records of tool executions, risk flags, session metadata +2. **Configuration** — custom risk rules, severity thresholds, notification preferences +3. **Aggregate statistics** — risk event counts by category, session, time period + +### Recommended Approach: Plugin-Scoped JSON/SQLite in `userData/plugins//` + +| Option | Pros | Cons | +|--------|------|------| +| **JSON files** | Simple, no dependencies, human-readable | Poor query performance for large logs; no concurrent write safety | +| **SQLite** | Fast queries, indexes, aggregation; proven in Maestro (stats-db.ts) | Requires `better-sqlite3` or similar; main-process only | +| **In-memory + periodic flush** | Fastest; no I/O during operation | Data loss on crash; limited history | + +**Recommendation: SQLite via a main-process plugin component.** Rationale: +- Maestro already uses `better-sqlite3` for `stats-db.ts` — the dependency exists +- Audit logs grow unboundedly; SQLite handles this with minimal overhead +- Time-range queries, aggregations, and search are first-class in SQL +- The main process can handle writes without blocking the renderer + +### Storage API Gap + +Currently, there is **no plugin-scoped storage API** (Gap #8 from [[extension-points]]). Plugins have no sanctioned way to: +- Get a writable directory path under `userData` +- Create or open a SQLite database +- Read/write JSON configuration files + +This gap must be addressed before the Auditor can persist data. The required API surface is small: + +```typescript +// Proposed plugin storage API +interface PluginStorageApi { + getDataPath(): string; // Returns userData/plugins// + readJSON(filename: string): Promise; + writeJSON(filename: string, data: unknown): Promise; + openDatabase(filename: string): Database; // SQLite handle (main-process only) +} +``` + +--- + +## Can It Flag Risky Ops from Tool Execution Data? + +**Yes, with caveats.** + +### What Works Well + +1. **Tool name matching** — `toolName` is a reliable string across all agents that emit tool-execution events. "Bash", "Write", "Edit", "Read" are consistent. +2. **Input argument inspection** — for Codex and OpenCode, `state.input` is a structured object with clear fields (command text, file paths, etc.). Pattern matching against these is straightforward. +3. **Timestamp correlation** — events have millisecond timestamps, enabling timeline reconstruction and session-scoped grouping. +4. **Exit code monitoring** — `process:exit` with non-zero codes flags failures, while `agent:error` provides structured error details. + +### What Requires Workarounds + +1. **Claude Code's `state.input` is untyped** — the auditor must handle `unknown` gracefully, attempting to extract command/path strings from whatever structure is present. +2. **No output/result events from Claude Code** — the auditor only sees intent (tool invoked with input), not outcome (succeeded/failed). Supplement with `process:data` parsing for critical rules. +3. **Factory Droid blindspot** — no tool-execution events. The auditor could fall back to `process:data` (raw output parsing), but this is fragile. + +### Confidence Assessment + +| Agent | Detection Confidence | Notes | +|-------|---------------------|-------| +| **Codex** | High | Structured state with status, input, output | +| **OpenCode** | High | Richest state model with metadata | +| **Claude Code** | Medium | Input available but untyped; no completion events | +| **Factory Droid** | Low | No tool-execution events at all | + +--- + +## Feasibility Verdict + +### Rating: **Moderate** + +The core monitoring capability is straightforward — all the event subscriptions exist and provide rich data. The complexity comes from two infrastructure gaps: plugin-scoped storage (required for persistence) and the uneven tool-execution data model across agents. + +### Required New Infrastructure + +| Infrastructure | Needed For | Complexity | Shared? | +|----------------|-----------|------------|---------| +| Plugin manifest + loader | Loading the plugin | Medium | Yes — all plugins need this | +| Plugin UI registration | Mounting audit log viewer component | Medium | Yes — all UI plugins need this | +| Sandboxed API surface | Restricting to read-only process APIs + storage | Medium | Yes — all plugins need this | +| **Plugin-scoped storage API** | Persisting audit logs and config | Medium | Yes — Auditor + Guardrails need this | +| **Main-process plugin component** | SQLite access for audit database | Medium | Partial — only plugins needing main-process access | + +### Infrastructure NOT Required + +- No middleware/interception layer (the Auditor only observes, never blocks) +- No new IPC handlers for events (all process events already forward to renderer) +- No process control APIs beyond what exists (no kill/interrupt needed) + +### Implementation Sketch + +A minimal AI Auditor plugin would: + +1. **Renderer component** (audit log viewer): + - On mount: subscribe to `onToolExecution()`, `onExit()`, `onAgentError()`, `onData()`, `onUsage()` + - Filter events by sessionId for the active agent or show all + - Apply risk detection rules to each tool-execution event + - Display a live-updating log with severity-colored entries + - On unmount: call all unsubscribe functions + +2. **Main-process component** (audit storage): + - Open SQLite database at `userData/plugins/ai-auditor/audit.db` + - Create tables: `audit_log` (id, sessionId, toolName, state, riskLevel, timestamp), `risk_rules` (pattern, severity) + - Expose IPC handlers: `auditor:log-event`, `auditor:query-log`, `auditor:get-risk-summary` + +3. **Risk engine** (shared logic): + - Pattern-match `toolName` + `state.input` against configurable risk rules + - Assign severity levels: info, warning, critical + - Emit flagged events to both renderer (for live display) and storage (for persistence) + +### Comparison to Agent Dashboard + +The Auditor is moderately harder than the [[concept-agent-dashboard|Dashboard]] because: +- Dashboard is **purely renderer-side**; Auditor needs a **main-process component** for SQLite +- Dashboard derives state from **live events only**; Auditor must **persist** an unbounded audit trail +- Dashboard needs no **risk detection logic**; Auditor needs a **pattern matching engine** (albeit simple) +- Dashboard works identically across agents; Auditor must handle **agent-specific state formats** + +--- + +## Risk Assessment + +| Risk | Likelihood | Impact | Mitigation | +|------|-----------|--------|------------| +| High event volume overwhelms storage writes | Medium | Medium | Batch inserts, write-ahead log, periodic flush instead of per-event writes | +| Agent-specific `state.input` formats break rules | Medium | Medium | Defensive parsing; type-narrowing utilities per agent; regression tests | +| Factory Droid has no tool-execution events | Certain | Low | Document limitation; raw output parsing as fallback; file issue for Factory Droid parser | +| Claude Code changes output format | Low | Medium | Version-pinned parsers; integration tests against known output samples | +| Audit log grows without bound | Certain | Low | Configurable retention policy (auto-prune entries older than N days); matches `stats.clearOldData()` pattern | +| Plugin storage API not designed yet | Certain | High | This is the primary blocker — must be resolved in the plugin infrastructure phase before Auditor can ship | diff --git a/docs/research/plugin-feasibility/concept-external-integration.md b/docs/research/plugin-feasibility/concept-external-integration.md new file mode 100644 index 000000000..e32b70c81 --- /dev/null +++ b/docs/research/plugin-feasibility/concept-external-integration.md @@ -0,0 +1,202 @@ +--- +type: research +title: "Plugin Concept: External Tool Integration" +created: 2026-02-18 +tags: + - plugin + - concept + - external-integration + - web-server + - api +related: + - "[[extension-points]]" + - "[[concept-agent-dashboard]]" + - "[[concept-ai-auditor]]" + - "[[concept-agent-guardrails]]" + - "[[concept-notifications]]" +--- + +# External Tool Integration Plugin Concept + +## Overview + +This plugin enables external tools (Obsidian, Notion, local dev tools, CI/CD systems, custom dashboards) to consume Maestro data and interact with agents. Examples: syncing agent output to Obsidian vaults, pushing usage stats to Notion databases, triggering agents from CI pipelines, or building custom monitoring dashboards. + +## What External Tools Would Want + +### Data Available for Consumption + +| Data Category | Source | Current API | Access Level | +|---|---|---|---| +| Agent output (stdout/stderr) | `process:data` events | WebSocket `session_state_change` (partial), REST `GET /api/session/:id` (logs) | Read-only, already exposed | +| Usage stats (tokens, cost, context window) | `process:usage` events | REST `GET /api/sessions` (per-session), Stats DB (aggregated) | Read-only, partial exposure | +| File changes | `process:tool-execution` events | Not exposed via web server | **Gap** | +| Auto Run progress | Zustand store → `web.broadcastAutoRunState()` | WebSocket `autorun_state` broadcast | Read-only, already exposed | +| Session lifecycle (create/delete/state) | IPC events | WebSocket `session_added`, `session_removed`, `session_state_change` | Read-only, already exposed | +| Tool executions (tool name, state, input) | `process:tool-execution` events | Not exposed via web server | **Gap** | +| Aggregated stats (by agent, by day, by source) | Stats SQLite DB | `stats:get-aggregation` IPC only | **Gap** — not on web server | +| History entries | History store | REST `GET /api/history` | Read-only, already exposed | +| Theme | Settings store | REST `GET /api/theme`, WebSocket `theme` broadcast | Read-only, already exposed | + +### Write Operations External Tools Would Want + +| Operation | Current API | Notes | +|---|---|---| +| Send command to agent | REST `POST /api/session/:id/send`, WebSocket `send_command` | Already exposed, token-gated | +| Interrupt agent | REST `POST /api/session/:id/interrupt`, WebSocket `switch_mode` | Already exposed | +| Create new session | Not exposed | **Gap** | +| Trigger Auto Run | Not exposed | **Gap** | + +## Existing Web Server Analysis + +### Architecture + +Maestro already has a Fastify-based web server (`src/main/web-server/`) with: + +- **REST API** at `/$TOKEN/api/*` — 6 endpoints (sessions, session detail, send, interrupt, theme, history) +- **WebSocket** at `/$TOKEN/ws` — bidirectional real-time communication with 10+ inbound message types and 12+ outbound broadcast types +- **Security** — UUID token regenerated per app launch, required in all URLs +- **Rate limiting** — 100 req/min GET, 30 req/min POST, per-IP +- **CORS** — enabled via `@fastify/cors` +- **On-demand startup** — server only runs when user enables the web interface + +### What Already Works + +A significant portion of external integration is **already possible** through the existing web server: + +1. **Session monitoring**: `GET /api/sessions` returns all sessions with state, usage stats, and tabs +2. **Session detail + logs**: `GET /api/session/:id` returns AI/shell logs, usage, state +3. **Real-time updates**: WebSocket broadcasts session state changes, auto-run progress, theme changes +4. **Command execution**: `POST /api/session/:id/send` sends commands to agents +5. **History**: `GET /api/history` returns conversation history entries + +An external tool like Obsidian could already poll `/api/sessions` and `/api/session/:id` to sync agent output, or connect via WebSocket for real-time updates. + +### What's Missing for a Complete Integration Story + +#### Gap A: No Tool Execution Events on WebSocket + +The WebSocket broadcasts session state changes but **not individual tool executions**. The `process:tool-execution` events (tool name, state, input/output) are only available via the renderer's preload API. External tools wanting to monitor file edits, command runs, or other tool activity have no access. + +**Proposed fix**: Add a `tool_execution` WebSocket broadcast type. The `forwarding-listeners.ts` already forwards these to the renderer; a parallel forward to the web server's broadcast service would be minimal. + +#### Gap B: No Stats/Analytics Endpoints + +The Stats API (`stats:get-aggregation`, `stats:get-stats`, etc.) is only available via IPC. External dashboards wanting usage analytics, cost tracking, or session lifecycle data cannot access it. + +**Proposed fix**: Add `GET /api/stats/aggregation?range=week` and `GET /api/stats/sessions?range=month` REST endpoints that proxy to the stats DB. + +#### Gap C: No Session Creation Endpoint + +External tools cannot create new agents/sessions via the web server. This limits CI/CD integration scenarios. + +**Proposed fix**: Add `POST /api/sessions` endpoint that triggers session creation via the renderer callback pattern (similar to `executeCommand`). + +#### Gap D: No Auto Run Trigger Endpoint + +External tools cannot start Auto Run batches. This limits automation scenarios where CI/CD pipelines want to trigger playbook execution. + +**Proposed fix**: Add `POST /api/session/:id/autorun` endpoint. + +#### Gap E: No Plugin Route Registration + +The Fastify routes are hardcoded in `apiRoutes.ts`. A plugin cannot register its own REST or WebSocket endpoints on the web server. + +**Proposed fix**: Expose a route registration API that plugins can call to add custom endpoints under `/$TOKEN/api/plugins//*`. Fastify natively supports dynamic route registration via `server.register()` with prefix scoping. + +## Plugin Architecture Assessment + +### Can This Work via the Existing Web Server? + +**Yes, substantially.** The existing web server is the correct integration surface. The question is whether a plugin *extends* it or merely *consumes* it. + +Three integration patterns emerge: + +#### Pattern 1: External Tool as Consumer (No Plugin Needed) + +External tools connect to Maestro's existing web server as clients. This works **today** for: +- Session monitoring and log sync +- Real-time state subscriptions via WebSocket +- Command execution +- History retrieval + +**No plugin system required.** The gaps (A–D) are core web server enhancements, not plugin features. + +#### Pattern 2: Plugin as Data Enricher (Main-Process Plugin) + +A plugin runs in the main process, subscribes to `ProcessManager` events, and either: +- Enriches existing web server responses (e.g., adds tool execution data to session detail) +- Writes derived data to plugin-scoped storage for external consumption + +This requires: Gap #1 (main-process listener registration), Gap #8 (plugin-scoped storage). + +#### Pattern 3: Plugin as Route Provider (Main-Process Plugin) + +A plugin registers custom REST/WebSocket endpoints on the web server for external tool consumption. Examples: +- `/api/plugins/obsidian/sync` — returns agent output formatted for Obsidian vault import +- `/api/plugins/notion/webhook` — receives Notion webhook callbacks +- `/api/plugins/metrics/prometheus` — returns Prometheus-format metrics + +This requires: Gap E (plugin route registration), plus Gap #1 and Gap #8. + +### Recommended Approach + +**v1: Core web server enhancements (Pattern 1) — no plugin system needed.** + +The highest-value integration scenarios (monitoring dashboards, log syncing, CI triggers) work by adding missing endpoints to the core web server. This benefits all external tools without requiring them to install plugins. + +Specific v1 additions to the core web server: +1. `tool_execution` WebSocket broadcast (Gap A) +2. Stats REST endpoints (Gap B) +3. Session creation endpoint (Gap C) — stretch goal + +**v2: Plugin route registration (Pattern 3) — for custom integrations.** + +Once the plugin system exists, allow plugins to register routes on the web server under a scoped prefix. This enables tool-specific formatters (Obsidian, Notion, Prometheus) without bloating the core API. + +## Feasibility by Integration Target + +| Target | Feasibility | Approach | Notes | +|---|---|---|---| +| Custom dashboard | **Trivial** | Pattern 1 — consume existing REST + WebSocket | Works today with minor gaps | +| Obsidian vault sync | **Easy** | Pattern 1 + Gap A (tool executions) | Poll `/api/session/:id` for logs, format as markdown | +| Notion database | **Moderate** | Pattern 2 or 3 — needs main-process HTTP out | Notion API requires OAuth + server-side calls; similar to [[concept-notifications]] CORS constraint | +| CI/CD trigger | **Moderate** | Pattern 1 + Gap C + D | Needs session creation and Auto Run trigger endpoints | +| Prometheus metrics | **Easy** | Pattern 3 — plugin registers `/metrics` endpoint | Transforms stats data to Prometheus exposition format | +| Grafana | **Trivial** | Pattern 1 — Grafana polls REST endpoints | Grafana's HTTP datasource plugin consumes any JSON API | + +## Required Infrastructure + +### Shared with Other Plugins +- Gap #1: Main-process plugin listener registration (shared with [[concept-agent-guardrails]], [[concept-ai-auditor]]) +- Gap #6: Plugin manifest type (shared, all plugins) +- Gap #8: Plugin-scoped storage (shared with [[concept-ai-auditor]], [[concept-agent-guardrails]], [[concept-notifications]]) + +### Unique to This Concept +- **Gap E (new): Plugin route registration on web server** — allow plugins to register Fastify routes under `/$TOKEN/api/plugins//`. Low complexity: Fastify's `register()` with prefix handles scoping natively. + +### Core Enhancements (Not Plugin Infrastructure) +- Gap A: Tool execution WebSocket broadcast — benefits all web clients, not just plugins +- Gap B: Stats REST endpoints — benefits all web clients +- Gap C: Session creation REST endpoint — benefits all web clients +- Gap D: Auto Run trigger REST endpoint — benefits all web clients + +## Security Considerations + +- The existing security token model is sufficient for external tool auth +- Plugin-registered routes inherit the token requirement automatically (scoped under `/$TOKEN/`) +- Rate limiting applies uniformly to plugin routes +- Plugins should NOT be able to modify or remove core routes +- Plugin routes should be namespaced (`/api/plugins//`) to prevent collisions + +## Verdict + +**Feasibility: Easy-to-Moderate** (depends on integration target) + +The existing web server is a strong foundation. Most external integration scenarios work today or with minor core enhancements (Gaps A–D). The unique plugin infrastructure needed (Gap E: route registration) is low complexity thanks to Fastify's native plugin/prefix system. + +**Key insight**: Unlike the other plugin concepts, the External Tool Integration story is primarily about **core web server completeness**, not plugin infrastructure. The most valuable integrations (dashboards, log sync, CI triggers) need zero plugin system — they need the web server to expose more of the data Maestro already has internally. + +Plugin route registration (Gap E) becomes valuable only for **tool-specific formatting** (Obsidian markdown, Prometheus metrics, Notion OAuth) where the transformation logic shouldn't live in core Maestro. + +**Dependency ordering**: Core web server enhancements (v1) can proceed independently of the plugin system. Plugin route registration (v2) depends on the shared plugin manifest/loader from Phase 02. diff --git a/docs/research/plugin-feasibility/concept-notifications.md b/docs/research/plugin-feasibility/concept-notifications.md new file mode 100644 index 000000000..308fca73f --- /dev/null +++ b/docs/research/plugin-feasibility/concept-notifications.md @@ -0,0 +1,324 @@ +--- +type: research +title: "Plugin Concept: Third-Party Notifications" +created: 2026-02-18 +tags: + - plugin + - concept + - notifications +related: + - "[[extension-points]]" + - "[[concept-ai-auditor]]" + - "[[concept-agent-dashboard]]" + - "[[concept-agent-guardrails]]" + - "[[concept-external-integration]]" +--- + +# Plugin Concept: Third-Party Notifications + +A plugin that sends notifications to external services (Slack, Discord, email) when significant events occur: agent completion, failures, auto-run batch completion, budget thresholds, etc. This is a "fan-out" plugin — it subscribes to internal events and pushes outbound HTTP requests to third-party APIs. + +--- + +## Event Subscriptions Needed + +### Primary Events + +| Event | API | Data Available | Notification Use | +|-------|-----|---------------|-----------------| +| `process:exit` | `window.maestro.process.onExit(cb)` | `sessionId`, exit code | Agent task completion (code 0) or failure (non-zero). Core trigger for "agent finished" notifications | +| `agent:error` | `window.maestro.process.onAgentError(cb)` | `sessionId`, `AgentError { type, message, recoverable, agentId, timestamp, raw? }` | Alert on failures: auth expired, token exhaustion, rate limits, crashes. Include error type and message in notification | +| `process:usage` | `window.maestro.process.onUsage(cb)` | `sessionId`, `UsageStats { inputTokens, outputTokens, cacheRead/Creation, totalCostUsd, contextWindow, reasoningTokens? }` | Budget threshold alerts: "Agent X has spent $Y" when cumulative cost exceeds configured limit | + +### Auto-Run Batch Events + +| Event Source | Observable? | How | Notification Use | +|-------------|------------|-----|-----------------| +| Batch run completion | **Partially** | No dedicated IPC event. Renderer-side only: `batchRunStates[sessionId].isRunning` transitions `true` → `false` in Zustand store, or `processingState` transitions to `'IDLE'` | "Batch run completed: X/Y tasks done in Z minutes" | +| Batch run started | **Partially** | Zustand store: `START_BATCH` action sets `processingState: 'INITIALIZING'` | "Batch run started with X documents" | +| Batch error/pause | **Partially** | Zustand store: `SET_ERROR` action sets `processingState: 'PAUSED_ERROR'`, populates `error` field | "Batch run paused: error on task X" | +| Loop iteration | **Partially** | Zustand store: `INCREMENT_LOOP` action increments `loopIteration` | "Loop N completed, starting loop N+1" | +| Individual task progress | **Partially** | Zustand store: `UPDATE_PROGRESS` action updates `currentDocTasksCompleted` | Optional: per-task progress (likely too noisy for external notifications) | + +### Auto-Run File Changes + +| Event | API | Data Available | Notification Use | +|-------|-----|---------------|-----------------| +| `autorun:fileChanged` | `window.maestro.autorun.onFileChanged(cb)` | `{ folderPath, filename, eventType: 'rename' | 'change' }` | Optional: notify when auto-run documents are modified (useful for team awareness) | + +### Supplementary Events + +| Event | API | Notification Use | +|-------|-----|-----------------| +| `process:tool-execution` | `window.maestro.process.onToolExecution(cb)` | Optional: alert on specific tool executions (e.g., "Agent ran Bash command") — likely too noisy for most users | +| `process:data` | `window.maestro.process.onData(cb)` | Optional: forward agent output snippets — high volume, not recommended for notifications | + +--- + +## Auto-Run State Observability Assessment + +### What's Available + +The auto-run (batch run) state is managed entirely in the renderer via a Zustand store (`useBatchStore` in `src/renderer/stores/batchStore.ts`). The state model is rich: + +```typescript +interface BatchRunState { + isRunning: boolean; + isStopping: boolean; + processingState: 'IDLE' | 'INITIALIZING' | 'RUNNING' | 'STOPPING' | 'PAUSED_ERROR' | 'COMPLETING'; + documents: string[]; + currentDocumentIndex: number; + totalTasksAcrossAllDocs: number; + completedTasksAcrossAllDocs: number; + loopEnabled: boolean; + loopIteration: number; + startTime?: number; + error?: AgentError; + // ... additional fields +} +``` + +### Key Finding: No IPC-Level Batch Events + +**Batch run lifecycle events are NOT emitted via IPC.** There is no `process:batch-complete`, `autorun:started`, or similar channel. Completion is handled entirely within the renderer: + +1. The `useBatchProcessor` hook dispatches `COMPLETE_BATCH` to the Zustand reducer +2. An `onComplete` callback fires with `BatchCompleteInfo { sessionId, sessionName, completedTasks, totalTasks, wasStopped, elapsedTimeMs }` +3. `broadcastAutoRunState(sessionId, null)` is called to notify web/mobile clients +4. The built-in `window.maestro.notification.show()` is called for OS-level completion notifications + +### How a Plugin Would Observe Batch State + +A renderer-side plugin has two options: + +1. **Zustand store subscription** (preferred): `useBatchStore.subscribe(state => ...)` — receives every state update, can detect transitions (RUNNING → IDLE = completion, RUNNING → PAUSED_ERROR = failure) +2. **Polling `getActiveProcesses()`**: Less efficient, doesn't capture batch-level metadata + +For a main-process plugin, batch state is **not directly observable**. The plugin would need either: +- A new IPC channel that the renderer fires on batch state transitions (Gap #11 proposal below) +- Or the plugin runs its renderer component, which subscribes to Zustand and forwards relevant events to the main process via IPC + +--- + +## Outbound HTTP Assessment + +### Slack Webhooks + +| Aspect | Detail | +|--------|--------| +| Protocol | HTTPS POST to `https://hooks.slack.com/services/T.../B.../xxx` | +| Auth | URL contains the token (no headers needed) | +| Payload | JSON: `{ "text": "message", "blocks": [...] }` | +| CORS | **No CORS headers** — Slack webhook endpoints do not send `Access-Control-Allow-Origin` | +| Renderer feasibility | **Blocked by CORS** — `fetch()` from Electron renderer will fail for Slack webhooks | +| Main process feasibility | **Works** — Node.js `fetch()` / `https.request()` has no CORS restrictions | + +### Discord Webhooks + +| Aspect | Detail | +|--------|--------| +| Protocol | HTTPS POST to `https://discord.com/api/webhooks//` | +| Auth | URL contains the token | +| Payload | JSON: `{ "content": "message", "embeds": [...] }` | +| CORS | **No CORS headers** for webhook endpoints | +| Renderer feasibility | **Blocked by CORS** | +| Main process feasibility | **Works** | + +### Email via SMTP + +| Aspect | Detail | +|--------|--------| +| Protocol | SMTP (TCP, not HTTP) | +| Libraries | `nodemailer` or similar | +| Renderer feasibility | **Impossible** — renderer has no TCP socket access | +| Main process feasibility | **Works** — Node.js has full network access; `nodemailer` is a standard dependency | + +### Generic Webhooks (Custom URLs) + +| Aspect | Detail | +|--------|--------| +| Protocol | HTTPS POST to user-configured URL | +| CORS | **Depends on endpoint** — most internal/self-hosted endpoints don't set CORS headers | +| Renderer feasibility | **Unreliable** — works only if endpoint sends appropriate CORS headers | +| Main process feasibility | **Works** for all endpoints | + +### Summary: Main Process Required for HTTP + +| Channel | Renderer | Main Process | +|---------|----------|-------------| +| Slack webhook | CORS blocked | Works | +| Discord webhook | CORS blocked | Works | +| Email (SMTP) | Impossible | Works | +| Custom webhook | Unreliable | Works | +| OS notification | Works (existing API) | N/A | + +**Conclusion: The notification plugin MUST have a main-process component for outbound HTTP.** The renderer cannot reliably reach third-party webhook endpoints due to CORS restrictions. Electron's renderer runs in a browser-like sandbox where `fetch()` respects CORS policies. + +--- + +## Existing Notification Infrastructure + +Maestro already has a `window.maestro.notification` API (`src/main/preload/notifications.ts`): + +| Method | Purpose | Plugin Relevance | +|--------|---------|-----------------| +| `show(title, body)` | OS-native notification via `Notification` API | Could be used as a local fallback alongside external notifications | +| `speak(text, command?)` | Execute custom notification command (TTS, logging) | Extensible — a plugin could register a custom command that sends webhooks | +| `stopSpeak(notificationId)` | Stop a running notification command | Process management for long-running commands | +| `onCommandCompleted(handler)` | Subscribe to command completion | Lifecycle management | + +This API handles **local** notifications only. The plugin would extend this with **external** (Slack, Discord, email) delivery. + +--- + +## Plugin Architecture: Renderer vs Main Process + +### Verdict: Needs Both Renderer AND Main Process Components + +``` +┌─────────────────────────────────────────────────────────┐ +│ RENDERER (event observation + UI) │ +│ │ +│ ┌─────────────────────────────────────────────────┐ │ +│ │ Event Listeners │ │ +│ │ • process.onExit() → agent completion │ │ +│ │ • process.onAgentError() → failure alerts │ │ +│ │ • process.onUsage() → budget tracking │ │ +│ │ • batchStore.subscribe() → auto-run lifecycle │ │ +│ └──────────────────┬──────────────────────────────┘ │ +│ │ IPC: notifications:send │ +│ ┌──────────────────┴──────────────────────────────┐ │ +│ │ Settings UI (configuration panel) │ │ +│ │ • Webhook URLs, channel selections │ │ +│ │ • Event filter checkboxes │ │ +│ │ • Budget threshold inputs │ │ +│ └─────────────────────────────────────────────────┘ │ +└─────────────────────────────────────────────────────────┘ + │ + IPC bridge + │ +┌─────────────────────────────────────────────────────────┐ +│ MAIN PROCESS (HTTP delivery) │ +│ │ +│ ┌─────────────────────────────────────────────────┐ │ +│ │ Notification Dispatcher │ │ +│ │ • Receives structured events via IPC │ │ +│ │ • Formats messages per channel (Slack, Discord) │ │ +│ │ • Sends HTTPS POST (no CORS restrictions) │ │ +│ │ • Handles retries, rate limits, failures │ │ +│ └─────────────────────────────────────────────────┘ │ +│ │ +│ ┌─────────────────────────────────────────────────┐ │ +│ │ Config Storage │ │ +│ │ • Webhook URLs, tokens (encrypted at rest) │ │ +│ │ • Per-event notification preferences │ │ +│ │ • Delivery history (last N notifications) │ │ +│ └─────────────────────────────────────────────────┘ │ +└─────────────────────────────────────────────────────────┘ +``` + +### Why Not Renderer-Only? + +1. **CORS blocks outbound webhooks** — the primary purpose of this plugin cannot be achieved from the renderer alone +2. **Credentials should not live in renderer memory** — webhook URLs contain auth tokens; main process is more secure +3. **Retry/queue logic belongs in a persistent process** — the renderer may be in the background or have its view destroyed + +### Why Not Main-Process-Only? + +1. **Batch state is only in renderer** — the Zustand store with `BatchRunState` is not accessible from the main process. The renderer must observe it and forward events. +2. **Settings UI must render in the app** — configuration needs a React component for webhook URL input, event selection, test buttons +3. **Process events already forward to renderer** — subscribing to `process:exit`, `agent:error`, etc. is easiest from the preload API + +--- + +## Required New Infrastructure + +| Infrastructure | Needed For | Complexity | Shared With Other Plugins? | +|----------------|-----------|------------|---------------------------| +| Plugin manifest + loader | Loading the plugin | Medium | Yes — all plugins | +| Plugin UI registration | Settings panel / config component | Medium | Yes — all UI plugins | +| Sandboxed API surface | Restricting renderer access | Medium | Yes — all plugins | +| **Main-process plugin component** | Outbound HTTP dispatch | Medium | Yes — Auditor, Guardrails need this too | +| **Plugin-scoped storage API** (Gap #8) | Persisting webhook configs and delivery history | Medium | Yes — Auditor, Guardrails | +| **Plugin-to-main IPC bridge** | Renderer event listener → main process HTTP dispatch | Low | Partial — any plugin with split architecture | + +### Infrastructure NOT Required + +- No middleware/interception layer (notifications only observe, never block) +- No new process control APIs (no kill/interrupt needed) +- No modifications to the event pipeline +- No new event channels for process events (all needed events already exist) + +### New Gap Identified + +| # | Gap | Severity | Blocks | +|---|-----|----------|--------| +| 11 (proposed) | No IPC-level auto-run batch lifecycle events | Medium | Notifications (batch completion), any main-process consumer of batch state | + +Currently, batch run completion is only observable in the renderer (Zustand store). For main-process plugins to react to batch completion, either: +- (A) The renderer plugin component subscribes to Zustand and fires a plugin IPC event, or +- (B) Core Maestro emits batch lifecycle events via IPC (cleaner, benefits all main-process consumers) + +Option (A) is sufficient for v1; option (B) is the proper long-term solution. + +--- + +## Feasibility Verdict + +### Rating: **Moderate** + +The notification plugin is architecturally straightforward — all needed events exist, and the delivery mechanism (outbound HTTP) is well-understood. The complexity comes from requiring both renderer and main-process components to bridge event observation with CORS-free HTTP dispatch. + +### Comparison to Other Concepts + +| Concept | Rating | Renderer-Only? | Main-Process Needed? | New Infra | +|---------|--------|----------------|---------------------|-----------| +| [[concept-agent-dashboard|Dashboard]] | Trivial | Yes | No | Minimal | +| **Notifications** | **Moderate** | **No** | **Yes (HTTP dispatch)** | **Plugin IPC bridge, storage** | +| [[concept-ai-auditor|Auditor]] | Moderate | No | Yes (SQLite) | Storage API | +| [[concept-agent-guardrails|Guardrails]] | Moderate-Hard | No | Yes (process control) | Process control API, storage | + +Notifications is slightly easier than the Auditor because: +- No unbounded storage (delivery history can be capped or omitted) +- No pattern-matching engine (just event → message formatting) +- Outbound HTTP is simpler than SQLite schema management + +But harder than the Dashboard because: +- Requires a main-process component (CORS) +- Requires credential storage (webhook URLs) +- Requires the renderer ↔ main process bridge for batch events + +### Implementation Sketch + +A minimal Third-Party Notifications plugin would: + +1. **Renderer component** (event observer + settings UI): + - On mount: subscribe to `onExit()`, `onAgentError()`, `onUsage()`, `batchStore.subscribe()` + - Detect significant transitions (agent done, error, batch complete, budget exceeded) + - Forward structured notification payloads to main process via plugin IPC + - Settings panel: webhook URL inputs, per-event toggles, test button + +2. **Main-process component** (HTTP dispatcher): + - Receive notification payloads via IPC + - Format for target service (Slack Block Kit, Discord embeds, email HTML) + - Send HTTPS POST with retry logic (exponential backoff, 3 retries) + - Log delivery results to plugin storage + +3. **Configuration storage** (via plugin storage API): + - Webhook URLs (encrypted at rest) + - Enabled channels per event type + - Budget threshold values + - Delivery history (last 100 entries, auto-pruned) + +--- + +## Risk Assessment + +| Risk | Likelihood | Impact | Mitigation | +|------|-----------|--------|------------| +| Webhook URL contains credentials — leak risk | Medium | High | Store in main process only; never expose in renderer; use Electron's `safeStorage` API for encryption at rest | +| Notification spam from high-frequency events | Medium | Medium | Debounce/throttle: max 1 notification per event type per N seconds; batch completion summary instead of per-task | +| Slack/Discord API rate limits (1 req/sec for Slack) | Medium | Low | Queue with rate limiting; batch multiple events into single message when possible | +| Auto-run batch state not observable from main process | Certain | Medium | v1: renderer subscribes to Zustand, forwards to main; v2: add IPC batch lifecycle events to core | +| Webhook endpoint is unreachable | Medium | Low | Retry with backoff; surface delivery failures in settings UI; don't block agent operations | +| User configures webhook URL incorrectly | Medium | Low | "Test notification" button in settings; validate URL format; show last delivery status | diff --git a/docs/research/plugin-feasibility/extension-points.md b/docs/research/plugin-feasibility/extension-points.md new file mode 100644 index 000000000..773976e30 --- /dev/null +++ b/docs/research/plugin-feasibility/extension-points.md @@ -0,0 +1,351 @@ +--- +type: research +title: Existing Extension Points for Plugin System +created: 2026-02-18 +tags: + - plugin + - architecture + - feasibility +related: + - "[[concept-agent-dashboard]]" + - "[[concept-ai-auditor]]" + - "[[concept-agent-guardrails]]" + - "[[concept-notifications]]" + - "[[concept-external-integration]]" +--- + +# Existing Extension Points for Plugins + +This document catalogs all existing architectural extension points in Maestro that a plugin system could leverage. Each section maps a source file to the APIs, events, and patterns it exposes, with assessments of read/write access and plugin consumption feasibility. + +--- + +## 1. ProcessManager Events (`src/main/process-manager/types.ts`) + +The `ProcessManager` extends Node's `EventEmitter` and emits typed events defined by `ProcessManagerEvents`. These are the primary real-time data streams a plugin could subscribe to. + +### Events + +| Event | Signature | Data Exposed | Access | +|-------|-----------|-------------|--------| +| `data` | `(sessionId, data: string)` | Raw agent output text | Read-only | +| `stderr` | `(sessionId, data: string)` | Agent stderr output | Read-only | +| `exit` | `(sessionId, code: number)` | Process exit with code | Read-only | +| `command-exit` | `(sessionId, code: number)` | Shell command exit (non-PTY) | Read-only | +| `usage` | `(sessionId, stats: UsageStats)` | Token counts, cost, context window, reasoning tokens | Read-only | +| `session-id` | `(sessionId, agentSessionId: string)` | Provider session ID assignment | Read-only | +| `agent-error` | `(sessionId, error: AgentError)` | Error type, message, recoverability, raw stderr/stdout | Read-only | +| `thinking-chunk` | `(sessionId, text: string)` | Streaming partial thinking content | Read-only | +| `tool-execution` | `(sessionId, tool: ToolExecution)` | Tool name, state, timestamp | Read-only | +| `slash-commands` | `(sessionId, commands: unknown[])` | Discovered slash commands | Read-only | +| `query-complete` | `(sessionId, data: QueryCompleteData)` | Agent type, source, duration, project path, tab ID | Read-only | + +### Key Types + +- **`UsageStats`**: `inputTokens`, `outputTokens`, `cacheReadInputTokens`, `cacheCreationInputTokens`, `totalCostUsd`, `contextWindow`, `reasoningTokens?` +- **`ToolExecution`**: `toolName: string`, `state: unknown`, `timestamp: number` +- **`QueryCompleteData`**: `sessionId`, `agentType`, `source` ('user'|'auto'), `startTime`, `duration`, `projectPath?`, `tabId?` +- **`AgentError`**: `type` (AgentErrorType), `message`, `recoverable`, `agentId`, `sessionId?`, `timestamp`, `raw?` (exitCode, stderr, stdout, errorLine) + +### Plugin Consumption + +All events are **read-only observables**. A plugin could subscribe to any event via the ProcessManager's EventEmitter API in the main process, or via the forwarded IPC events in the renderer. No write access is provided through events — modifying agent behavior requires calling ProcessManager methods directly (`kill()`, `write()`, `interrupt()`). + +--- + +## 2. Process Listener Registration (`src/main/process-listeners/`) + +### Pattern + +The `setupProcessListeners()` function in `index.ts` is the single orchestration point. It takes a `ProcessManager` instance and a `ProcessListenerDependencies` object, then delegates to focused listener modules: + +| Module | Events Handled | Purpose | +|--------|---------------|---------| +| `forwarding-listeners` | `slash-commands`, `thinking-chunk`, `tool-execution`, `stderr`, `command-exit` | Forwards events to renderer via IPC | +| `data-listener` | `data` | Output with group chat buffering + web broadcast | +| `usage-listener` | `usage` | Usage stats with group chat participant/moderator updates | +| `session-id-listener` | `session-id` | Session ID with group chat storage | +| `error-listener` | `agent-error` | Error forwarding | +| `stats-listener` | `query-complete` | Stats database recording | +| `exit-listener` | `exit` | Group chat routing, recovery, synthesis | + +### `ProcessListenerDependencies` Interface + +This is the dependency injection contract. It provides access to: + +- **`getProcessManager()`** — access to ProcessManager +- **`getWebServer()`** — web server for broadcasting +- **`getAgentDetector()`** — agent type detection +- **`safeSend`** — safe IPC send to renderer +- **`powerManager`** — sleep prevention +- **`groupChatEmitters`** — group chat state/message emission +- **`groupChatRouter`** — moderator/agent response routing +- **`groupChatStorage`** — load/update group chats +- **`sessionRecovery`** — session recovery detection +- **`outputBuffer`** — group chat output buffering +- **`outputParser`** — text extraction, session ID parsing +- **`usageAggregator`** — context token calculation +- **`getStatsDB()`** — stats database +- **`debugLog()`** — debug logging +- **`patterns`** — regex patterns for session ID routing +- **`logger`** — structured logging + +### Plugin Consumption + +A plugin could register as an additional listener module by following the same pattern: a function that takes `(processManager, deps)` and attaches `.on()` handlers. However, there is no formal plugin registration mechanism — currently all listeners are hardcoded in `setupProcessListeners()`. **Gap: Need a plugin listener registration API.** + +### Group Chat Session Routing (Prior Art) + +The `GROUP_CHAT_PREFIX = 'group-chat-'` constant enables fast string-check routing of events to group chat handlers. This prefix-based routing is excellent prior art for plugin event filtering — plugins could use similar session ID prefix patterns or explicit filter registration. + +--- + +## 3. Preload API Surface (`src/main/preload/index.ts`) + +The preload script exposes `window.maestro.*` namespaces via Electron's `contextBridge`. This is the renderer-side API surface. There are **35 namespaces** total: + +### Namespace Catalog + +| Namespace | Source | Description | Access | +|-----------|--------|-------------|--------| +| `settings` | `settings.ts` | Read/write app settings | Read-write | +| `sessions` | `settings.ts` | Session persistence (load/save) | Read-write | +| `groups` | `settings.ts` | Group persistence | Read-write | +| `process` | `process.ts` | Spawn, write, kill, events | Read-write | +| `agentError` | `settings.ts` | Error state management | Read-write | +| `context` | `context.ts` | Context merge operations | Read-write | +| `web` | `web.ts` | Web interface management | Read-write | +| `git` | `git.ts` | Git operations (status, diff, log, worktrees) | Read-write | +| `fs` | `fs.ts` | Filesystem operations | Read-write | +| `webserver` | `web.ts` | Web server lifecycle | Read-write | +| `live` | `web.ts` | Live session management | Read-write | +| `agents` | `agents.ts` | Agent detection, config | Read (mostly) | +| `dialog` | `system.ts` | OS file/folder dialogs | Read-write | +| `fonts` | `system.ts` | System font listing | Read-only | +| `shells` | `system.ts` | Terminal shell detection | Read-only | +| `shell` | `system.ts` | Open URLs/paths externally | Write-only | +| `tunnel` | `system.ts` | Cloudflare tunnel management | Read-write | +| `sshRemote` | `sshRemote.ts` | SSH remote configuration | Read-write | +| `sync` | `system.ts` | Settings sync (import/export) | Read-write | +| `devtools` | `system.ts` | DevTools toggle | Write-only | +| `power` | `system.ts` | Sleep prevention | Read-write | +| `updates` | `system.ts` | App update management | Read-write | +| `logger` | `logger.ts` | Structured logging | Write-only | +| `claude` | `sessions.ts` | Claude Code sessions (DEPRECATED) | Read-write | +| `agentSessions` | `sessions.ts` | Agent session management | Read-write | +| `tempfile` | `files.ts` | Temp file operations | Read-write | +| `history` | `files.ts` | History entries | Read-write | +| `cli` | `files.ts` | CLI activity tracking | Read-only | +| `speckit` | `commands.ts` | Spec Kit commands | Read-write | +| `openspec` | `commands.ts` | OpenSpec commands | Read-write | +| `notification` | `notifications.ts` | OS notifications, TTS | Write-only | +| `attachments` | `attachments.ts` | Image/file attachments | Read-write | +| `autorun` | `autorun.ts` | Auto Run document management | Read-write | +| `playbooks` | `autorun.ts` | Playbook management | Read-write | +| `marketplace` | `autorun.ts` | Playbook Exchange | Read-only | +| `debug` | `debug.ts` | Debug package generation | Read-write | +| `documentGraph` | `debug.ts` | File watching, document graph | Read-write | +| `groupChat` | `groupChat.ts` | Group chat management | Read-write | +| `app` | `system.ts` | App lifecycle (quit, relaunch) | Write-only | +| `stats` | `stats.ts` | Usage analytics | Read-write | +| `leaderboard` | `leaderboard.ts` | Leaderboard registration | Read-write | +| `symphony` | `symphony.ts` | Token donations / OSS contributions | Read-write | +| `tabNaming` | `tabNaming.ts` | Auto tab name generation | Read-write | +| `directorNotes` | `directorNotes.ts` | Unified history + synopsis | Read-write | +| `wakatime` | `wakatime.ts` | WakaTime integration | Read-only | + +### Plugin Consumption + +These APIs are available to any code running in the renderer process. A renderer-side plugin could call these directly. However, there is **no sandboxing** — a plugin would have full access to all namespaces. **Gap: Need a scoped/sandboxed API surface for plugins that limits access to approved namespaces.** + +--- + +## 4. IPC Handler Registration (`src/main/ipc/handlers/index.ts`) + +### Pattern + +`registerAllHandlers(deps: HandlerDependencies)` is called once during app initialization. It registers ~25 handler modules, each setting up `ipcMain.handle()` calls for their domain. + +### `HandlerDependencies` Interface + +Provides access to core singletons: + +- `mainWindow` / `getMainWindow()` — BrowserWindow reference +- `app` — Electron App instance +- `getAgentDetector()` — agent detection +- `agentConfigsStore` — agent configuration persistence +- `getProcessManager()` — process management +- `settingsStore` — app settings +- `sessionsStore` / `groupsStore` — session/group persistence +- `getWebServer()` — web server reference +- `tunnelManager` — Cloudflare tunnels +- `claudeSessionOriginsStore` — session origin tracking + +### Registered Handler Modules + +Git, Autorun, Playbooks, History, Agents, Process, Persistence, System, Claude, AgentSessions, GroupChat, Debug, Speckit, OpenSpec, Context, Marketplace, Stats, DocumentGraph, SshRemote, Filesystem, Attachments, Web, Leaderboard, Notifications, Symphony, AgentError, TabNaming, DirectorNotes, Wakatime. + +### Plugin Consumption + +A plugin could register new IPC handlers following the same pattern: export a `registerXxxHandlers(deps)` function and call `ipcMain.handle()`. However, there is **no dynamic handler registration** — all handlers are registered at startup. **Gap: Need a runtime handler registration mechanism for plugins, or a plugin-specific IPC namespace.** + +--- + +## 5. Layer Stack / Modal Priority System (`src/renderer/constants/modalPriorities.ts`) + +### Pattern + +Modals and overlays use numeric priorities to determine stacking order and Escape key handling. The layer stack system ensures only the topmost layer handles Escape. + +### Priority Ranges + +| Range | Purpose | Examples | +|-------|---------|---------| +| 1000+ | Critical modals | Quit confirm (1020), Agent error (1010), Tour (1050) | +| 900–999 | High priority | Rename instance (900), Gist publish (980) | +| 700–899 | Standard modals | New instance (750), Quick actions (700), Batch runner (720) | +| 400–699 | Settings/info | Settings (450), Usage dashboard (540), About (600) | +| 100–399 | Overlays/previews | File preview (100), Git diff (200) | +| 1–99 | Autocomplete | Slash autocomplete (50), File tree filter (30) | + +### Plugin Consumption + +A plugin could register a modal or panel with its own priority value. The system is purely convention-based (numeric constants) — there's no enforcement mechanism. **Gap: Need a reserved priority range for plugin modals (e.g., 300–399) and a registration API so plugins don't collide with core priorities.** + +--- + +## 6. Right Panel Tab System (`src/renderer/types/index.ts`) + +### Current Definition + +```typescript +export type RightPanelTab = 'files' | 'history' | 'autorun'; +``` + +This is a string literal union type controlling which tab is active in the Right Bar. + +### Plugin Consumption + +Adding a plugin-provided tab would require extending this union type at runtime, which TypeScript's type system doesn't support dynamically. **Gap: Need to refactor `RightPanelTab` to allow dynamic registration of custom tabs (e.g., string-based with a registry, or a union with a `plugin:${string}` pattern).** + +--- + +## 7. Marketplace Manifest Structure (`src/shared/marketplace-types.ts`) + +### Prior Art for Plugin Manifests + +The `MarketplacePlaybook` interface is excellent prior art for a plugin manifest: + +| Field | Type | Plugin Analog | +|-------|------|---------------| +| `id` | `string` | Plugin slug ID | +| `title` | `string` | Display name | +| `description` | `string` | Short description | +| `category` | `string` | Plugin category | +| `author` | `string` | Plugin author | +| `authorLink?` | `string` | Author URL | +| `tags?` | `string[]` | Searchable tags | +| `lastUpdated` | `string` | Version date | +| `path` | `string` | Entry point / folder path | +| `documents` | `MarketplaceDocument[]` | Plugin files / components | +| `source?` | `PlaybookSource` | 'official' or 'local' | + +### Existing Infrastructure + +- **Manifest fetching** from GitHub (`https://raw.githubusercontent.com/...`) +- **Local cache** (`userData/marketplace-cache.json`) +- **Error handling** with typed errors: `MarketplaceFetchError`, `MarketplaceCacheError`, `MarketplaceImportError` +- **API response types** for manifest retrieval, document fetching, and import operations + +### Plugin Consumption + +A plugin manifest system could reuse much of this infrastructure: GitHub-hosted registry, local cache, import/install flow. The `MarketplaceManifest` structure maps well to a `PluginRegistry` structure. **Gap: Need a `PluginManifest` type with additional fields for permissions, entry points (main/renderer), API version requirements, and dependency declarations.** + +--- + +## 8. Stats Subscription API (`src/main/preload/stats.ts`) + +### API Methods + +| Method | Parameters | Returns | Access | +|--------|-----------|---------|--------| +| `recordQuery` | `QueryEvent` | `Promise` (ID) | Write | +| `startAutoRun` | `AutoRunSession` | `Promise` (ID) | Write | +| `endAutoRun` | `id, duration, tasksCompleted` | `Promise` | Write | +| `recordAutoTask` | `AutoRunTask` | `Promise` (ID) | Write | +| `getStats` | `range, filters?` | `Promise>` | Read | +| `getAutoRunSessions` | `range` | `Promise>` | Read | +| `getAutoRunTasks` | `autoRunSessionId` | `Promise>` | Read | +| `getAggregation` | `range` | `Promise` | Read | +| `exportCsv` | `range` | `Promise` | Read | +| `onStatsUpdate` | `callback` | Unsubscribe function | Read (subscription) | +| `clearOldData` | `olderThanDays` | `Promise` | Write | +| `getDatabaseSize` | — | `Promise` | Read | +| `getEarliestTimestamp` | — | `Promise` | Read | +| `recordSessionCreated` | `SessionCreatedEvent` | `Promise` | Write | +| `recordSessionClosed` | `sessionId, closedAt` | `Promise` | Write | +| `getSessionLifecycle` | `range` | `Promise>` | Read | + +### Key Types + +- **`StatsAggregation`**: `totalQueries`, `totalDuration`, `avgDuration`, `byAgent` (per-agent count/duration), `bySource` (user/auto counts), `byDay` (date/count/duration arrays) +- **`QueryEvent`**: `sessionId`, `agentType`, `source`, `startTime`, `duration`, `projectPath?`, `tabId?` + +### Plugin Consumption + +The stats API is well-suited for dashboard plugins. Read methods allow querying historical data with time range filters. `onStatsUpdate` enables real-time refresh. Write methods could let plugins record custom metrics. **Note: the stats database (SQLite via `stats-db.ts`) is not directly accessible from the renderer — all access goes through IPC handlers.** + +--- + +## 9. Process API (`src/main/preload/process.ts`) + +### Commands (Write) + +| Method | Description | +|--------|-------------| +| `spawn(config)` | Spawn a new agent or terminal process | +| `write(sessionId, data)` | Write to process stdin | +| `interrupt(sessionId)` | Send Ctrl+C (SIGINT) | +| `kill(sessionId)` | Kill a process | +| `resize(sessionId, cols, rows)` | Resize terminal | +| `runCommand(config)` | Run a single shell command | +| `getActiveProcesses()` | List active processes | + +### Event Subscriptions (Read) + +| Event | Callback Signature | +|-------|-------------------| +| `onData` | `(sessionId, data: string)` | +| `onExit` | `(sessionId, code: number)` | +| `onSessionId` | `(sessionId, agentSessionId: string)` | +| `onSlashCommands` | `(sessionId, commands: string[])` | +| `onThinkingChunk` | `(sessionId, content: string)` | +| `onToolExecution` | `(sessionId, toolEvent: ToolExecutionEvent)` | +| `onSshRemote` | `(sessionId, sshRemote: SshRemoteInfo \| null)` | +| `onUsage` | `(sessionId, usageStats: UsageStats)` | +| `onAgentError` | `(sessionId, error: AgentError)` | +| `onStderr` | `(sessionId, data: string)` | +| `onCommandExit` | `(sessionId, code: number)` | + +Plus remote-command event subscriptions for the web interface (`onRemoteCommand`, `onRemoteSwitchMode`, `onRemoteInterrupt`, `onRemoteSelectSession`, `onRemoteSelectTab`, `onRemoteNewTab`, `onRemoteCloseTab`, `onRemoteRenameTab`). + +### Plugin Consumption + +This is the most plugin-relevant API surface. A renderer-side plugin can subscribe to all events for real-time monitoring (dashboard, auditor) and invoke `kill()` / `interrupt()` for guardrails. The `getActiveProcesses()` method provides a snapshot of all running processes. **Gap: No session-scoped filtering in subscriptions — plugins receive all events for all sessions and must filter themselves.** + +--- + +## Summary of Identified Gaps + +| # | Gap | Severity | Blocks | +|---|-----|----------|--------| +| 1 | No plugin listener registration API (main process) | High | Guardrails, Auditor | +| 2 | No sandboxed/scoped renderer API surface | High | All renderer plugins | +| 3 | No runtime IPC handler registration for plugins | Medium | External Integration | +| 4 | No reserved modal priority range for plugins | Low | Dashboard (if modal) | +| 5 | Static `RightPanelTab` union — no dynamic tab registration | Medium | Dashboard | +| 6 | No `PluginManifest` type (permissions, entry points, API version) | High | All plugins | +| 7 | No session-scoped event filtering in process subscriptions | Low | Performance optimization | +| 8 | No plugin-scoped storage API | Medium | Auditor, Guardrails | +| 9 | No middleware/interception layer in ProcessManager event chain | High | Guardrails (approach A) | +| 10 | No plugin UI registration system (panels, tabs, widgets) | High | Dashboard, any UI plugin | diff --git a/package-lock.json b/package-lock.json index 62cc894d0..7f6c48fb4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -263,7 +263,6 @@ "integrity": "sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@babel/code-frame": "^7.27.1", "@babel/generator": "^7.28.5", @@ -667,7 +666,6 @@ } ], "license": "MIT", - "peer": true, "engines": { "node": ">=18" }, @@ -711,7 +709,6 @@ } ], "license": "MIT", - "peer": true, "engines": { "node": ">=18" } @@ -2285,7 +2282,6 @@ "resolved": "https://registry.npmjs.org/@opentelemetry/api/-/api-1.9.0.tgz", "integrity": "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg==", "license": "Apache-2.0", - "peer": true, "engines": { "node": ">=8.0.0" } @@ -2307,7 +2303,6 @@ "resolved": "https://registry.npmjs.org/@opentelemetry/context-async-hooks/-/context-async-hooks-2.2.0.tgz", "integrity": "sha512-qRkLWiUEZNAmYapZ7KGS5C4OmBLcP/H2foXeOEaowYCR0wi89fHejrfYfbuLVCMLp/dWZXKvQusdbUEZjERfwQ==", "license": "Apache-2.0", - "peer": true, "engines": { "node": "^18.19.0 || >=20.6.0" }, @@ -2320,7 +2315,6 @@ "resolved": "https://registry.npmjs.org/@opentelemetry/core/-/core-2.2.0.tgz", "integrity": "sha512-FuabnnUm8LflnieVxs6eP7Z383hgQU4W1e3KJS6aOG3RxWxcHyBxH8fDMHNgu/gFx/M2jvTOW/4/PHhLz6bjWw==", "license": "Apache-2.0", - "peer": true, "dependencies": { "@opentelemetry/semantic-conventions": "^1.29.0" }, @@ -2336,7 +2330,6 @@ "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation/-/instrumentation-0.208.0.tgz", "integrity": "sha512-Eju0L4qWcQS+oXxi6pgh7zvE2byogAkcsVv0OjHF/97iOz1N/aKE6etSGowYkie+YA1uo6DNwdSxaaNnLvcRlA==", "license": "Apache-2.0", - "peer": true, "dependencies": { "@opentelemetry/api-logs": "0.208.0", "import-in-the-middle": "^2.0.0", @@ -2724,7 +2717,6 @@ "resolved": "https://registry.npmjs.org/@opentelemetry/resources/-/resources-2.2.0.tgz", "integrity": "sha512-1pNQf/JazQTMA0BiO5NINUzH0cbLbbl7mntLa4aJNmCCXSj0q03T5ZXXL0zw4G55TjdL9Tz32cznGClf+8zr5A==", "license": "Apache-2.0", - "peer": true, "dependencies": { "@opentelemetry/core": "2.2.0", "@opentelemetry/semantic-conventions": "^1.29.0" @@ -2741,7 +2733,6 @@ "resolved": "https://registry.npmjs.org/@opentelemetry/sdk-trace-base/-/sdk-trace-base-2.2.0.tgz", "integrity": "sha512-xWQgL0Bmctsalg6PaXExmzdedSp3gyKV8mQBwK/j9VGdCDu2fmXIb2gAehBKbkXCpJ4HPkgv3QfoJWRT4dHWbw==", "license": "Apache-2.0", - "peer": true, "dependencies": { "@opentelemetry/core": "2.2.0", "@opentelemetry/resources": "2.2.0", @@ -2759,7 +2750,6 @@ "resolved": "https://registry.npmjs.org/@opentelemetry/semantic-conventions/-/semantic-conventions-1.38.0.tgz", "integrity": "sha512-kocjix+/sSggfJhwXqClZ3i9Y/MI0fp7b+g7kCRm6psy2dsf8uApTRclwG18h8Avm7C9+fnt+O36PspJ/OzoWg==", "license": "Apache-2.0", - "peer": true, "engines": { "node": ">=14" } @@ -3818,7 +3808,8 @@ "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz", "integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==", "dev": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/@types/babel__core": { "version": "7.20.5", @@ -4356,7 +4347,6 @@ "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.27.tgz", "integrity": "sha512-cisd7gxkzjBKU2GgdYrTdtQx1SORymWyaAFhaxQPK9bYO9ot3Y5OikQRvY0VYQtvwjeQnizCINJAenh/V7MK2w==", "license": "MIT", - "peer": true, "dependencies": { "@types/prop-types": "*", "csstype": "^3.2.2" @@ -4368,7 +4358,6 @@ "integrity": "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==", "dev": true, "license": "MIT", - "peer": true, "peerDependencies": { "@types/react": "^18.0.0" } @@ -4494,7 +4483,6 @@ "integrity": "sha512-hM5faZwg7aVNa819m/5r7D0h0c9yC4DUlWAOvHAtISdFTc8xB86VmX5Xqabrama3wIPJ/q9RbGS1worb6JfnMg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.50.1", "@typescript-eslint/types": "8.50.1", @@ -4925,7 +4913,6 @@ "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "license": "MIT", - "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -5007,7 +4994,6 @@ "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", @@ -6011,7 +5997,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "baseline-browser-mapping": "^2.8.25", "caniuse-lite": "^1.0.30001754", @@ -6494,7 +6479,6 @@ "resolved": "https://registry.npmjs.org/chevrotain/-/chevrotain-11.0.3.tgz", "integrity": "sha512-ci2iJH6LeIkvP9eJW6gpueU8cnZhv85ELY8w8WiFtNjMHA5ad6pQLaJo9mEly/9qUyCpvqX8/POVUTf18/HFdw==", "license": "Apache-2.0", - "peer": true, "dependencies": { "@chevrotain/cst-dts-gen": "11.0.3", "@chevrotain/gast": "11.0.3", @@ -7220,7 +7204,6 @@ "resolved": "https://registry.npmjs.org/cytoscape/-/cytoscape-3.33.1.tgz", "integrity": "sha512-iJc4TwyANnOGR1OmWhsS9ayRS3s+XQ185FmuHObThD+5AeJCakAAbWv8KimMTt08xCCLNgneQwFp+JRJOr9qGQ==", "license": "MIT", - "peer": true, "engines": { "node": ">=0.10" } @@ -7630,7 +7613,6 @@ "resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz", "integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==", "license": "ISC", - "peer": true, "engines": { "node": ">=12" } @@ -8128,7 +8110,6 @@ "integrity": "sha512-rcJUkMfnJpfCboZoOOPf4L29TRtEieHNOeAbYPWPxlaBw/Z1RKrRA86dOI9rwaI4tQSc/RD82zTNHprfUHXsoQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "app-builder-lib": "24.13.3", "builder-util": "24.13.1", @@ -8224,7 +8205,8 @@ "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz", "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==", "dev": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/dompurify": { "version": "3.3.0", @@ -8368,6 +8350,7 @@ "integrity": "sha512-oHkV0iogWfyK+ah9ZIvMDpei1m9ZRpdXcvde1wTpra2U8AFDNNpqJdnin5z+PM1GbQ5BoaKCWas2HSjtR0HwMg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "app-builder-lib": "24.13.3", "archiver": "^5.3.1", @@ -8381,6 +8364,7 @@ "integrity": "sha512-+25nxyyznAXF7Nef3y0EbBeqmGZgeN/BxHX29Rs39djAfaFalmQ89SE6CWyDCHzGL0yt/ycBtNOmGTW0FyGWNw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "archiver-utils": "^2.1.0", "async": "^3.2.4", @@ -8400,6 +8384,7 @@ "integrity": "sha512-bEL/yUb/fNNiNTuUz979Z0Yg5L+LzLxGJz8x79lYmR54fmTIb6ob/hNQgkQnIUDWIFjZVQwl9Xs356I6BAMHfw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "glob": "^7.1.4", "graceful-fs": "^4.2.0", @@ -8422,6 +8407,7 @@ "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "core-util-is": "~1.0.0", "inherits": "~2.0.3", @@ -8438,6 +8424,7 @@ "integrity": "sha512-D3uMHtGc/fcO1Gt1/L7i1e33VOvD4A9hfQLP+6ewd+BvG/gQ84Yh4oftEhAdjSMgBgwGL+jsppT7JYNpo6MHHg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "buffer-crc32": "^0.2.13", "crc32-stream": "^4.0.2", @@ -8454,6 +8441,7 @@ "integrity": "sha512-NT7w2JVU7DFroFdYkeq8cywxrgjPHWkdX1wjpRQXPX5Asews3tA+Ght6lddQO5Mkumffp3X7GEqku3epj2toIw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "crc-32": "^1.2.0", "readable-stream": "^3.4.0" @@ -8468,6 +8456,7 @@ "integrity": "sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "graceful-fs": "^4.2.0", "jsonfile": "^6.0.1", @@ -8483,6 +8472,7 @@ "integrity": "sha512-FGuPw30AdOIUTRMC2OMRtQV+jkVj2cfPqSeWXv1NEAJ1qZ5zb1X6z1mFhbfOB/iy3ssJCD+3KuZ8r8C3uVFlAg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "universalify": "^2.0.0" }, @@ -8495,7 +8485,8 @@ "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", "dev": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/electron-builder-squirrel-windows/node_modules/string_decoder": { "version": "1.1.1", @@ -8503,6 +8494,7 @@ "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "safe-buffer": "~5.1.0" } @@ -8513,6 +8505,7 @@ "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">= 10.0.0" } @@ -8523,6 +8516,7 @@ "integrity": "sha512-9qv4rlDiopXg4E69k+vMHjNN63YFMe9sZMrdlvKnCjlCRWeCBswPPMPUfx+ipsAWq1LXHe70RcbaHdJJpS6hyQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "archiver-utils": "^3.0.4", "compress-commons": "^4.1.2", @@ -8538,6 +8532,7 @@ "integrity": "sha512-KVgf4XQVrTjhyWmx6cte4RxonPLR9onExufI1jhvw/MQ4BB6IsZD5gT8Lq+u/+pRkWna/6JoHpiQioaqFP5Rzw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "glob": "^7.2.3", "graceful-fs": "^4.2.0", @@ -9219,7 +9214,6 @@ "integrity": "sha512-LEyamqS7W5HB3ujJyvi0HQK/dtVINZvd5mAAp9eT5S/ujByGjiZLCzPcHVzuXbpJDJF/cxwHlfceVUDZ2lnSTw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -11123,7 +11117,6 @@ "resolved": "https://registry.npmjs.org/immer/-/immer-10.2.0.tgz", "integrity": "sha512-d/+XTN3zfODyjr89gM3mPq1WNX2B8pYsu7eORitdwyA2sBubnTl3laYlBk4sXY5FUa5qTZGBDPJICVbvqzjlbw==", "license": "MIT", - "peer": true, "funding": { "type": "opencollective", "url": "https://opencollective.com/immer" @@ -11944,7 +11937,6 @@ "integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==", "dev": true, "license": "MIT", - "peer": true, "bin": { "jiti": "bin/jiti.js" } @@ -12414,14 +12406,16 @@ "resolved": "https://registry.npmjs.org/lodash.defaults/-/lodash.defaults-4.2.0.tgz", "integrity": "sha512-qjxPLHd3r5DnsdGacqOMU6pb/avJzdh9tFX2ymgoZE27BmjXrNy/y4LoaiTeAb+O3gL8AfpJGtqfX/ae2leYYQ==", "dev": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/lodash.difference": { "version": "4.5.0", "resolved": "https://registry.npmjs.org/lodash.difference/-/lodash.difference-4.5.0.tgz", "integrity": "sha512-dS2j+W26TQ7taQBGN8Lbbq04ssV3emRw4NY58WErlTO29pIqS0HmoT5aJ9+TUQ1N3G+JOZSji4eugsWwGp9yPA==", "dev": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/lodash.escaperegexp": { "version": "4.1.2", @@ -12434,7 +12428,8 @@ "resolved": "https://registry.npmjs.org/lodash.flatten/-/lodash.flatten-4.4.0.tgz", "integrity": "sha512-C5N2Z3DgnnKr0LOpv/hKCgKdb7ZZwafIrsesve6lmzvZIRZRGaZ/l6Q8+2W7NaT+ZwO3fFlSCzCzrDCFdJfZ4g==", "dev": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/lodash.isequal": { "version": "4.5.0", @@ -12448,7 +12443,8 @@ "resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz", "integrity": "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==", "dev": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/lodash.merge": { "version": "4.6.2", @@ -12462,7 +12458,8 @@ "resolved": "https://registry.npmjs.org/lodash.union/-/lodash.union-4.6.0.tgz", "integrity": "sha512-c4pB2CdGrGdjMKYLA+XiRDO7Y0PRQbm/Gzg8qMj+QH+pFVAoTp5sBpO0odL3FjoPCGjK96p6qsP+yQoiLoOBcw==", "dev": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/log-symbols": { "version": "4.1.0", @@ -12553,6 +12550,7 @@ "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==", "dev": true, "license": "MIT", + "peer": true, "bin": { "lz-string": "bin/bin.js" } @@ -15050,7 +15048,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", @@ -15291,6 +15288,7 @@ "integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "ansi-regex": "^5.0.1", "ansi-styles": "^5.0.0", @@ -15306,6 +15304,7 @@ "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=10" }, @@ -15650,7 +15649,6 @@ "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", "license": "MIT", - "peer": true, "dependencies": { "loose-envify": "^1.1.0" }, @@ -15680,7 +15678,6 @@ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", "license": "MIT", - "peer": true, "dependencies": { "loose-envify": "^1.1.0", "scheduler": "^0.23.2" @@ -15728,7 +15725,6 @@ "resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.2.0.tgz", "integrity": "sha512-ROY9fvHhwOD9ySfrF0wmvu//bKCQ6AeZZq1nJNtbDC+kk5DuSuNX/n6YWYF/SYy7bSba4D4FSz8DJeKY/S/r+g==", "license": "MIT", - "peer": true, "dependencies": { "@types/use-sync-external-store": "^0.0.6", "use-sync-external-store": "^1.4.0" @@ -15915,8 +15911,7 @@ "version": "5.0.1", "resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz", "integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/redux-thunk": { "version": "3.1.0", @@ -17673,7 +17668,6 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -17984,7 +17978,6 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "dev": true, "license": "Apache-2.0", - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -18358,7 +18351,6 @@ "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "esbuild": "^0.21.3", "postcss": "^8.4.43", @@ -18864,7 +18856,6 @@ "integrity": "sha512-n1RxDp8UJm6N0IbJLQo+yzLZ2sQCDyl1o0LeugbPWf8+8Fttp29GghsQBjYJVmWq3gBFfe9Hs1spR44vovn2wA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@vitest/expect": "4.0.15", "@vitest/mocker": "4.0.15", @@ -19455,7 +19446,6 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -19469,7 +19459,6 @@ "integrity": "sha512-tI2l/nFHC5rLh7+5+o7QjKjSR04ivXDF4jcgV0f/bTQ+OJiITy5S6gaynVsEM+7RqzufMnVbIon6Sr5x1SDYaQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.5.0", @@ -20067,7 +20056,6 @@ "integrity": "sha512-0wZ1IRqGGhMP76gLqz8EyfBXKk0J2qo2+H3fi4mcUP/KtTocoX08nmIAHl1Z2kJIZbZee8KOpBCSNPRgauucjw==", "dev": true, "license": "MIT", - "peer": true, "funding": { "url": "https://github.com/sponsors/colinhacks" } diff --git a/package.json b/package.json index 467788a3e..e2a306bae 100644 --- a/package.json +++ b/package.json @@ -19,14 +19,15 @@ "dev": "concurrently \"npm run dev:main\" \"npm run dev:renderer\"", "dev:prod-data": "USE_PROD_DATA=1 concurrently \"npm run dev:main:prod-data\" \"npm run dev:renderer\"", "dev:demo": "MAESTRO_DEMO_DIR=/tmp/maestro-demo npm run dev", - "dev:main": "npm run build:prompts && tsc -p tsconfig.main.json && npm run build:preload && NODE_ENV=development electron .", - "dev:main:prod-data": "npm run build:prompts && tsc -p tsconfig.main.json && npm run build:preload && NODE_ENV=development USE_PROD_DATA=1 electron .", + "dev:main": "npm run build:prompts && tsc -p tsconfig.main.json && npm run build:encores && npm run build:preload && NODE_ENV=development electron .", + "dev:main:prod-data": "npm run build:prompts && tsc -p tsconfig.main.json && npm run build:encores && npm run build:preload && NODE_ENV=development USE_PROD_DATA=1 electron .", "dev:renderer": "vite", "dev:web": "vite --config vite.config.web.mts", "dev:win": "powershell -NoProfile -ExecutionPolicy Bypass -File ./scripts/start-dev.ps1", "build": "npm run build:prompts && npm run build:main && npm run build:preload && npm run build:renderer && npm run build:web && npm run build:cli", "build:prompts": "node scripts/generate-prompts.mjs", - "build:main": "tsc -p tsconfig.main.json", + "build:main": "tsc -p tsconfig.main.json && npm run build:encores", + "build:encores": "node -e \"const fs=require('fs');fs.cpSync('src/encores','dist/encores',{recursive:true})\"", "build:preload": "node scripts/build-preload.mjs", "build:cli": "node scripts/build-cli.mjs", "build:renderer": "vite build", diff --git a/scripts/start-dev.ps1 b/scripts/start-dev.ps1 index 6e0792eca..cf342a985 100644 --- a/scripts/start-dev.ps1 +++ b/scripts/start-dev.ps1 @@ -15,7 +15,7 @@ Start-Process powershell -ArgumentList '-NoExit', '-Command', $cmdRenderer Write-Host "Waiting for renderer dev server to start..." -ForegroundColor Yellow Start-Sleep -Seconds 5 -$cmdBuild = "Set-Location -LiteralPath '$repoRootEscaped'; npm run build:prompts; npx tsc -p tsconfig.main.json; npm run build:preload; `$env:NODE_ENV='development'; npx electron ." +$cmdBuild = "Set-Location -LiteralPath '$repoRootEscaped'; npm run build:prompts; npx tsc -p tsconfig.main.json; npm run build:encores; npm run build:preload; `$env:NODE_ENV='development'; npx electron ." Start-Process powershell -ArgumentList '-NoExit', '-Command', $cmdBuild Write-Host "Launched renderer and main developer windows." -ForegroundColor Green diff --git a/src/__tests__/main/encore-activation.test.ts b/src/__tests__/main/encore-activation.test.ts new file mode 100644 index 000000000..c8cce966c --- /dev/null +++ b/src/__tests__/main/encore-activation.test.ts @@ -0,0 +1,433 @@ +/** + * Tests for Main-Process Plugin Activation, Storage, and IPC Bridge + * + * Covers: + * - activateEncore() calls module's activate(api) + * - deactivateEncore() calls deactivate() and cleans up + * - Plugin that throws during activation gets state 'error' + * - Plugin that throws during deactivation is logged but doesn't propagate + * - EncoreStorage read/write/list/delete operations + * - EncoreStorage path traversal prevention + * - IPC bridge routes messages to correct encore + * - unregisterAll() removes all channels for an encore + */ + +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import path from 'path'; +import type { LoadedEncore } from '../../shared/encore-types'; + +// Mock electron +vi.mock('electron', () => ({ + ipcMain: { handle: vi.fn() }, + app: { getPath: vi.fn(() => '/mock/userData'), getVersion: vi.fn(() => '1.0.0') }, + Notification: vi.fn().mockImplementation(() => ({ + show: vi.fn(), + })), +})); + +// Mock logger +vi.mock('../../main/utils/logger', () => ({ + logger: { + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + debug: vi.fn(), + }, +})); + +// Mock sentry +const mockCaptureException = vi.fn(); +vi.mock('../../main/utils/sentry', () => ({ + captureException: (...args: unknown[]) => mockCaptureException(...args), +})); + +// Mock fs/promises +const mockReadFile = vi.fn(); +const mockWriteFile = vi.fn(); +const mockReaddir = vi.fn(); +const mockMkdir = vi.fn(); +const mockUnlink = vi.fn(); +const mockAccess = vi.fn(); + +vi.mock('fs/promises', () => ({ + default: { + readFile: (...args: unknown[]) => mockReadFile(...args), + writeFile: (...args: unknown[]) => mockWriteFile(...args), + readdir: (...args: unknown[]) => mockReaddir(...args), + mkdir: (...args: unknown[]) => mockMkdir(...args), + unlink: (...args: unknown[]) => mockUnlink(...args), + access: (...args: unknown[]) => mockAccess(...args), + }, + readFile: (...args: unknown[]) => mockReadFile(...args), + writeFile: (...args: unknown[]) => mockWriteFile(...args), + readdir: (...args: unknown[]) => mockReaddir(...args), + mkdir: (...args: unknown[]) => mockMkdir(...args), + unlink: (...args: unknown[]) => mockUnlink(...args), + access: (...args: unknown[]) => mockAccess(...args), +})); + +// Mock stats/singleton +vi.mock('../../main/stats/singleton', () => ({ + getStatsDB: vi.fn(() => ({ + getAggregation: vi.fn().mockResolvedValue({ totalQueries: 42 }), + })), +})); + +// Track require calls for encore modules +const mockEncoreModules: Record = {}; +vi.mock('../../main/encore-storage', async () => { + const actual = await vi.importActual('../../main/encore-storage'); + return actual; +}); + +import { EncoreHost, type EncoreHostDependencies } from '../../main/encore-host'; +import { EncoreStorage } from '../../main/encore-storage'; +import { EncoreIpcBridge } from '../../main/encore-ipc-bridge'; +import { logger } from '../../main/utils/logger'; + +/** + * Helper to create a LoadedEncore for testing. + */ +function makeEncore(overrides: Partial & { permissions?: string[] } = {}): LoadedEncore { + const { permissions, ...rest } = overrides; + return { + manifest: { + id: 'test-encore', + name: 'Test Plugin', + version: '1.0.0', + description: 'A test encore', + author: 'Test Author', + main: 'index.js', + permissions: (permissions ?? ['storage']) as any, + }, + state: 'discovered', + path: '/mock/encores/test-encore', + ...rest, + }; +} + +/** + * Helper to create mock dependencies. + */ +function makeDeps(overrides: Partial = {}): EncoreHostDependencies { + const mockProcessManager = { + getAll: vi.fn(() => []), + kill: vi.fn(() => true), + write: vi.fn(() => true), + on: vi.fn(() => mockProcessManager), + removeListener: vi.fn(() => mockProcessManager), + }; + + const storeData: Record = {}; + const mockSettingsStore = { + get: vi.fn((key: string) => storeData[key]), + set: vi.fn((key: string, value: unknown) => { + storeData[key] = value; + }), + store: storeData, + }; + + const mockApp = { + getPath: vi.fn(() => '/mock/userData'), + getVersion: vi.fn(() => '2.0.0'), + }; + + return { + getProcessManager: () => mockProcessManager as any, + getMainWindow: () => null, + settingsStore: mockSettingsStore as any, + app: mockApp as any, + ...overrides, + }; +} + +// ============================================================================ +// Plugin Activation Tests +// ============================================================================ + +describe('EncoreHost activation', () => { + let host: EncoreHost; + let deps: EncoreHostDependencies; + + beforeEach(() => { + vi.clearAllMocks(); + // Clear the require cache for mock encore modules + for (const key of Object.keys(mockEncoreModules)) { + delete mockEncoreModules[key]; + } + deps = makeDeps(); + host = new EncoreHost(deps); + }); + + it('sets state to error when entry point does not exist', async () => { + const encore = makeEncore(); + mockAccess.mockRejectedValueOnce(new Error('ENOENT')); + + await host.activateEncore(encore); + + expect(encore.state).toBe('error'); + expect(encore.error).toContain('Encore entry point not found'); + expect(mockCaptureException).toHaveBeenCalledWith( + expect.any(Error), + { encoreId: 'test-encore' } + ); + }); + + it('sets state to error and reports to Sentry when require() fails', async () => { + const encore = makeEncore(); + // fs.access passes, but require() will fail since the file doesn't actually exist + mockAccess.mockResolvedValueOnce(undefined); + + await host.activateEncore(encore); + + expect(encore.state).toBe('error'); + expect(encore.error).toBeDefined(); + expect(mockCaptureException).toHaveBeenCalledWith( + expect.any(Error), + { encoreId: 'test-encore' } + ); + }); + + it('deactivateEncore calls deactivate() and cleans up', async () => { + const encore = makeEncore({ permissions: ['process:read'] }); + + // Create context manually (simulating a previously activated encore) + host.createEncoreContext(encore); + expect(host.getEncoreContext('test-encore')).toBeDefined(); + + // Deactivate should clean up context + await host.deactivateEncore('test-encore'); + expect(host.getEncoreContext('test-encore')).toBeUndefined(); + }); + + it('deactivation errors are logged but do not propagate', async () => { + const encore = makeEncore(); + + // Create a context + host.createEncoreContext(encore); + + // Deactivate an encore that was never actually module-loaded (no module to call deactivate on) + // This should complete without throwing + await expect(host.deactivateEncore('test-encore')).resolves.not.toThrow(); + expect(host.getEncoreContext('test-encore')).toBeUndefined(); + }); +}); + +// ============================================================================ +// Plugin Storage Tests +// ============================================================================ + +describe('EncoreStorage', () => { + let storage: EncoreStorage; + + beforeEach(() => { + vi.clearAllMocks(); + storage = new EncoreStorage('test-encore', '/mock/userData/encores/test-encore/data'); + }); + + describe('read', () => { + it('returns file contents on success', async () => { + mockReadFile.mockResolvedValueOnce('{"key": "value"}'); + + const result = await storage.read('config.json'); + expect(result).toBe('{"key": "value"}'); + expect(mockReadFile).toHaveBeenCalledWith( + path.join('/mock/userData/encores/test-encore/data', 'config.json'), + 'utf-8' + ); + }); + + it('returns null for non-existent files', async () => { + mockReadFile.mockRejectedValueOnce(new Error('ENOENT')); + + const result = await storage.read('missing.json'); + expect(result).toBeNull(); + }); + }); + + describe('write', () => { + it('creates directory and writes file', async () => { + mockMkdir.mockResolvedValueOnce(undefined); + mockWriteFile.mockResolvedValueOnce(undefined); + + await storage.write('config.json', '{"key": "value"}'); + + expect(mockMkdir).toHaveBeenCalledWith( + '/mock/userData/encores/test-encore/data', + { recursive: true } + ); + expect(mockWriteFile).toHaveBeenCalledWith( + path.join('/mock/userData/encores/test-encore/data', 'config.json'), + '{"key": "value"}', + 'utf-8' + ); + }); + }); + + describe('list', () => { + it('returns files in directory', async () => { + mockReaddir.mockResolvedValueOnce(['config.json', 'data.db']); + + const files = await storage.list(); + expect(files).toEqual(['config.json', 'data.db']); + }); + + it('returns empty array when directory does not exist', async () => { + mockReaddir.mockRejectedValueOnce(new Error('ENOENT')); + + const files = await storage.list(); + expect(files).toEqual([]); + }); + }); + + describe('delete', () => { + it('deletes a file', async () => { + mockUnlink.mockResolvedValueOnce(undefined); + + await storage.delete('config.json'); + expect(mockUnlink).toHaveBeenCalledWith( + path.join('/mock/userData/encores/test-encore/data', 'config.json') + ); + }); + + it('silently ignores missing files', async () => { + mockUnlink.mockRejectedValueOnce(new Error('ENOENT')); + + await expect(storage.delete('missing.json')).resolves.not.toThrow(); + }); + }); + + describe('path traversal prevention', () => { + it('rejects filenames with ..', async () => { + await expect(storage.read('../../../etc/passwd')).rejects.toThrow( + 'Path traversal is not allowed' + ); + }); + + it('rejects absolute paths', async () => { + await expect(storage.read('/etc/passwd')).rejects.toThrow( + 'Absolute paths are not allowed' + ); + }); + + it('rejects filenames with null bytes', async () => { + await expect(storage.read('file\0.txt')).rejects.toThrow( + 'Filename contains null bytes' + ); + }); + + it('rejects filenames with forward slashes', async () => { + await expect(storage.read('sub/file.txt')).rejects.toThrow( + 'Path separators are not allowed' + ); + }); + + it('rejects filenames with backslashes', async () => { + await expect(storage.read('sub\\file.txt')).rejects.toThrow( + 'Path separators are not allowed' + ); + }); + + it('applies validation to write operations', async () => { + await expect(storage.write('../escape.txt', 'data')).rejects.toThrow( + 'Path traversal is not allowed' + ); + }); + + it('applies validation to delete operations', async () => { + await expect(storage.delete('/etc/shadow')).rejects.toThrow( + 'Absolute paths are not allowed' + ); + }); + }); +}); + +// ============================================================================ +// Plugin IPC Bridge Tests +// ============================================================================ + +describe('EncoreIpcBridge', () => { + let bridge: EncoreIpcBridge; + + beforeEach(() => { + bridge = new EncoreIpcBridge(); + }); + + it('register and invoke routes to correct handler', async () => { + const handler = vi.fn().mockReturnValue('result'); + + bridge.register('my-encore', 'getData', handler); + const result = await bridge.invoke('my-encore', 'getData', 'arg1', 'arg2'); + + expect(handler).toHaveBeenCalledWith('arg1', 'arg2'); + expect(result).toBe('result'); + }); + + it('invoke throws when no handler registered', async () => { + await expect(bridge.invoke('unknown', 'channel')).rejects.toThrow( + "No handler registered for channel 'encore:unknown:channel'" + ); + }); + + it('send fires handler without waiting for result', () => { + const handler = vi.fn(); + bridge.register('my-encore', 'notify', handler); + + bridge.send('my-encore', 'notify', 'event-data'); + expect(handler).toHaveBeenCalledWith('event-data'); + }); + + it('send silently ignores missing handlers', () => { + expect(() => bridge.send('unknown', 'channel', 'data')).not.toThrow(); + }); + + it('send logs errors from handler without propagating', () => { + const handler = vi.fn().mockImplementation(() => { + throw new Error('handler boom'); + }); + + bridge.register('my-encore', 'bad', handler); + expect(() => bridge.send('my-encore', 'bad')).not.toThrow(); + expect(logger.error).toHaveBeenCalledWith( + expect.stringContaining('handler boom'), + expect.any(String) + ); + }); + + it('register returns unsubscribe function', async () => { + const handler = vi.fn().mockReturnValue('ok'); + const unsub = bridge.register('my-encore', 'temp', handler); + + // Handler should be reachable + expect(bridge.hasHandler('my-encore', 'temp')).toBe(true); + + // Unsubscribe + unsub(); + expect(bridge.hasHandler('my-encore', 'temp')).toBe(false); + + // Should throw now + await expect(bridge.invoke('my-encore', 'temp')).rejects.toThrow(); + }); + + it('unregisterAll removes all channels for a specific encore', () => { + bridge.register('encore-a', 'ch1', vi.fn()); + bridge.register('encore-a', 'ch2', vi.fn()); + bridge.register('encore-b', 'ch1', vi.fn()); + + bridge.unregisterAll('encore-a'); + + expect(bridge.hasHandler('encore-a', 'ch1')).toBe(false); + expect(bridge.hasHandler('encore-a', 'ch2')).toBe(false); + expect(bridge.hasHandler('encore-b', 'ch1')).toBe(true); + }); + + it('does not affect other encores when unregistering', () => { + bridge.register('encore-a', 'shared-name', vi.fn()); + bridge.register('encore-b', 'shared-name', vi.fn()); + + bridge.unregisterAll('encore-a'); + + expect(bridge.hasHandler('encore-a', 'shared-name')).toBe(false); + expect(bridge.hasHandler('encore-b', 'shared-name')).toBe(true); + }); +}); diff --git a/src/__tests__/main/encore-host.test.ts b/src/__tests__/main/encore-host.test.ts new file mode 100644 index 000000000..cb05ba2e6 --- /dev/null +++ b/src/__tests__/main/encore-host.test.ts @@ -0,0 +1,393 @@ +/** + * Tests for Plugin Host API Sandboxing + * + * Covers: + * - Permission-based API scoping + * - Settings namespacing + * - Storage path traversal prevention + * - Event subscription cleanup on destroy + */ + +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import path from 'path'; +import type { LoadedEncore } from '../../shared/encore-types'; + +// Mock electron +vi.mock('electron', () => ({ + ipcMain: { handle: vi.fn() }, + app: { getPath: vi.fn(() => '/mock/userData'), getVersion: vi.fn(() => '1.0.0') }, + Notification: vi.fn().mockImplementation(() => ({ + show: vi.fn(), + })), +})); + +// Mock logger +vi.mock('../../main/utils/logger', () => ({ + logger: { + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + debug: vi.fn(), + }, +})); + +// Mock fs/promises +const mockReadFile = vi.fn(); +const mockWriteFile = vi.fn(); +const mockReaddir = vi.fn(); +const mockMkdir = vi.fn(); +const mockUnlink = vi.fn(); + +vi.mock('fs/promises', () => ({ + default: { + readFile: (...args: unknown[]) => mockReadFile(...args), + writeFile: (...args: unknown[]) => mockWriteFile(...args), + readdir: (...args: unknown[]) => mockReaddir(...args), + mkdir: (...args: unknown[]) => mockMkdir(...args), + unlink: (...args: unknown[]) => mockUnlink(...args), + }, + readFile: (...args: unknown[]) => mockReadFile(...args), + writeFile: (...args: unknown[]) => mockWriteFile(...args), + readdir: (...args: unknown[]) => mockReaddir(...args), + mkdir: (...args: unknown[]) => mockMkdir(...args), + unlink: (...args: unknown[]) => mockUnlink(...args), +})); + +// Mock stats/singleton +vi.mock('../../main/stats/singleton', () => ({ + getStatsDB: vi.fn(() => ({ + getAggregation: vi.fn().mockResolvedValue({ totalQueries: 42 }), + })), +})); + +import { EncoreHost, type EncoreHostDependencies } from '../../main/encore-host'; + +/** + * Helper to create a LoadedEncore for testing. + */ +function makeEncore(overrides: Partial & { permissions?: string[] } = {}): LoadedEncore { + const { permissions, ...rest } = overrides; + return { + manifest: { + id: 'test-encore', + name: 'Test Plugin', + version: '1.0.0', + description: 'A test encore', + author: 'Test Author', + main: 'index.js', + permissions: (permissions ?? []) as any, + }, + state: 'discovered', + path: '/mock/encores/test-encore', + ...rest, + }; +} + +/** + * Helper to create mock dependencies. + */ +function makeDeps(overrides: Partial = {}): EncoreHostDependencies { + const eventHandlers = new Map void>>(); + + const mockProcessManager = { + getAll: vi.fn(() => [ + { sessionId: 's1', toolType: 'claude-code', pid: 1234, startTime: 1000, cwd: '/test' }, + ]), + kill: vi.fn(() => true), + write: vi.fn(() => true), + on: vi.fn((event: string, handler: (...args: any[]) => void) => { + if (!eventHandlers.has(event)) eventHandlers.set(event, new Set()); + eventHandlers.get(event)!.add(handler); + return mockProcessManager; + }), + removeListener: vi.fn((event: string, handler: (...args: any[]) => void) => { + eventHandlers.get(event)?.delete(handler); + return mockProcessManager; + }), + // Expose for test assertions + _eventHandlers: eventHandlers, + }; + + const storeData: Record = {}; + const mockSettingsStore = { + get: vi.fn((key: string) => storeData[key]), + set: vi.fn((key: string, value: unknown) => { + storeData[key] = value; + }), + store: storeData, + }; + + const mockApp = { + getPath: vi.fn(() => '/mock/userData'), + getVersion: vi.fn(() => '2.0.0'), + }; + + return { + getProcessManager: () => mockProcessManager as any, + getMainWindow: () => null, + settingsStore: mockSettingsStore as any, + app: mockApp as any, + ...overrides, + }; +} + +describe('EncoreHost', () => { + let host: EncoreHost; + let deps: EncoreHostDependencies; + + beforeEach(() => { + vi.clearAllMocks(); + deps = makeDeps(); + host = new EncoreHost(deps); + }); + + describe('permission-based API scoping', () => { + it('provides only maestro API when no permissions declared', () => { + const encore = makeEncore({ permissions: [] }); + const ctx = host.createEncoreContext(encore); + + expect(ctx.api.maestro).toBeDefined(); + expect(ctx.api.maestro.encoreId).toBe('test-encore'); + expect(ctx.api.process).toBeUndefined(); + expect(ctx.api.processControl).toBeUndefined(); + expect(ctx.api.stats).toBeUndefined(); + expect(ctx.api.settings).toBeUndefined(); + expect(ctx.api.storage).toBeUndefined(); + expect(ctx.api.notifications).toBeUndefined(); + }); + + it('provides process API with process:read permission', () => { + const encore = makeEncore({ permissions: ['process:read'] }); + const ctx = host.createEncoreContext(encore); + + expect(ctx.api.process).toBeDefined(); + expect(ctx.api.processControl).toBeUndefined(); + }); + + it('provides processControl API with process:write permission', () => { + const encore = makeEncore({ permissions: ['process:write'] }); + const ctx = host.createEncoreContext(encore); + + expect(ctx.api.processControl).toBeDefined(); + expect(ctx.api.process).toBeUndefined(); + }); + + it('provides stats API with stats:read permission', () => { + const encore = makeEncore({ permissions: ['stats:read'] }); + const ctx = host.createEncoreContext(encore); + + expect(ctx.api.stats).toBeDefined(); + }); + + it('provides settings API with settings:read permission', () => { + const encore = makeEncore({ permissions: ['settings:read'] }); + const ctx = host.createEncoreContext(encore); + + expect(ctx.api.settings).toBeDefined(); + }); + + it('provides storage API with storage permission', () => { + const encore = makeEncore({ permissions: ['storage'] }); + const ctx = host.createEncoreContext(encore); + + expect(ctx.api.storage).toBeDefined(); + }); + + it('provides notifications API with notifications permission', () => { + const encore = makeEncore({ permissions: ['notifications'] }); + const ctx = host.createEncoreContext(encore); + + expect(ctx.api.notifications).toBeDefined(); + }); + }); + + describe('maestro API', () => { + it('provides correct metadata', () => { + const encore = makeEncore(); + const ctx = host.createEncoreContext(encore); + + expect(ctx.api.maestro.version).toBe('2.0.0'); + expect(ctx.api.maestro.platform).toBe(process.platform); + expect(ctx.api.maestro.encoreId).toBe('test-encore'); + expect(ctx.api.maestro.encoreDir).toBe('/mock/encores/test-encore'); + expect(ctx.api.maestro.dataDir).toBe( + path.join('/mock/userData', 'encores', 'test-encore', 'data') + ); + }); + }); + + describe('process API', () => { + it('getActiveProcesses returns safe fields only', async () => { + const encore = makeEncore({ permissions: ['process:read'] }); + const ctx = host.createEncoreContext(encore); + + const processes = await ctx.api.process!.getActiveProcesses(); + expect(processes).toEqual([ + { sessionId: 's1', toolType: 'claude-code', pid: 1234, startTime: 1000, name: null }, + ]); + }); + + it('onData subscribes to data events', () => { + const encore = makeEncore({ permissions: ['process:read'] }); + const ctx = host.createEncoreContext(encore); + + const callback = vi.fn(); + const unsub = ctx.api.process!.onData(callback); + + expect(typeof unsub).toBe('function'); + const pm = deps.getProcessManager()!; + expect(pm.on).toHaveBeenCalledWith('data', expect.any(Function)); + }); + }); + + describe('processControl API', () => { + it('kill delegates to ProcessManager and logs', () => { + const encore = makeEncore({ permissions: ['process:write'] }); + const ctx = host.createEncoreContext(encore); + + const result = ctx.api.processControl!.kill('s1'); + expect(result).toBe(true); + expect(deps.getProcessManager()!.kill).toHaveBeenCalledWith('s1'); + }); + + it('write delegates to ProcessManager and logs', () => { + const encore = makeEncore({ permissions: ['process:write'] }); + const ctx = host.createEncoreContext(encore); + + const result = ctx.api.processControl!.write('s1', 'hello'); + expect(result).toBe(true); + expect(deps.getProcessManager()!.write).toHaveBeenCalledWith('s1', 'hello'); + }); + }); + + describe('settings API', () => { + it('namespaces keys with encore ID prefix', async () => { + const encore = makeEncore({ permissions: ['settings:read', 'settings:write'] }); + const ctx = host.createEncoreContext(encore); + + await ctx.api.settings!.set('refreshRate', 5000); + expect(deps.settingsStore.set).toHaveBeenCalledWith( + 'encore:test-encore:refreshRate', + 5000 + ); + + await ctx.api.settings!.get('refreshRate'); + expect(deps.settingsStore.get).toHaveBeenCalledWith('encore:test-encore:refreshRate'); + }); + + it('settings:read without settings:write throws on set', async () => { + const encore = makeEncore({ permissions: ['settings:read'] }); + const ctx = host.createEncoreContext(encore); + + await expect(ctx.api.settings!.set('key', 'value')).rejects.toThrow( + "does not have 'settings:write' permission" + ); + }); + + it('getAll returns only namespaced keys', async () => { + const d = makeDeps(); + // Populate store with mixed keys + (d.settingsStore as any).store['encore:test-encore:a'] = 1; + (d.settingsStore as any).store['encore:test-encore:b'] = 2; + (d.settingsStore as any).store['encore:other-encore:c'] = 3; + (d.settingsStore as any).store['someGlobalSetting'] = 'x'; + + const h = new EncoreHost(d); + const encore = makeEncore({ permissions: ['settings:read'] }); + const ctx = h.createEncoreContext(encore); + + const all = await ctx.api.settings!.getAll(); + expect(all).toEqual({ a: 1, b: 2 }); + }); + }); + + describe('storage API', () => { + it('prevents path traversal with ..', async () => { + const encore = makeEncore({ permissions: ['storage'] }); + const ctx = host.createEncoreContext(encore); + + await expect(ctx.api.storage!.read('../../../etc/passwd')).rejects.toThrow( + 'Path traversal is not allowed' + ); + }); + + it('prevents absolute paths', async () => { + const encore = makeEncore({ permissions: ['storage'] }); + const ctx = host.createEncoreContext(encore); + + await expect(ctx.api.storage!.read('/etc/passwd')).rejects.toThrow( + 'Absolute paths are not allowed' + ); + }); + + it('read returns null for non-existent files', async () => { + mockReadFile.mockRejectedValueOnce(new Error('ENOENT')); + const encore = makeEncore({ permissions: ['storage'] }); + const ctx = host.createEncoreContext(encore); + + const result = await ctx.api.storage!.read('config.json'); + expect(result).toBeNull(); + }); + + it('write creates directory on first write', async () => { + mockMkdir.mockResolvedValueOnce(undefined); + mockWriteFile.mockResolvedValueOnce(undefined); + + const encore = makeEncore({ permissions: ['storage'] }); + const ctx = host.createEncoreContext(encore); + + await ctx.api.storage!.write('config.json', '{}'); + expect(mockMkdir).toHaveBeenCalledWith( + expect.stringContaining(path.join('encores', 'test-encore', 'data')), + { recursive: true } + ); + }); + + it('list returns empty array when directory does not exist', async () => { + mockReaddir.mockRejectedValueOnce(new Error('ENOENT')); + const encore = makeEncore({ permissions: ['storage'] }); + const ctx = host.createEncoreContext(encore); + + const files = await ctx.api.storage!.list(); + expect(files).toEqual([]); + }); + }); + + describe('destroyEncoreContext', () => { + it('cleans up all event subscriptions', () => { + const encore = makeEncore({ permissions: ['process:read'] }); + const ctx = host.createEncoreContext(encore); + + // Subscribe to multiple events + ctx.api.process!.onData(vi.fn()); + ctx.api.process!.onExit(vi.fn()); + ctx.api.process!.onUsage(vi.fn()); + + // 3 event subscriptions + expect(ctx.eventSubscriptions.length).toBe(3); + + // Destroy the context + host.destroyEncoreContext('test-encore'); + + // Subscriptions array should be cleared + expect(ctx.eventSubscriptions.length).toBe(0); + + // removeListener should have been called for each + const pm = deps.getProcessManager()!; + expect(pm.removeListener).toHaveBeenCalledTimes(3); + }); + + it('does not crash when destroying non-existent context', () => { + expect(() => host.destroyEncoreContext('non-existent')).not.toThrow(); + }); + + it('removes context from internal map', () => { + const encore = makeEncore({ permissions: [] }); + host.createEncoreContext(encore); + expect(host.getEncoreContext('test-encore')).toBeDefined(); + + host.destroyEncoreContext('test-encore'); + expect(host.getEncoreContext('test-encore')).toBeUndefined(); + }); + }); +}); diff --git a/src/__tests__/main/encore-loader.test.ts b/src/__tests__/main/encore-loader.test.ts new file mode 100644 index 000000000..da05f6078 --- /dev/null +++ b/src/__tests__/main/encore-loader.test.ts @@ -0,0 +1,298 @@ +/** + * Tests for Plugin Manifest Validation and Discovery + * + * Covers: + * - validateEncoreManifest() type guard + * - discoverEncores() directory scanning + * - loadEncore() manifest reading + */ + +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import path from 'path'; + +// Mock electron +vi.mock('electron', () => ({ + ipcMain: { handle: vi.fn() }, + app: { getPath: vi.fn(() => '/mock/userData') }, +})); + +// Mock logger +vi.mock('../../main/utils/logger', () => ({ + logger: { + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + debug: vi.fn(), + }, +})); + +// Mock fs/promises +const mockReadFile = vi.fn(); +const mockReaddir = vi.fn(); +const mockStat = vi.fn(); +const mockMkdir = vi.fn(); + +vi.mock('fs/promises', () => ({ + default: { + readFile: (...args: unknown[]) => mockReadFile(...args), + readdir: (...args: unknown[]) => mockReaddir(...args), + stat: (...args: unknown[]) => mockStat(...args), + mkdir: (...args: unknown[]) => mockMkdir(...args), + }, + readFile: (...args: unknown[]) => mockReadFile(...args), + readdir: (...args: unknown[]) => mockReaddir(...args), + stat: (...args: unknown[]) => mockStat(...args), + mkdir: (...args: unknown[]) => mockMkdir(...args), +})); + +import { validateEncoreManifest, discoverEncores, loadEncore } from '../../main/encore-loader'; + +/** + * Helper to create a valid manifest object for testing. + */ +function validManifest(overrides: Record = {}) { + return { + id: 'test-encore', + name: 'Test Plugin', + version: '1.0.0', + description: 'A test encore', + author: 'Test Author', + main: 'index.js', + permissions: ['stats:read'], + ...overrides, + }; +} + +describe('validateEncoreManifest', () => { + it('accepts a valid manifest', () => { + expect(validateEncoreManifest(validManifest())).toBe(true); + }); + + it('accepts a valid manifest with all optional fields', () => { + const manifest = validManifest({ + authorLink: 'https://example.com', + minMaestroVersion: '1.0.0', + renderer: 'renderer.js', + ui: { rightPanelTabs: [{ id: 'tab1', label: 'Tab 1' }], settingsSection: true }, + settings: [{ key: 'enabled', type: 'boolean', label: 'Enabled', default: true }], + tags: ['dashboard', 'monitoring'], + }); + expect(validateEncoreManifest(manifest)).toBe(true); + }); + + it('rejects null', () => { + expect(validateEncoreManifest(null)).toBe(false); + }); + + it('rejects non-object', () => { + expect(validateEncoreManifest('string')).toBe(false); + }); + + it('rejects manifest missing required field: id', () => { + const { id, ...rest } = validManifest(); + expect(validateEncoreManifest(rest)).toBe(false); + }); + + it('rejects manifest missing required field: name', () => { + const { name, ...rest } = validManifest(); + expect(validateEncoreManifest(rest)).toBe(false); + }); + + it('rejects manifest missing required field: version', () => { + const { version, ...rest } = validManifest(); + expect(validateEncoreManifest(rest)).toBe(false); + }); + + it('rejects manifest missing required field: description', () => { + const { description, ...rest } = validManifest(); + expect(validateEncoreManifest(rest)).toBe(false); + }); + + it('rejects manifest missing required field: author', () => { + const { author, ...rest } = validManifest(); + expect(validateEncoreManifest(rest)).toBe(false); + }); + + it('rejects manifest missing required field: main', () => { + const { main, ...rest } = validManifest(); + expect(validateEncoreManifest(rest)).toBe(false); + }); + + it('rejects manifest with empty string for required field', () => { + expect(validateEncoreManifest(validManifest({ id: '' }))).toBe(false); + expect(validateEncoreManifest(validManifest({ name: ' ' }))).toBe(false); + }); + + it('rejects manifest with invalid slug format (uppercase)', () => { + expect(validateEncoreManifest(validManifest({ id: 'TestPlugin' }))).toBe(false); + }); + + it('rejects manifest with invalid slug format (spaces)', () => { + expect(validateEncoreManifest(validManifest({ id: 'test encore' }))).toBe(false); + }); + + it('rejects manifest with invalid slug format (underscores)', () => { + expect(validateEncoreManifest(validManifest({ id: 'test_plugin' }))).toBe(false); + }); + + it('rejects manifest with invalid slug format (leading hyphen)', () => { + expect(validateEncoreManifest(validManifest({ id: '-test' }))).toBe(false); + }); + + it('accepts valid slug formats', () => { + expect(validateEncoreManifest(validManifest({ id: 'my-encore' }))).toBe(true); + expect(validateEncoreManifest(validManifest({ id: 'encore123' }))).toBe(true); + expect(validateEncoreManifest(validManifest({ id: 'a' }))).toBe(true); + }); + + it('rejects manifest with missing permissions array', () => { + const { permissions, ...rest } = validManifest(); + expect(validateEncoreManifest(rest)).toBe(false); + }); + + it('rejects manifest with permissions as non-array', () => { + expect(validateEncoreManifest(validManifest({ permissions: 'stats:read' }))).toBe(false); + }); + + it('rejects unknown permissions', () => { + expect(validateEncoreManifest(validManifest({ permissions: ['unknown:perm'] }))).toBe(false); + }); + + it('accepts empty permissions array', () => { + expect(validateEncoreManifest(validManifest({ permissions: [] }))).toBe(true); + }); + + it('accepts all known permissions', () => { + const allPerms = [ + 'process:read', 'process:write', 'stats:read', + 'settings:read', 'settings:write', 'notifications', + 'network', 'storage', 'middleware', + ]; + expect(validateEncoreManifest(validManifest({ permissions: allPerms }))).toBe(true); + }); + + it('does not fail on extra unknown fields (forward compatibility)', () => { + const manifest = validManifest({ futureField: 'some value', anotherField: 42 }); + expect(validateEncoreManifest(manifest)).toBe(true); + }); +}); + +describe('loadEncore', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('loads a valid encore as discovered', async () => { + const manifest = validManifest(); + mockReadFile.mockResolvedValue(JSON.stringify(manifest)); + + const result = await loadEncore('/encores/test-encore'); + + expect(result.state).toBe('discovered'); + expect(result.manifest.id).toBe('test-encore'); + expect(result.path).toBe('/encores/test-encore'); + expect(result.error).toBeUndefined(); + }); + + it('returns error state when manifest.json is missing', async () => { + mockReadFile.mockRejectedValue(Object.assign(new Error('ENOENT'), { code: 'ENOENT' })); + + const result = await loadEncore('/encores/broken'); + + expect(result.state).toBe('error'); + expect(result.error).toContain('Failed to read manifest.json'); + }); + + it('returns error state for invalid JSON', async () => { + mockReadFile.mockResolvedValue('not valid json {{{'); + + const result = await loadEncore('/encores/bad-json'); + + expect(result.state).toBe('error'); + expect(result.error).toContain('Invalid JSON'); + }); + + it('returns error state for manifest that fails validation', async () => { + mockReadFile.mockResolvedValue(JSON.stringify({ id: 'BAD ID' })); + + const result = await loadEncore('/encores/bad-manifest'); + + expect(result.state).toBe('error'); + expect(result.error).toContain('validation failed'); + }); +}); + +describe('discoverEncores', () => { + beforeEach(() => { + vi.clearAllMocks(); + mockMkdir.mockResolvedValue(undefined); + }); + + it('returns empty array for empty directory', async () => { + mockReaddir.mockResolvedValue([]); + + const result = await discoverEncores('/encores'); + + expect(result).toEqual([]); + }); + + it('discovers valid encores from subdirectories', async () => { + mockReaddir.mockResolvedValue(['encore-a', 'encore-b']); + mockStat.mockResolvedValue({ isDirectory: () => true }); + mockReadFile.mockImplementation((filePath: string) => { + if (filePath.includes('encore-a')) { + return Promise.resolve(JSON.stringify(validManifest({ id: 'encore-a' }))); + } + return Promise.resolve(JSON.stringify(validManifest({ id: 'encore-b' }))); + }); + + const result = await discoverEncores('/encores'); + + expect(result).toHaveLength(2); + expect(result[0].state).toBe('discovered'); + expect(result[1].state).toBe('discovered'); + }); + + it('returns error state for encores with invalid manifests', async () => { + mockReaddir.mockResolvedValue(['good-encore', 'bad-encore']); + mockStat.mockResolvedValue({ isDirectory: () => true }); + mockReadFile.mockImplementation((filePath: string) => { + if (filePath.includes('good-encore')) { + return Promise.resolve(JSON.stringify(validManifest({ id: 'good-encore' }))); + } + return Promise.resolve('not json'); + }); + + const result = await discoverEncores('/encores'); + + expect(result).toHaveLength(2); + const good = result.find((p) => p.manifest.id === 'good-encore'); + const bad = result.find((p) => p.manifest.id !== 'good-encore'); + expect(good?.state).toBe('discovered'); + expect(bad?.state).toBe('error'); + }); + + it('skips non-directory entries', async () => { + mockReaddir.mockResolvedValue(['file.txt', 'encore-dir']); + mockStat.mockImplementation((entryPath: string) => { + if (entryPath.includes('file.txt')) { + return Promise.resolve({ isDirectory: () => false }); + } + return Promise.resolve({ isDirectory: () => true }); + }); + mockReadFile.mockResolvedValue(JSON.stringify(validManifest({ id: 'encore-dir' }))); + + const result = await discoverEncores('/encores'); + + expect(result).toHaveLength(1); + expect(result[0].manifest.id).toBe('encore-dir'); + }); + + it('creates the encores directory if it does not exist', async () => { + mockReaddir.mockResolvedValue([]); + + await discoverEncores('/encores'); + + expect(mockMkdir).toHaveBeenCalledWith('/encores', { recursive: true }); + }); +}); diff --git a/src/__tests__/main/encore-reference.test.ts b/src/__tests__/main/encore-reference.test.ts new file mode 100644 index 000000000..b52ff3691 --- /dev/null +++ b/src/__tests__/main/encore-reference.test.ts @@ -0,0 +1,412 @@ +/** + * Tests for Reference Plugins (Agent Status Exporter & Notification Webhook) + * + * Covers: + * - Agent Status Exporter: activate/deactivate exports, event subscriptions, debounced writes, JSON schema + * - Notification Webhook: activate/deactivate exports, webhook sending, settings-based gating + */ + +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; + +// ─── Agent Status Exporter Tests ──────────────────────────────────────────── + +describe('Agent Status Exporter Plugin', () => { + let dashboard: any; + let mockApi: any; + let storageWrites: Array<{ filename: string; data: string }>; + let eventHandlers: Record void>>; + + beforeEach(() => { + vi.useFakeTimers(); + storageWrites = []; + eventHandlers = {}; + + // Fresh module for each test to reset module-level state + vi.resetModules(); + dashboard = require('../../encores/agent-status-exporter/index.js'); + + mockApi = { + process: { + getActiveProcesses: vi.fn().mockResolvedValue([]), + onData: vi.fn((cb: any) => { + if (!eventHandlers['data']) eventHandlers['data'] = []; + eventHandlers['data'].push(cb); + return vi.fn(); + }), + onUsage: vi.fn((cb: any) => { + if (!eventHandlers['usage']) eventHandlers['usage'] = []; + eventHandlers['usage'].push(cb); + return vi.fn(); + }), + onToolExecution: vi.fn((cb: any) => { + if (!eventHandlers['tool']) eventHandlers['tool'] = []; + eventHandlers['tool'].push(cb); + return vi.fn(); + }), + onExit: vi.fn((cb: any) => { + if (!eventHandlers['exit']) eventHandlers['exit'] = []; + eventHandlers['exit'].push(cb); + return vi.fn(); + }), + }, + storage: { + write: vi.fn(async (filename: string, data: string) => { + storageWrites.push({ filename, data }); + }), + read: vi.fn().mockResolvedValue(null), + list: vi.fn().mockResolvedValue([]), + delete: vi.fn().mockResolvedValue(undefined), + }, + settings: { + get: vi.fn().mockResolvedValue(undefined), + }, + }; + }); + + afterEach(async () => { + // Clean up heartbeat timer by deactivating + try { await dashboard.deactivate(); } catch { /* ignore if not activated */ } + vi.useRealTimers(); + }); + + it('exports activate and deactivate functions', () => { + expect(typeof dashboard.activate).toBe('function'); + expect(typeof dashboard.deactivate).toBe('function'); + }); + + it('calls getActiveProcesses on activate', async () => { + await dashboard.activate(mockApi); + expect(mockApi.process.getActiveProcesses).toHaveBeenCalledOnce(); + }); + + it('subscribes to all four process events', async () => { + await dashboard.activate(mockApi); + expect(mockApi.process.onUsage).toHaveBeenCalledOnce(); + expect(mockApi.process.onToolExecution).toHaveBeenCalledOnce(); + expect(mockApi.process.onExit).toHaveBeenCalledOnce(); + expect(mockApi.process.onData).toHaveBeenCalledOnce(); + }); + + it('seeds state from already-running processes', async () => { + mockApi.process.getActiveProcesses.mockResolvedValue([ + { sessionId: 'sess-1', toolType: 'claude-code', pid: 1234, startTime: 1000 }, + ]); + + await dashboard.activate(mockApi); + + // activate() now writes immediately (no debounce needed) + expect(storageWrites.length).toBeGreaterThanOrEqual(1); + const output = JSON.parse(storageWrites[storageWrites.length - 1].data); + expect(output.agents).toHaveLength(1); + expect(output.agents[0].sessionId).toBe('sess-1'); + expect(output.agents[0].agentType).toBe('claude-code'); + expect(output.agents[0].pid).toBe(1234); + }); + + it('writes valid JSON matching the expected schema', async () => { + mockApi.process.getActiveProcesses.mockResolvedValue([ + { sessionId: 'sess-1', toolType: 'claude-code', pid: 1234, startTime: 1000 }, + ]); + + await dashboard.activate(mockApi); + + const output = JSON.parse(storageWrites[storageWrites.length - 1].data); + + // Top-level keys + expect(output).toHaveProperty('timestamp'); + expect(output).toHaveProperty('agents'); + expect(output).toHaveProperty('totals'); + + // Agent shape + const agent = output.agents[0]; + expect(agent).toHaveProperty('sessionId'); + expect(agent).toHaveProperty('agentType'); + expect(agent).toHaveProperty('pid'); + expect(agent).toHaveProperty('startTime'); + expect(agent).toHaveProperty('runtimeSeconds'); + expect(agent).toHaveProperty('status'); + expect(agent).toHaveProperty('tokens'); + expect(agent).toHaveProperty('cost'); + expect(agent.tokens).toHaveProperty('input'); + expect(agent.tokens).toHaveProperty('output'); + expect(agent.tokens).toHaveProperty('cacheRead'); + expect(agent.tokens).toHaveProperty('contextWindow'); + expect(agent).toHaveProperty('lastTool'); + + // Totals shape + expect(output.totals).toHaveProperty('activeAgents'); + expect(output.totals).toHaveProperty('totalInputTokens'); + expect(output.totals).toHaveProperty('totalOutputTokens'); + expect(output.totals).toHaveProperty('totalCost'); + }); + + it('debounces multiple rapid writes (only last one within 500ms executes)', async () => { + await dashboard.activate(mockApi); + // activate() writes immediately; capture baseline + const initialWriteCount = storageWrites.length; + + // Simulate rapid usage events + const usageCb = eventHandlers['usage'][0]; + usageCb('sess-1', { inputTokens: 100, outputTokens: 10 }); + usageCb('sess-1', { inputTokens: 200, outputTokens: 20 }); + usageCb('sess-1', { inputTokens: 300, outputTokens: 30 }); + + // Before debounce timeout — no new writes (only advance 400ms, not enough) + await vi.advanceTimersByTimeAsync(400); + expect(storageWrites.length).toBe(initialWriteCount); + + // After debounce timeout — exactly one write + await vi.advanceTimersByTimeAsync(100); + expect(storageWrites.length).toBe(initialWriteCount + 1); + + // Verify final state has latest values + const output = JSON.parse(storageWrites[storageWrites.length - 1].data); + const agent = output.agents.find((a: any) => a.sessionId === 'sess-1'); + expect(agent.tokens.input).toBe(300); + expect(agent.tokens.output).toBe(30); + }); + + it('updates token counts on usage events', async () => { + await dashboard.activate(mockApi); + + const usageCb = eventHandlers['usage'][0]; + usageCb('sess-1', { + inputTokens: 1500, + outputTokens: 320, + cacheReadTokens: 800, + contextWindow: 200000, + totalCostUsd: 0.42, + }); + + await vi.advanceTimersByTimeAsync(500); + + const output = JSON.parse(storageWrites[storageWrites.length - 1].data); + const agent = output.agents.find((a: any) => a.sessionId === 'sess-1'); + expect(agent.tokens.input).toBe(1500); + expect(agent.tokens.output).toBe(320); + expect(agent.tokens.cacheRead).toBe(800); + expect(agent.tokens.contextWindow).toBe(200000); + expect(agent.cost).toBe(0.42); + }); + + it('updates lastTool on tool execution events', async () => { + await dashboard.activate(mockApi); + + const toolCb = eventHandlers['tool'][0]; + toolCb('sess-1', { toolName: 'Edit' }); + + await vi.advanceTimersByTimeAsync(500); + + const output = JSON.parse(storageWrites[storageWrites.length - 1].data); + const agent = output.agents.find((a: any) => a.sessionId === 'sess-1'); + expect(agent.lastTool).not.toBeNull(); + expect(agent.lastTool.name).toBe('Edit'); + expect(typeof agent.lastTool.timestamp).toBe('number'); + }); + + it('marks agent as exited on exit event', async () => { + mockApi.process.getActiveProcesses.mockResolvedValue([ + { sessionId: 'sess-1', toolType: 'claude-code', pid: 1234, startTime: 1000 }, + ]); + + await dashboard.activate(mockApi); + + const exitCb = eventHandlers['exit'][0]; + exitCb('sess-1', 0); + + // Only advance the debounce time, not the 30s cleanup + await vi.advanceTimersByTimeAsync(500); + + const output = JSON.parse(storageWrites[storageWrites.length - 1].data); + const agent = output.agents.find((a: any) => a.sessionId === 'sess-1'); + expect(agent.status).toBe('exited'); + }); + + it('removes exited agent after 30 seconds', async () => { + mockApi.process.getActiveProcesses.mockResolvedValue([ + { sessionId: 'sess-1', toolType: 'claude-code', pid: 1234, startTime: 1000 }, + ]); + + await dashboard.activate(mockApi); + + const exitCb = eventHandlers['exit'][0]; + exitCb('sess-1', 0); + + // Advance past 30s cleanup + debounce + await vi.advanceTimersByTimeAsync(30500); + + const output = JSON.parse(storageWrites[storageWrites.length - 1].data); + expect(output.agents.find((a: any) => a.sessionId === 'sess-1')).toBeUndefined(); + }); + + it('catches agents that started between getActiveProcesses and event subscription via onData', async () => { + await dashboard.activate(mockApi); + + const dataCb = eventHandlers['data'][0]; + dataCb('late-sess', 'some output'); + + await vi.advanceTimersByTimeAsync(500); + + const output = JSON.parse(storageWrites[storageWrites.length - 1].data); + expect(output.agents.find((a: any) => a.sessionId === 'late-sess')).toBeDefined(); + }); + + it('deactivate marks all agents as exited and writes final status', async () => { + mockApi.process.getActiveProcesses.mockResolvedValue([ + { sessionId: 'sess-1', toolType: 'claude-code', pid: 1234, startTime: 1000 }, + ]); + + await dashboard.activate(mockApi); + await dashboard.deactivate(); + + const output = JSON.parse(storageWrites[storageWrites.length - 1].data); + // All agents should be exited in the final write + for (const agent of output.agents) { + expect(agent.status).toBe('exited'); + } + }); + + it('computes correct totals', async () => { + mockApi.process.getActiveProcesses.mockResolvedValue([ + { sessionId: 'sess-1', toolType: 'claude-code', pid: 1, startTime: 1000 }, + { sessionId: 'sess-2', toolType: 'codex', pid: 2, startTime: 2000 }, + ]); + + await dashboard.activate(mockApi); + + const usageCb = eventHandlers['usage'][0]; + usageCb('sess-1', { inputTokens: 1000, outputTokens: 200, totalCostUsd: 0.10 }); + usageCb('sess-2', { inputTokens: 2000, outputTokens: 400, totalCostUsd: 0.20 }); + + await vi.advanceTimersByTimeAsync(500); + + const output = JSON.parse(storageWrites[storageWrites.length - 1].data); + expect(output.totals.activeAgents).toBe(2); + expect(output.totals.totalInputTokens).toBe(3000); + expect(output.totals.totalOutputTokens).toBe(600); + expect(output.totals.totalCost).toBeCloseTo(0.30); + }); +}); + +// ─── Notification Webhook Tests ────────────────────────────────────────────── + +describe('Notification Webhook Plugin', () => { + let webhook: any; + let mockApi: any; + let eventHandlers: Record void>>; + let settingsData: Record; + + beforeEach(() => { + vi.resetModules(); + webhook = require('../../encores/notification-webhook/index.js'); + + eventHandlers = {}; + settingsData = { + webhookUrl: 'https://example.com/webhook', + notifyOnCompletion: true, + notifyOnError: true, + }; + + mockApi = { + process: { + onExit: vi.fn((cb: any) => { + if (!eventHandlers['exit']) eventHandlers['exit'] = []; + eventHandlers['exit'].push(cb); + return vi.fn(); + }), + onData: vi.fn((cb: any) => { + if (!eventHandlers['data']) eventHandlers['data'] = []; + eventHandlers['data'].push(cb); + return vi.fn(); + }), + }, + settings: { + get: vi.fn(async (key: string) => settingsData[key]), + set: vi.fn(), + getAll: vi.fn().mockResolvedValue({}), + }, + }; + }); + + it('exports activate and deactivate functions', () => { + expect(typeof webhook.activate).toBe('function'); + expect(typeof webhook.deactivate).toBe('function'); + }); + + it('subscribes to onExit and onData on activate', async () => { + await webhook.activate(mockApi); + expect(mockApi.process.onExit).toHaveBeenCalledOnce(); + expect(mockApi.process.onData).toHaveBeenCalledOnce(); + }); + + it('checks settings before sending webhook on exit', async () => { + await webhook.activate(mockApi); + + const exitCb = eventHandlers['exit'][0]; + await exitCb('sess-1', 0); + + expect(mockApi.settings.get).toHaveBeenCalledWith('notifyOnCompletion'); + expect(mockApi.settings.get).toHaveBeenCalledWith('webhookUrl'); + }); + + it('skips webhook when notifyOnCompletion is false', async () => { + settingsData.notifyOnCompletion = false; + await webhook.activate(mockApi); + + const exitCb = eventHandlers['exit'][0]; + await exitCb('sess-1', 0); + + // Should have checked notifyOnCompletion but not webhookUrl + expect(mockApi.settings.get).toHaveBeenCalledWith('notifyOnCompletion'); + // webhookUrl should not be checked since we skipped early + const webhookUrlCalls = mockApi.settings.get.mock.calls.filter( + (c: any[]) => c[0] === 'webhookUrl' + ); + expect(webhookUrlCalls.length).toBe(0); + }); + + it('skips webhook when URL is empty', async () => { + settingsData.webhookUrl = ''; + await webhook.activate(mockApi); + + const exitCb = eventHandlers['exit'][0]; + // Should not throw even with empty URL + await exitCb('sess-1', 0); + }); + + it('containsError detects common error patterns', () => { + expect(webhook.containsError('Error: something went wrong')).toBe(true); + expect(webhook.containsError('FATAL crash')).toBe(true); + expect(webhook.containsError('panic: runtime error')).toBe(true); + expect(webhook.containsError('Traceback (most recent call last)')).toBe(true); + expect(webhook.containsError('ECONNREFUSED 127.0.0.1:3000')).toBe(true); + expect(webhook.containsError('Permission denied')).toBe(true); + }); + + it('containsError returns false for normal output', () => { + expect(webhook.containsError('Hello world')).toBe(false); + expect(webhook.containsError('Build succeeded')).toBe(false); + expect(webhook.containsError('')).toBe(false); + expect(webhook.containsError(null)).toBe(false); + expect(webhook.containsError(undefined)).toBe(false); + }); + + it('deactivate calls all unsubscribers', async () => { + const unsubExit = vi.fn(); + const unsubData = vi.fn(); + mockApi.process.onExit.mockReturnValue(unsubExit); + mockApi.process.onData.mockReturnValue(unsubData); + + await webhook.activate(mockApi); + await webhook.deactivate(); + + expect(unsubExit).toHaveBeenCalledOnce(); + expect(unsubData).toHaveBeenCalledOnce(); + }); + + it('sendWebhook handles invalid URLs gracefully', async () => { + // Should resolve (not throw) even with invalid URL + const result = await webhook.sendWebhook('not-a-valid-url', { test: true }); + expect(result).toHaveProperty('error'); + }); +}); diff --git a/src/__tests__/renderer/components/EncoreManager.test.tsx b/src/__tests__/renderer/components/EncoreManager.test.tsx new file mode 100644 index 000000000..553530d23 --- /dev/null +++ b/src/__tests__/renderer/components/EncoreManager.test.tsx @@ -0,0 +1,206 @@ +/** + * Tests for EncoreManager modal component + */ + +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { render, screen, fireEvent, waitFor } from '@testing-library/react'; +import { EncoreManager } from '../../../renderer/components/EncoreManager'; +import type { Theme } from '../../../renderer/types'; +import type { LoadedEncore } from '../../../shared/encore-types'; + +// Mock the Modal component to simplify testing +vi.mock('../../../renderer/components/ui/Modal', () => ({ + Modal: ({ + children, + title, + onClose, + }: { + children: React.ReactNode; + title: string; + onClose: () => void; + }) => ( +
+ + {children} +
+ ), +})); + +const mockTheme: Theme = { + id: 'dark', + name: 'Dark', + mode: 'dark', + colors: { + bgMain: '#1a1a2e', + bgSidebar: '#16213e', + bgActivity: '#0f3460', + border: '#333', + textMain: '#e4e4e7', + textDim: '#a1a1aa', + accent: '#6366f1', + success: '#22c55e', + warning: '#f59e0b', + error: '#ef4444', + }, +}; + +const mockEncores: LoadedEncore[] = [ + { + manifest: { + id: 'active-encore', + name: 'Active Encore', + version: '1.0.0', + description: 'An active encore', + author: 'Test Author', + main: 'index.js', + permissions: ['stats:read', 'process:write'], + }, + state: 'active', + path: '/encores/active-encore', + }, + { + manifest: { + id: 'disabled-encore', + name: 'Disabled Encore', + version: '0.5.0', + description: 'A disabled encore', + author: 'Other Author', + main: 'index.js', + permissions: ['settings:read'], + }, + state: 'disabled', + path: '/encores/disabled-encore', + }, + { + manifest: { + id: 'error-encore', + name: 'Error Encore', + version: '0.1.0', + description: 'A broken encore', + author: 'Bug Author', + main: 'index.js', + permissions: ['middleware'], + }, + state: 'error', + path: '/encores/error-encore', + error: 'Failed to load: missing dependency', + }, +]; + +describe('EncoreManager', () => { + const defaultProps = { + theme: mockTheme, + encores: mockEncores, + loading: false, + onClose: vi.fn(), + onEnableEncore: vi.fn().mockResolvedValue(undefined), + onDisableEncore: vi.fn().mockResolvedValue(undefined), + onRefresh: vi.fn().mockResolvedValue(undefined), + }; + + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('renders encore list with names and versions', () => { + render(); + + expect(screen.getByText('Active Encore')).toBeInTheDocument(); + expect(screen.getByText('v1.0.0')).toBeInTheDocument(); + expect(screen.getByText('Disabled Encore')).toBeInTheDocument(); + expect(screen.getByText('Error Encore')).toBeInTheDocument(); + }); + + it('shows encore count', () => { + render(); + + expect(screen.getByText('3 encores discovered')).toBeInTheDocument(); + }); + + it('shows loading state', () => { + render(); + + expect(screen.getByText('Loading encores...')).toBeInTheDocument(); + }); + + it('shows empty state when no encores', () => { + render(); + + expect(screen.getByText('No encores installed')).toBeInTheDocument(); + }); + + it('shows error message for error-state encores', () => { + render(); + + expect(screen.getByText('Failed to load: missing dependency')).toBeInTheDocument(); + }); + + it('shows permission badges', () => { + render(); + + expect(screen.getByText('stats:read')).toBeInTheDocument(); + expect(screen.getByText('process:write')).toBeInTheDocument(); + expect(screen.getByText('settings:read')).toBeInTheDocument(); + expect(screen.getByText('middleware')).toBeInTheDocument(); + }); + + it('shows author names', () => { + render(); + + expect(screen.getByText('by Test Author')).toBeInTheDocument(); + expect(screen.getByText('by Other Author')).toBeInTheDocument(); + }); + + it('calls onDisableEncore when toggling active encore', async () => { + render(); + + const toggleButtons = screen.getAllByTitle('Disable encore'); + fireEvent.click(toggleButtons[0]); + + await waitFor(() => { + expect(defaultProps.onDisableEncore).toHaveBeenCalledWith('active-encore'); + }); + }); + + it('calls onEnableEncore when toggling disabled encore', async () => { + render(); + + const toggleButtons = screen.getAllByTitle('Enable encore'); + fireEvent.click(toggleButtons[0]); + + await waitFor(() => { + expect(defaultProps.onEnableEncore).toHaveBeenCalledWith('disabled-encore'); + }); + }); + + it('calls onRefresh when Refresh button is clicked', async () => { + render(); + + const refreshButton = screen.getByText('Refresh'); + fireEvent.click(refreshButton); + + await waitFor(() => { + expect(defaultProps.onRefresh).toHaveBeenCalledOnce(); + }); + }); + + it('calls shell.showItemInFolder when Open Folder is clicked', async () => { + render(); + + const openFolderButton = screen.getByText('Open Folder'); + fireEvent.click(openFolderButton); + + await waitFor(() => { + expect(window.maestro.encores.getDir).toHaveBeenCalled(); + expect(window.maestro.shell.showItemInFolder).toHaveBeenCalledWith('/tmp/encores'); + }); + }); + + it('singular encore text when only one encore', () => { + render(); + + expect(screen.getByText('1 encore discovered')).toBeInTheDocument(); + }); +}); diff --git a/src/__tests__/renderer/components/EncoreTabContent.test.tsx b/src/__tests__/renderer/components/EncoreTabContent.test.tsx new file mode 100644 index 000000000..0333e8d26 --- /dev/null +++ b/src/__tests__/renderer/components/EncoreTabContent.test.tsx @@ -0,0 +1,132 @@ +/** + * Tests for EncoreTabContent component + */ + +import { describe, it, expect } from 'vitest'; +import { render, screen } from '@testing-library/react'; +import { EncoreTabContent } from '../../../renderer/components/EncoreTabContent'; +import type { Theme } from '../../../renderer/types'; +import type { LoadedEncore } from '../../../shared/encore-types'; + +const mockTheme: Theme = { + id: 'dark', + name: 'Dark', + mode: 'dark', + colors: { + bgMain: '#1a1a2e', + bgSidebar: '#16213e', + bgActivity: '#0f3460', + border: '#333', + textMain: '#e4e4e7', + textDim: '#a1a1aa', + accent: '#6366f1', + success: '#22c55e', + warning: '#f59e0b', + error: '#ef4444', + }, +}; + +const mockEncores: LoadedEncore[] = [ + { + manifest: { + id: 'ui-encore', + name: 'UI Encore', + version: '1.0.0', + description: 'Encore with UI', + author: 'Test', + main: 'index.js', + renderer: 'renderer.html', + permissions: [], + }, + state: 'active', + path: '/encores/ui-encore', + }, + { + manifest: { + id: 'no-ui-encore', + name: 'No UI Encore', + version: '1.0.0', + description: 'Encore without UI', + author: 'Test', + main: 'index.js', + permissions: [], + }, + state: 'active', + path: '/encores/no-ui-encore', + }, +]; + +describe('EncoreTabContent', () => { + it('renders iframe for encore with renderer entry', () => { + const { container } = render( + + ); + + const iframe = container.querySelector('iframe'); + expect(iframe).toBeTruthy(); + expect(iframe?.getAttribute('src')).toBe('file:///encores/ui-encore/renderer.html'); + expect(iframe?.getAttribute('sandbox')).toBe('allow-scripts'); + expect(iframe?.getAttribute('title')).toContain('UI Encore'); + }); + + it('shows "no UI" message for encore without renderer', () => { + render( + + ); + + expect(screen.getByText('No UI Encore')).toBeInTheDocument(); + expect(screen.getByText('This encore has no UI')).toBeInTheDocument(); + }); + + it('shows "not found" message for unknown encore', () => { + render( + + ); + + expect(screen.getByText('Encore not found: unknown-encore')).toBeInTheDocument(); + }); + + it('sets data attributes on wrapper', () => { + const { container } = render( + + ); + + const wrapper = container.querySelector('[data-encore-id="ui-encore"]'); + expect(wrapper).toBeTruthy(); + expect(wrapper?.getAttribute('data-tab-id')).toBe('dashboard'); + }); + + it('iframe does not have allow-same-origin in sandbox', () => { + const { container } = render( + + ); + + const iframe = container.querySelector('iframe'); + expect(iframe?.getAttribute('sandbox')).not.toContain('allow-same-origin'); + }); +}); diff --git a/src/__tests__/renderer/components/SettingsModal.test.tsx b/src/__tests__/renderer/components/SettingsModal.test.tsx index c72d1a4cc..c90d3cdf2 100644 --- a/src/__tests__/renderer/components/SettingsModal.test.tsx +++ b/src/__tests__/renderer/components/SettingsModal.test.tsx @@ -466,7 +466,7 @@ describe('SettingsModal', () => { await vi.advanceTimersByTimeAsync(50); }); - // Start on Encore Features tab (last tab) + // Start on Encore tab (last tab) expect(screen.getByText('Encore Features', { selector: 'h3' })).toBeInTheDocument(); // Press Cmd+Shift+] to wrap to general @@ -490,7 +490,7 @@ describe('SettingsModal', () => { // Start on general tab (first tab) expect(screen.getByText('Default Terminal Shell')).toBeInTheDocument(); - // Press Cmd+Shift+[ to wrap to Encore Features (last tab) + // Press Cmd+Shift+[ to wrap to Encore (last tab) fireEvent.keyDown(window, { key: '[', metaKey: true, shiftKey: true }); await act(async () => { diff --git a/src/__tests__/renderer/hooks/useEncoreRegistry.test.ts b/src/__tests__/renderer/hooks/useEncoreRegistry.test.ts new file mode 100644 index 000000000..143871140 --- /dev/null +++ b/src/__tests__/renderer/hooks/useEncoreRegistry.test.ts @@ -0,0 +1,165 @@ +/** + * Tests for useEncoreRegistry hook + */ + +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { renderHook, act, waitFor } from '@testing-library/react'; +import { useEncoreRegistry } from '../../../renderer/hooks/useEncoreRegistry'; +import type { LoadedEncore } from '../../../shared/encore-types'; + +const mockEncores: LoadedEncore[] = [ + { + manifest: { + id: 'test-encore', + name: 'Test Encore', + version: '1.0.0', + description: 'A test encore', + author: 'Test', + main: 'index.js', + permissions: ['stats:read'], + ui: { + rightPanelTabs: [{ id: 'test-tab', label: 'Test Tab', icon: 'chart' }], + }, + }, + state: 'active', + path: '/encores/test-encore', + }, + { + manifest: { + id: 'disabled-encore', + name: 'Disabled Encore', + version: '0.1.0', + description: 'A disabled encore', + author: 'Test', + main: 'index.js', + permissions: [], + }, + state: 'disabled', + path: '/encores/disabled-encore', + }, +]; + +describe('useEncoreRegistry', () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(window.maestro.encores.getAll).mockResolvedValue({ success: true, encores: mockEncores }); + vi.mocked(window.maestro.encores.enable).mockResolvedValue({ success: true, enabled: true }); + vi.mocked(window.maestro.encores.disable).mockResolvedValue({ success: true, disabled: true }); + }); + + it('loads encores on mount', async () => { + const { result } = renderHook(() => useEncoreRegistry()); + + expect(result.current.loading).toBe(true); + + await waitFor(() => { + expect(result.current.loading).toBe(false); + }); + + expect(result.current.encores).toEqual(mockEncores); + expect(window.maestro.encores.getAll).toHaveBeenCalledOnce(); + }); + + it('getActiveEncores filters to active encores', async () => { + const { result } = renderHook(() => useEncoreRegistry()); + + await waitFor(() => { + expect(result.current.loading).toBe(false); + }); + + const active = result.current.getActiveEncores(); + expect(active).toHaveLength(1); + expect(active[0].manifest.id).toBe('test-encore'); + }); + + it('getEncoreTabs collects tabs from active encores', async () => { + const { result } = renderHook(() => useEncoreRegistry()); + + await waitFor(() => { + expect(result.current.loading).toBe(false); + }); + + const tabs = result.current.getEncoreTabs(); + expect(tabs).toHaveLength(1); + expect(tabs[0]).toEqual({ + encoreId: 'test-encore', + tabId: 'test-tab', + label: 'Test Tab', + icon: 'chart', + }); + }); + + it('enableEncore calls IPC and refreshes', async () => { + const { result } = renderHook(() => useEncoreRegistry()); + + await waitFor(() => { + expect(result.current.loading).toBe(false); + }); + + await act(async () => { + await result.current.enableEncore('disabled-encore'); + }); + + expect(window.maestro.encores.enable).toHaveBeenCalledWith('disabled-encore'); + // Should have called getAll twice: once on mount, once after enable + expect(window.maestro.encores.getAll).toHaveBeenCalledTimes(2); + }); + + it('disableEncore calls IPC and refreshes', async () => { + const { result } = renderHook(() => useEncoreRegistry()); + + await waitFor(() => { + expect(result.current.loading).toBe(false); + }); + + await act(async () => { + await result.current.disableEncore('test-encore'); + }); + + expect(window.maestro.encores.disable).toHaveBeenCalledWith('test-encore'); + expect(window.maestro.encores.getAll).toHaveBeenCalledTimes(2); + }); + + it('refreshEncores re-fetches from main process', async () => { + const { result } = renderHook(() => useEncoreRegistry()); + + await waitFor(() => { + expect(result.current.loading).toBe(false); + }); + + await act(async () => { + await result.current.refreshEncores(); + }); + + expect(window.maestro.encores.getAll).toHaveBeenCalledTimes(2); + }); + + it('returns empty tabs when no encores have UI', async () => { + vi.mocked(window.maestro.encores.getAll).mockResolvedValue({ + success: true, + encores: [ + { + manifest: { + id: 'no-ui', + name: 'No UI Encore', + version: '1.0.0', + description: 'No UI', + author: 'Test', + main: 'index.js', + permissions: [], + }, + state: 'active', + path: '/encores/no-ui', + }, + ], + }); + + const { result } = renderHook(() => useEncoreRegistry()); + + await waitFor(() => { + expect(result.current.loading).toBe(false); + }); + + expect(result.current.getEncoreTabs()).toHaveLength(0); + }); +}); diff --git a/src/__tests__/setup.ts b/src/__tests__/setup.ts index b802be0f2..7cd1b207e 100644 --- a/src/__tests__/setup.ts +++ b/src/__tests__/setup.ts @@ -537,6 +537,21 @@ const mockMaestro = { }, // Synchronous platform string (replaces async os.getPlatform IPC) platform: 'darwin', + encores: { + getAll: vi.fn().mockResolvedValue({ success: true, encores: [] }), + enable: vi.fn().mockResolvedValue({ success: true, enabled: true }), + disable: vi.fn().mockResolvedValue({ success: true, disabled: true }), + getDir: vi.fn().mockResolvedValue({ success: true, dir: '/tmp/encores' }), + refresh: vi.fn().mockResolvedValue({ success: true, encores: [] }), + settings: { + get: vi.fn().mockResolvedValue({ success: true, settings: {} }), + set: vi.fn().mockResolvedValue({ success: true, set: true }), + }, + bridge: { + invoke: vi.fn().mockResolvedValue(undefined), + send: vi.fn(), + }, + }, }; // Only mock window.maestro if window exists (jsdom environment) diff --git a/src/encores/agent-status-exporter/README.md b/src/encores/agent-status-exporter/README.md new file mode 100644 index 000000000..ecfd6e27d --- /dev/null +++ b/src/encores/agent-status-exporter/README.md @@ -0,0 +1,64 @@ +# Agent Status Exporter + +Exports real-time agent status to a JSON file that external programs can read. + +## How It Works + +When enabled, this plugin monitors all active agents and writes a `status.json` file containing live metrics: token usage, cost, tool executions, and runtime. The file updates every 500ms (debounced) whenever agent activity occurs. A heartbeat write runs every 10 seconds even when idle, so consumers can distinguish "no active agents" from "stale data." + +## Output File + +By default, the status file is written to the plugin's data directory: + +``` +~/.config/maestro/plugins/agent-status-exporter/data/status.json +``` + +You can override this with the **Output Path** setting below. Set it to any absolute path (e.g., `/tmp/maestro-status.json`) to write the file elsewhere. + +## JSON Schema + +```json +{ + "timestamp": 1700000000000, + "agents": [ + { + "sessionId": "abc-123", + "agentType": "claude-code", + "pid": 12345, + "startTime": 1700000000000, + "runtimeSeconds": 42, + "status": "active", + "tokens": { + "input": 1500, + "output": 800, + "cacheRead": 200, + "contextWindow": 128000 + }, + "cost": 0.0234, + "lastTool": { + "name": "Edit", + "timestamp": 1700000000000 + } + } + ], + "totals": { + "activeAgents": 1, + "totalInputTokens": 1500, + "totalOutputTokens": 800, + "totalCost": 0.0234 + } +} +``` + +## Settings + +| Setting | Type | Default | Description | +|---------|------|---------|-------------| +| Output Path | string | *(plugin data dir)* | Absolute path for the status.json file. Leave empty to use the default location. | + +## Permissions + +- **process:read** — Subscribes to agent lifecycle events (data, usage, tool execution, exit) +- **storage** — Writes the status.json file to disk +- **settings:read** — Reads the configured output path diff --git a/src/encores/agent-status-exporter/index.js b/src/encores/agent-status-exporter/index.js new file mode 100644 index 000000000..f3b2dcd61 --- /dev/null +++ b/src/encores/agent-status-exporter/index.js @@ -0,0 +1,218 @@ +/** + * Agent Status Exporter Plugin + * + * Exports real-time agent status to a JSON file for external consumption. + * Main-process-only plugin — no renderer/iframe UI. + * + * A heartbeat timer writes status every 10 seconds even when idle, + * so consumers can distinguish "no active agents" from "stale data." + */ + +const fsPromises = require('fs').promises; +const pathModule = require('path'); + +const LOG_TAG = '[agent-status-exporter]'; +const HEARTBEAT_INTERVAL_MS = 10000; + +/** @type {Map} */ +const agents = new Map(); + +let debounceTimer = null; +let heartbeatTimer = null; +let api = null; + +function debounceWriteStatus() { + if (debounceTimer) { + clearTimeout(debounceTimer); + } + debounceTimer = setTimeout(() => { + debounceTimer = null; + writeStatus(); + }, 500); +} + +async function writeStatus() { + if (!api) return; + + try { + const now = Date.now(); + const agentList = []; + let totalInputTokens = 0; + let totalOutputTokens = 0; + let totalCost = 0; + let activeAgents = 0; + + for (const agent of agents.values()) { + const runtimeSeconds = Math.floor((now - agent.startTime) / 1000); + agentList.push({ + sessionId: agent.sessionId, + agentType: agent.agentType, + pid: agent.pid, + startTime: agent.startTime, + runtimeSeconds, + status: agent.status, + tokens: { ...agent.tokens }, + cost: agent.cost, + lastTool: agent.lastTool ? { ...agent.lastTool } : null, + }); + + if (agent.status === 'active') { + activeAgents++; + } + totalInputTokens += agent.tokens.input; + totalOutputTokens += agent.tokens.output; + totalCost += agent.cost; + } + + const output = { + timestamp: now, + agents: agentList, + totals: { + activeAgents, + totalInputTokens, + totalOutputTokens, + totalCost, + }, + }; + + const json = JSON.stringify(output, null, 2); + + // Check for custom output path; fall back to encore storage. + // api.settings is always available since this encore declares settings:read. + const customPath = await api.settings.get('outputPath'); + + if (customPath && typeof customPath === 'string' && pathModule.isAbsolute(customPath)) { + await fsPromises.mkdir(pathModule.dirname(customPath), { recursive: true }); + await fsPromises.writeFile(customPath, json, 'utf-8'); + } else { + await api.storage.write('status.json', json); + } + } catch (err) { + // Filesystem permission/access errors are expected (e.g., user-configured + // outputPath becomes inaccessible). Log but don't escalate — the heartbeat + // interval must keep running so subsequent writes can succeed. + const code = err && err.code; + if (code === 'EACCES' || code === 'EPERM' || code === 'ENOSPC' || code === 'EROFS') { + console.warn(`${LOG_TAG} Write failed (${code}):`, err.message); + return; + } + + // Unexpected error — log with full stack for debugging. Cannot rethrow + // because writeStatus is called from setInterval/setTimeout and an + // unhandled rejection would crash the main process. + console.error(`${LOG_TAG} Unexpected error writing status:`, err); + } +} + +function ensureAgent(sessionId) { + if (!agents.has(sessionId)) { + agents.set(sessionId, { + sessionId, + agentType: 'unknown', + pid: 0, + startTime: Date.now(), + tokens: { input: 0, output: 0, cacheRead: 0, contextWindow: 0 }, + cost: 0, + lastTool: null, + status: 'active', + exitedAt: null, + }); + } + return agents.get(sessionId); +} + +async function activate(pluginApi) { + api = pluginApi; + + // Seed initial state from already-running agents + try { + const active = await api.process.getActiveProcesses(); + for (const proc of active) { + const agent = ensureAgent(proc.sessionId); + agent.agentType = proc.toolType || 'unknown'; + agent.pid = proc.pid || 0; + agent.startTime = proc.startTime || Date.now(); + } + } catch (err) { + console.error(`${LOG_TAG} Failed to get active processes:`, err); + } + + // Subscribe to usage updates + api.process.onUsage((sessionId, stats) => { + const agent = ensureAgent(sessionId); + if (stats.inputTokens !== undefined) agent.tokens.input = stats.inputTokens; + if (stats.outputTokens !== undefined) agent.tokens.output = stats.outputTokens; + if (stats.cacheReadTokens !== undefined) agent.tokens.cacheRead = stats.cacheReadTokens; + if (stats.contextWindow !== undefined) agent.tokens.contextWindow = stats.contextWindow; + if (stats.totalCostUsd !== undefined) agent.cost = stats.totalCostUsd; + debounceWriteStatus(); + }); + + // Subscribe to tool executions + api.process.onToolExecution((sessionId, tool) => { + const agent = ensureAgent(sessionId); + agent.lastTool = { + name: tool?.toolName ?? 'unknown', + timestamp: Date.now(), + }; + debounceWriteStatus(); + }); + + // Subscribe to agent exits + api.process.onExit((sessionId, code) => { + const agent = agents.get(sessionId); + if (agent) { + agent.status = 'exited'; + agent.exitedAt = Date.now(); + debounceWriteStatus(); + + // Remove exited agent after 30 seconds + setTimeout(() => { + agents.delete(sessionId); + debounceWriteStatus(); + }, 30000); + } + }); + + // Subscribe to data events to catch agents that started between getActiveProcesses and subscriptions + api.process.onData((sessionId, _data) => { + if (!agents.has(sessionId)) { + ensureAgent(sessionId); + debounceWriteStatus(); + } + }); + + // Write initial status immediately + await writeStatus(); + + // Start heartbeat — writes status every 10s even when idle so consumers + // always see a fresh timestamp and know Maestro is running + heartbeatTimer = setInterval(() => { + writeStatus(); + }, HEARTBEAT_INTERVAL_MS); +} + +async function deactivate() { + // Clear pending timers + if (debounceTimer) { + clearTimeout(debounceTimer); + debounceTimer = null; + } + if (heartbeatTimer) { + clearInterval(heartbeatTimer); + heartbeatTimer = null; + } + + // Mark all agents as exited and write final status + for (const agent of agents.values()) { + agent.status = 'exited'; + agent.exitedAt = Date.now(); + } + await writeStatus(); + + // Cleanup + agents.clear(); + api = null; +} + +module.exports = { activate, deactivate }; diff --git a/src/encores/agent-status-exporter/manifest.json b/src/encores/agent-status-exporter/manifest.json new file mode 100644 index 000000000..fa91e04c1 --- /dev/null +++ b/src/encores/agent-status-exporter/manifest.json @@ -0,0 +1,14 @@ +{ + "id": "agent-status-exporter", + "name": "Agent Status Exporter", + "version": "2.0.0", + "description": "Exports real-time agent status (active agents, token usage, cost, tool executions) to a JSON file for external consumption.", + "author": "Maestro Core", + "firstParty": true, + "main": "index.js", + "permissions": ["process:read", "storage", "settings:read"], + "settings": [ + { "key": "outputPath", "type": "string", "label": "Output Path", "default": "" } + ], + "tags": ["status", "exporter", "monitoring", "agents"] +} diff --git a/src/encores/notification-webhook/README.md b/src/encores/notification-webhook/README.md new file mode 100644 index 000000000..80d7809cb --- /dev/null +++ b/src/encores/notification-webhook/README.md @@ -0,0 +1,65 @@ +# Notification Webhook + +Sends HTTP POST requests to a webhook URL when agents complete tasks or encounter errors. + +## How It Works + +When enabled, this plugin monitors agent output for error patterns and listens for agent exit events. When a matching event occurs and a webhook URL is configured, it sends a JSON payload via HTTP POST. + +## Setup + +1. Enable the plugin in the Plugins tab +2. Set your **Webhook URL** (any HTTP/HTTPS endpoint that accepts POST requests) +3. Toggle which events you want notifications for + +## Webhook Payloads + +### Agent Exit + +Sent when an agent process exits (task completion or crash). + +```json +{ + "event": "agent.exit", + "sessionId": "abc-123", + "exitCode": 0, + "lastOutput": "...last ~1000 characters of agent output...", + "timestamp": 1700000000000 +} +``` + +The `lastOutput` field contains the last ~1000 characters of the agent's output, giving context about what it was working on when it exited. + +### Agent Error + +Sent when error patterns are detected in agent output. + +```json +{ + "event": "agent.error", + "sessionId": "abc-123", + "snippet": "Error: ENOENT: no such file or directory...", + "timestamp": 1700000000000 +} +``` + +## Error Detection + +The plugin watches for these patterns in agent output: +- `Error:`, `FATAL`, `panic:`, `Traceback` +- `ECONNREFUSED`, `ENOENT`, `Permission denied` + +## Settings + +| Setting | Type | Default | Description | +|---------|------|---------|-------------| +| Webhook URL | string | *(empty)* | The URL to send POST requests to. No webhooks are sent if empty. | +| Notify on Agent Completion | boolean | `true` | Send a webhook when an agent exits. | +| Notify on Agent Error | boolean | `true` | Send a webhook when an error is detected in agent output. | + +## Permissions + +- **process:read** — Subscribes to agent data and exit events +- **settings:write** — Reads and stores webhook configuration +- **notifications** — Desktop notification capability +- **network** — Sends HTTP requests to the configured webhook URL diff --git a/src/encores/notification-webhook/index.js b/src/encores/notification-webhook/index.js new file mode 100644 index 000000000..e60fb0426 --- /dev/null +++ b/src/encores/notification-webhook/index.js @@ -0,0 +1,189 @@ +/** + * Notification Webhook Plugin + * + * Sends HTTP POST requests on agent lifecycle events. + * Main-process-only plugin — uses Node.js http/https modules directly. + */ + +const http = require('http'); +const https = require('https'); +const { URL } = require('url'); + +let api = null; +const unsubscribers = []; + +/** Per-session rolling buffer of recent output (last ~1000 chars) */ +const sessionOutput = new Map(); +/** Per-session agent info cache (looked up on first data event) */ +const sessionAgentType = new Map(); +const sessionAgentName = new Map(); +const MAX_BUFFER = 1000; + +/** + * Sends a webhook POST request. + * Never throws — all errors are caught and logged. + */ +function sendWebhook(url, payload) { + return new Promise((resolve) => { + try { + const parsed = new URL(url); + const transport = parsed.protocol === 'https:' ? https : http; + const body = JSON.stringify(payload); + + // Resolve 'localhost' to IPv4 127.0.0.1 to avoid IPv6 ::1 ECONNREFUSED + const hostname = parsed.hostname === 'localhost' ? '127.0.0.1' : parsed.hostname; + + const req = transport.request( + { + hostname, + port: parsed.port || (parsed.protocol === 'https:' ? 443 : 80), + path: parsed.pathname + parsed.search, + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Content-Length': Buffer.byteLength(body), + }, + timeout: 10000, + }, + (res) => { + // Consume response data to free memory + res.resume(); + resolve({ status: res.statusCode }); + } + ); + + req.on('timeout', () => { + req.destroy(); + console.error('[notification-webhook] Request timed out'); + resolve({ error: 'timeout' }); + }); + + req.on('error', (err) => { + console.error('[notification-webhook] Request error:', err.message); + resolve({ error: err.message }); + }); + + req.write(body); + req.end(); + } catch (err) { + console.error('[notification-webhook] Failed to send webhook:', err.message); + resolve({ error: err.message }); + } + }); +} + +/** Common error indicators in agent output */ +const ERROR_PATTERNS = [ + 'Error:', + 'FATAL', + 'panic:', + 'Traceback', + 'ECONNREFUSED', + 'ENOENT', + 'Permission denied', +]; + +function containsError(data) { + if (typeof data !== 'string') return false; + return ERROR_PATTERNS.some((pattern) => data.includes(pattern)); +} + +async function activate(pluginApi) { + api = pluginApi; + + // Buffer recent output per session so we can include it in exit webhooks + const unsubData = api.process.onData(async (sessionId, data) => { + if (typeof data !== 'string') return; + + // Look up agent type on first data event for this session + if (!sessionAgentType.has(sessionId)) { + try { + const procs = await api.process.getActiveProcesses(); + const proc = procs.find((p) => p.sessionId === sessionId); + if (proc) { + sessionAgentType.set(sessionId, proc.toolType); + if (proc.name) sessionAgentName.set(sessionId, proc.name); + } + } catch { + // Ignore lookup errors + } + } + + // Update rolling buffer + const existing = sessionOutput.get(sessionId) || ''; + const updated = (existing + data).slice(-MAX_BUFFER); + sessionOutput.set(sessionId, updated); + + // Check for error patterns + if (!containsError(data)) return; + + try { + const notifyOnError = await api.settings.get('notifyOnError'); + if (notifyOnError === false) return; + + const webhookUrl = await api.settings.get('webhookUrl'); + if (!webhookUrl) return; + + await sendWebhook(webhookUrl, { + event: 'agent.error', + sessionId, + agentType: sessionAgentType.get(sessionId) || null, + agentName: sessionAgentName.get(sessionId) || null, + snippet: data.substring(0, 500), + timestamp: Date.now(), + }); + } catch (err) { + console.error('[notification-webhook] Error handling data event:', err.message); + } + }); + unsubscribers.push(unsubData); + + // Subscribe to agent exits + const unsubExit = api.process.onExit(async (sessionId, code) => { + // Grab buffered output and agent info before cleanup + const lastOutput = sessionOutput.get(sessionId) || ''; + const agentType = sessionAgentType.get(sessionId) || null; + const agentName = sessionAgentName.get(sessionId) || null; + sessionOutput.delete(sessionId); + sessionAgentType.delete(sessionId); + sessionAgentName.delete(sessionId); + + try { + const notifyOnCompletion = await api.settings.get('notifyOnCompletion'); + if (notifyOnCompletion === false) return; + + const webhookUrl = await api.settings.get('webhookUrl'); + if (!webhookUrl) return; + + await sendWebhook(webhookUrl, { + event: 'agent.exit', + sessionId, + agentType, + agentName, + exitCode: code, + lastOutput: lastOutput.trim(), + timestamp: Date.now(), + }); + } catch (err) { + console.error('[notification-webhook] Error handling exit event:', err.message); + } + }); + unsubscribers.push(unsubExit); +} + +async function deactivate() { + for (const unsub of unsubscribers) { + try { + unsub(); + } catch { + // Ignore cleanup errors + } + } + unsubscribers.length = 0; + sessionOutput.clear(); + sessionAgentType.clear(); + sessionAgentName.clear(); + api = null; +} + +module.exports = { activate, deactivate, sendWebhook, containsError }; diff --git a/src/encores/notification-webhook/manifest.json b/src/encores/notification-webhook/manifest.json new file mode 100644 index 000000000..d6b6201a0 --- /dev/null +++ b/src/encores/notification-webhook/manifest.json @@ -0,0 +1,16 @@ +{ + "id": "notification-webhook", + "name": "Notification Webhook", + "version": "1.5.0", + "description": "Send webhook notifications when agents complete tasks or encounter errors.", + "author": "Maestro Core", + "firstParty": true, + "main": "index.js", + "permissions": ["process:read", "settings:write", "notifications", "network"], + "settings": [ + { "key": "webhookUrl", "type": "string", "label": "Webhook URL", "default": "" }, + { "key": "notifyOnCompletion", "type": "boolean", "label": "Notify on Agent Completion", "default": true }, + { "key": "notifyOnError", "type": "boolean", "label": "Notify on Agent Error", "default": true } + ], + "tags": ["notifications", "webhook", "automation"] +} diff --git a/src/main/encore-host.ts b/src/main/encore-host.ts new file mode 100644 index 000000000..3cba4033b --- /dev/null +++ b/src/main/encore-host.ts @@ -0,0 +1,454 @@ +/** + * Encore Host + * + * Manages encore lifecycle and provides scoped API objects to encores. + * Each encore receives a EncoreAPI object with only the namespaces + * permitted by its declared permissions. + */ + +import path from 'path'; +import fs from 'fs/promises'; +import { Notification, type App, type BrowserWindow } from 'electron'; +import { logger } from './utils/logger'; +import { captureException } from './utils/sentry'; +import type { ProcessManager } from './process-manager'; +import type Store from 'electron-store'; +import type { MaestroSettings, SessionsData } from './stores/types'; +import type { + LoadedEncore, + EncoreAPI, + EncoreContext, + EncoreModule, + EncoreProcessAPI, + EncoreProcessControlAPI, + EncoreStatsAPI, + EncoreSettingsAPI, + EncoreStorageAPI, + EncoreNotificationsAPI, + EncoreMaestroAPI, + EncoreIpcBridgeAPI, +} from '../shared/encore-types'; +import type { StatsAggregation } from '../shared/stats-types'; +import { getStatsDB } from './stats/singleton'; +import { EncoreStorage } from './encore-storage'; +import type { EncoreIpcBridge } from './encore-ipc-bridge'; + +const LOG_CONTEXT = '[Encores]'; + +// ============================================================================ +// Dependencies Interface +// ============================================================================ + +export interface EncoreHostDependencies { + getProcessManager: () => ProcessManager | null; + getMainWindow: () => BrowserWindow | null; + settingsStore: Store; + sessionsStore?: Store; + app: App; + ipcBridge?: EncoreIpcBridge; +} + +// ============================================================================ +// EncoreHost +// ============================================================================ + +export class EncoreHost { + private deps: EncoreHostDependencies; + private encoreContexts: Map = new Map(); + /** + * Stores loaded encore module references for deactivation. + * TRUST BOUNDARY: Encore modules run in the same Node.js process as Maestro. + * For v1, this is acceptable because we only ship trusted/first-party encores. + * Third-party sandboxing (e.g., vm2, worker threads) is a v2 concern. + */ + private encoreModules: Map = new Map(); + private encoreStorages: Map = new Map(); + + constructor(deps: EncoreHostDependencies) { + this.deps = deps; + } + + /** + * Activates an encore by loading its main entry point and calling activate(). + * The encore receives a scoped EncoreAPI based on its declared permissions. + */ + async activateEncore(encore: LoadedEncore): Promise { + const encoreId = encore.manifest.id; + + try { + const entryPoint = path.join(encore.path, encore.manifest.main); + + // Verify the entry point exists + try { + await fs.access(entryPoint); + } catch { + throw new Error(`Encore entry point not found: ${encore.manifest.main}`); + } + + // Load the module using require() — encores are Node.js modules for v1 simplicity + // eslint-disable-next-line @typescript-eslint/no-var-requires + const encoreModule: EncoreModule = require(entryPoint); + + // Create context and activate + const context = this.createEncoreContext(encore); + + if (typeof encoreModule.activate === 'function') { + await encoreModule.activate(context.api); + } + + this.encoreModules.set(encoreId, encoreModule); + encore.state = 'active'; + logger.info(`Encore '${encoreId}' activated`, LOG_CONTEXT); + } catch (err) { + encore.state = 'error'; + encore.error = err instanceof Error ? err.message : String(err); + logger.error(`Encore '${encoreId}' failed to activate: ${encore.error}`, LOG_CONTEXT); + await captureException(err, { encoreId }); + } + } + + /** + * Deactivates an encore by calling its deactivate() function and cleaning up. + * Deactivation errors are logged but never propagated. + */ + async deactivateEncore(encoreId: string): Promise { + try { + const encoreModule = this.encoreModules.get(encoreId); + if (encoreModule && typeof encoreModule.deactivate === 'function') { + await encoreModule.deactivate(); + } + } catch (err) { + logger.error( + `Encore '${encoreId}' threw during deactivation: ${err instanceof Error ? err.message : String(err)}`, + LOG_CONTEXT + ); + } + + this.encoreModules.delete(encoreId); + this.encoreStorages.delete(encoreId); + this.destroyEncoreContext(encoreId); + + // Remove any IPC bridge handlers registered by this encore + if (this.deps.ipcBridge) { + this.deps.ipcBridge.unregisterAll(encoreId); + } + } + + /** + * Creates a scoped API based on the encore's declared permissions. + */ + createEncoreContext(encore: LoadedEncore): EncoreContext { + const eventSubscriptions: Array<() => void> = []; + + const api: EncoreAPI = { + process: this.createProcessAPI(encore, eventSubscriptions), + processControl: this.createProcessControlAPI(encore), + stats: this.createStatsAPI(encore, eventSubscriptions), + settings: this.createSettingsAPI(encore), + storage: this.createStorageAPI(encore), + notifications: this.createNotificationsAPI(encore), + maestro: this.createMaestroAPI(encore), + ipcBridge: this.createIpcBridgeAPI(encore), + }; + + const context: EncoreContext = { + encoreId: encore.manifest.id, + api, + cleanup: () => { + for (const unsub of eventSubscriptions) { + unsub(); + } + eventSubscriptions.length = 0; + }, + eventSubscriptions, + }; + + this.encoreContexts.set(encore.manifest.id, context); + logger.info(`Encore context created for '${encore.manifest.id}'`, LOG_CONTEXT); + return context; + } + + /** + * Cleans up event listeners, timers, etc. for an encore. + */ + destroyEncoreContext(encoreId: string): void { + const context = this.encoreContexts.get(encoreId); + if (!context) { + logger.warn(`No context to destroy for encore '${encoreId}'`, LOG_CONTEXT); + return; + } + + context.cleanup(); + this.encoreContexts.delete(encoreId); + logger.info(`Encore context destroyed for '${encoreId}'`, LOG_CONTEXT); + } + + /** + * Returns an encore context by ID, if one exists. + */ + getEncoreContext(encoreId: string): EncoreContext | undefined { + return this.encoreContexts.get(encoreId); + } + + // ======================================================================== + // Private API Factory Methods + // ======================================================================== + + private hasPermission(encore: LoadedEncore, permission: string): boolean { + return encore.manifest.permissions.includes(permission as any); + } + + private createProcessAPI( + encore: LoadedEncore, + eventSubscriptions: Array<() => void> + ): EncoreProcessAPI | undefined { + if (!this.hasPermission(encore, 'process:read')) { + return undefined; + } + + const getProcessManager = this.deps.getProcessManager; + const sessionsStore = this.deps.sessionsStore; + + return { + getActiveProcesses: async () => { + const pm = getProcessManager(); + if (!pm) return []; + // Look up session names from the sessions store + const storedSessions = sessionsStore?.get('sessions', []) ?? []; + const nameMap = new Map(storedSessions.map((s) => [s.id, s.name])); + + return pm.getAll().map((p) => { + // Process sessionId format: {baseId}-ai-{tabId}, {baseId}-terminal, etc. + const baseId = p.sessionId.replace(/-ai-.+$|-terminal$|-batch-\d+$|-synopsis-\d+$/, ''); + return { + sessionId: p.sessionId, + toolType: p.toolType, + pid: p.pid, + startTime: p.startTime, + name: nameMap.get(baseId) || null, + }; + }); + }, + + onData: (callback) => { + const pm = getProcessManager(); + if (!pm) return () => {}; + const handler = (sessionId: string, data: string) => callback(sessionId, data); + pm.on('data', handler); + const unsub = () => pm.removeListener('data', handler); + eventSubscriptions.push(unsub); + return unsub; + }, + + onUsage: (callback) => { + const pm = getProcessManager(); + if (!pm) return () => {}; + const handler = (sessionId: string, stats: any) => callback(sessionId, stats); + pm.on('usage', handler); + const unsub = () => pm.removeListener('usage', handler); + eventSubscriptions.push(unsub); + return unsub; + }, + + onToolExecution: (callback) => { + const pm = getProcessManager(); + if (!pm) return () => {}; + const handler = (sessionId: string, tool: any) => + callback(sessionId, { toolName: tool.toolName, state: tool.state, timestamp: tool.timestamp }); + pm.on('tool-execution', handler); + const unsub = () => pm.removeListener('tool-execution', handler); + eventSubscriptions.push(unsub); + return unsub; + }, + + onExit: (callback) => { + const pm = getProcessManager(); + if (!pm) return () => {}; + const handler = (sessionId: string, code: number) => callback(sessionId, code); + pm.on('exit', handler); + const unsub = () => pm.removeListener('exit', handler); + eventSubscriptions.push(unsub); + return unsub; + }, + + onThinkingChunk: (callback) => { + const pm = getProcessManager(); + if (!pm) return () => {}; + const handler = (sessionId: string, text: string) => callback(sessionId, text); + pm.on('thinking-chunk', handler); + const unsub = () => pm.removeListener('thinking-chunk', handler); + eventSubscriptions.push(unsub); + return unsub; + }, + }; + } + + private createProcessControlAPI(encore: LoadedEncore): EncoreProcessControlAPI | undefined { + if (!this.hasPermission(encore, 'process:write')) { + return undefined; + } + + const getProcessManager = this.deps.getProcessManager; + const encoreId = encore.manifest.id; + + return { + kill: (sessionId: string) => { + const pm = getProcessManager(); + if (!pm) return false; + logger.info(`[Encore:${encoreId}] killed session ${sessionId}`, LOG_CONTEXT); + return pm.kill(sessionId); + }, + + write: (sessionId: string, data: string) => { + const pm = getProcessManager(); + if (!pm) return false; + logger.info(`[Encore:${encoreId}] wrote to session ${sessionId}`, LOG_CONTEXT); + return pm.write(sessionId, data); + }, + }; + } + + private createStatsAPI( + encore: LoadedEncore, + eventSubscriptions: Array<() => void> + ): EncoreStatsAPI | undefined { + if (!this.hasPermission(encore, 'stats:read')) { + return undefined; + } + + const getMainWindow = this.deps.getMainWindow; + + return { + getAggregation: async (range: string): Promise => { + const db = getStatsDB(); + if (!db) { + throw new Error('Stats database not available'); + } + return db.getAggregatedStats(range as any); + }, + + onStatsUpdate: (callback) => { + const win = getMainWindow(); + if (!win) return () => {}; + const listener = (_event: unknown, channel: string) => { + if (channel === 'stats:updated') callback(); + }; + win.webContents.on('ipc-message', listener); + const unsub = () => { + const currentWin = getMainWindow(); + if (currentWin) { + currentWin.webContents.removeListener('ipc-message', listener); + } + }; + eventSubscriptions.push(unsub); + return unsub; + }, + }; + } + + private createSettingsAPI(encore: LoadedEncore): EncoreSettingsAPI | undefined { + const canRead = this.hasPermission(encore, 'settings:read'); + const canWrite = this.hasPermission(encore, 'settings:write'); + + if (!canRead && !canWrite) { + return undefined; + } + + const store = this.deps.settingsStore; + const prefix = `encore:${encore.manifest.id}:`; + + return { + get: async (key: string) => { + return store.get(`${prefix}${key}` as any); + }, + + set: async (key: string, value: unknown) => { + if (!canWrite) { + throw new Error(`Encore '${encore.manifest.id}' does not have 'settings:write' permission`); + } + store.set(`${prefix}${key}` as any, value as any); + }, + + getAll: async () => { + const all = store.store; + const result: Record = {}; + for (const [k, v] of Object.entries(all)) { + if (k.startsWith(prefix)) { + result[k.slice(prefix.length)] = v; + } + } + return result; + }, + }; + } + + private createStorageAPI(encore: LoadedEncore): EncoreStorageAPI | undefined { + if (!this.hasPermission(encore, 'storage')) { + return undefined; + } + + const storageDir = path.join(this.deps.app.getPath('userData'), 'encores', encore.manifest.id, 'data'); + const storage = new EncoreStorage(encore.manifest.id, storageDir); + this.encoreStorages.set(encore.manifest.id, storage); + + return { + read: (filename: string) => storage.read(filename), + write: (filename: string, data: string) => storage.write(filename, data), + list: () => storage.list(), + delete: (filename: string) => storage.delete(filename), + }; + } + + private createIpcBridgeAPI(encore: LoadedEncore): EncoreIpcBridgeAPI | undefined { + const bridge = this.deps.ipcBridge; + if (!bridge) { + return undefined; + } + + const encoreId = encore.manifest.id; + const getMainWindow = this.deps.getMainWindow; + + return { + onMessage: (channel: string, handler: (...args: unknown[]) => unknown) => { + return bridge.register(encoreId, channel, handler); + }, + sendToRenderer: (channel: string, ...args: unknown[]) => { + const win = getMainWindow(); + if (win) { + win.webContents.send(`encore:${encoreId}:${channel}`, ...args); + } + }, + }; + } + + private createNotificationsAPI(encore: LoadedEncore): EncoreNotificationsAPI | undefined { + if (!this.hasPermission(encore, 'notifications')) { + return undefined; + } + + return { + show: async (title: string, body: string) => { + new Notification({ title, body }).show(); + }, + + playSound: async (sound: string) => { + const win = this.deps.getMainWindow(); + if (win) { + win.webContents.send('encore:playSound', sound); + } + }, + }; + } + + private createMaestroAPI(encore: LoadedEncore): EncoreMaestroAPI { + const encoresDir = path.join(this.deps.app.getPath('userData'), 'encores'); + + return { + version: this.deps.app.getVersion(), + platform: process.platform, + encoreId: encore.manifest.id, + encoreDir: encore.path, + dataDir: path.join(encoresDir, encore.manifest.id, 'data'), + }; + } +} diff --git a/src/main/encore-ipc-bridge.ts b/src/main/encore-ipc-bridge.ts new file mode 100644 index 000000000..1b5ffff9b --- /dev/null +++ b/src/main/encore-ipc-bridge.ts @@ -0,0 +1,105 @@ +/** + * Encore IPC Bridge + * + * Enables split-architecture encores where a renderer component sends data + * to its main-process component via IPC. This is what makes patterns like + * Notifications work: renderer subscribes to Zustand batch state, forwards + * events via bridge, and the main-process component dispatches webhooks. + */ + +import { logger } from './utils/logger'; +import { captureException } from './utils/sentry'; + +const LOG_CONTEXT = '[Encores]'; + +/** + * Routes IPC messages between renderer and main-process encore components. + * Channels are namespaced as `encore::`. + */ +export class EncoreIpcBridge { + /** Handlers keyed by `encore::` */ + private handlers: Map unknown> = new Map(); + + /** + * Builds the internal channel key. + */ + private channelKey(encoreId: string, channel: string): string { + return `encore:${encoreId}:${channel}`; + } + + /** + * Registers a handler for a specific encore channel. + * Returns an unsubscribe function. + */ + register(encoreId: string, channel: string, handler: (...args: unknown[]) => unknown): () => void { + const key = this.channelKey(encoreId, channel); + this.handlers.set(key, handler); + logger.debug(`IPC bridge handler registered: ${key}`, LOG_CONTEXT); + + return () => { + this.handlers.delete(key); + }; + } + + /** + * Invokes a registered handler and returns its result. + * Throws if no handler is registered for the channel. + */ + async invoke(encoreId: string, channel: string, ...args: unknown[]): Promise { + const key = this.channelKey(encoreId, channel); + const handler = this.handlers.get(key); + if (!handler) { + throw new Error(`No handler registered for channel '${key}'`); + } + return handler(...args); + } + + /** + * Sends a one-way message to a registered handler (fire-and-forget). + * Silently ignores if no handler is registered. + */ + send(encoreId: string, channel: string, ...args: unknown[]): void { + const key = this.channelKey(encoreId, channel); + const handler = this.handlers.get(key); + if (handler) { + try { + handler(...args); + } catch (err) { + logger.error( + `IPC bridge send error on '${key}': ${err instanceof Error ? err.message : String(err)}`, + LOG_CONTEXT + ); + captureException(err, { encoreId, channel, key }); + } + } + } + + /** + * Removes all handlers for a given encore. + */ + unregisterAll(encoreId: string): void { + const prefix = `encore:${encoreId}:`; + const keysToDelete: string[] = []; + + for (const key of this.handlers.keys()) { + if (key.startsWith(prefix)) { + keysToDelete.push(key); + } + } + + for (const key of keysToDelete) { + this.handlers.delete(key); + } + + if (keysToDelete.length > 0) { + logger.debug(`IPC bridge: removed ${keysToDelete.length} handler(s) for encore '${encoreId}'`, LOG_CONTEXT); + } + } + + /** + * Returns whether a handler is registered for a channel. + */ + hasHandler(encoreId: string, channel: string): boolean { + return this.handlers.has(this.channelKey(encoreId, channel)); + } +} diff --git a/src/main/encore-loader.ts b/src/main/encore-loader.ts new file mode 100644 index 000000000..06b6d92e4 --- /dev/null +++ b/src/main/encore-loader.ts @@ -0,0 +1,271 @@ +/** + * Plugin Discovery and Loader + * + * Discovers encores from the userData/encores/ directory, reads and validates + * their manifest.json files, and returns LoadedEncore objects. + * + * Plugins with invalid manifests are returned with state 'error' rather than + * throwing, so that other encores can still load. + */ + +import fs from 'fs/promises'; +import path from 'path'; +import type { App } from 'electron'; +import { logger } from './utils/logger'; +import type { EncoreManifest, LoadedEncore } from '../shared/encore-types'; +import { KNOWN_PERMISSIONS } from '../shared/encore-types'; + +const LOG_CONTEXT = '[Encores]'; + +/** + * Valid slug pattern: lowercase alphanumeric and hyphens only. + */ +const SLUG_REGEX = /^[a-z0-9]+(?:-[a-z0-9]+)*$/; + +/** + * Returns the encores directory path under userData. + */ +export function getEncoresDir(app: App): string { + return path.join(app.getPath('userData'), 'encores'); +} + +/** + * Type guard that validates an unknown value is a valid EncoreManifest. + * Checks required fields, slug format, and permissions. + * Logs warnings for unknown fields (forward compatibility). + */ +export function validateEncoreManifest(manifest: unknown): manifest is EncoreManifest { + if (!manifest || typeof manifest !== 'object') { + return false; + } + + const obj = manifest as Record; + + // Required string fields + const requiredStrings = ['id', 'name', 'version', 'description', 'author', 'main'] as const; + for (const field of requiredStrings) { + if (typeof obj[field] !== 'string' || (obj[field] as string).trim() === '') { + logger.debug(`Manifest validation failed: missing or empty required field '${field}'`, LOG_CONTEXT); + return false; + } + } + + // Validate id is a valid slug + if (!SLUG_REGEX.test(obj.id as string)) { + logger.debug(`Manifest validation failed: invalid slug format for id '${obj.id}'`, LOG_CONTEXT); + return false; + } + + // Validate permissions array + if (!Array.isArray(obj.permissions)) { + logger.debug('Manifest validation failed: permissions must be an array', LOG_CONTEXT); + return false; + } + + const knownSet = new Set(KNOWN_PERMISSIONS); + for (const perm of obj.permissions) { + if (typeof perm !== 'string' || !knownSet.has(perm)) { + logger.debug(`Manifest validation failed: unknown permission '${perm}'`, LOG_CONTEXT); + return false; + } + } + + // Log warnings for unknown top-level fields (forward compatibility) + const knownFields = new Set([ + 'id', 'name', 'version', 'description', 'author', 'authorLink', + 'minMaestroVersion', 'main', 'renderer', 'permissions', 'ui', + 'settings', 'tags', 'firstParty', + ]); + for (const key of Object.keys(obj)) { + if (!knownFields.has(key)) { + logger.debug(`Manifest contains unknown field '${key}' (ignored for forward compatibility)`, LOG_CONTEXT); + } + } + + return true; +} + +/** + * Loads a single encore from a directory path. + * Reads manifest.json, validates it, and returns a LoadedEncore. + * On validation failure, returns a LoadedEncore with state 'error'. + */ +export async function loadEncore(pluginPath: string): Promise { + const manifestPath = path.join(pluginPath, 'manifest.json'); + + // Create a minimal error manifest for failure cases + const errorPlugin = (error: string): LoadedEncore => ({ + manifest: { + id: path.basename(pluginPath), + name: path.basename(pluginPath), + version: '0.0.0', + description: '', + author: '', + main: '', + permissions: [], + }, + state: 'error', + path: pluginPath, + error, + }); + + let raw: string; + try { + raw = await fs.readFile(manifestPath, 'utf-8'); + } catch (err) { + const message = `Failed to read manifest.json: ${err instanceof Error ? err.message : String(err)}`; + logger.warn(message, LOG_CONTEXT, { pluginPath }); + return errorPlugin(message); + } + + let parsed: unknown; + try { + parsed = JSON.parse(raw); + } catch (err) { + const message = `Invalid JSON in manifest.json: ${err instanceof Error ? err.message : String(err)}`; + logger.warn(message, LOG_CONTEXT, { pluginPath }); + return errorPlugin(message); + } + + if (!validateEncoreManifest(parsed)) { + const message = 'Manifest validation failed: check required fields, id format, and permissions'; + logger.warn(message, LOG_CONTEXT, { pluginPath }); + return errorPlugin(message); + } + + // Attempt to load README.md if present + let readme: string | undefined; + try { + readme = await fs.readFile(path.join(pluginPath, 'README.md'), 'utf-8'); + } catch { + // No README — that's fine + } + + return { + manifest: parsed, + state: 'discovered', + path: pluginPath, + readme, + }; +} + +/** + * Copies bundled first-party encores from src/encores/ to userData/encores/. + * Only copies if the encore doesn't already exist in userData (preserves user modifications). + * On version mismatch, overwrites with the bundled version (first-party encores are always updated). + */ +export async function bootstrapBundledEncores(encoresDir: string): Promise { + // Resolve bundled encores directory relative to the app root + // In dev: src/encores/ In production: resources/encores/ (if packaged) + const bundledDir = path.join(__dirname, '..', 'encores'); + + let bundledEntries: string[]; + try { + bundledEntries = await fs.readdir(bundledDir); + } catch { + // No bundled encores directory — this is fine in some build configurations + logger.debug('No bundled encores directory found, skipping bootstrap', LOG_CONTEXT); + return; + } + + await fs.mkdir(encoresDir, { recursive: true }); + + // Clean up deprecated/renamed encore directories + const deprecatedEncores = ['agent-dashboard']; + for (const oldId of deprecatedEncores) { + const oldPath = path.join(encoresDir, oldId); + try { + await fs.rm(oldPath, { recursive: true, force: true }); + logger.info(`Removed deprecated encore directory '${oldId}'`, LOG_CONTEXT); + } catch { + // Doesn't exist or already removed — fine + } + } + + for (const entry of bundledEntries) { + const srcPath = path.join(bundledDir, entry); + const destPath = path.join(encoresDir, entry); + + try { + const stat = await fs.stat(srcPath); + if (!stat.isDirectory()) continue; + + // Check if bundled encore has a valid manifest + const srcManifestPath = path.join(srcPath, 'manifest.json'); + let srcManifestRaw: string; + try { + srcManifestRaw = await fs.readFile(srcManifestPath, 'utf-8'); + } catch { + continue; // Skip entries without manifest.json + } + + const srcManifest = JSON.parse(srcManifestRaw); + + // Check if destination already exists + let shouldCopy = false; + try { + const destManifestPath = path.join(destPath, 'manifest.json'); + const destManifestRaw = await fs.readFile(destManifestPath, 'utf-8'); + const destManifest = JSON.parse(destManifestRaw); + // Overwrite if version differs (update bundled encores) + if (destManifest.version !== srcManifest.version) { + shouldCopy = true; + logger.info(`Updating bundled encore '${entry}' from v${destManifest.version} to v${srcManifest.version}`, LOG_CONTEXT); + } + } catch { + // Destination doesn't exist or has invalid manifest — copy it + shouldCopy = true; + logger.info(`Installing bundled encore '${entry}' v${srcManifest.version}`, LOG_CONTEXT); + } + + if (shouldCopy) { + // Remove existing destination if it exists + await fs.rm(destPath, { recursive: true, force: true }); + + // Copy entire encore directory (including subdirectories) + await fs.cp(srcPath, destPath, { recursive: true }); + } + } catch (err) { + logger.warn(`Failed to bootstrap bundled encore '${entry}': ${err instanceof Error ? err.message : String(err)}`, LOG_CONTEXT); + } + } +} + +/** + * Scans the encores directory for subdirectories and loads each one. + * Creates the encores directory if it doesn't exist. + * Non-directory entries are skipped. + */ +export async function discoverEncores(encoresDir: string): Promise { + // Ensure encores directory exists + await fs.mkdir(encoresDir, { recursive: true }); + + let entries: string[]; + try { + entries = await fs.readdir(encoresDir); + } catch (err) { + logger.error(`Failed to read encores directory: ${err instanceof Error ? err.message : String(err)}`, LOG_CONTEXT); + return []; + } + + const encores: LoadedEncore[] = []; + + for (const entry of entries) { + const entryPath = path.join(encoresDir, entry); + + try { + const stat = await fs.stat(entryPath); + if (!stat.isDirectory()) { + continue; + } + } catch { + continue; + } + + const encore = await loadEncore(entryPath); + encores.push(encore); + } + + logger.info(`Discovered ${encores.length} encore(s) in ${encoresDir}`, LOG_CONTEXT); + return encores; +} diff --git a/src/main/encore-manager.ts b/src/main/encore-manager.ts new file mode 100644 index 000000000..a3e7a7bee --- /dev/null +++ b/src/main/encore-manager.ts @@ -0,0 +1,252 @@ +/** + * Plugin Manager + * + * Orchestrates the encore lifecycle: discovery, enabling, and disabling. + * Uses a singleton-via-getter pattern consistent with other Maestro managers. + */ + +import type { App } from 'electron'; +import type Store from 'electron-store'; +import { logger } from './utils/logger'; +import { getEncoresDir, discoverEncores, bootstrapBundledEncores } from './encore-loader'; +import type { LoadedEncore } from '../shared/encore-types'; +import type { EncoreHost } from './encore-host'; +import type { MaestroSettings } from './stores/types'; + +const LOG_CONTEXT = '[Encores]'; + +/** + * Manages the lifecycle of all encores. + */ +export class EncoreManager { + private encores: Map = new Map(); + private encoresDir: string; + private host: EncoreHost | null = null; + private settingsStore: Store | null = null; + + constructor(app: App) { + this.encoresDir = getEncoresDir(app); + } + + /** + * Sets the EncoreHost used to create/destroy encore contexts. + */ + setHost(host: EncoreHost): void { + this.host = host; + } + + /** + * Sets the settings store for tracking user-explicit disables. + */ + setSettingsStore(store: Store): void { + this.settingsStore = store; + } + + /** + * Discover and load all encores from the encores directory. + * All encores start disabled. Encores the user previously enabled are + * re-activated automatically (persisted via `encore::enabled` flag). + */ + async initialize(): Promise { + // Deactivate any currently active encores before re-scanning + if (this.host) { + for (const encore of this.encores.values()) { + if (encore.state === 'active') { + try { + await this.host.deactivateEncore(encore.manifest.id); + } catch (err) { + logger.warn(`Failed to deactivate '${encore.manifest.id}' during re-init: ${err}`, LOG_CONTEXT); + } + } + } + } + + // Copy bundled first-party encores to userData/encores/ if not already present + await bootstrapBundledEncores(this.encoresDir); + + const discovered = await discoverEncores(this.encoresDir); + + this.encores.clear(); + for (const encore of discovered) { + this.encores.set(encore.manifest.id, encore); + } + + const errorCount = discovered.filter((p) => p.state === 'error').length; + const okCount = discovered.length - errorCount; + logger.info( + `Encore system initialized: ${okCount} valid, ${errorCount} with errors`, + LOG_CONTEXT + ); + + // Re-enable encores the user had previously toggled on + for (const encore of discovered) { + if (encore.state !== 'discovered') continue; + if (!this.isUserEnabled(encore.manifest.id)) continue; + + logger.info(`Restoring enabled encore '${encore.manifest.id}'`, LOG_CONTEXT); + await this.enableEncore(encore.manifest.id); + } + } + + /** + * Checks if a user has previously enabled an encore (persisted across restarts). + */ + private isUserEnabled(encoreId: string): boolean { + if (!this.settingsStore) return false; + return this.settingsStore.get(`encore:${encoreId}:enabled` as any) === true; + } + + /** + * Returns all discovered encores. + */ + getEncores(): LoadedEncore[] { + return Array.from(this.encores.values()); + } + + /** + * Returns a specific encore by ID. + */ + getEncore(id: string): LoadedEncore | undefined { + return this.encores.get(id); + } + + /** + * Returns encores with state 'active'. + */ + getActiveEncores(): LoadedEncore[] { + return this.getEncores().filter((p) => p.state === 'active'); + } + + /** + * Transitions an encore from 'discovered' or 'disabled' to 'active'. + * Calls EncoreHost.activateEncore() which loads and runs the module's activate(). + */ + async enableEncore(id: string): Promise { + const encore = this.encores.get(id); + if (!encore) { + logger.warn(`Cannot enable unknown encore '${id}'`, LOG_CONTEXT); + return false; + } + + if (encore.state !== 'discovered' && encore.state !== 'disabled') { + logger.warn( + `Cannot enable encore '${id}' in state '${encore.state}'`, + LOG_CONTEXT + ); + return false; + } + + if (this.host) { + await this.host.activateEncore(encore); + // activateEncore sets state to 'active' or 'error' + } else { + encore.state = 'active'; + } + + // Persist enabled state so the encore restores on next startup + if (this.settingsStore && encore.state === 'active') { + this.settingsStore.set(`encore:${id}:enabled` as any, true as any); + } + + logger.info(`Encore '${id}' enabled (state: ${encore.state})`, LOG_CONTEXT); + return encore.state === 'active'; + } + + /** + * Transitions an encore from 'active' to 'disabled'. + * Calls EncoreHost.deactivateEncore() which runs deactivate() and cleans up. + */ + async disableEncore(id: string): Promise { + const encore = this.encores.get(id); + if (!encore) { + logger.warn(`Cannot disable unknown encore '${id}'`, LOG_CONTEXT); + return false; + } + + if (encore.state !== 'active') { + logger.warn( + `Cannot disable encore '${id}' in state '${encore.state}'`, + LOG_CONTEXT + ); + return false; + } + + if (this.host) { + await this.host.deactivateEncore(id); + } + + encore.state = 'disabled'; + + // Clear persisted enabled state + if (this.settingsStore) { + this.settingsStore.set(`encore:${id}:enabled` as any, false as any); + } + + logger.info(`Encore '${id}' disabled`, LOG_CONTEXT); + return true; + } + + /** + * Returns the encores directory path. + */ + getEncoresDir(): string { + return this.encoresDir; + } + + /** + * Get an encore-scoped setting value. + * Keys are namespaced to `encore::`. + */ + getEncoreSetting(encoreId: string, key: string): unknown { + if (!this.settingsStore) return undefined; + return this.settingsStore.get(`encore:${encoreId}:${key}` as any); + } + + /** + * Set an encore-scoped setting value. + * Keys are namespaced to `encore::`. + */ + setEncoreSetting(encoreId: string, key: string, value: unknown): void { + if (!this.settingsStore) return; + this.settingsStore.set(`encore:${encoreId}:${key}` as any, value as any); + } + + /** + * Get all settings for a specific encore (stripped of the namespace prefix). + */ + getAllEncoreSettings(encoreId: string): Record { + if (!this.settingsStore) return {}; + const prefix = `encore:${encoreId}:`; + const all = this.settingsStore.store; + const result: Record = {}; + for (const [k, v] of Object.entries(all)) { + if (k.startsWith(prefix) && !k.endsWith(':enabled')) { + result[k.slice(prefix.length)] = v; + } + } + return result; + } +} + +// ============================================================================ +// Singleton access (consistent with other Maestro managers) +// ============================================================================ + +let encoreManagerInstance: EncoreManager | null = null; + +/** + * Get the EncoreManager singleton. + * Returns null if not yet initialized via createEncoreManager(). + */ +export function getEncoreManager(): EncoreManager | null { + return encoreManagerInstance; +} + +/** + * Create and store the EncoreManager singleton. + * Call this once during app initialization. + */ +export function createEncoreManager(app: App): EncoreManager { + encoreManagerInstance = new EncoreManager(app); + return encoreManagerInstance; +} diff --git a/src/main/encore-storage.ts b/src/main/encore-storage.ts new file mode 100644 index 000000000..f70246060 --- /dev/null +++ b/src/main/encore-storage.ts @@ -0,0 +1,114 @@ +/** + * Plugin-Scoped Storage + * + * Provides file-based storage scoped to each encore. + * Files are stored under `userData/encores//data/`. + * All filenames are validated to prevent path traversal attacks. + */ + +import path from 'path'; +import fs from 'fs/promises'; +import { logger } from './utils/logger'; + +const LOG_CONTEXT = '[Encores]'; + +/** + * Validates a filename to prevent path traversal. + * Rejects filenames containing '..', '/', '\', null bytes, or absolute paths. + */ +function validateFilename(filename: string, baseDir: string): void { + if (!filename || typeof filename !== 'string') { + throw new Error('Filename must be a non-empty string'); + } + + if (filename.includes('\0')) { + throw new Error('Filename contains null bytes'); + } + + if (path.isAbsolute(filename)) { + throw new Error('Absolute paths are not allowed'); + } + + if (filename.includes('..')) { + throw new Error('Path traversal is not allowed'); + } + + if (filename.includes('/') || filename.includes('\\')) { + throw new Error('Path separators are not allowed in filenames'); + } + + const resolved = path.resolve(baseDir, filename); + if (!resolved.startsWith(baseDir)) { + throw new Error('Path traversal is not allowed'); + } +} + +/** + * Plugin-scoped file storage. + * Each encore gets its own isolated storage directory. + */ +export class EncoreStorage { + private encoreId: string; + private baseDir: string; + + constructor(encoreId: string, baseDir: string) { + this.encoreId = encoreId; + this.baseDir = baseDir; + } + + /** + * Reads a file from the encore's storage directory. + * Returns null if the file does not exist. + */ + async read(filename: string): Promise { + validateFilename(filename, this.baseDir); + try { + return await fs.readFile(path.join(this.baseDir, filename), 'utf-8'); + } catch { + return null; + } + } + + /** + * Writes data to a file in the encore's storage directory. + * Creates the directory on first write (lazy creation). + */ + async write(filename: string, data: string): Promise { + validateFilename(filename, this.baseDir); + await fs.mkdir(this.baseDir, { recursive: true }); + await fs.writeFile(path.join(this.baseDir, filename), data, 'utf-8'); + logger.debug(`[Plugin:${this.encoreId}] wrote file '${filename}'`, LOG_CONTEXT); + } + + /** + * Lists all files in the encore's storage directory. + * Returns an empty array if the directory doesn't exist. + */ + async list(): Promise { + try { + return await fs.readdir(this.baseDir); + } catch { + return []; + } + } + + /** + * Deletes a file from the encore's storage directory. + * No-op if the file doesn't exist. + */ + async delete(filename: string): Promise { + validateFilename(filename, this.baseDir); + try { + await fs.unlink(path.join(this.baseDir, filename)); + } catch { + // Ignore if file doesn't exist + } + } + + /** + * Returns the base directory for this encore's storage. + */ + getBaseDir(): string { + return this.baseDir; + } +} diff --git a/src/main/index.ts b/src/main/index.ts index 2794e1dc5..3de863693 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -53,6 +53,7 @@ import { registerAgentErrorHandlers, registerDirectorNotesHandlers, registerWakatimeHandlers, + registerEncoreHandlers, setupLoggerEventForwarding, cleanupAllGroomingSessions, getActiveGroomingSessionCount, @@ -91,6 +92,9 @@ import { } from './constants'; // initAutoUpdater is now used by window-manager.ts (Phase 4 refactoring) import { checkWslEnvironment } from './utils/wslDetector'; +import { createEncoreManager } from './encore-manager'; +import { EncoreHost } from './encore-host'; +import { EncoreIpcBridge } from './encore-ipc-bridge'; // Extracted modules (Phase 1 refactoring) import { parseParticipantSessionId } from './group-chat/session-parser'; import { extractTextFromStreamJson } from './group-chat/output-parser'; @@ -241,6 +245,7 @@ let mainWindow: BrowserWindow | null = null; let processManager: ProcessManager | null = null; let webServer: WebServer | null = null; let agentDetector: AgentDetector | null = null; +const encoreIpcBridge = new EncoreIpcBridge(); // Create safeSend with dependency injection (Phase 2 refactoring) const safeSend = createSafeSend(() => mainWindow); @@ -368,6 +373,27 @@ app.whenReady().then(async () => { logger.debug('Setting up process event listeners', 'Startup'); setupProcessListeners(); + // Initialize encore system + logger.info('Initializing encore system', 'Startup'); + try { + const encoreManager = createEncoreManager(app); + const encoreHost = new EncoreHost({ + getProcessManager: () => processManager, + getMainWindow: () => mainWindow, + settingsStore: store, + sessionsStore, + app, + ipcBridge: encoreIpcBridge, + }); + encoreManager.setHost(encoreHost); + encoreManager.setSettingsStore(store); + await encoreManager.initialize(); + logger.info('Encore system initialized', 'Startup'); + } catch (error) { + logger.error(`Failed to initialize encore system: ${error}`, 'Startup'); + logger.warn('Continuing without encores - encore features will be unavailable', 'Startup'); + } + // Create main window logger.info('Creating main window', 'Startup'); createWindow(); @@ -660,6 +686,9 @@ function setupIpcHandlers() { // Register WakaTime handlers (CLI check, API key validation) registerWakatimeHandlers(wakatimeManager); + + // Register Plugin system IPC handlers + registerEncoreHandlers({ ipcBridge: encoreIpcBridge }); } // Handle process output streaming (set up after initialization) diff --git a/src/main/ipc/handlers/claude.ts b/src/main/ipc/handlers/claude.ts index eaa22d9fb..aacbd9572 100644 --- a/src/main/ipc/handlers/claude.ts +++ b/src/main/ipc/handlers/claude.ts @@ -1561,14 +1561,14 @@ export function registerClaudeHandlers(deps: ClaudeHandlerDependencies): void { const installedContent = await fs.readFile(installedPluginsPath, 'utf-8'); const installedPlugins = JSON.parse(installedContent); - for (const pluginId of Object.keys(enabledPlugins)) { - if (!enabledPlugins[pluginId]) continue; + for (const encoreId of Object.keys(enabledPlugins)) { + if (!enabledPlugins[encoreId]) continue; - const pluginInfo = installedPlugins.plugins?.[pluginId]; + const pluginInfo = installedPlugins.encores?.[encoreId]; if (!pluginInfo?.installPath) continue; const pluginCommandsDir = path.join(pluginInfo.installPath, 'commands'); - const pluginName = pluginId.split('@')[0]; + const pluginName = encoreId.split('@')[0]; await scanCommandsDir(pluginCommandsDir, pluginName); } } catch { diff --git a/src/main/ipc/handlers/encores.ts b/src/main/ipc/handlers/encores.ts new file mode 100644 index 000000000..df64b1184 --- /dev/null +++ b/src/main/ipc/handlers/encores.ts @@ -0,0 +1,169 @@ +/** + * Plugin IPC Handlers + * + * Provides handlers for querying and managing encores from the renderer process. + */ + +import { ipcMain } from 'electron'; +import { logger } from '../../utils/logger'; +import { createIpcHandler, type CreateHandlerOptions } from '../../utils/ipcHandler'; +import { getEncoreManager } from '../../encore-manager'; +import type { EncoreIpcBridge } from '../../encore-ipc-bridge'; + +const LOG_CONTEXT = '[Encores]'; + +// ============================================================================ +// Dependencies Interface +// ============================================================================ + +export interface EncoreHandlerDependencies { + ipcBridge?: EncoreIpcBridge; +} + +/** + * Helper to create handler options with consistent context. + */ +const handlerOpts = (operation: string, logSuccess = true): CreateHandlerOptions => ({ + context: LOG_CONTEXT, + operation, + logSuccess, +}); + +/** + * Get the EncoreManager, throwing if not initialized. + */ +function requireEncoreManager() { + const manager = getEncoreManager(); + if (!manager) { + throw new Error('Plugin manager not initialized'); + } + return manager; +} + +// ============================================================================ +// Handler Registration +// ============================================================================ + +/** + * Register all Plugin-related IPC handlers. + */ +export function registerEncoreHandlers(deps: EncoreHandlerDependencies): void { + const { ipcBridge } = deps; + + // EncoreManager must already be created and initialized by main startup + // (see index.ts — createEncoreManager + initialize runs before this) + if (!getEncoreManager()) { + logger.error('registerEncoreHandlers called before EncoreManager was initialized', LOG_CONTEXT); + } + + // ------------------------------------------------------------------------- + // encores:getAll — returns all LoadedEncore[] + // ------------------------------------------------------------------------- + ipcMain.handle( + 'encores:getAll', + createIpcHandler(handlerOpts('getAll', false), async () => { + const pm = requireEncoreManager(); + return { encores: pm.getEncores() }; + }) + ); + + // ------------------------------------------------------------------------- + // encores:enable — enables an encore by ID + // ------------------------------------------------------------------------- + ipcMain.handle( + 'encores:enable', + createIpcHandler(handlerOpts('enable'), async (id: string) => { + const pm = requireEncoreManager(); + const result = await pm.enableEncore(id); + return { enabled: result }; + }) + ); + + // ------------------------------------------------------------------------- + // encores:disable — disables an encore by ID + // ------------------------------------------------------------------------- + ipcMain.handle( + 'encores:disable', + createIpcHandler(handlerOpts('disable'), async (id: string) => { + const pm = requireEncoreManager(); + const result = await pm.disableEncore(id); + return { disabled: result }; + }) + ); + + // ------------------------------------------------------------------------- + // encores:getDir — returns the encores directory path + // ------------------------------------------------------------------------- + ipcMain.handle( + 'encores:getDir', + createIpcHandler(handlerOpts('getDir', false), async () => { + const pm = requireEncoreManager(); + return { dir: pm.getEncoresDir() }; + }) + ); + + // ------------------------------------------------------------------------- + // encores:refresh — re-scans encores directory + // ------------------------------------------------------------------------- + ipcMain.handle( + 'encores:refresh', + createIpcHandler(handlerOpts('refresh'), async () => { + const pm = requireEncoreManager(); + await pm.initialize(); + return { encores: pm.getEncores() }; + }) + ); + + // ------------------------------------------------------------------------- + // encores:settings:get — get all settings for an encore + // ------------------------------------------------------------------------- + ipcMain.handle( + 'encores:settings:get', + createIpcHandler(handlerOpts('settings:get', false), async (encoreId: string) => { + const pm = requireEncoreManager(); + return { settings: pm.getAllEncoreSettings(encoreId) }; + }) + ); + + // ------------------------------------------------------------------------- + // encores:settings:set — set a single encore setting + // ------------------------------------------------------------------------- + ipcMain.handle( + 'encores:settings:set', + createIpcHandler(handlerOpts('settings:set'), async (encoreId: string, key: string, value: unknown) => { + const pm = requireEncoreManager(); + pm.setEncoreSetting(encoreId, key, value); + return { set: true }; + }) + ); + + // ------------------------------------------------------------------------- + // encores:bridge:invoke — invoke a handler registered by a main-process encore + // ------------------------------------------------------------------------- + ipcMain.handle( + 'encores:bridge:invoke', + createIpcHandler(handlerOpts('bridge:invoke', false), async (encoreId: string, channel: string, ...args: unknown[]) => { + if (!ipcBridge) { + throw new Error('Plugin IPC bridge not initialized'); + } + const result = await ipcBridge.invoke(encoreId, channel, ...args); + return { result } as Record; + }) + ); + + // ------------------------------------------------------------------------- + // encores:bridge:send — fire-and-forget message to a main-process encore + // ------------------------------------------------------------------------- + ipcMain.handle( + 'encores:bridge:send', + createIpcHandler(handlerOpts('bridge:send', false), async (encoreId: string, channel: string, ...args: unknown[]) => { + if (!ipcBridge) { + throw new Error('Plugin IPC bridge not initialized'); + } + ipcBridge.send(encoreId, channel, ...args); + return {} as Record; + }) + ); + + logger.debug(`${LOG_CONTEXT} Plugin IPC handlers registered`); +} diff --git a/src/main/ipc/handlers/index.ts b/src/main/ipc/handlers/index.ts index ba41c326b..dd5ffbda1 100644 --- a/src/main/ipc/handlers/index.ts +++ b/src/main/ipc/handlers/index.ts @@ -53,6 +53,7 @@ import { registerAgentErrorHandlers } from './agent-error'; import { registerTabNamingHandlers, TabNamingHandlerDependencies } from './tabNaming'; import { registerDirectorNotesHandlers, DirectorNotesHandlerDependencies } from './director-notes'; import { registerWakatimeHandlers } from './wakatime'; +import { registerEncoreHandlers, EncoreHandlerDependencies } from './encores'; import { AgentDetector } from '../../agents'; import { ProcessManager } from '../../process-manager'; import { WebServer } from '../../web-server'; @@ -97,6 +98,8 @@ export type { TabNamingHandlerDependencies }; export { registerDirectorNotesHandlers }; export type { DirectorNotesHandlerDependencies }; export { registerWakatimeHandlers }; +export { registerEncoreHandlers }; +export type { EncoreHandlerDependencies }; export type { AgentsHandlerDependencies }; export type { ProcessHandlerDependencies }; export type { PersistenceHandlerDependencies }; @@ -280,6 +283,8 @@ export function registerAllHandlers(deps: HandlerDependencies): void { getProcessManager: deps.getProcessManager, getAgentDetector: deps.getAgentDetector, }); + // Register encore system handlers + registerEncoreHandlers({}); // Setup logger event forwarding to renderer setupLoggerEventForwarding(deps.getMainWindow); } diff --git a/src/main/preload/encores.ts b/src/main/preload/encores.ts new file mode 100644 index 000000000..62ceaaa9f --- /dev/null +++ b/src/main/preload/encores.ts @@ -0,0 +1,61 @@ +/** + * Preload API for Encore operations + * + * Provides the window.maestro.encores namespace for: + * - Listing all discovered encores + * - Enabling/disabling encores + * - Getting the encores directory path + * - Refreshing the encore list + */ + +import { ipcRenderer } from 'electron'; + +export interface EncoreBridgeApi { + invoke: (encoreId: string, channel: string, ...args: unknown[]) => Promise; + send: (encoreId: string, channel: string, ...args: unknown[]) => void; +} + +export interface EncoreSettingsApi { + get: (encoreId: string) => Promise; + set: (encoreId: string, key: string, value: unknown) => Promise; +} + +export interface EncoresApi { + getAll: () => Promise; + enable: (id: string) => Promise; + disable: (id: string) => Promise; + getDir: () => Promise; + refresh: () => Promise; + settings: EncoreSettingsApi; + bridge: EncoreBridgeApi; +} + +/** + * Creates the Encores API object for preload exposure + */ +export function createEncoresApi(): EncoresApi { + return { + getAll: () => ipcRenderer.invoke('encores:getAll'), + + enable: (id: string) => ipcRenderer.invoke('encores:enable', id), + + disable: (id: string) => ipcRenderer.invoke('encores:disable', id), + + getDir: () => ipcRenderer.invoke('encores:getDir'), + + refresh: () => ipcRenderer.invoke('encores:refresh'), + + settings: { + get: (encoreId: string) => ipcRenderer.invoke('encores:settings:get', encoreId), + set: (encoreId: string, key: string, value: unknown) => ipcRenderer.invoke('encores:settings:set', encoreId, key, value), + }, + + bridge: { + invoke: (encoreId: string, channel: string, ...args: unknown[]) => + ipcRenderer.invoke('encores:bridge:invoke', encoreId, channel, ...args), + send: (encoreId: string, channel: string, ...args: unknown[]) => { + ipcRenderer.invoke('encores:bridge:send', encoreId, channel, ...args); + }, + }, + }; +} diff --git a/src/main/preload/index.ts b/src/main/preload/index.ts index e91a1f1f8..1f267cb7f 100644 --- a/src/main/preload/index.ts +++ b/src/main/preload/index.ts @@ -50,6 +50,7 @@ import { createSymphonyApi } from './symphony'; import { createTabNamingApi } from './tabNaming'; import { createDirectorNotesApi } from './directorNotes'; import { createWakatimeApi } from './wakatime'; +import { createEncoresApi } from './encores'; // Expose protected methods that allow the renderer process to use // the ipcRenderer without exposing the entire object @@ -191,6 +192,9 @@ contextBridge.exposeInMainWorld('maestro', { // WakaTime API (CLI check, API key validation) wakatime: createWakatimeApi(), + + // Encores API (encore feature discovery and management) + encores: createEncoresApi(), }); // Re-export factory functions for external consumers (e.g., tests) @@ -264,6 +268,8 @@ export { createDirectorNotesApi, // WakaTime createWakatimeApi, + // Plugins + createEncoresApi, }; // Re-export types for TypeScript consumers @@ -472,3 +478,7 @@ export type { // From wakatime WakatimeApi, } from './wakatime'; +export type { + // From encores + EncoresApi, +} from './encores'; diff --git a/src/renderer/App.tsx b/src/renderer/App.tsx index 873e54c9d..e8002666b 100644 --- a/src/renderer/App.tsx +++ b/src/renderer/App.tsx @@ -61,7 +61,6 @@ const DocumentGraphView = lazy(() => const DirectorNotesModal = lazy(() => import('./components/DirectorNotes').then((m) => ({ default: m.DirectorNotesModal })) ); - // Re-import the type for SymphonyContributionData (types don't need lazy loading) import type { SymphonyContributionData } from './components/SymphonyModal'; @@ -133,6 +132,7 @@ import { import type { TabCompletionSuggestion } from './hooks'; import { useMainPanelProps, useSessionListProps, useRightPanelProps } from './hooks/props'; import { useAgentListeners } from './hooks/agent/useAgentListeners'; +import { useEncoreRegistry } from './hooks/useEncoreRegistry'; // Import contexts import { useLayerStack } from './contexts/LayerStackContext'; @@ -382,6 +382,9 @@ function MaestroConsoleInner() { setDirectorNotesOpen, } = useModalActions(); + // --- PLUGIN REGISTRY --- + const encoreRegistry = useEncoreRegistry(); + // --- MOBILE LANDSCAPE MODE (reading-only view) --- const isMobileLandscape = useMobileLandscape(); @@ -8541,7 +8544,7 @@ You are taking over this conversation. Based on the context above, provide a bri {/* --- RIGHT PANEL (hidden in mobile landscape, when no sessions, group chat is active, or log viewer is open) --- */} {!isMobileLandscape && sessions.length > 0 && !activeGroupChatId && !logViewerOpen && ( - + )} @@ -8628,6 +8631,7 @@ You are taking over this conversation. Based on the context above, provide a bri hasNoAgents={hasNoAgents} onThemeImportError={(msg) => setFlashNotification(msg)} onThemeImportSuccess={(msg) => setFlashNotification(msg)} + encoreRegistry={encoreRegistry} /> )} diff --git a/src/renderer/components/EncoreManager.tsx b/src/renderer/components/EncoreManager.tsx new file mode 100644 index 000000000..a61d5890e --- /dev/null +++ b/src/renderer/components/EncoreManager.tsx @@ -0,0 +1,730 @@ +/** + * EncoreManager - Browse, enable, configure, and read about encores. + * + * Shows all discovered encores with their state, permissions, and toggle controls. + * Click an encore to expand its detail view with README and settings. + */ + +import { useState, useCallback, useEffect } from 'react'; +import ReactMarkdown from 'react-markdown'; +import { + Puzzle, + RefreshCw, + FolderOpen, + ToggleLeft, + ToggleRight, + AlertCircle, + Loader2, + ChevronLeft, + ChevronRight, +} from 'lucide-react'; +import type { Theme } from '../types'; +import type { LoadedEncore, EncorePermission, EncoreSettingDefinition } from '../../shared/encore-types'; +import { Modal } from './ui/Modal'; +import { MODAL_PRIORITIES } from '../constants/modalPriorities'; + +interface EncoreManagerProps { + theme: Theme; + encores: LoadedEncore[]; + loading: boolean; + onClose: () => void; + onEnableEncore: (id: string) => Promise; + onDisableEncore: (id: string) => Promise; + onRefresh: () => Promise; + /** When true, renders content directly without Modal wrapper (for embedding in Settings tab) */ + embedded?: boolean; +} + +/** Returns a color for a permission badge based on its risk level */ +function getPermissionColor( + permission: EncorePermission, + theme: Theme +): { bg: string; text: string } { + if (permission === 'middleware') { + return { bg: `${theme.colors.error}20`, text: theme.colors.error }; + } + if (permission.endsWith(':write') || permission === 'process:write' || permission === 'settings:write') { + return { bg: `${theme.colors.warning}20`, text: theme.colors.warning }; + } + return { bg: `${theme.colors.success}20`, text: theme.colors.success }; +} + +/** Validates a setting value. Returns an error message or null if valid. */ +function validateSetting(key: string, value: unknown): string | null { + if (typeof value !== 'string') return null; + // Path-like settings: validate absolute path if non-empty + const isPathKey = key.toLowerCase().includes('path') || key.toLowerCase().includes('dir'); + if (isPathKey && value.trim() !== '') { + if (!value.startsWith('/') && !value.match(/^[a-zA-Z]:\\/)) { + return 'Must be an absolute path (e.g., /tmp/status.json)'; + } + } + // URL-like settings: validate URL format if non-empty + const isUrlKey = key.toLowerCase().includes('url') || key.toLowerCase().includes('endpoint'); + if (isUrlKey && value.trim() !== '') { + try { + new URL(value); + } catch { + return 'Must be a valid URL (e.g., https://example.com/webhook)'; + } + } + return null; +} + +/** Encore settings editor for encores that declare settings in their manifest */ +export function EncoreSettings({ + encore, + theme, +}: { + encore: LoadedEncore; + theme: Theme; +}) { + const settings = encore.manifest.settings; + + const [values, setValues] = useState>({}); + const [localValues, setLocalValues] = useState>({}); + const [loaded, setLoaded] = useState(false); + const [savedKeys, setSavedKeys] = useState>(new Set()); + const [errors, setErrors] = useState>({}); + + useEffect(() => { + let cancelled = false; + (async () => { + try { + const result = await window.maestro.encores.settings.get(encore.manifest.id); + if (!cancelled && result?.success && result.settings) { + setValues(result.settings); + } + } catch { + // Ignore load errors + } finally { + if (!cancelled) setLoaded(true); + } + })(); + return () => { cancelled = true; }; + }, [encore.manifest.id]); + + /** Save a setting immediately (for toggles, selects, numbers) */ + const handleSave = useCallback(async (key: string, value: unknown) => { + const error = validateSetting(key, value); + setErrors((prev) => ({ ...prev, [key]: error })); + if (error) return; + + setValues((prev) => ({ ...prev, [key]: value })); + try { + await window.maestro.encores.settings.set(encore.manifest.id, key, value); + setSavedKeys((prev) => new Set(prev).add(key)); + setTimeout(() => { + setSavedKeys((prev) => { + const next = new Set(prev); + next.delete(key); + return next; + }); + }, 1500); + } catch { + // Ignore save errors + } + }, [encore.manifest.id]); + + /** Save a text setting on blur */ + const handleBlurSave = useCallback((key: string, value: string) => { + handleSave(key, value); + }, [handleSave]); + + if (!loaded || !settings || settings.length === 0) return null; + + return ( +
+

+ Settings +

+ {settings.map((setting: EncoreSettingDefinition) => { + const savedValue = values[setting.key] ?? setting.default; + const error = errors[setting.key]; + const saved = savedKeys.has(setting.key); + + if (setting.type === 'boolean') { + return ( +
handleSave(setting.key, !savedValue)} + > + + {setting.label} + +
+ {saved && ( + + Saved + + )} + + {savedValue ? ( + + ) : ( + + )} + +
+
+ ); + } + + if (setting.type === 'string') { + const localKey = setting.key; + const displayValue = localValues[localKey] ?? (typeof savedValue === 'string' ? savedValue : ''); + return ( +
+
+ + {saved && ( + + Saved + + )} +
+ { + setLocalValues((prev) => ({ ...prev, [localKey]: e.target.value })); + // Clear error while typing + if (errors[setting.key]) { + setErrors((prev) => ({ ...prev, [setting.key]: null })); + } + }} + onBlur={(e) => { + handleBlurSave(setting.key, e.target.value); + // Clear local override after save + setLocalValues((prev) => { + const next = { ...prev }; + delete next[localKey]; + return next; + }); + }} + onKeyDown={(e) => { + if (e.key === 'Enter') { + (e.target as HTMLInputElement).blur(); + } + }} + placeholder={(() => { + if (typeof setting.default === 'string' && setting.default) return setting.default; + // For path settings with empty default, show the encore's default data dir as hint + const isPathKey = setting.key.toLowerCase().includes('path') || setting.key.toLowerCase().includes('dir'); + if (isPathKey && encore.path) return `Default: ${encore.path}/data/status.json`; + return 'Not set'; + })()} + className="w-full px-2 py-1.5 rounded text-xs border bg-transparent outline-none" + style={{ + borderColor: error ? theme.colors.error : theme.colors.border, + color: theme.colors.textMain, + }} + /> + {error && ( +

+ {error} +

+ )} +
+ ); + } + + if (setting.type === 'number') { + return ( +
+
+ + {saved && ( + + Saved + + )} +
+ { + const num = Number(e.target.value); + if (!isNaN(num) && e.target.value !== '') { + handleSave(setting.key, num); + } + }} + onBlur={(e) => { + const num = Number(e.target.value); + if (!isNaN(num) && e.target.value !== '') { + handleSave(setting.key, num); + } + }} + className="w-full px-2 py-1.5 rounded text-xs border bg-transparent outline-none" + style={{ + borderColor: theme.colors.border, + color: theme.colors.textMain, + }} + /> +
+ ); + } + + if (setting.type === 'select' && setting.options) { + return ( +
+
+ + {saved && ( + + Saved + + )} +
+ +
+ ); + } + + return null; + })} +
+ ); +} + +export function EncoreManager({ + theme, + encores, + loading, + onClose, + onEnableEncore, + onDisableEncore, + onRefresh, + embedded, +}: EncoreManagerProps) { + const [togglingIds, setTogglingIds] = useState>(new Set()); + const [refreshing, setRefreshing] = useState(false); + const [selectedEncoreId, setSelectedEncoreId] = useState(null); + + const handleToggle = useCallback( + async (encore: LoadedEncore, e?: React.MouseEvent) => { + if (e) e.stopPropagation(); + const id = encore.manifest.id; + setTogglingIds((prev) => new Set(prev).add(id)); + try { + if (encore.state === 'active' || encore.state === 'loaded') { + await onDisableEncore(id); + } else { + await onEnableEncore(id); + } + } finally { + setTogglingIds((prev) => { + const next = new Set(prev); + next.delete(id); + return next; + }); + } + }, + [onEnableEncore, onDisableEncore] + ); + + const handleRefresh = useCallback(async () => { + setRefreshing(true); + try { + await onRefresh(); + } finally { + setRefreshing(false); + } + }, [onRefresh]); + + const handleOpenFolder = useCallback(async () => { + try { + const result = await window.maestro.encores.getDir(); + // IPC handler returns { success: true, dir: '...' } via createIpcHandler + const dir = result?.success ? result.dir : null; + if (dir) { + await window.maestro.shell.showItemInFolder(dir); + } + } catch (err) { + console.error('Failed to open encores folder:', err); + } + }, []); + + const isEnabled = (encore: LoadedEncore) => + encore.state === 'active' || encore.state === 'loaded'; + + const selectedEncore = selectedEncoreId + ? encores.find((p) => p.manifest.id === selectedEncoreId) + : null; + + // Detail view for a selected encore + const detailView = selectedEncore && ( +
+ {/* Back button */} + + + {/* Encore header */} +
+
+
+ + {selectedEncore.manifest.name} + + + v{selectedEncore.manifest.version} + +
+
+ by {selectedEncore.manifest.author} +
+
+ + {/* Toggle */} + +
+ + {/* Permissions */} + {selectedEncore.manifest.permissions.length > 0 && ( +
+ {selectedEncore.manifest.permissions.map((perm) => { + const colors = getPermissionColor(perm, theme); + return ( + + {perm} + + ); + })} +
+ )} + + {/* Error */} + {selectedEncore.state === 'error' && selectedEncore.error && ( +
+ + {selectedEncore.error} +
+ )} + + {/* Settings */} + + + {/* README */} + {selectedEncore.readme ? ( +
+ ( +

+ {children} +

+ ), + h2: ({ children }) => ( +

+ {children} +

+ ), + h3: ({ children }) => ( +

+ {children} +

+ ), + p: ({ children }) => ( +

+ {children} +

+ ), + code: ({ children, className }) => { + const isBlock = className?.includes('language-'); + if (isBlock) { + return ( +
+											{children}
+										
+ ); + } + return ( + + {children} + + ); + }, + pre: ({ children }) => <>{children}, + ul: ({ children }) =>
    {children}
, + ol: ({ children }) =>
    {children}
, + li: ({ children }) => ( +
  • + {children} +
  • + ), + table: ({ children }) => ( + + {children} +
    + ), + th: ({ children }) => ( + + {children} + + ), + td: ({ children }) => ( + + {children} + + ), + strong: ({ children }) => ( + {children} + ), + }} + > + {selectedEncore.readme} +
    +
    + ) : ( +
    + No README available for this encore. +
    + )} +
    + ); + + // List view + const listView = ( +
    + {/* Toolbar */} +
    + + {encores.length} encore{encores.length !== 1 ? 's' : ''} discovered + +
    + + +
    +
    + + {/* Encore List */} + {loading ? ( +
    + + + Loading encores... + +
    + ) : encores.length === 0 ? ( +
    + +

    No encores installed

    +

    + Place encore folders in the encores directory to get started. +

    +
    + ) : ( +
    + {encores.map((encore) => { + const toggling = togglingIds.has(encore.manifest.id); + const enabled = isEnabled(encore); + + return ( +
    setSelectedEncoreId(encore.manifest.id)} + > + {/* Header row */} +
    +
    + + {encore.manifest.name} + + + v{encore.manifest.version} + +
    + +
    + {/* Toggle */} + + {/* Expand arrow */} + +
    +
    + + {/* Author */} +
    + by {encore.manifest.author} +
    + + {/* Description */} +
    + {encore.manifest.description} +
    + + {/* Permissions */} + {encore.manifest.permissions.length > 0 && ( +
    + {encore.manifest.permissions.map((perm) => { + const colors = getPermissionColor(perm, theme); + return ( + + {perm} + + ); + })} +
    + )} + + {/* Error message */} + {encore.state === 'error' && encore.error && ( +
    + + {encore.error} +
    + )} +
    + ); + })} +
    + )} +
    + ); + + const content = selectedEncore ? detailView : listView; + + if (embedded) { + return content; + } + + return ( + } + > + {content} + + ); +} diff --git a/src/renderer/components/EncoreTabContent.tsx b/src/renderer/components/EncoreTabContent.tsx new file mode 100644 index 000000000..4c58b698f --- /dev/null +++ b/src/renderer/components/EncoreTabContent.tsx @@ -0,0 +1,72 @@ +/** + * EncoreTabContent - Renders encore UI within the Right Panel. + * + * Uses an iframe to load the encore's renderer entry point, providing natural + * sandboxing for untrusted UI code. The iframe uses sandbox="allow-scripts" + * without allow-same-origin to prevent the encore from accessing the parent + * frame's DOM or IPC bridge. + */ + +import type { Theme } from '../types'; +import type { LoadedEncore } from '../../shared/encore-types'; +import { Puzzle } from 'lucide-react'; + +interface EncoreTabContentProps { + encoreId: string; + tabId: string; + theme: Theme; + encores: LoadedEncore[]; +} + +export function EncoreTabContent({ encoreId, tabId, theme, encores }: EncoreTabContentProps) { + const encore = encores.find((p) => p.manifest.id === encoreId); + + if (!encore) { + return ( +
    + + Encore not found: {encoreId} +
    + ); + } + + const rendererEntry = encore.manifest.renderer; + + if (!rendererEntry) { + return ( +
    + + + {encore.manifest.name} + + This encore has no UI +
    + ); + } + + const iframeSrc = `file://${encore.path}/${rendererEntry}`; + + return ( +
    + {/* iframe provides natural sandboxing for untrusted encore UI code. + sandbox="allow-scripts" lets JS run but without allow-same-origin + the encore cannot access the parent frame's DOM or IPC bridge. */} +