From 7a272c5f65d91a63e80e4a02c8617cb07768ce50 Mon Sep 17 00:00:00 2001 From: Croissant Le Doux Date: Thu, 19 Mar 2026 13:17:51 -0400 Subject: [PATCH 1/3] feat: add coordinating agent via MCP server Enable a Claude Code agent to programmatically create and orchestrate other tasks in Parallel Code. Adds an MCP server with 9 tools (create_task, send_prompt, wait_for_idle, get_task_diff, merge_task, etc.), a main-process orchestrator, REST API endpoints, and UI support including coordinator mode toggle, sub-task status strip, and "via Coordinator" sidebar labels. Co-Authored-By: Claude Opus 4.6 (1M context) --- COORDINATING-AGENT.md | 128 ++++ electron/ipc/channels.ts | 8 + electron/ipc/register.ts | 84 +++ electron/mcp/client.ts | 88 +++ electron/mcp/orchestrator.ts | 333 ++++++++++ electron/mcp/prompt-detect.ts | 44 ++ electron/mcp/server.ts | 264 ++++++++ electron/mcp/types.ts | 90 +++ electron/remote/server.ts | 128 ++++ package-lock.json | 1019 ++++++++++++++++++++++++++++-- package.json | 5 +- src/App.tsx | 3 + src/components/NewTaskDialog.tsx | 45 ++ src/components/Sidebar.tsx | 122 ++-- src/components/SubTaskStrip.tsx | 77 +++ src/components/TaskPanel.tsx | 14 + src/store/persistence.ts | 8 + src/store/store.ts | 1 + src/store/taskStatus.ts | 53 +- src/store/tasks.ts | 119 ++++ src/store/types.ts | 5 + 21 files changed, 2507 insertions(+), 131 deletions(-) create mode 100644 COORDINATING-AGENT.md create mode 100644 electron/mcp/client.ts create mode 100644 electron/mcp/orchestrator.ts create mode 100644 electron/mcp/prompt-detect.ts create mode 100644 electron/mcp/server.ts create mode 100644 electron/mcp/types.ts create mode 100644 src/components/SubTaskStrip.tsx diff --git a/COORDINATING-AGENT.md b/COORDINATING-AGENT.md new file mode 100644 index 00000000..ff76b48b --- /dev/null +++ b/COORDINATING-AGENT.md @@ -0,0 +1,128 @@ +# Coordinating Agent — Implementation Notes + +## Overview + +A Claude Code agent running inside a task can now programmatically create and coordinate other tasks in Parallel Code via MCP tools. The coordinating agent can spawn sub-tasks, send prompts, monitor completion, read diffs, and merge results — all without manual human orchestration. + +## Architecture + +``` +Claude Code (coordinating task PTY) + | MCP stdio (JSON-RPC) + v +MCP Server Process (electron/mcp/server.ts) + | HTTP + Bearer token + v +Electron Remote Server (electron/remote/server.ts) + | Direct function calls + v +Orchestrator (electron/mcp/orchestrator.ts) + | Uses existing backend primitives + v +PTY / Git / Worktree functions +``` + +## MCP Tools Available to Coordinating Agents + +| Tool | Description | +| ----------------- | --------------------------------------- | +| `create_task` | Create a new task with worktree + agent | +| `list_tasks` | List all orchestrated tasks with status | +| `get_task_status` | Get task status + git summary | +| `send_prompt` | Send prompt to a task's agent | +| `wait_for_idle` | Wait until agent becomes idle | +| `get_task_diff` | Get changed files + unified diff | +| `get_task_output` | Get recent terminal output | +| `merge_task` | Merge task branch to main | +| `close_task` | Close and clean up a task | + +## Files Created + +- `electron/mcp/prompt-detect.ts` — Shared prompt detection (extracted from taskStatus.ts) +- `electron/mcp/types.ts` — Shared types for orchestrator, API, MCP tools +- `electron/mcp/orchestrator.ts` — Main-process task lifecycle management +- `electron/mcp/client.ts` — HTTP client for MCP server -> remote server +- `electron/mcp/server.ts` — Standalone MCP server (stdio transport) +- `src/components/SubTaskStrip.tsx` — Sub-task status chips on coordinator panel + +## Files Modified + +- `electron/ipc/channels.ts` — MCP IPC channels +- `electron/remote/server.ts` — Orchestrator REST API endpoints +- `electron/ipc/register.ts` — Orchestrator init, MCP IPC handlers +- `src/store/types.ts` — `coordinatorMode`, `coordinatedBy`, `mcpConfigPath` on Task +- `src/store/tasks.ts` — Coordinator task creation, MCP event listeners +- `src/store/taskStatus.ts` — Imports from shared prompt-detect module +- `src/store/persistence.ts` — Persist/restore coordinator fields +- `src/store/store.ts` — Export `initMCPListeners` +- `src/App.tsx` — Initialize MCP listeners +- `src/components/NewTaskDialog.tsx` — "Coordinator mode" checkbox +- `src/components/TaskPanel.tsx` — SubTaskStrip, `--mcp-config` in agent args +- `src/components/Sidebar.tsx` — "via Coordinator" labels on sub-tasks +- `package.json` — Added `@modelcontextprotocol/sdk`, dev build identity + +## UI Changes + +1. **New Task Dialog** — "Coordinator mode" checkbox after agent selector. When enabled, shows a warning banner and auto-starts the MCP infrastructure on task creation. + +2. **Sidebar** — Sub-tasks created by a coordinator show a "via {name}" label below the task name. Clicking the label navigates to the coordinator task. + +3. **Task Panel** — Coordinator tasks show a horizontal sub-task status strip between the branch info bar and the notes panel. Each chip shows a StatusDot + task name and is clickable. + +--- + +## Testing + +### Build + +The build identity has been changed to `com.parallel-code.app.dev` / "Parallel Code Dev" so it installs separately from the production app. + +```bash +# From the worktree directory +npm run build +# .dmg is in release/ +open release/*.dmg +``` + +### Test 1: MCP Server Standalone + +1. Start the dev app +2. Open Settings, start the remote server (or it auto-starts with coordinator mode) +3. Run the MCP server directly to verify it connects: + ```bash + node dist-electron/mcp/server.js --url http://127.0.0.1:7777 --token + ``` + (The token is printed in the app's remote server settings) +4. Use the [MCP Inspector](https://github.com/modelcontextprotocol/inspector) or send JSON-RPC over stdin to call tools like `list_tasks` + +### Test 2: Coordinator Task End-to-End + +1. Link a project in the app +2. Click "New Task" +3. Check the **"Coordinator mode"** checkbox +4. Enter a prompt like: `Create two tasks: one to add a README.md and one to add a LICENSE file. Wait for both to complete, then merge them.` +5. Submit — the app will: + - Auto-start the remote server + - Write a temp MCP config file + - Spawn Claude Code with `--mcp-config ` +6. Verify: + - Sub-tasks appear in the sidebar with "via {coordinator-name}" labels + - The coordinator's panel shows a sub-task status strip with chips + - Clicking a chip navigates to that sub-task + - The coordinating agent can merge and close sub-tasks + +### Test 3: UI Elements + +1. Create a coordinator task (as above) +2. Wait for it to create at least one sub-task +3. Check sidebar: sub-task should show "via {name}" in muted text +4. Check coordinator panel: sub-task strip should appear with status dots +5. Click the "via" label — should navigate to coordinator +6. Click a chip in the strip — should navigate to that sub-task + +### Test 4: Edge Cases + +- Create multiple sub-tasks rapidly +- Close a sub-task while its agent is busy +- Close the coordinator while sub-tasks are still running +- Collapse and uncollapse a coordinator task diff --git a/electron/ipc/channels.ts b/electron/ipc/channels.ts index d36e57a7..5ca1e80c 100644 --- a/electron/ipc/channels.ts +++ b/electron/ipc/channels.ts @@ -89,4 +89,12 @@ export enum IPC { // Notifications ShowNotification = 'show_notification', NotificationClicked = 'notification_clicked', + + // MCP / Coordinating agent + StartMCPServer = 'start_mcp_server', + StopMCPServer = 'stop_mcp_server', + GetMCPStatus = 'get_mcp_status', + MCP_TaskCreated = 'mcp_task_created', + MCP_TaskClosed = 'mcp_task_closed', + MCP_TaskStateSync = 'mcp_task_state_sync', } diff --git a/electron/ipc/register.ts b/electron/ipc/register.ts index b12ce0aa..a05fa0ad 100644 --- a/electron/ipc/register.ts +++ b/electron/ipc/register.ts @@ -15,6 +15,7 @@ import { } from './pty.js'; import { ensurePlansDirectory, startPlanWatcher, readPlanForWorktree } from './plans.js'; import { startRemoteServer } from '../remote/server.js'; +import { Orchestrator } from '../mcp/orchestrator.js'; import { getGitIgnoredDirs, getMainBranch, @@ -76,6 +77,11 @@ export function registerAllHandlers(win: BrowserWindow): void { let remoteServer: ReturnType | null = null; const taskNames = new Map(); + // --- MCP orchestrator --- + const orchestrator = new Orchestrator(); + orchestrator.setWindow(win); + let mcpProcess: ReturnType | null = null; + // --- PTY commands --- ipcMain.handle(IPC.SpawnAgent, (_e, args) => { if (args.cwd) validatePath(args.cwd, 'cwd'); @@ -469,6 +475,7 @@ export function registerAllHandlers(win: BrowserWindow): void { lastLine: '', }; }, + orchestrator, }); return { url: remoteServer.url, @@ -499,6 +506,83 @@ export function registerAllHandlers(win: BrowserWindow): void { }; }); + // --- MCP server management --- + ipcMain.handle( + IPC.StartMCPServer, + async ( + _e, + args: { + coordinatorTaskId: string; + projectId: string; + projectRoot: string; + }, + ) => { + // Set orchestrator's default project + orchestrator.setDefaultProject(args.projectId, args.projectRoot); + + // Start remote server if not running + if (!remoteServer) { + const thisDir = path.dirname(fileURLToPath(import.meta.url)); + const distRemote = path.join(thisDir, '..', '..', 'dist-remote'); + remoteServer = startRemoteServer({ + port: 7777, + staticDir: distRemote, + getTaskName: (taskId: string) => taskNames.get(taskId) ?? taskId, + getAgentStatus: (agentId: string) => { + const meta = getAgentMeta(agentId); + return { + status: meta ? ('running' as const) : ('exited' as const), + exitCode: null, + lastLine: '', + }; + }, + orchestrator, + }); + } + + // Write temp MCP config file + const thisDir = path.dirname(fileURLToPath(import.meta.url)); + const mcpServerPath = path.join(thisDir, '..', 'mcp', 'server.js'); + const serverUrl = `http://127.0.0.1:${remoteServer.port}`; + + const mcpConfig = { + mcpServers: { + 'parallel-code': { + command: 'node', + args: [mcpServerPath, '--url', serverUrl, '--token', remoteServer.token], + }, + }, + }; + + const configPath = path.join( + app.getPath('temp'), + `parallel-code-mcp-${args.coordinatorTaskId}.json`, + ); + fs.writeFileSync(configPath, JSON.stringify(mcpConfig, null, 2)); + + return { + configPath, + serverUrl, + token: remoteServer.token, + port: remoteServer.port, + }; + }, + ); + + ipcMain.handle(IPC.StopMCPServer, async () => { + if (mcpProcess) { + mcpProcess.kill(); + mcpProcess = null; + } + }); + + ipcMain.handle(IPC.GetMCPStatus, () => { + return { + mcpRunning: mcpProcess !== null && mcpProcess.exitCode === null, + remoteRunning: remoteServer !== null, + }; + }); + // --- Forward window events to renderer --- win.on('focus', () => { if (!win.isDestroyed()) win.webContents.send(IPC.WindowFocus); diff --git a/electron/mcp/client.ts b/electron/mcp/client.ts new file mode 100644 index 00000000..9ee77f24 --- /dev/null +++ b/electron/mcp/client.ts @@ -0,0 +1,88 @@ +// HTTP client wrapper for calling the remote server API. +// Used by the MCP server to delegate tool calls to the Electron app. + +import type { ApiTaskSummary, ApiTaskDetail, ApiDiffResult, ApiMergeResult } from './types.js'; + +export class MCPClient { + constructor( + private baseUrl: string, + private token: string, + ) {} + + private async request(method: string, path: string, body?: unknown): Promise { + const url = `${this.baseUrl}${path}`; + const headers: Record = { + Authorization: `Bearer ${this.token}`, + 'Content-Type': 'application/json', + }; + + const res = await fetch(url, { + method, + headers, + body: body !== undefined ? JSON.stringify(body) : undefined, + }); + + if (!res.ok) { + const text = await res.text().catch(() => ''); + throw new Error(`API ${method} ${path} failed (${res.status}): ${text}`); + } + + return (await res.json()) as T; + } + + async createTask(opts: { + name: string; + prompt?: string; + projectId?: string; + }): Promise { + return this.request('POST', '/api/tasks', opts); + } + + async listTasks(): Promise { + return this.request('GET', '/api/tasks'); + } + + async getTaskStatus(taskId: string): Promise { + return this.request('GET', `/api/tasks/${encodeURIComponent(taskId)}`); + } + + async sendPrompt(taskId: string, prompt: string): Promise { + await this.request('POST', `/api/tasks/${encodeURIComponent(taskId)}/prompt`, { + prompt, + }); + } + + async waitForIdle(taskId: string, timeoutMs?: number): Promise<{ status: string }> { + return this.request<{ status: string }>( + 'POST', + `/api/tasks/${encodeURIComponent(taskId)}/wait`, + { timeoutMs }, + ); + } + + async getTaskDiff(taskId: string): Promise { + return this.request('GET', `/api/tasks/${encodeURIComponent(taskId)}/diff`); + } + + async getTaskOutput(taskId: string): Promise<{ output: string }> { + return this.request<{ output: string }>( + 'GET', + `/api/tasks/${encodeURIComponent(taskId)}/output`, + ); + } + + async mergeTask( + taskId: string, + opts?: { squash?: boolean; message?: string; cleanup?: boolean }, + ): Promise { + return this.request( + 'POST', + `/api/tasks/${encodeURIComponent(taskId)}/merge`, + opts ?? {}, + ); + } + + async closeTask(taskId: string): Promise { + await this.request('DELETE', `/api/tasks/${encodeURIComponent(taskId)}`); + } +} diff --git a/electron/mcp/orchestrator.ts b/electron/mcp/orchestrator.ts new file mode 100644 index 00000000..3da2aa1d --- /dev/null +++ b/electron/mcp/orchestrator.ts @@ -0,0 +1,333 @@ +// Main-process task orchestration for coordinating agents. +// Manages task lifecycle independently of the SolidJS renderer, +// using existing backend primitives (pty, git, tasks). + +import { randomUUID } from 'crypto'; +import type { BrowserWindow } from 'electron'; +import { createTask as createBackendTask, deleteTask } from '../ipc/tasks.js'; +import { + spawnAgent, + writeToAgent, + killAgent, + subscribeToAgent, + unsubscribeFromAgent, + getAgentScrollback, +} from '../ipc/pty.js'; +import { getChangedFiles, getAllFileDiffs, mergeTask as gitMergeTask } from '../ipc/git.js'; +import { stripAnsi, chunkContainsAgentPrompt } from './prompt-detect.js'; +import type { OrchestratedTask, ApiTaskSummary, ApiTaskDetail, ApiDiffResult } from './types.js'; + +const DEFAULT_WAIT_TIMEOUT_MS = 300_000; // 5 minutes +const PROMPT_WRITE_DELAY_MS = 50; + +export class Orchestrator { + private tasks = new Map(); + private tailBuffers = new Map(); + private idleResolvers = new Map void>>(); + private subscribers = new Map void>(); + private decoders = new Map(); + private win: BrowserWindow | null = null; + private projectRoot: string | null = null; + private projectId: string | null = null; + + setWindow(win: BrowserWindow): void { + this.win = win; + } + + setDefaultProject(projectId: string, projectRoot: string): void { + this.projectId = projectId; + this.projectRoot = projectRoot; + } + + async createTask(opts: { + name: string; + prompt?: string; + coordinatorTaskId: string; + projectId?: string; + projectRoot?: string; + agentCommand?: string; + agentArgs?: string[]; + }): Promise { + const root = opts.projectRoot ?? this.projectRoot; + const projId = opts.projectId ?? this.projectId; + if (!root || !projId) throw new Error('No project configured for orchestrator'); + + // Create worktree + branch via existing backend + const result = await createBackendTask(opts.name, root, ['.claude', 'node_modules'], 'task'); + + const agentId = randomUUID(); + const task: OrchestratedTask = { + id: result.id, + name: opts.name, + projectId: projId, + branchName: result.branch_name, + worktreePath: result.worktree_path, + agentId, + coordinatorTaskId: opts.coordinatorTaskId, + status: 'creating', + exitCode: null, + }; + + this.tasks.set(task.id, task); + this.tailBuffers.set(agentId, ''); + + // Subscribe to PTY output for prompt detection + const decoder = new TextDecoder(); + this.decoders.set(agentId, decoder); + + const outputCb = (encoded: string) => { + const bytes = Buffer.from(encoded, 'base64'); + const text = (this.decoders.get(agentId) ?? new TextDecoder()).decode(bytes, { + stream: true, + }); + const prev = this.tailBuffers.get(agentId) ?? ''; + const combined = prev + text; + this.tailBuffers.set( + agentId, + combined.length > 4096 ? combined.slice(combined.length - 4096) : combined, + ); + + // Check for agent prompt + const stripped = stripAnsi(combined) + // eslint-disable-next-line no-control-regex + .replace(/[\x00-\x1f\x7f]/g, ' ') + .replace(/\s+/g, ' ') + .trim(); + if (chunkContainsAgentPrompt(stripped)) { + if (task.status === 'running') { + task.status = 'idle'; + } + // Resolve any waiting promises + const resolvers = this.idleResolvers.get(task.id); + if (resolvers?.length) { + for (const resolve of resolvers) resolve(); + this.idleResolvers.delete(task.id); + } + } else if (task.status === 'idle') { + task.status = 'running'; + } + }; + this.subscribers.set(agentId, outputCb); + + // Spawn the agent process + if (!this.win) throw new Error('No window set on orchestrator'); + + const command = opts.agentCommand ?? 'claude'; + const args = opts.agentArgs ?? []; + const channelId = randomUUID(); + + spawnAgent(this.win, { + taskId: task.id, + agentId, + command, + args, + cwd: result.worktree_path, + env: {}, + cols: 120, + rows: 40, + onOutput: { __CHANNEL_ID__: channelId }, + }); + + // Subscribe for output monitoring + subscribeToAgent(agentId, outputCb); + task.status = 'running'; + + // Notify renderer + this.notifyRenderer('MCP_TaskCreated', { + taskId: task.id, + name: task.name, + projectId: task.projectId, + branchName: task.branchName, + worktreePath: task.worktreePath, + agentId: task.agentId, + coordinatorTaskId: task.coordinatorTaskId, + }); + + // Send initial prompt if provided + if (opts.prompt) { + // Wait a bit for the agent to initialize + await this.waitForIdleInternal(task.id, 60_000).catch(() => {}); + await this.sendPrompt(task.id, opts.prompt); + } + + return task; + } + + listTasks(): ApiTaskSummary[] { + return Array.from(this.tasks.values()).map((t) => ({ + id: t.id, + name: t.name, + branchName: t.branchName, + status: t.status, + coordinatorTaskId: t.coordinatorTaskId, + })); + } + + getTaskStatus(taskId: string): ApiTaskDetail | null { + const task = this.tasks.get(taskId); + if (!task) return null; + return { + id: task.id, + name: task.name, + branchName: task.branchName, + worktreePath: task.worktreePath, + projectId: task.projectId, + agentId: task.agentId, + status: task.status, + coordinatorTaskId: task.coordinatorTaskId, + exitCode: task.exitCode, + }; + } + + async sendPrompt(taskId: string, prompt: string): Promise { + const task = this.tasks.get(taskId); + if (!task) throw new Error(`Task not found: ${taskId}`); + + // Send text then Enter separately (like the frontend does) + writeToAgent(task.agentId, prompt); + await new Promise((r) => setTimeout(r, PROMPT_WRITE_DELAY_MS)); + writeToAgent(task.agentId, '\r'); + task.status = 'running'; + } + + waitForIdle(taskId: string, timeoutMs?: number): Promise { + return this.waitForIdleInternal(taskId, timeoutMs ?? DEFAULT_WAIT_TIMEOUT_MS); + } + + private waitForIdleInternal(taskId: string, timeoutMs: number): Promise { + const task = this.tasks.get(taskId); + if (!task) return Promise.reject(new Error(`Task not found: ${taskId}`)); + if (task.status === 'idle' || task.status === 'exited') return Promise.resolve(); + + return new Promise((resolve, reject) => { + const timer = setTimeout(() => { + const resolvers = this.idleResolvers.get(taskId); + if (resolvers) { + const idx = resolvers.indexOf(resolve); + if (idx >= 0) resolvers.splice(idx, 1); + } + reject(new Error(`Timed out waiting for task ${taskId} to become idle`)); + }, timeoutMs); + + const wrappedResolve = () => { + clearTimeout(timer); + resolve(); + }; + + let resolvers = this.idleResolvers.get(taskId); + if (!resolvers) { + resolvers = []; + this.idleResolvers.set(taskId, resolvers); + } + resolvers.push(wrappedResolve); + }); + } + + async getTaskDiff(taskId: string): Promise { + const task = this.tasks.get(taskId); + if (!task) throw new Error(`Task not found: ${taskId}`); + + const [files, diff] = await Promise.all([ + getChangedFiles(task.worktreePath), + getAllFileDiffs(task.worktreePath), + ]); + + return { files, diff }; + } + + getTaskOutput(taskId: string): string { + const task = this.tasks.get(taskId); + if (!task) throw new Error(`Task not found: ${taskId}`); + + // Try scrollback buffer first, fall back to tail buffer + const scrollback = getAgentScrollback(task.agentId); + if (scrollback) { + const decoded = Buffer.from(scrollback, 'base64').toString('utf8'); + return stripAnsi(decoded); + } + return stripAnsi(this.tailBuffers.get(task.agentId) ?? ''); + } + + async mergeTask( + taskId: string, + opts?: { squash?: boolean; message?: string; cleanup?: boolean }, + ): Promise<{ mainBranch: string; linesAdded: number; linesRemoved: number }> { + const task = this.tasks.get(taskId); + if (!task) throw new Error(`Task not found: ${taskId}`); + + const root = this.projectRoot; + if (!root) throw new Error('No project root configured'); + + const result = await gitMergeTask( + root, + task.branchName, + opts?.squash ?? false, + opts?.message ?? null, + opts?.cleanup ?? false, + ); + + if (opts?.cleanup) { + await this.cleanupTask(taskId); + } + + return { + mainBranch: result.main_branch, + linesAdded: result.lines_added, + linesRemoved: result.lines_removed, + }; + } + + async closeTask(taskId: string): Promise { + const task = this.tasks.get(taskId); + if (!task) throw new Error(`Task not found: ${taskId}`); + await this.cleanupTask(taskId); + } + + private async cleanupTask(taskId: string): Promise { + const task = this.tasks.get(taskId); + if (!task) return; + + // Unsubscribe from PTY output + const cb = this.subscribers.get(task.agentId); + if (cb) { + unsubscribeFromAgent(task.agentId, cb); + this.subscribers.delete(task.agentId); + } + + // Kill the agent + try { + killAgent(task.agentId); + } catch { + /* already dead */ + } + + // Remove worktree + const root = this.projectRoot; + if (root) { + try { + await deleteTask([task.agentId], task.branchName, true, root); + } catch (err) { + console.warn('Failed to delete orchestrated task worktree:', err); + } + } + + // Clean up internal state + this.tailBuffers.delete(task.agentId); + this.decoders.delete(task.agentId); + this.idleResolvers.delete(taskId); + this.tasks.delete(taskId); + + // Notify renderer + this.notifyRenderer('MCP_TaskClosed', { taskId }); + } + + getTask(taskId: string): OrchestratedTask | undefined { + return this.tasks.get(taskId); + } + + private notifyRenderer(channel: string, data: unknown): void { + if (this.win && !this.win.isDestroyed()) { + this.win.webContents.send(channel, data); + } + } +} diff --git a/electron/mcp/prompt-detect.ts b/electron/mcp/prompt-detect.ts new file mode 100644 index 00000000..87bf236b --- /dev/null +++ b/electron/mcp/prompt-detect.ts @@ -0,0 +1,44 @@ +// Shared prompt-detection helpers used by both the renderer (taskStatus.ts) +// and the main-process orchestrator (orchestrator.ts). + +/** Strip ANSI escape sequences (CSI, OSC, and single-char escapes) from terminal output. */ +export function stripAnsi(text: string): string { + return text.replace( + // eslint-disable-next-line no-control-regex + /[\u001b\u009b][[()#;?]*(?:[0-9]{1,4}(?:;[0-9]{0,4})*)?[0-9A-ORZcf-nq-uy=><~]|\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)?/g, + '', + ); +} + +/** + * Patterns that indicate the agent is waiting for user input (i.e. idle). + * Each regex is tested against the last non-empty line of stripped output. + */ +export const PROMPT_PATTERNS: RegExp[] = [ + /❯\s*$/, // Claude Code prompt + /(?:^|\s)\$\s*$/, // bash/zsh dollar prompt (preceded by whitespace or BOL) + /(?:^|\s)%\s*$/, // zsh percent prompt + /(?:^|\s)#\s*$/, // root prompt + /\[Y\/n\]\s*$/i, // Y/n confirmation + /\[y\/N\]\s*$/i, // y/N confirmation +]; + +/** + * Patterns for known agent main input prompts (ready for a new task). + * Tested against the stripped data chunk (not a single line), because TUI + * apps like Claude Code use cursor positioning instead of newlines. + */ +export const AGENT_READY_TAIL_PATTERNS: RegExp[] = [ + /❯/, // Claude Code + /›/, // Codex CLI +]; + +/** Check stripped output for known agent prompt characters. + * Only checks the tail of the chunk — the agent's main prompt renders as + * the last visible element, while TUI selection UIs place ❯ earlier in + * the render followed by option text and other choices. */ +export function chunkContainsAgentPrompt(stripped: string): boolean { + if (stripped.length === 0) return false; + const tail = stripped.slice(-50); + return AGENT_READY_TAIL_PATTERNS.some((re) => re.test(tail)); +} diff --git a/electron/mcp/server.ts b/electron/mcp/server.ts new file mode 100644 index 00000000..cb057720 --- /dev/null +++ b/electron/mcp/server.ts @@ -0,0 +1,264 @@ +#!/usr/bin/env node +// MCP server entry point — standalone Node.js script. +// Speaks MCP over stdio to Claude Code, delegates to the Electron app via HTTP. + +import { Server } from '@modelcontextprotocol/sdk/server/index.js'; +import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'; +import { CallToolRequestSchema, ListToolsRequestSchema } from '@modelcontextprotocol/sdk/types.js'; +import { MCPClient } from './client.js'; + +// Parse CLI args +const args = process.argv.slice(2); +let url = ''; +let token = ''; + +for (let i = 0; i < args.length; i++) { + if (args[i] === '--url' && args[i + 1]) { + url = args[++i]; + } else if (args[i] === '--token' && args[i + 1]) { + token = args[++i]; + } +} + +if (!url || !token) { + console.error('Usage: node server.js --url --token '); + process.exit(1); +} + +const client = new MCPClient(url, token); + +const server = new Server( + { name: 'parallel-code', version: '1.0.0' }, + { capabilities: { tools: {} } }, +); + +// --- Tool definitions --- + +server.setRequestHandler(ListToolsRequestSchema, async () => ({ + tools: [ + { + name: 'create_task', + description: + 'Create a new task with its own git worktree and AI agent. The agent starts automatically.', + inputSchema: { + type: 'object' as const, + properties: { + name: { type: 'string', description: 'Task name (used for branch name)' }, + prompt: { + type: 'string', + description: 'Initial prompt to send to the agent after it starts', + }, + projectId: { + type: 'string', + description: 'Project ID (uses default if not specified)', + }, + }, + required: ['name'], + }, + }, + { + name: 'list_tasks', + description: 'List all orchestrated tasks with their current status.', + inputSchema: { type: 'object' as const, properties: {} }, + }, + { + name: 'get_task_status', + description: 'Get detailed status of a specific task including git info and agent state.', + inputSchema: { + type: 'object' as const, + properties: { + taskId: { type: 'string', description: 'Task ID' }, + }, + required: ['taskId'], + }, + }, + { + name: 'send_prompt', + description: "Send a prompt/instruction to a task's AI agent.", + inputSchema: { + type: 'object' as const, + properties: { + taskId: { type: 'string', description: 'Task ID' }, + prompt: { type: 'string', description: 'Prompt text to send' }, + }, + required: ['taskId', 'prompt'], + }, + }, + { + name: 'wait_for_idle', + description: + "Wait until a task's agent becomes idle (sitting at its prompt). Returns when the agent is ready for the next instruction.", + inputSchema: { + type: 'object' as const, + properties: { + taskId: { type: 'string', description: 'Task ID' }, + timeoutMs: { + type: 'number', + description: 'Timeout in milliseconds (default: 300000 = 5 min)', + }, + }, + required: ['taskId'], + }, + }, + { + name: 'get_task_diff', + description: "Get the changed files and unified diff for a task's work.", + inputSchema: { + type: 'object' as const, + properties: { + taskId: { type: 'string', description: 'Task ID' }, + }, + required: ['taskId'], + }, + }, + { + name: 'get_task_output', + description: "Get recent terminal output from a task's agent (stripped of ANSI codes).", + inputSchema: { + type: 'object' as const, + properties: { + taskId: { type: 'string', description: 'Task ID' }, + }, + required: ['taskId'], + }, + }, + { + name: 'merge_task', + description: "Merge a task's branch into the main branch.", + inputSchema: { + type: 'object' as const, + properties: { + taskId: { type: 'string', description: 'Task ID' }, + squash: { type: 'boolean', description: 'Squash merge (default: false)' }, + message: { type: 'string', description: 'Custom merge commit message' }, + cleanup: { + type: 'boolean', + description: 'Clean up worktree and branch after merge (default: false)', + }, + }, + required: ['taskId'], + }, + }, + { + name: 'close_task', + description: 'Close and clean up a task — kills the agent, removes worktree and branch.', + inputSchema: { + type: 'object' as const, + properties: { + taskId: { type: 'string', description: 'Task ID' }, + }, + required: ['taskId'], + }, + }, + ], +})); + +// --- Tool execution --- + +server.setRequestHandler(CallToolRequestSchema, async (request) => { + const { name, arguments: params } = request.params; + + try { + switch (name) { + case 'create_task': { + const result = await client.createTask({ + name: (params as Record).name as string, + prompt: (params as Record).prompt as string | undefined, + projectId: (params as Record).projectId as string | undefined, + }); + return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] }; + } + + case 'list_tasks': { + const tasks = await client.listTasks(); + return { content: [{ type: 'text', text: JSON.stringify(tasks, null, 2) }] }; + } + + case 'get_task_status': { + const result = await client.getTaskStatus( + (params as Record).taskId as string, + ); + return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] }; + } + + case 'send_prompt': { + await client.sendPrompt( + (params as Record).taskId as string, + (params as Record).prompt as string, + ); + return { content: [{ type: 'text', text: 'Prompt sent successfully.' }] }; + } + + case 'wait_for_idle': { + const result = await client.waitForIdle( + (params as Record).taskId as string, + (params as Record).timeoutMs as number | undefined, + ); + return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] }; + } + + case 'get_task_diff': { + const result = await client.getTaskDiff( + (params as Record).taskId as string, + ); + // Return files summary + truncated diff + const summary = result.files + .map((f) => `${f.status} ${f.path} (+${f.lines_added} -${f.lines_removed})`) + .join('\n'); + const diffText = + result.diff.length > 50_000 + ? result.diff.slice(0, 50_000) + '\n... (diff truncated)' + : result.diff; + return { + content: [{ type: 'text', text: `Changed files:\n${summary}\n\n${diffText}` }], + }; + } + + case 'get_task_output': { + const result = await client.getTaskOutput( + (params as Record).taskId as string, + ); + return { content: [{ type: 'text', text: result.output }] }; + } + + case 'merge_task': { + const p = params as Record; + const result = await client.mergeTask(p.taskId as string, { + squash: p.squash as boolean | undefined, + message: p.message as string | undefined, + cleanup: p.cleanup as boolean | undefined, + }); + return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] }; + } + + case 'close_task': { + await client.closeTask((params as Record).taskId as string); + return { content: [{ type: 'text', text: 'Task closed successfully.' }] }; + } + + default: + return { + content: [{ type: 'text', text: `Unknown tool: ${name}` }], + isError: true, + }; + } + } catch (err) { + return { + content: [ + { type: 'text', text: `Error: ${err instanceof Error ? err.message : String(err)}` }, + ], + isError: true, + }; + } +}); + +// Start the server +async function main() { + const transport = new StdioServerTransport(); + await server.connect(transport); +} + +main().catch((err) => { + console.error('MCP server failed to start:', err); + process.exit(1); +}); diff --git a/electron/mcp/types.ts b/electron/mcp/types.ts new file mode 100644 index 00000000..ec0a03bd --- /dev/null +++ b/electron/mcp/types.ts @@ -0,0 +1,90 @@ +// Shared types for the MCP coordinating-agent system. + +export interface OrchestratedTask { + id: string; + name: string; + projectId: string; + branchName: string; + worktreePath: string; + agentId: string; + coordinatorTaskId: string; + status: 'creating' | 'running' | 'idle' | 'exited'; + exitCode: number | null; +} + +// --- MCP tool input schemas --- + +export interface CreateTaskInput { + name: string; + prompt?: string; + projectId?: string; +} + +export type ListTasksInput = Record; + +export interface GetTaskStatusInput { + taskId: string; +} + +export interface SendPromptInput { + taskId: string; + prompt: string; +} + +export interface WaitForIdleInput { + taskId: string; + timeoutMs?: number; +} + +export interface GetTaskDiffInput { + taskId: string; +} + +export interface GetTaskOutputInput { + taskId: string; +} + +export interface MergeTaskInput { + taskId: string; + squash?: boolean; + message?: string; + cleanup?: boolean; +} + +export interface CloseTaskInput { + taskId: string; +} + +// --- API request/response types --- + +export interface ApiTaskSummary { + id: string; + name: string; + branchName: string; + status: string; + coordinatorTaskId: string; +} + +export interface ApiTaskDetail extends ApiTaskSummary { + worktreePath: string; + projectId: string; + agentId: string; + exitCode: number | null; +} + +export interface ApiDiffResult { + files: Array<{ + path: string; + lines_added: number; + lines_removed: number; + status: string; + committed: boolean; + }>; + diff: string; +} + +export interface ApiMergeResult { + mainBranch: string; + linesAdded: number; + linesRemoved: number; +} diff --git a/electron/remote/server.ts b/electron/remote/server.ts index f9c99fbd..f714a827 100644 --- a/electron/remote/server.ts +++ b/electron/remote/server.ts @@ -19,6 +19,7 @@ import { onPtyEvent, } from '../ipc/pty.js'; import { parseClientMessage, type ServerMessage, type RemoteAgent } from './protocol.js'; +import type { Orchestrator } from '../mcp/orchestrator.js'; const MIME: Record = { '.html': 'text/html', @@ -102,6 +103,7 @@ export function startRemoteServer(opts: { exitCode: number | null; lastLine: string; }; + orchestrator?: Orchestrator; }): RemoteServer { const token = randomBytes(24).toString('base64url'); const ips = getNetworkIps(); @@ -169,6 +171,132 @@ export function startRemoteServer(opts: { return; } + // --- Orchestrator task API routes --- + const orch = opts.orchestrator; + if (orch) { + // Helper to read JSON body + const readBody = (): Promise> => + new Promise((resolve, reject) => { + let data = ''; + req.on('data', (chunk: Buffer) => { + data += chunk.toString(); + if (data.length > 1_000_000) { + reject(new Error('Body too large')); + req.destroy(); + } + }); + req.on('end', () => { + try { + resolve(data ? (JSON.parse(data) as Record) : {}); + } catch { + resolve({}); + } + }); + req.on('error', reject); + }); + + const jsonReply = (status: number, body: unknown) => { + res.writeHead(status, { ...SECURITY_HEADERS, 'Content-Type': 'application/json' }); + res.end(JSON.stringify(body)); + }; + + const taskIdMatch = url.pathname.match(/^\/api\/tasks\/([^/]+)(?:\/(.+))?$/); + + if (url.pathname === '/api/tasks' && req.method === 'POST') { + readBody() + .then(async (body) => { + const result = await orch.createTask({ + name: body.name as string, + prompt: body.prompt as string | undefined, + coordinatorTaskId: (body.coordinatorTaskId as string) ?? 'api', + projectId: body.projectId as string | undefined, + }); + jsonReply(201, orch.getTaskStatus(result.id)); + }) + .catch((err) => jsonReply(500, { error: String(err) })); + return; + } + + if (url.pathname === '/api/tasks' && req.method === 'GET') { + jsonReply(200, orch.listTasks()); + return; + } + + if (taskIdMatch && !taskIdMatch[2] && req.method === 'GET') { + const detail = orch.getTaskStatus(decodeURIComponent(taskIdMatch[1])); + if (!detail) { + jsonReply(404, { error: 'task not found' }); + } else { + jsonReply(200, detail); + } + return; + } + + if (taskIdMatch && taskIdMatch[2] === 'prompt' && req.method === 'POST') { + readBody() + .then(async (body) => { + await orch.sendPrompt(decodeURIComponent(taskIdMatch[1]), body.prompt as string); + jsonReply(200, { ok: true }); + }) + .catch((err) => jsonReply(500, { error: String(err) })); + return; + } + + if (taskIdMatch && taskIdMatch[2] === 'wait' && req.method === 'POST') { + readBody() + .then(async (body) => { + await orch.waitForIdle( + decodeURIComponent(taskIdMatch[1]), + body.timeoutMs as number | undefined, + ); + const status = orch.getTaskStatus(decodeURIComponent(taskIdMatch[1])); + jsonReply(200, { status: status?.status ?? 'unknown' }); + }) + .catch((err) => jsonReply(500, { error: String(err) })); + return; + } + + if (taskIdMatch && taskIdMatch[2] === 'diff' && req.method === 'GET') { + orch + .getTaskDiff(decodeURIComponent(taskIdMatch[1])) + .then((result) => jsonReply(200, result)) + .catch((err) => jsonReply(500, { error: String(err) })); + return; + } + + if (taskIdMatch && taskIdMatch[2] === 'output' && req.method === 'GET') { + try { + const output = orch.getTaskOutput(decodeURIComponent(taskIdMatch[1])); + jsonReply(200, { output }); + } catch (err) { + jsonReply(500, { error: String(err) }); + } + return; + } + + if (taskIdMatch && taskIdMatch[2] === 'merge' && req.method === 'POST') { + readBody() + .then(async (body) => { + const result = await orch.mergeTask(decodeURIComponent(taskIdMatch[1]), { + squash: body.squash as boolean | undefined, + message: body.message as string | undefined, + cleanup: body.cleanup as boolean | undefined, + }); + jsonReply(200, result); + }) + .catch((err) => jsonReply(500, { error: String(err) })); + return; + } + + if (taskIdMatch && !taskIdMatch[2] && req.method === 'DELETE') { + orch + .closeTask(decodeURIComponent(taskIdMatch[1])) + .then(() => jsonReply(200, { ok: true })) + .catch((err) => jsonReply(500, { error: String(err) })); + return; + } + } + res.writeHead(404, { ...SECURITY_HEADERS, 'Content-Type': 'application/json' }); res.end(JSON.stringify({ error: 'not found' })); return; diff --git a/package-lock.json b/package-lock.json index a90c9269..7d26a300 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,6 +9,7 @@ "version": "1.1.1", "license": "MIT", "dependencies": { + "@modelcontextprotocol/sdk": "^1.27.1", "@xterm/addon-fit": "^0.11.0", "@xterm/addon-web-links": "^0.12.0", "@xterm/addon-webgl": "^0.19.0", @@ -75,6 +76,7 @@ "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@babel/code-frame": "^7.29.0", "@babel/generator": "^7.29.0", @@ -718,7 +720,6 @@ "dev": true, "license": "BSD-2-Clause", "optional": true, - "peer": true, "dependencies": { "cross-dirname": "^0.1.0", "debug": "^4.3.4", @@ -740,7 +741,6 @@ "dev": true, "license": "MIT", "optional": true, - "peer": true, "dependencies": { "graceful-fs": "^4.2.0", "jsonfile": "^6.0.1", @@ -757,7 +757,6 @@ "dev": true, "license": "MIT", "optional": true, - "peer": true, "dependencies": { "universalify": "^2.0.0" }, @@ -772,7 +771,6 @@ "dev": true, "license": "MIT", "optional": true, - "peer": true, "engines": { "node": ">= 10.0.0" } @@ -1479,6 +1477,18 @@ "@hapi/hoek": "^11.0.2" } }, + "node_modules/@hono/node-server": { + "version": "1.19.11", + "resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.11.tgz", + "integrity": "sha512-dr8/3zEaB+p0D2n/IUrlPF1HZm586qgJNXK1a9fhg/PzdtkK7Ksd5l312tJX2yBuALqDYBlG20QEbayqPyxn+g==", + "license": "MIT", + "engines": { + "node": ">=18.14.1" + }, + "peerDependencies": { + "hono": "^4" + } + }, "node_modules/@humanfs/core": { "version": "0.19.1", "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz", @@ -1775,6 +1785,68 @@ "node": ">= 10.0.0" } }, + "node_modules/@modelcontextprotocol/sdk": { + "version": "1.27.1", + "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.27.1.tgz", + "integrity": "sha512-sr6GbP+4edBwFndLbM60gf07z0FQ79gaExpnsjMGePXqFcSSb7t6iscpjk9DhFhwd+mTEQrzNafGP8/iGGFYaA==", + "license": "MIT", + "dependencies": { + "@hono/node-server": "^1.19.9", + "ajv": "^8.17.1", + "ajv-formats": "^3.0.1", + "content-type": "^1.0.5", + "cors": "^2.8.5", + "cross-spawn": "^7.0.5", + "eventsource": "^3.0.2", + "eventsource-parser": "^3.0.0", + "express": "^5.2.1", + "express-rate-limit": "^8.2.1", + "hono": "^4.11.4", + "jose": "^6.1.3", + "json-schema-typed": "^8.0.2", + "pkce-challenge": "^5.0.0", + "raw-body": "^3.0.0", + "zod": "^3.25 || ^4.0", + "zod-to-json-schema": "^3.25.1" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@cfworker/json-schema": "^4.1.1", + "zod": "^3.25 || ^4.0" + }, + "peerDependenciesMeta": { + "@cfworker/json-schema": { + "optional": true + }, + "zod": { + "optional": false + } + } + }, + "node_modules/@modelcontextprotocol/sdk/node_modules/ajv": { + "version": "8.18.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.18.0.tgz", + "integrity": "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==", + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/@modelcontextprotocol/sdk/node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "license": "MIT" + }, "node_modules/@npmcli/agent": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/@npmcli/agent/-/agent-3.0.0.tgz", @@ -2600,6 +2672,7 @@ "integrity": "sha512-IgSWvLobTDOjnaxAfDTIHaECbkNlAlKv2j5SjpB2v7QHKv1FIfjwMy8FsDbVfDX/KjmCmYICcw7uGaXLhtsLNg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.56.0", "@typescript-eslint/types": "8.56.0", @@ -3002,12 +3075,51 @@ "node": "^18.17.0 || >=20.5.0" } }, + "node_modules/accepts": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz", + "integrity": "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==", + "license": "MIT", + "dependencies": { + "mime-types": "^3.0.0", + "negotiator": "^1.0.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/accepts/node_modules/mime-db": { + "version": "1.54.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", + "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/accepts/node_modules/mime-types": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz", + "integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==", + "license": "MIT", + "dependencies": { + "mime-db": "^1.54.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/acorn": { "version": "8.16.0", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", "dev": true, "license": "MIT", + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -3041,6 +3153,7 @@ "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", @@ -3052,6 +3165,45 @@ "url": "https://github.com/sponsors/epoberezkin" } }, + "node_modules/ajv-formats": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-3.0.1.tgz", + "integrity": "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==", + "license": "MIT", + "dependencies": { + "ajv": "^8.0.0" + }, + "peerDependencies": { + "ajv": "^8.0.0" + }, + "peerDependenciesMeta": { + "ajv": { + "optional": true + } + } + }, + "node_modules/ajv-formats/node_modules/ajv": { + "version": "8.18.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.18.0.tgz", + "integrity": "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==", + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ajv-formats/node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "license": "MIT" + }, "node_modules/ajv-keywords": { "version": "3.5.2", "resolved": "https://registry.npmjs.org/ajv-keywords/-/ajv-keywords-3.5.2.tgz", @@ -3465,6 +3617,46 @@ "readable-stream": "^3.4.0" } }, + "node_modules/body-parser": { + "version": "2.2.2", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.2.tgz", + "integrity": "sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA==", + "license": "MIT", + "dependencies": { + "bytes": "^3.1.2", + "content-type": "^1.0.5", + "debug": "^4.4.3", + "http-errors": "^2.0.0", + "iconv-lite": "^0.7.0", + "on-finished": "^2.4.1", + "qs": "^6.14.1", + "raw-body": "^3.0.1", + "type-is": "^2.0.1" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/body-parser/node_modules/iconv-lite": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.2.tgz", + "integrity": "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/boolean": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/boolean/-/boolean-3.2.0.tgz", @@ -3520,6 +3712,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", @@ -3653,6 +3846,15 @@ "node": ">= 10.0.0" } }, + "node_modules/bytes": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", + "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/cacache": { "version": "19.0.1", "resolved": "https://registry.npmjs.org/cacache/-/cacache-19.0.1.tgz", @@ -3772,7 +3974,6 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", - "dev": true, "license": "MIT", "dependencies": { "es-errors": "^1.3.0", @@ -3782,6 +3983,22 @@ "node": ">= 0.4" } }, + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/callsites": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", @@ -4107,6 +4324,28 @@ "url": "https://github.com/open-cli-tools/concurrently?sponsor=1" } }, + "node_modules/content-disposition": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.0.1.tgz", + "integrity": "sha512-oIXISMynqSqm241k6kcQ5UwttDILMK4BiurCfGEREw6+X9jkkpEe5T9FZaApyLGGOnFuyMWZpdolTXMtvEJ08Q==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/content-type": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", + "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, "node_modules/convert-source-map": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", @@ -4114,6 +4353,24 @@ "dev": true, "license": "MIT" }, + "node_modules/cookie": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", + "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie-signature": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz", + "integrity": "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==", + "license": "MIT", + "engines": { + "node": ">=6.6.0" + } + }, "node_modules/core-util-is": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.2.tgz", @@ -4122,6 +4379,23 @@ "license": "MIT", "optional": true }, + "node_modules/cors": { + "version": "2.8.6", + "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.6.tgz", + "integrity": "sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==", + "license": "MIT", + "dependencies": { + "object-assign": "^4", + "vary": "^1" + }, + "engines": { + "node": ">= 0.10" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/crc": { "version": "3.8.0", "resolved": "https://registry.npmjs.org/crc/-/crc-3.8.0.tgz", @@ -4139,14 +4413,12 @@ "integrity": "sha512-+R08/oI0nl3vfPcqftZRpytksBXDzOUveBq/NBVx0sUp1axwzPQrKinNx5yd5sxPu8j1wIy8AfnVQ+5eFdha6Q==", "dev": true, "license": "MIT", - "optional": true, - "peer": true + "optional": true }, "node_modules/cross-spawn": { "version": "7.0.6", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", - "dev": true, "license": "MIT", "dependencies": { "path-key": "^3.1.0", @@ -4161,14 +4433,12 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", - "dev": true, "license": "ISC" }, "node_modules/cross-spawn/node_modules/which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", - "dev": true, "license": "ISC", "dependencies": { "isexe": "^2.0.0" @@ -4190,7 +4460,6 @@ "version": "4.4.3", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", - "dev": true, "license": "MIT", "dependencies": { "ms": "^2.1.3" @@ -4320,6 +4589,15 @@ "node": ">=0.4.0" } }, + "node_modules/depd": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", + "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/dequal": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", @@ -4414,6 +4692,7 @@ "integrity": "sha512-glMJgnTreo8CFINujtAhCgN96QAqApDMZ8Vl1r8f0QT8QprvC1UCltV4CcWj20YoIyLZx6IUskaJZ0NV8fokcg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "app-builder-lib": "26.8.1", "builder-util": "26.8.1", @@ -4523,7 +4802,6 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", - "dev": true, "license": "MIT", "dependencies": { "call-bind-apply-helpers": "^1.0.1", @@ -4541,6 +4819,12 @@ "dev": true, "license": "MIT" }, + "node_modules/ee-first": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", + "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", + "license": "MIT" + }, "node_modules/ejs": { "version": "3.1.10", "resolved": "https://registry.npmjs.org/ejs/-/ejs-3.1.10.tgz", @@ -4722,7 +5006,6 @@ "dev": true, "hasInstallScript": true, "license": "MIT", - "peer": true, "dependencies": { "@electron/asar": "^3.2.1", "debug": "^4.1.1", @@ -4743,7 +5026,6 @@ "integrity": "sha512-YJDaCJZEnBmcbw13fvdAM9AwNOJwOzrE4pqMqBq5nFiEqXUqHwlK4B+3pUw6JNvfSPtX05xFHtYy/1ni01eGCw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "graceful-fs": "^4.1.2", "jsonfile": "^4.0.0", @@ -4776,6 +5058,15 @@ "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", "license": "MIT" }, + "node_modules/encodeurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", + "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/encoding": { "version": "0.1.13", "resolved": "https://registry.npmjs.org/encoding/-/encoding-0.1.13.tgz", @@ -4844,7 +5135,6 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", - "dev": true, "license": "MIT", "engines": { "node": ">= 0.4" @@ -4854,7 +5144,6 @@ "version": "1.3.0", "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", - "dev": true, "license": "MIT", "engines": { "node": ">= 0.4" @@ -4871,7 +5160,6 @@ "version": "1.1.1", "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", - "dev": true, "license": "MIT", "dependencies": { "es-errors": "^1.3.0" @@ -4956,6 +5244,12 @@ "node": ">=6" } }, + "node_modules/escape-html": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", + "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", + "license": "MIT" + }, "node_modules/escape-string-regexp": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", @@ -4975,6 +5269,7 @@ "integrity": "sha512-VmQ+sifHUbI/IcSopBCF/HO3YiHQx/AVd3UVyYL6weuwW+HvON9VYn5l6Zl1WZzPWXPNZrSQpxwkkZ/VuvJZzg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -5228,6 +5523,15 @@ "node": ">=0.10.0" } }, + "node_modules/etag": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", + "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, "node_modules/eventemitter3": { "version": "5.0.4", "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.4.tgz", @@ -5235,6 +5539,27 @@ "dev": true, "license": "MIT" }, + "node_modules/eventsource": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/eventsource/-/eventsource-3.0.7.tgz", + "integrity": "sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA==", + "license": "MIT", + "dependencies": { + "eventsource-parser": "^3.0.1" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/eventsource-parser": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/eventsource-parser/-/eventsource-parser-3.0.6.tgz", + "integrity": "sha512-Vo1ab+QXPzZ4tCa8SwIHJFaSzy4R6SHf7BY79rFBDf0idraZWAkYrDjDj8uWaSm3S2TK+hJ7/t1CEmZ7jXw+pg==", + "license": "MIT", + "engines": { + "node": ">=18.0.0" + } + }, "node_modules/expect-type": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz", @@ -5252,6 +5577,92 @@ "dev": true, "license": "Apache-2.0" }, + "node_modules/express": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz", + "integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==", + "license": "MIT", + "dependencies": { + "accepts": "^2.0.0", + "body-parser": "^2.2.1", + "content-disposition": "^1.0.0", + "content-type": "^1.0.5", + "cookie": "^0.7.1", + "cookie-signature": "^1.2.1", + "debug": "^4.4.0", + "depd": "^2.0.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "finalhandler": "^2.1.0", + "fresh": "^2.0.0", + "http-errors": "^2.0.0", + "merge-descriptors": "^2.0.0", + "mime-types": "^3.0.0", + "on-finished": "^2.4.1", + "once": "^1.4.0", + "parseurl": "^1.3.3", + "proxy-addr": "^2.0.7", + "qs": "^6.14.0", + "range-parser": "^1.2.1", + "router": "^2.2.0", + "send": "^1.1.0", + "serve-static": "^2.2.0", + "statuses": "^2.0.1", + "type-is": "^2.0.1", + "vary": "^1.1.2" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/express-rate-limit": { + "version": "8.3.1", + "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.3.1.tgz", + "integrity": "sha512-D1dKN+cmyPWuvB+G2SREQDzPY1agpBIcTa9sJxOPMCNeH3gwzhqJRDWCXW3gg0y//+LQ/8j52JbMROWyrKdMdw==", + "license": "MIT", + "dependencies": { + "ip-address": "10.1.0" + }, + "engines": { + "node": ">= 16" + }, + "funding": { + "url": "https://github.com/sponsors/express-rate-limit" + }, + "peerDependencies": { + "express": ">= 4.11" + } + }, + "node_modules/express/node_modules/mime-db": { + "version": "1.54.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", + "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/express/node_modules/mime-types": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz", + "integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==", + "license": "MIT", + "dependencies": { + "mime-db": "^1.54.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/extract-zip": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/extract-zip/-/extract-zip-2.0.1.tgz", @@ -5288,7 +5699,6 @@ "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", - "dev": true, "license": "MIT" }, "node_modules/fast-json-stable-stringify": { @@ -5305,13 +5715,29 @@ "dev": true, "license": "MIT" }, - "node_modules/fd-slicer": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/fd-slicer/-/fd-slicer-1.1.0.tgz", - "integrity": "sha512-cE1qsB/VwyQozZ+q1dGxR8LBYNZeofhEdUNGSMbQD3Gw2lAzX9Zb3uIU6Ebc/Fmyjo9AWWfnn0AUCHqtevs/8g==", - "dev": true, - "license": "MIT", - "dependencies": { + "node_modules/fast-uri": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.0.tgz", + "integrity": "sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/fd-slicer": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/fd-slicer/-/fd-slicer-1.1.0.tgz", + "integrity": "sha512-cE1qsB/VwyQozZ+q1dGxR8LBYNZeofhEdUNGSMbQD3Gw2lAzX9Zb3uIU6Ebc/Fmyjo9AWWfnn0AUCHqtevs/8g==", + "dev": true, + "license": "MIT", + "dependencies": { "pend": "~1.2.0" } }, @@ -5399,6 +5825,27 @@ "node": ">=8" } }, + "node_modules/finalhandler": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.1.tgz", + "integrity": "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "on-finished": "^2.4.1", + "parseurl": "^1.3.3", + "statuses": "^2.0.1" + }, + "engines": { + "node": ">= 18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/find-up": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", @@ -5505,6 +5952,24 @@ "node": ">= 6" } }, + "node_modules/forwarded": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", + "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fresh": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-2.0.0.tgz", + "integrity": "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/fs-extra": { "version": "8.1.0", "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-8.1.0.tgz", @@ -5559,7 +6024,6 @@ "version": "1.1.2", "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", - "dev": true, "license": "MIT", "funding": { "url": "https://github.com/sponsors/ljharb" @@ -5601,7 +6065,6 @@ "version": "1.3.0", "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", - "dev": true, "license": "MIT", "dependencies": { "call-bind-apply-helpers": "^1.0.2", @@ -5626,7 +6089,6 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", - "dev": true, "license": "MIT", "dependencies": { "dunder-proto": "^1.0.1", @@ -5786,7 +6248,6 @@ "version": "1.2.0", "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", - "dev": true, "license": "MIT", "engines": { "node": ">= 0.4" @@ -5856,7 +6317,6 @@ "version": "1.1.0", "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", - "dev": true, "license": "MIT", "engines": { "node": ">= 0.4" @@ -5885,7 +6345,6 @@ "version": "2.0.2", "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", - "dev": true, "license": "MIT", "dependencies": { "function-bind": "^1.1.2" @@ -5930,6 +6389,16 @@ "url": "https://opencollective.com/unified" } }, + "node_modules/hono": { + "version": "4.12.8", + "resolved": "https://registry.npmjs.org/hono/-/hono-4.12.8.tgz", + "integrity": "sha512-VJCEvtrezO1IAR+kqEYnxUOoStaQPGrCmX3j4wDTNOcD1uRPFpGlwQUIW8niPuvHXaTUxeOUl5MMDGrl+tmO9A==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">=16.9.0" + } + }, "node_modules/hosted-git-info": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-4.1.0.tgz", @@ -6000,6 +6469,26 @@ "dev": true, "license": "BSD-2-Clause" }, + "node_modules/http-errors": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", + "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", + "license": "MIT", + "dependencies": { + "depd": "~2.0.0", + "inherits": "~2.0.4", + "setprototypeof": "~1.2.0", + "statuses": "~2.0.2", + "toidentifier": "~1.0.1" + }, + "engines": { + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/http-proxy-agent": { "version": "7.0.2", "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", @@ -6163,7 +6652,6 @@ "version": "2.0.4", "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", - "dev": true, "license": "ISC" }, "node_modules/inline-style-parser": { @@ -6177,12 +6665,20 @@ "version": "10.1.0", "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.1.0.tgz", "integrity": "sha512-XXADHxXmvT9+CRxhXg56LJovE+bmWnEWB78LB83VZTprKTmaC5QfruXocxzTZ2Kl0DNwKuBdlIhjL8LeY8Sf8Q==", - "dev": true, "license": "MIT", "engines": { "node": ">= 12" } }, + "node_modules/ipaddr.js": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", + "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", + "license": "MIT", + "engines": { + "node": ">= 0.10" + } + }, "node_modules/is-extglob": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", @@ -6248,6 +6744,12 @@ "node": ">=0.12.0" } }, + "node_modules/is-promise": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz", + "integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==", + "license": "MIT" + }, "node_modules/is-unicode-supported": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/is-unicode-supported/-/is-unicode-supported-0.1.0.tgz", @@ -6360,6 +6862,15 @@ "node": ">= 20" } }, + "node_modules/jose": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/jose/-/jose-6.2.2.tgz", + "integrity": "sha512-d7kPDd34KO/YnzaDOlikGpOurfF0ByC2sEV4cANCtdqLlTfBlw2p14O/5d/zv40gJPbIQxfES3nSx1/oYNyuZQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, "node_modules/js-tokens": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", @@ -6407,6 +6918,12 @@ "dev": true, "license": "MIT" }, + "node_modules/json-schema-typed": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/json-schema-typed/-/json-schema-typed-8.0.2.tgz", + "integrity": "sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA==", + "license": "BSD-2-Clause" + }, "node_modules/json-stable-stringify-without-jsonify": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", @@ -7025,7 +7542,6 @@ "version": "1.1.0", "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", - "dev": true, "license": "MIT", "engines": { "node": ">= 0.4" @@ -7052,6 +7568,15 @@ "url": "https://opencollective.com/unified" } }, + "node_modules/media-typer": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.0.tgz", + "integrity": "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/merge-anything": { "version": "5.1.7", "resolved": "https://registry.npmjs.org/merge-anything/-/merge-anything-5.1.7.tgz", @@ -7068,6 +7593,18 @@ "url": "https://github.com/sponsors/mesqueeb" } }, + "node_modules/merge-descriptors": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-2.0.0.tgz", + "integrity": "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/micromark-util-character": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/micromark-util-character/-/micromark-util-character-2.1.1.tgz", @@ -7438,7 +7975,6 @@ "integrity": "sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "minimist": "^1.2.6" }, @@ -7481,7 +8017,6 @@ "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", - "dev": true, "license": "MIT" }, "node_modules/nano-spawn": { @@ -7527,7 +8062,6 @@ "version": "1.0.0", "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.0.0.tgz", "integrity": "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==", - "dev": true, "license": "MIT", "engines": { "node": ">= 0.6" @@ -7680,6 +8214,27 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-inspect": { + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/object-keys": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/object-keys/-/object-keys-1.1.1.tgz", @@ -7702,11 +8257,22 @@ ], "license": "MIT" }, + "node_modules/on-finished": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", + "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", + "license": "MIT", + "dependencies": { + "ee-first": "1.1.1" + }, + "engines": { + "node": ">= 0.8" + } + }, "node_modules/once": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", - "dev": true, "license": "ISC", "dependencies": { "wrappy": "1" @@ -7884,6 +8450,15 @@ "url": "https://github.com/inikulin/parse5?sponsor=1" } }, + "node_modules/parseurl": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", + "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/path-exists": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", @@ -7907,7 +8482,6 @@ "version": "3.1.1", "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", - "dev": true, "license": "MIT", "engines": { "node": ">=8" @@ -7937,6 +8511,16 @@ "dev": true, "license": "ISC" }, + "node_modules/path-to-regexp": { + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.3.0.tgz", + "integrity": "sha512-7jdwVIRtsP8MYpdXSwOS0YdD0Du+qOoF/AEPIt88PcCFrZCzx41oxku1jD88hZBwbNUIEfpqvuhjFaMAqMTWnA==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/pathe": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", @@ -7979,6 +8563,7 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -7999,6 +8584,15 @@ "node": ">=0.10" } }, + "node_modules/pkce-challenge": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/pkce-challenge/-/pkce-challenge-5.0.1.tgz", + "integrity": "sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ==", + "license": "MIT", + "engines": { + "node": ">=16.20.0" + } + }, "node_modules/plist": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/plist/-/plist-3.1.0.tgz", @@ -8059,7 +8653,6 @@ "dev": true, "license": "MIT", "optional": true, - "peer": true, "dependencies": { "commander": "^9.4.0" }, @@ -8077,7 +8670,6 @@ "dev": true, "license": "MIT", "optional": true, - "peer": true, "engines": { "node": "^12.20.0 || >=14" } @@ -8164,6 +8756,19 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/proxy-addr": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", + "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", + "license": "MIT", + "dependencies": { + "forwarded": "0.2.0", + "ipaddr.js": "1.9.1" + }, + "engines": { + "node": ">= 0.10" + } + }, "node_modules/proxy-from-env": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", @@ -8327,6 +8932,21 @@ "node": ">=6" } }, + "node_modules/qs": { + "version": "6.15.0", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.0.tgz", + "integrity": "sha512-mAZTtNCeetKMH+pSjrb76NAM8V9a05I9aBZOHztWy/UqcJdQYNsf59vrRKWnojAT9Y+GbIvoTBC++CPHqpDBhQ==", + "license": "BSD-3-Clause", + "dependencies": { + "side-channel": "^1.1.0" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/quick-lru": { "version": "5.1.1", "resolved": "https://registry.npmjs.org/quick-lru/-/quick-lru-5.1.1.tgz", @@ -8340,6 +8960,46 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/range-parser": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", + "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/raw-body": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-3.0.2.tgz", + "integrity": "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==", + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "http-errors": "~2.0.1", + "iconv-lite": "~0.7.0", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/raw-body/node_modules/iconv-lite": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.2.tgz", + "integrity": "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/read-binary-file-arch": { "version": "1.0.6", "resolved": "https://registry.npmjs.org/read-binary-file-arch/-/read-binary-file-arch-1.0.6.tgz", @@ -8401,6 +9061,15 @@ "node": ">=0.10.0" } }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/require-main-filename": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/require-main-filename/-/require-main-filename-2.0.0.tgz", @@ -8493,7 +9162,6 @@ "deprecated": "Rimraf versions prior to v4 are no longer supported", "dev": true, "license": "ISC", - "peer": true, "dependencies": { "glob": "^7.1.3" }, @@ -8565,6 +9233,22 @@ "fsevents": "~2.3.2" } }, + "node_modules/router": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz", + "integrity": "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.0", + "depd": "^2.0.0", + "is-promise": "^4.0.0", + "parseurl": "^1.3.3", + "path-to-regexp": "^8.0.0" + }, + "engines": { + "node": ">= 18" + } + }, "node_modules/rxjs": { "version": "7.8.2", "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.2.tgz", @@ -8600,7 +9284,6 @@ "version": "2.1.2", "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", - "dev": true, "license": "MIT" }, "node_modules/sanitize-filename": { @@ -8641,6 +9324,57 @@ "license": "MIT", "optional": true }, + "node_modules/send": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/send/-/send-1.2.1.tgz", + "integrity": "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.3", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "fresh": "^2.0.0", + "http-errors": "^2.0.1", + "mime-types": "^3.0.2", + "ms": "^2.1.3", + "on-finished": "^2.4.1", + "range-parser": "^1.2.1", + "statuses": "^2.0.2" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/send/node_modules/mime-db": { + "version": "1.54.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", + "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/send/node_modules/mime-types": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz", + "integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==", + "license": "MIT", + "dependencies": { + "mime-db": "^1.54.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/serialize-error": { "version": "7.0.1", "resolved": "https://registry.npmjs.org/serialize-error/-/serialize-error-7.0.1.tgz", @@ -8663,6 +9397,7 @@ "resolved": "https://registry.npmjs.org/seroval/-/seroval-1.5.0.tgz", "integrity": "sha512-OE4cvmJ1uSPrKorFIH9/w/Qwuvi/IMcGbv5RKgcJ/zjA/IohDLU6SVaxFN9FwajbP7nsX0dQqMDes1whk3y+yw==", "license": "MIT", + "peer": true, "engines": { "node": ">=10" } @@ -8679,17 +9414,41 @@ "seroval": "^1.0" } }, + "node_modules/serve-static": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-2.2.1.tgz", + "integrity": "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==", + "license": "MIT", + "dependencies": { + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "parseurl": "^1.3.3", + "send": "^1.2.0" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/set-blocking": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz", "integrity": "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==", "license": "ISC" }, + "node_modules/setprototypeof": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", + "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", + "license": "ISC" + }, "node_modules/shebang-command": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", - "dev": true, "license": "MIT", "dependencies": { "shebang-regex": "^3.0.0" @@ -8702,7 +9461,6 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", - "dev": true, "license": "MIT", "engines": { "node": ">=8" @@ -8740,6 +9498,78 @@ "node": ">=20" } }, + "node_modules/side-channel": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", + "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3", + "side-channel-list": "^1.0.0", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-list": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.0.tgz", + "integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-map": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/siginfo": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", @@ -8842,6 +9672,7 @@ "resolved": "https://registry.npmjs.org/solid-js/-/solid-js-1.9.11.tgz", "integrity": "sha512-WEJtcc5mkh/BnHA6Yrg4whlF8g6QwpmXXRg4P2ztPmcKeHHlH4+djYecBLhSpecZY2RRECXYUwIc/C2r3yzQ4Q==", "license": "MIT", + "peer": true, "dependencies": { "csstype": "^3.1.0", "seroval": "~1.5.0", @@ -8942,6 +9773,15 @@ "node": ">= 6" } }, + "node_modules/statuses": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", + "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/std-env": { "version": "3.10.0", "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz", @@ -9124,7 +9964,6 @@ "integrity": "sha512-yYrrsWnrXMcdsnu/7YMYAofM1ktpL5By7vZhf15CrXijWWrEYZks5AXBudalfSWJLlnen/QUJUB5aoB0kqZUGA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "mkdirp": "^0.5.1", "rimraf": "~2.6.2" @@ -9279,6 +10118,15 @@ "node": ">=8.0" } }, + "node_modules/toidentifier": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", + "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", + "license": "MIT", + "engines": { + "node": ">=0.6" + } + }, "node_modules/tree-kill": { "version": "1.2.2", "resolved": "https://registry.npmjs.org/tree-kill/-/tree-kill-1.2.2.tgz", @@ -9356,12 +10204,52 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/type-is": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.0.1.tgz", + "integrity": "sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw==", + "license": "MIT", + "dependencies": { + "content-type": "^1.0.5", + "media-typer": "^1.1.0", + "mime-types": "^3.0.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/type-is/node_modules/mime-db": { + "version": "1.54.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", + "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/type-is/node_modules/mime-types": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz", + "integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==", + "license": "MIT", + "dependencies": { + "mime-db": "^1.54.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/typescript": { "version": "5.9.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "dev": true, "license": "Apache-2.0", + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -9505,6 +10393,15 @@ "node": ">= 4.0.0" } }, + "node_modules/unpipe": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", + "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/update-browserslist-db": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", @@ -9560,6 +10457,15 @@ "dev": true, "license": "MIT" }, + "node_modules/vary": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", + "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/verror": { "version": "1.10.1", "resolved": "https://registry.npmjs.org/verror/-/verror-1.10.1.tgz", @@ -9610,6 +10516,7 @@ "integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "esbuild": "^0.27.0", "fdir": "^6.5.0", @@ -9922,7 +10829,6 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", - "dev": true, "license": "ISC" }, "node_modules/ws": { @@ -10042,6 +10948,25 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/zod": { + "version": "4.3.6", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz", + "integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==", + "license": "MIT", + "peer": true, + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, + "node_modules/zod-to-json-schema": { + "version": "3.25.1", + "resolved": "https://registry.npmjs.org/zod-to-json-schema/-/zod-to-json-schema-3.25.1.tgz", + "integrity": "sha512-pM/SU9d3YAggzi6MtR4h7ruuQlqKtad8e9S0fmxcMi+ueAK5Korys/aWcV9LIIHTVbj01NdzxcnXSN+O74ZIVA==", + "license": "ISC", + "peerDependencies": { + "zod": "^3.25 || ^4" + } + }, "node_modules/zwitch": { "version": "2.0.4", "resolved": "https://registry.npmjs.org/zwitch/-/zwitch-2.0.4.tgz", diff --git a/package.json b/package.json index 0b373024..9e23de4e 100644 --- a/package.json +++ b/package.json @@ -27,6 +27,7 @@ }, "license": "MIT", "dependencies": { + "@modelcontextprotocol/sdk": "^1.27.1", "@xterm/addon-fit": "^0.11.0", "@xterm/addon-web-links": "^0.12.0", "@xterm/addon-webgl": "^0.19.0", @@ -69,8 +70,8 @@ "*.{js,cjs,json,md,html,css}": "prettier --write" }, "build": { - "appId": "com.parallel-code.app", - "productName": "Parallel Code", + "appId": "com.parallel-code.app.dev", + "productName": "Parallel Code Dev", "directories": { "buildResources": "build", "output": "release" diff --git a/src/App.tsx b/src/App.tsx index 227a6fbf..c3ec8d98 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -43,6 +43,7 @@ import { setNewTaskDropUrl, validateProjectPaths, setPlanContent, + initMCPListeners, } from './store/store'; import { isGitHubUrl } from './lib/github-url'; import type { PersistedWindowState } from './store/types'; @@ -310,6 +311,7 @@ function App() { await captureWindowState(); setupAutosave(); startTaskStatusPolling(); + const stopMCPListeners = initMCPListeners(); const stopNotificationWatcher = startDesktopNotificationWatcher(windowFocused); // Listen for plan content pushed from backend plan watcher @@ -575,6 +577,7 @@ function App() { unlistenCloseRequested(); cleanupShortcuts(); stopTaskStatusPolling(); + stopMCPListeners(); stopNotificationWatcher(); offPlanContent(); unlistenFocusChanged?.(); diff --git a/src/components/NewTaskDialog.tsx b/src/components/NewTaskDialog.tsx index 1789bdd6..65a7b999 100644 --- a/src/components/NewTaskDialog.tsx +++ b/src/components/NewTaskDialog.tsx @@ -42,6 +42,7 @@ export function NewTaskDialog(props: NewTaskDialogProps) { const [selectedDirs, setSelectedDirs] = createSignal>(new Set()); const [directMode, setDirectMode] = createSignal(false); const [skipPermissions, setSkipPermissions] = createSignal(false); + const [coordinatorMode, setCoordinatorMode] = createSignal(false); const [branchPrefix, setBranchPrefix] = createSignal(''); let promptRef!: HTMLTextAreaElement; let formRef!: HTMLFormElement; @@ -105,6 +106,7 @@ export function NewTaskDialog(props: NewTaskDialogProps) { setLoading(false); setDirectMode(false); setSkipPermissions(false); + setCoordinatorMode(false); void (async () => { if (store.availableAgents.length === 0) { @@ -307,6 +309,7 @@ export function NewTaskDialog(props: NewTaskDialogProps) { branchPrefixOverride: prefix, githubUrl: ghUrl, skipPermissions: agentSupportsSkipPermissions() && skipPermissions(), + coordinatorMode: coordinatorMode() || undefined, }); } // Drop flow: prefill prompt without auto-sending @@ -501,6 +504,48 @@ export function NewTaskDialog(props: NewTaskDialogProps) { onSelect={setSelectedAgent} /> + {/* Coordinator mode toggle */} +
+ + +
+ This agent will be able to create tasks, send prompts, and merge branches + automatically via MCP tools. The remote server will be started automatically. +
+
+
+ {/* Direct mode toggle */}
store.tasks[props.taskId]; + const coordinatorName = () => { + const t = task(); + if (!t?.coordinatedBy) return null; + return store.tasks[t.coordinatedBy]?.name ?? null; + }; return ( {(t) => ( @@ -745,32 +750,47 @@ function CollapsedTaskRow(props: { taskId: string }) { 'text-overflow': 'ellipsis', opacity: '0.6', display: 'flex', - 'align-items': 'center', - gap: '6px', + 'flex-direction': 'column', + gap: '1px', border: store.sidebarFocused && store.sidebarFocusedTaskId === props.taskId ? `1.5px solid var(--border-focus)` : '1.5px solid transparent', }} > - - - - {t().branchName} - +
+ + + + {t().branchName} + + + {t().name} +
+ + {(name) => ( + + via {name()} + + )} - {t().name}
)} @@ -787,6 +807,11 @@ interface TaskRowProps { function TaskRow(props: TaskRowProps) { const task = () => store.tasks[props.taskId]; const idx = () => props.globalIndex(props.taskId); + const coordinatorName = () => { + const t = task(); + if (!t?.coordinatedBy) return null; + return store.tasks[t.coordinatedBy]?.name ?? null; + }; return ( {(t) => ( @@ -814,32 +839,53 @@ function TaskRow(props: TaskRowProps) { 'text-overflow': 'ellipsis', opacity: props.dragFromIndex() === idx() ? '0.4' : '1', display: 'flex', - 'align-items': 'center', - gap: '6px', + 'flex-direction': 'column', + gap: '1px', border: store.sidebarFocused && store.sidebarFocusedTaskId === props.taskId ? `1.5px solid var(--border-focus)` : '1.5px solid transparent', }} > - - - - {t().branchName} - +
+ + + + {t().branchName} + + + {t().name} +
+ + {(name) => ( + { + e.stopPropagation(); + const coordId = t().coordinatedBy; + if (coordId) setActiveTask(coordId); + }} + > + via {name()} + + )} - {t().name} )} diff --git a/src/components/SubTaskStrip.tsx b/src/components/SubTaskStrip.tsx new file mode 100644 index 00000000..e6c956f3 --- /dev/null +++ b/src/components/SubTaskStrip.tsx @@ -0,0 +1,77 @@ +import { For, Show, createMemo } from 'solid-js'; +import { store, setActiveTask, getTaskDotStatus } from '../store/store'; +import { StatusDot } from './StatusDot'; +import { theme } from '../lib/theme'; +import { sf } from '../lib/fontScale'; + +interface SubTaskStripProps { + coordinatorTaskId: string; +} + +export function SubTaskStrip(props: SubTaskStripProps) { + const subTasks = createMemo(() => + store.taskOrder + .map((id) => store.tasks[id]) + .filter((t) => t && t.coordinatedBy === props.coordinatorTaskId), + ); + + return ( + 0}> +
+ + Sub-tasks: + + + {(task) => ( + + )} + +
+
+ ); +} diff --git a/src/components/TaskPanel.tsx b/src/components/TaskPanel.tsx index 18f26aad..3954126b 100644 --- a/src/components/TaskPanel.tsx +++ b/src/components/TaskPanel.tsx @@ -44,6 +44,7 @@ import { PushDialog } from './PushDialog'; import { DiffViewerDialog } from './DiffViewerDialog'; import { PlanViewerDialog } from './PlanViewerDialog'; import { EditProjectDialog } from './EditProjectDialog'; +import { SubTaskStrip } from './SubTaskStrip'; import { theme } from '../lib/theme'; import { sf } from '../lib/fontScale'; import { mod, isMac } from '../lib/platform'; @@ -1234,6 +1235,9 @@ export function TaskPanel(props: TaskPanelProps) { ...(props.task.skipPermissions && a().def.skip_permissions_args?.length ? (a().def.skip_permissions_args ?? []) : []), + ...(props.task.coordinatorMode && props.task.mcpConfigPath + ? ['--mcp-config', props.task.mcpConfigPath] + : []), ]} cwd={props.task.worktreePath} onExit={(code) => markAgentExited(a().id, code)} @@ -1362,6 +1366,16 @@ export function TaskPanel(props: TaskPanelProps) { children={[ titleBar(), branchInfoBar(), + ...(props.task.coordinatorMode + ? [ + { + id: 'subtask-strip', + initialSize: 32, + fixed: true, + content: () => , + } as PanelChild, + ] + : []), notesAndFiles(), shellSection(), aiTerminal(), diff --git a/src/store/persistence.ts b/src/store/persistence.ts index 595d6701..f944f305 100644 --- a/src/store/persistence.ts +++ b/src/store/persistence.ts @@ -67,6 +67,8 @@ export async function saveState(): Promise { githubUrl: task.githubUrl, savedInitialPrompt: task.savedInitialPrompt, planFileName: task.planFileName, + coordinatorMode: task.coordinatorMode, + coordinatedBy: task.coordinatedBy, }; } @@ -92,6 +94,8 @@ export async function saveState(): Promise { savedInitialPrompt: task.savedInitialPrompt, planFileName: task.planFileName, collapsed: true, + coordinatorMode: task.coordinatorMode, + coordinatedBy: task.coordinatedBy, }; } @@ -343,6 +347,8 @@ export async function loadState(): Promise { githubUrl: pt.githubUrl, savedInitialPrompt: pt.savedInitialPrompt, planFileName: pt.planFileName, + coordinatorMode: pt.coordinatorMode, + coordinatedBy: pt.coordinatedBy, }; s.tasks[taskId] = task; @@ -410,6 +416,8 @@ export async function loadState(): Promise { planFileName: pt.planFileName, collapsed: true, savedAgentDef: agentDef ?? undefined, + coordinatorMode: pt.coordinatorMode, + coordinatedBy: pt.coordinatedBy, }; s.tasks[taskId] = task; diff --git a/src/store/store.ts b/src/store/store.ts index aeee5da9..86b84324 100644 --- a/src/store/store.ts +++ b/src/store/store.ts @@ -49,6 +49,7 @@ export { setNewTaskDropUrl, setNewTaskPrefillPrompt, setPlanContent, + initMCPListeners, } from './tasks'; export { setActiveTask, diff --git a/src/store/taskStatus.ts b/src/store/taskStatus.ts index a72c1995..e3a300d9 100644 --- a/src/store/taskStatus.ts +++ b/src/store/taskStatus.ts @@ -3,6 +3,11 @@ import { invoke } from '../lib/ipc'; import { IPC } from '../../electron/ipc/channels'; import { store, setStore } from './core'; import type { WorktreeStatus } from '../ipc/types'; +import { + stripAnsi as sharedStripAnsi, + PROMPT_PATTERNS as SHARED_PROMPT_PATTERNS, + chunkContainsAgentPrompt as sharedChunkContainsAgentPrompt, +} from '../../electron/mcp/prompt-detect'; // --- Trust-specific patterns (subset of QUESTION_PATTERNS) --- // These are auto-accepted when autoTrustFolders is enabled. @@ -78,32 +83,10 @@ function clearAutoTrustState(agentId: string): void { export type TaskDotStatus = 'busy' | 'waiting' | 'ready'; // --- Prompt detection helpers --- +// Re-exported from shared module for backward compatibility. -/** Strip ANSI escape sequences (CSI, OSC, and single-char escapes) from terminal output. */ -export function stripAnsi(text: string): string { - return text.replace( - // eslint-disable-next-line no-control-regex - /[\u001b\u009b][[()#;?]*(?:[0-9]{1,4}(?:;[0-9]{0,4})*)?[0-9A-ORZcf-nq-uy=><~]|\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)?/g, - '', - ); -} - -/** - * Patterns that indicate the agent is waiting for user input (i.e. idle). - * Each regex is tested against the last non-empty line of stripped output. - * - * - Claude Code prompt: ends with ❯ (possibly with trailing whitespace) - * - Common shell prompts: $, %, #, > - * - Y/n confirmation prompts - */ -const PROMPT_PATTERNS: RegExp[] = [ - /❯\s*$/, // Claude Code prompt - /(?:^|\s)\$\s*$/, // bash/zsh dollar prompt (preceded by whitespace or BOL) - /(?:^|\s)%\s*$/, // zsh percent prompt - /(?:^|\s)#\s*$/, // root prompt - /\[Y\/n\]\s*$/i, // Y/n confirmation - /\[y\/N\]\s*$/i, // y/N confirmation -]; +export const stripAnsi = sharedStripAnsi; +const PROMPT_PATTERNS = SHARED_PROMPT_PATTERNS; /** Returns true if `line` looks like a prompt waiting for input. */ function looksLikePrompt(line: string): boolean { @@ -112,25 +95,7 @@ function looksLikePrompt(line: string): boolean { return PROMPT_PATTERNS.some((re) => re.test(stripped)); } -/** - * Patterns for known agent main input prompts (ready for a new task). - * Tested against the stripped data chunk (not a single line), because TUI - * apps like Claude Code use cursor positioning instead of newlines. - */ -const AGENT_READY_TAIL_PATTERNS: RegExp[] = [ - /❯/, // Claude Code - /›/, // Codex CLI -]; - -/** Check stripped output for known agent prompt characters. - * Only checks the tail of the chunk — the agent's main prompt renders as - * the last visible element, while TUI selection UIs place ❯ earlier in - * the render followed by option text and other choices. */ -function chunkContainsAgentPrompt(stripped: string): boolean { - if (stripped.length === 0) return false; - const tail = stripped.slice(-50); - return AGENT_READY_TAIL_PATTERNS.some((re) => re.test(tail)); -} +const chunkContainsAgentPrompt = sharedChunkContainsAgentPrompt; // --- Agent ready event callbacks --- // Fired from markAgentOutput when a main prompt is detected in a PTY chunk. diff --git a/src/store/tasks.ts b/src/store/tasks.ts index 2bf3168d..d536cf07 100644 --- a/src/store/tasks.ts +++ b/src/store/tasks.ts @@ -57,6 +57,7 @@ export interface CreateTaskOptions { branchPrefixOverride?: string; githubUrl?: string; skipPermissions?: boolean; + coordinatorMode?: boolean; } export async function createTask(opts: CreateTaskOptions): Promise { @@ -96,6 +97,7 @@ export async function createTask(opts: CreateTaskOptions): Promise { skipPermissions: skipPermissions || undefined, githubUrl, savedInitialPrompt: initialPrompt || undefined, + coordinatorMode: opts.coordinatorMode || undefined, }; const agent: Agent = { @@ -126,6 +128,21 @@ export async function createTask(opts: CreateTaskOptions): Promise { markAgentSpawned(agentId); rescheduleTaskStatusPolling(); updateWindowTitle(name); + + // Start MCP server for coordinator tasks + if (opts.coordinatorMode) { + try { + const mcpResult = await invoke<{ configPath: string }>(IPC.StartMCPServer, { + coordinatorTaskId: result.id, + projectId, + projectRoot, + }); + setStore('tasks', result.id, 'mcpConfigPath', mcpResult.configPath); + } catch (err) { + console.warn('Failed to start MCP server for coordinator:', err); + } + } + return result.id; } @@ -601,6 +618,108 @@ export function setNewTaskPrefillPrompt(prompt: string, projectId: string | null setStore('newTaskPrefillPrompt', { prompt, projectId }); } +// --- MCP orchestrator event listeners --- + +interface MCPTaskCreatedEvent { + taskId: string; + name: string; + projectId: string; + branchName: string; + worktreePath: string; + agentId: string; + coordinatorTaskId: string; +} + +/** Call once during app initialization to listen for orchestrator events. */ +export function initMCPListeners(): () => void { + const cleanups: Array<() => void> = []; + + cleanups.push( + window.electron.ipcRenderer.on(IPC.MCP_TaskCreated, (data: unknown) => { + const evt = data as MCPTaskCreatedEvent; + const task: Task = { + id: evt.taskId, + name: evt.name, + projectId: evt.projectId, + branchName: evt.branchName, + worktreePath: evt.worktreePath, + agentIds: [evt.agentId], + shellAgentIds: [], + notes: '', + lastPrompt: '', + coordinatedBy: evt.coordinatorTaskId, + }; + + const agent: Agent = { + id: evt.agentId, + taskId: evt.taskId, + def: { + id: 'claude', + name: 'Claude Code', + command: 'claude', + args: [], + resume_args: [], + skip_permissions_args: [], + description: '', + }, + resumed: false, + status: 'running', + exitCode: null, + signal: null, + lastOutput: [], + generation: 0, + }; + + setStore( + produce((s) => { + s.tasks[evt.taskId] = task; + s.agents[evt.agentId] = agent; + s.taskOrder.push(evt.taskId); + }), + ); + markAgentSpawned(evt.agentId); + rescheduleTaskStatusPolling(); + }), + ); + + cleanups.push( + window.electron.ipcRenderer.on(IPC.MCP_TaskClosed, (data: unknown) => { + const { taskId } = data as { taskId: string }; + const task = store.tasks[taskId]; + if (!task) return; + + const agentIds = [...task.agentIds]; + for (const agentId of agentIds) { + clearAgentActivity(agentId); + } + + setStore( + produce((s) => { + delete s.tasks[taskId]; + delete s.taskGitStatus[taskId]; + cleanupPanelEntries(s, taskId); + for (const agentId of agentIds) { + delete s.agents[agentId]; + } + if (s.activeTaskId === taskId) { + const idx = s.taskOrder.indexOf(taskId); + const filtered = s.taskOrder.filter((id) => id !== taskId); + const neighborIdx = idx <= 0 ? 0 : idx - 1; + s.activeTaskId = filtered[neighborIdx] ?? null; + const neighbor = s.activeTaskId ? s.tasks[s.activeTaskId] : null; + s.activeAgentId = neighbor?.agentIds[0] ?? null; + } + }), + ); + rescheduleTaskStatusPolling(); + }), + ); + + return () => { + for (const cleanup of cleanups) cleanup(); + }; +} + export function setPlanContent( taskId: string, content: string | null, diff --git a/src/store/types.ts b/src/store/types.ts index 3d2c1279..e56dac56 100644 --- a/src/store/types.ts +++ b/src/store/types.ts @@ -52,6 +52,9 @@ export interface Task { savedAgentDef?: AgentDef; planContent?: string; planFileName?: string; + coordinatorMode?: boolean; + coordinatedBy?: string; // taskId of the coordinator that created this task + mcpConfigPath?: string; // path to MCP config file (for coordinator tasks) } export interface Terminal { @@ -77,6 +80,8 @@ export interface PersistedTask { savedInitialPrompt?: string; collapsed?: boolean; planFileName?: string; + coordinatorMode?: boolean; + coordinatedBy?: string; } export interface PersistedTerminal { From 6650b058873ab1d3ac0ffebd8ba8f333ee938c3d Mon Sep 17 00:00:00 2001 From: Croissant Le Doux Date: Thu, 19 Mar 2026 16:03:14 -0400 Subject: [PATCH 2/3] Fixes to MCP and task coordination UI --- electron/ipc/channels.ts | 1 + electron/ipc/register.ts | 77 +++++- electron/mcp/orchestrator.ts | 65 ++++- electron/mcp/server.ts | 4 +- electron/mcp/types.ts | 4 +- electron/preload.cjs | 9 + electron/remote/server.ts | 95 ++++++-- package-lock.json | 215 +++++++++-------- package.json | 9 +- src/components/CloseTaskDialog.tsx | 20 +- src/components/Sidebar.tsx | 376 ++++++++++++++++++++--------- src/components/SidebarFooter.tsx | 50 +++- src/store/coordinator-preamble.ts | 31 +++ src/store/core.ts | 4 + src/store/mcpStatus.ts | 38 +++ src/store/sidebar-order.ts | 41 +++- src/store/store.ts | 7 + src/store/tasks.ts | 74 ++++-- src/store/types.ts | 8 + 19 files changed, 842 insertions(+), 286 deletions(-) create mode 100644 src/store/coordinator-preamble.ts create mode 100644 src/store/mcpStatus.ts diff --git a/electron/ipc/channels.ts b/electron/ipc/channels.ts index 5ca1e80c..b6301e41 100644 --- a/electron/ipc/channels.ts +++ b/electron/ipc/channels.ts @@ -94,6 +94,7 @@ export enum IPC { StartMCPServer = 'start_mcp_server', StopMCPServer = 'stop_mcp_server', GetMCPStatus = 'get_mcp_status', + GetMCPLogs = 'get_mcp_logs', MCP_TaskCreated = 'mcp_task_created', MCP_TaskClosed = 'mcp_task_closed', MCP_TaskStateSync = 'mcp_task_state_sync', diff --git a/electron/ipc/register.ts b/electron/ipc/register.ts index a05fa0ad..6635384c 100644 --- a/electron/ipc/register.ts +++ b/electron/ipc/register.ts @@ -14,7 +14,7 @@ import { getAgentMeta, } from './pty.js'; import { ensurePlansDirectory, startPlanWatcher, readPlanForWorktree } from './plans.js'; -import { startRemoteServer } from '../remote/server.js'; +import { startRemoteServer, getMCPLogs } from '../remote/server.js'; import { Orchestrator } from '../mcp/orchestrator.js'; import { getGitIgnoredDirs, @@ -80,7 +80,6 @@ export function registerAllHandlers(win: BrowserWindow): void { // --- MCP orchestrator --- const orchestrator = new Orchestrator(); orchestrator.setWindow(win); - let mcpProcess: ReturnType | null = null; // --- PTY commands --- ipcMain.handle(IPC.SpawnAgent, (_e, args) => { @@ -515,10 +514,11 @@ export function registerAllHandlers(win: BrowserWindow): void { coordinatorTaskId: string; projectId: string; projectRoot: string; + worktreePath?: string; }, ) => { - // Set orchestrator's default project - orchestrator.setDefaultProject(args.projectId, args.projectRoot); + // Set orchestrator's default project + coordinator task ID + orchestrator.setDefaultProject(args.projectId, args.projectRoot, args.coordinatorTaskId); // Start remote server if not running if (!remoteServer) { @@ -540,25 +540,75 @@ export function registerAllHandlers(win: BrowserWindow): void { }); } - // Write temp MCP config file + // Write temp MCP config file — use the bundled single-file MCP server + // (built by esbuild, no external deps needed at runtime) const thisDir = path.dirname(fileURLToPath(import.meta.url)); - const mcpServerPath = path.join(thisDir, '..', 'mcp', 'server.js'); + let mcpServerPath = path.join(thisDir, '..', 'mcp-server.cjs'); + + // In packaged builds, asar-unpacked files live in app.asar.unpacked/ + if (mcpServerPath.includes('/app.asar/')) { + mcpServerPath = mcpServerPath.replace('/app.asar/', '/app.asar.unpacked/'); + } const serverUrl = `http://127.0.0.1:${remoteServer.port}`; const mcpConfig = { mcpServers: { 'parallel-code': { + type: 'stdio' as const, command: 'node', args: [mcpServerPath, '--url', serverUrl, '--token', remoteServer.token], }, }, }; + const configJson = JSON.stringify(mcpConfig, null, 2); + + // Write temp config for --mcp-config flag const configPath = path.join( app.getPath('temp'), `parallel-code-mcp-${args.coordinatorTaskId}.json`, ); - fs.writeFileSync(configPath, JSON.stringify(mcpConfig, null, 2)); + fs.writeFileSync(configPath, configJson); + + // Also write .mcp.json into the worktree so Claude Code auto-discovers it. + // Immediately git-exclude it so the token never gets committed. + if (args.worktreePath) { + const worktreeMcpPath = path.join(args.worktreePath, '.mcp.json'); + fs.writeFileSync(worktreeMcpPath, configJson); + + // Append to .git/info/exclude (local-only gitignore, not committed) + try { + const gitDir = path.join(args.worktreePath, '.git'); + // Worktrees use a .git file pointing to the real gitdir + let infoDir: string; + if (fs.statSync(gitDir).isFile()) { + const gitFileContent = fs.readFileSync(gitDir, 'utf-8').trim(); + const realGitDir = gitFileContent.replace(/^gitdir:\s*/, ''); + infoDir = path.join( + path.isAbsolute(realGitDir) ? realGitDir : path.resolve(args.worktreePath, realGitDir), + 'info', + ); + } else { + infoDir = path.join(gitDir, 'info'); + } + fs.mkdirSync(infoDir, { recursive: true }); + const excludePath = path.join(infoDir, 'exclude'); + const existing = fs.existsSync(excludePath) + ? fs.readFileSync(excludePath, 'utf-8') + : ''; + if (!existing.includes('.mcp.json')) { + fs.appendFileSync(excludePath, '\n# Parallel Code MCP config (contains ephemeral token)\n.mcp.json\n'); + } + } catch (err) { + console.warn('[MCP] Could not git-exclude .mcp.json:', err); + } + + console.log('[MCP] Worktree .mcp.json written to:', worktreeMcpPath); + } + + console.log('[MCP] Config written to:', configPath); + console.log('[MCP] Server path:', mcpServerPath); + console.log('[MCP] Remote URL:', serverUrl); return { configPath, @@ -570,19 +620,22 @@ export function registerAllHandlers(win: BrowserWindow): void { ); ipcMain.handle(IPC.StopMCPServer, async () => { - if (mcpProcess) { - mcpProcess.kill(); - mcpProcess = null; - } + // The MCP server process is spawned by Claude Code (via --mcp-config), + // not by us. This handler is a no-op but kept for API completeness. }); ipcMain.handle(IPC.GetMCPStatus, () => { + // The MCP server process is spawned by Claude Code (via --mcp-config), + // not by us. We report whether the remote HTTP server that the MCP + // server connects to is running — if it's up, MCP tools should work. return { - mcpRunning: mcpProcess !== null && mcpProcess.exitCode === null, + mcpRunning: remoteServer !== null, remoteRunning: remoteServer !== null, }; }); + ipcMain.handle(IPC.GetMCPLogs, () => getMCPLogs()); + // --- Forward window events to renderer --- win.on('focus', () => { if (!win.isDestroyed()) win.webContents.send(IPC.WindowFocus); diff --git a/electron/mcp/orchestrator.ts b/electron/mcp/orchestrator.ts index 3da2aa1d..e14f1f75 100644 --- a/electron/mcp/orchestrator.ts +++ b/electron/mcp/orchestrator.ts @@ -12,10 +12,12 @@ import { subscribeToAgent, unsubscribeFromAgent, getAgentScrollback, + onPtyEvent, } from '../ipc/pty.js'; import { getChangedFiles, getAllFileDiffs, mergeTask as gitMergeTask } from '../ipc/git.js'; import { stripAnsi, chunkContainsAgentPrompt } from './prompt-detect.js'; import type { OrchestratedTask, ApiTaskSummary, ApiTaskDetail, ApiDiffResult } from './types.js'; +import { IPC } from '../ipc/channels.js'; const DEFAULT_WAIT_TIMEOUT_MS = 300_000; // 5 minutes const PROMPT_WRITE_DELAY_MS = 50; @@ -29,14 +31,37 @@ export class Orchestrator { private win: BrowserWindow | null = null; private projectRoot: string | null = null; private projectId: string | null = null; + private defaultCoordinatorTaskId: string | null = null; + constructor() { + // Listen for PTY exits to update task status when agents are killed externally + // (e.g., user closes a child task from the UI). + // No cleanup needed — orchestrator lives for the entire app lifetime. + onPtyEvent('exit', (agentId, data) => { + for (const task of this.tasks.values()) { + if (task.agentId === agentId) { + const { exitCode } = (data ?? {}) as { exitCode?: number }; + task.status = 'exited'; + task.exitCode = exitCode ?? null; + // Resolve any idle waiters so they don't hang + const resolvers = this.idleResolvers.get(task.id); + if (resolvers?.length) { + for (const resolve of resolvers) resolve(); + this.idleResolvers.delete(task.id); + } + break; + } + } + }); + } setWindow(win: BrowserWindow): void { this.win = win; } - setDefaultProject(projectId: string, projectRoot: string): void { + setDefaultProject(projectId: string, projectRoot: string, coordinatorTaskId?: string): void { this.projectId = projectId; this.projectRoot = projectRoot; + if (coordinatorTaskId) this.defaultCoordinatorTaskId = coordinatorTaskId; } async createTask(opts: { @@ -55,6 +80,10 @@ export class Orchestrator { // Create worktree + branch via existing backend const result = await createBackendTask(opts.name, root, ['.claude', 'node_modules'], 'task'); + const coordinatorId = opts.coordinatorTaskId !== 'api' + ? opts.coordinatorTaskId + : (this.defaultCoordinatorTaskId ?? opts.coordinatorTaskId); + const agentId = randomUUID(); const task: OrchestratedTask = { id: result.id, @@ -63,7 +92,7 @@ export class Orchestrator { branchName: result.branch_name, worktreePath: result.worktree_path, agentId, - coordinatorTaskId: opts.coordinatorTaskId, + coordinatorTaskId: coordinatorId, status: 'creating', exitCode: null, }; @@ -132,8 +161,24 @@ export class Orchestrator { subscribeToAgent(agentId, outputCb); task.status = 'running'; - // Notify renderer - this.notifyRenderer('MCP_TaskCreated', { + // Check scrollback in case the prompt was emitted before we subscribed + const scrollback = getAgentScrollback(agentId); + if (scrollback) { + const decoded = Buffer.from(scrollback, 'base64').toString('utf8'); + const stripped = stripAnsi(decoded) + // eslint-disable-next-line no-control-regex + .replace(/[\x00-\x1f\x7f]/g, ' ') + .replace(/\s+/g, ' ') + .trim(); + if (chunkContainsAgentPrompt(stripped)) { + task.status = 'idle'; + } + } + + // Notify renderer with the prompt — the renderer sets it as initialPrompt + // on the task, and PromptInput auto-delivers it using the same code path + // as manually created tasks (stability checks, quiescence detection, etc.) + this.notifyRenderer(IPC.MCP_TaskCreated, { taskId: task.id, name: task.name, projectId: task.projectId, @@ -141,15 +186,9 @@ export class Orchestrator { worktreePath: task.worktreePath, agentId: task.agentId, coordinatorTaskId: task.coordinatorTaskId, + prompt: opts.prompt, }); - // Send initial prompt if provided - if (opts.prompt) { - // Wait a bit for the agent to initialize - await this.waitForIdleInternal(task.id, 60_000).catch(() => {}); - await this.sendPrompt(task.id, opts.prompt); - } - return task; } @@ -176,6 +215,7 @@ export class Orchestrator { status: task.status, coordinatorTaskId: task.coordinatorTaskId, exitCode: task.exitCode, + pendingPrompt: task.pendingPrompt, }; } @@ -188,6 +228,7 @@ export class Orchestrator { await new Promise((r) => setTimeout(r, PROMPT_WRITE_DELAY_MS)); writeToAgent(task.agentId, '\r'); task.status = 'running'; + task.pendingPrompt = undefined; } waitForIdle(taskId: string, timeoutMs?: number): Promise { @@ -318,7 +359,7 @@ export class Orchestrator { this.tasks.delete(taskId); // Notify renderer - this.notifyRenderer('MCP_TaskClosed', { taskId }); + this.notifyRenderer(IPC.MCP_TaskClosed, { taskId }); } getTask(taskId: string): OrchestratedTask | undefined { diff --git a/electron/mcp/server.ts b/electron/mcp/server.ts index cb057720..3695f83a 100644 --- a/electron/mcp/server.ts +++ b/electron/mcp/server.ts @@ -39,14 +39,14 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({ { name: 'create_task', description: - 'Create a new task with its own git worktree and AI agent. The agent starts automatically.', + 'Create a new task with its own git worktree and AI agent. The agent starts automatically and the prompt is delivered once the agent is ready.', inputSchema: { type: 'object' as const, properties: { name: { type: 'string', description: 'Task name (used for branch name)' }, prompt: { type: 'string', - description: 'Initial prompt to send to the agent after it starts', + description: 'Initial prompt to send to the agent once it finishes starting up.', }, projectId: { type: 'string', diff --git a/electron/mcp/types.ts b/electron/mcp/types.ts index ec0a03bd..a1ec8f66 100644 --- a/electron/mcp/types.ts +++ b/electron/mcp/types.ts @@ -8,8 +8,9 @@ export interface OrchestratedTask { worktreePath: string; agentId: string; coordinatorTaskId: string; - status: 'creating' | 'running' | 'idle' | 'exited'; + status: 'creating' | 'running' | 'idle' | 'exited' | 'error'; exitCode: number | null; + pendingPrompt?: string; } // --- MCP tool input schemas --- @@ -70,6 +71,7 @@ export interface ApiTaskDetail extends ApiTaskSummary { projectId: string; agentId: string; exitCode: number | null; + pendingPrompt?: string; } export interface ApiDiffResult { diff --git a/electron/preload.cjs b/electron/preload.cjs index e2c8baf9..95ff6bb6 100644 --- a/electron/preload.cjs +++ b/electron/preload.cjs @@ -76,12 +76,21 @@ const ALLOWED_CHANNELS = new Set([ 'get_remote_status', // Plan 'plan_content', + 'read_plan_content', // Ask about code 'ask_about_code', 'cancel_ask_about_code', // Notifications 'show_notification', 'notification_clicked', + // MCP orchestration + 'start_mcp_server', + 'stop_mcp_server', + 'get_mcp_status', + 'get_mcp_logs', + 'mcp_task_created', + 'mcp_task_closed', + 'mcp_task_state_sync', ]); function isAllowedChannel(channel) { diff --git a/electron/remote/server.ts b/electron/remote/server.ts index f714a827..ffdc26ec 100644 --- a/electron/remote/server.ts +++ b/electron/remote/server.ts @@ -21,6 +21,27 @@ import { import { parseClientMessage, type ServerMessage, type RemoteAgent } from './protocol.js'; import type { Orchestrator } from '../mcp/orchestrator.js'; +// --- MCP log ring buffer --- +export interface MCPLogEntry { + ts: number; + level: 'info' | 'error'; + msg: string; +} + +const MAX_LOG_ENTRIES = 200; +const mcpLogs: MCPLogEntry[] = []; + +function mcpLog(level: 'info' | 'error', msg: string): void { + const entry: MCPLogEntry = { ts: Date.now(), level, msg }; + mcpLogs.push(entry); + if (mcpLogs.length > MAX_LOG_ENTRIES) mcpLogs.splice(0, mcpLogs.length - MAX_LOG_ENTRIES); + console.log(`[MCP ${level}] ${msg}`); +} + +export function getMCPLogs(): MCPLogEntry[] { + return mcpLogs.slice(); +} + const MIME: Record = { '.html': 'text/html', '.js': 'application/javascript', @@ -205,25 +226,33 @@ export function startRemoteServer(opts: { if (url.pathname === '/api/tasks' && req.method === 'POST') { readBody() .then(async (body) => { + mcpLog('info', `create_task name=${body.name as string}`); const result = await orch.createTask({ name: body.name as string, prompt: body.prompt as string | undefined, coordinatorTaskId: (body.coordinatorTaskId as string) ?? 'api', projectId: body.projectId as string | undefined, }); + mcpLog('info', `create_task OK id=${result.id}`); jsonReply(201, orch.getTaskStatus(result.id)); }) - .catch((err) => jsonReply(500, { error: String(err) })); + .catch((err) => { + mcpLog('error', `create_task FAIL: ${String(err)}`); + jsonReply(500, { error: String(err) }); + }); return; } if (url.pathname === '/api/tasks' && req.method === 'GET') { + mcpLog('info', 'list_tasks'); jsonReply(200, orch.listTasks()); return; } if (taskIdMatch && !taskIdMatch[2] && req.method === 'GET') { - const detail = orch.getTaskStatus(decodeURIComponent(taskIdMatch[1])); + const taskId = decodeURIComponent(taskIdMatch[1]); + mcpLog('info', `get_task_status id=${taskId}`); + const detail = orch.getTaskStatus(taskId); if (!detail) { jsonReply(404, { error: 'task not found' }); } else { @@ -235,40 +264,56 @@ export function startRemoteServer(opts: { if (taskIdMatch && taskIdMatch[2] === 'prompt' && req.method === 'POST') { readBody() .then(async (body) => { - await orch.sendPrompt(decodeURIComponent(taskIdMatch[1]), body.prompt as string); + const taskId = decodeURIComponent(taskIdMatch[1]); + mcpLog('info', `send_prompt id=${taskId}`); + await orch.sendPrompt(taskId, body.prompt as string); jsonReply(200, { ok: true }); }) - .catch((err) => jsonReply(500, { error: String(err) })); + .catch((err) => { + mcpLog('error', `send_prompt FAIL: ${String(err)}`); + jsonReply(500, { error: String(err) }); + }); return; } if (taskIdMatch && taskIdMatch[2] === 'wait' && req.method === 'POST') { readBody() .then(async (body) => { - await orch.waitForIdle( - decodeURIComponent(taskIdMatch[1]), - body.timeoutMs as number | undefined, - ); - const status = orch.getTaskStatus(decodeURIComponent(taskIdMatch[1])); + const taskId = decodeURIComponent(taskIdMatch[1]); + mcpLog('info', `wait_for_idle id=${taskId}`); + await orch.waitForIdle(taskId, body.timeoutMs as number | undefined); + const status = orch.getTaskStatus(taskId); + mcpLog('info', `wait_for_idle OK id=${taskId} status=${status?.status}`); jsonReply(200, { status: status?.status ?? 'unknown' }); }) - .catch((err) => jsonReply(500, { error: String(err) })); + .catch((err) => { + mcpLog('error', `wait_for_idle FAIL: ${String(err)}`); + jsonReply(500, { error: String(err) }); + }); return; } if (taskIdMatch && taskIdMatch[2] === 'diff' && req.method === 'GET') { + const taskId = decodeURIComponent(taskIdMatch[1]); + mcpLog('info', `get_task_diff id=${taskId}`); orch - .getTaskDiff(decodeURIComponent(taskIdMatch[1])) + .getTaskDiff(taskId) .then((result) => jsonReply(200, result)) - .catch((err) => jsonReply(500, { error: String(err) })); + .catch((err) => { + mcpLog('error', `get_task_diff FAIL: ${String(err)}`); + jsonReply(500, { error: String(err) }); + }); return; } if (taskIdMatch && taskIdMatch[2] === 'output' && req.method === 'GET') { + const taskId = decodeURIComponent(taskIdMatch[1]); + mcpLog('info', `get_task_output id=${taskId}`); try { - const output = orch.getTaskOutput(decodeURIComponent(taskIdMatch[1])); + const output = orch.getTaskOutput(taskId); jsonReply(200, { output }); } catch (err) { + mcpLog('error', `get_task_output FAIL: ${String(err)}`); jsonReply(500, { error: String(err) }); } return; @@ -277,22 +322,36 @@ export function startRemoteServer(opts: { if (taskIdMatch && taskIdMatch[2] === 'merge' && req.method === 'POST') { readBody() .then(async (body) => { - const result = await orch.mergeTask(decodeURIComponent(taskIdMatch[1]), { + const taskId = decodeURIComponent(taskIdMatch[1]); + mcpLog('info', `merge_task id=${taskId} squash=${body.squash ?? false}`); + const result = await orch.mergeTask(taskId, { squash: body.squash as boolean | undefined, message: body.message as string | undefined, cleanup: body.cleanup as boolean | undefined, }); + mcpLog('info', `merge_task OK id=${taskId}`); jsonReply(200, result); }) - .catch((err) => jsonReply(500, { error: String(err) })); + .catch((err) => { + mcpLog('error', `merge_task FAIL: ${String(err)}`); + jsonReply(500, { error: String(err) }); + }); return; } if (taskIdMatch && !taskIdMatch[2] && req.method === 'DELETE') { + const taskId = decodeURIComponent(taskIdMatch[1]); + mcpLog('info', `close_task id=${taskId}`); orch - .closeTask(decodeURIComponent(taskIdMatch[1])) - .then(() => jsonReply(200, { ok: true })) - .catch((err) => jsonReply(500, { error: String(err) })); + .closeTask(taskId) + .then(() => { + mcpLog('info', `close_task OK id=${taskId}`); + jsonReply(200, { ok: true }); + }) + .catch((err) => { + mcpLog('error', `close_task FAIL: ${String(err)}`); + jsonReply(500, { error: String(err) }); + }); return; } } diff --git a/package-lock.json b/package-lock.json index 7d26a300..cb27df31 100644 --- a/package-lock.json +++ b/package-lock.json @@ -31,6 +31,7 @@ "concurrently": "^9.2.1", "electron": "^40.6.0", "electron-builder": "^26.8.1", + "esbuild": "^0.27.4", "eslint": "^9.39.3", "eslint-config-prettier": "^10.1.8", "eslint-plugin-solid": "^0.14.5", @@ -776,9 +777,9 @@ } }, "node_modules/@esbuild/aix-ppc64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.3.tgz", - "integrity": "sha512-9fJMTNFTWZMh5qwrBItuziu834eOCUcEqymSH7pY+zoMVEZg3gcPuBNxH1EvfVYe9h0x/Ptw8KBzv7qxb7l8dg==", + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.4.tgz", + "integrity": "sha512-cQPwL2mp2nSmHHJlCyoXgHGhbEPMrEEU5xhkcy3Hs/O7nGZqEpZ2sUtLaL9MORLtDfRvVl2/3PAuEkYZH0Ty8Q==", "cpu": [ "ppc64" ], @@ -793,9 +794,9 @@ } }, "node_modules/@esbuild/android-arm": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.3.tgz", - "integrity": "sha512-i5D1hPY7GIQmXlXhs2w8AWHhenb00+GxjxRncS2ZM7YNVGNfaMxgzSGuO8o8SJzRc/oZwU2bcScvVERk03QhzA==", + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.4.tgz", + "integrity": "sha512-X9bUgvxiC8CHAGKYufLIHGXPJWnr0OCdR0anD2e21vdvgCI8lIfqFbnoeOz7lBjdrAGUhqLZLcQo6MLhTO2DKQ==", "cpu": [ "arm" ], @@ -810,9 +811,9 @@ } }, "node_modules/@esbuild/android-arm64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.3.tgz", - "integrity": "sha512-YdghPYUmj/FX2SYKJ0OZxf+iaKgMsKHVPF1MAq/P8WirnSpCStzKJFjOjzsW0QQ7oIAiccHdcqjbHmJxRb/dmg==", + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.4.tgz", + "integrity": "sha512-gdLscB7v75wRfu7QSm/zg6Rx29VLdy9eTr2t44sfTW7CxwAtQghZ4ZnqHk3/ogz7xao0QAgrkradbBzcqFPasw==", "cpu": [ "arm64" ], @@ -827,9 +828,9 @@ } }, "node_modules/@esbuild/android-x64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.3.tgz", - "integrity": "sha512-IN/0BNTkHtk8lkOM8JWAYFg4ORxBkZQf9zXiEOfERX/CzxW3Vg1ewAhU7QSWQpVIzTW+b8Xy+lGzdYXV6UZObQ==", + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.4.tgz", + "integrity": "sha512-PzPFnBNVF292sfpfhiyiXCGSn9HZg5BcAz+ivBuSsl6Rk4ga1oEXAamhOXRFyMcjwr2DVtm40G65N3GLeH1Lvw==", "cpu": [ "x64" ], @@ -844,9 +845,9 @@ } }, "node_modules/@esbuild/darwin-arm64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.3.tgz", - "integrity": "sha512-Re491k7ByTVRy0t3EKWajdLIr0gz2kKKfzafkth4Q8A5n1xTHrkqZgLLjFEHVD+AXdUGgQMq+Godfq45mGpCKg==", + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.4.tgz", + "integrity": "sha512-b7xaGIwdJlht8ZFCvMkpDN6uiSmnxxK56N2GDTMYPr2/gzvfdQN8rTfBsvVKmIVY/X7EM+/hJKEIbbHs9oA4tQ==", "cpu": [ "arm64" ], @@ -861,9 +862,9 @@ } }, "node_modules/@esbuild/darwin-x64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.3.tgz", - "integrity": "sha512-vHk/hA7/1AckjGzRqi6wbo+jaShzRowYip6rt6q7VYEDX4LEy1pZfDpdxCBnGtl+A5zq8iXDcyuxwtv3hNtHFg==", + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.4.tgz", + "integrity": "sha512-sR+OiKLwd15nmCdqpXMnuJ9W2kpy0KigzqScqHI3Hqwr7IXxBp3Yva+yJwoqh7rE8V77tdoheRYataNKL4QrPw==", "cpu": [ "x64" ], @@ -878,9 +879,9 @@ } }, "node_modules/@esbuild/freebsd-arm64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.3.tgz", - "integrity": "sha512-ipTYM2fjt3kQAYOvo6vcxJx3nBYAzPjgTCk7QEgZG8AUO3ydUhvelmhrbOheMnGOlaSFUoHXB6un+A7q4ygY9w==", + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.4.tgz", + "integrity": "sha512-jnfpKe+p79tCnm4GVav68A7tUFeKQwQyLgESwEAUzyxk/TJr4QdGog9sqWNcUbr/bZt/O/HXouspuQDd9JxFSw==", "cpu": [ "arm64" ], @@ -895,9 +896,9 @@ } }, "node_modules/@esbuild/freebsd-x64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.3.tgz", - "integrity": "sha512-dDk0X87T7mI6U3K9VjWtHOXqwAMJBNN2r7bejDsc+j03SEjtD9HrOl8gVFByeM0aJksoUuUVU9TBaZa2rgj0oA==", + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.4.tgz", + "integrity": "sha512-2kb4ceA/CpfUrIcTUl1wrP/9ad9Atrp5J94Lq69w7UwOMolPIGrfLSvAKJp0RTvkPPyn6CIWrNy13kyLikZRZQ==", "cpu": [ "x64" ], @@ -912,9 +913,9 @@ } }, "node_modules/@esbuild/linux-arm": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.3.tgz", - "integrity": "sha512-s6nPv2QkSupJwLYyfS+gwdirm0ukyTFNl3KTgZEAiJDd+iHZcbTPPcWCcRYH+WlNbwChgH2QkE9NSlNrMT8Gfw==", + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.4.tgz", + "integrity": "sha512-aBYgcIxX/wd5n2ys0yESGeYMGF+pv6g0DhZr3G1ZG4jMfruU9Tl1i2Z+Wnj9/KjGz1lTLCcorqE2viePZqj4Eg==", "cpu": [ "arm" ], @@ -929,9 +930,9 @@ } }, "node_modules/@esbuild/linux-arm64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.3.tgz", - "integrity": "sha512-sZOuFz/xWnZ4KH3YfFrKCf1WyPZHakVzTiqji3WDc0BCl2kBwiJLCXpzLzUBLgmp4veFZdvN5ChW4Eq/8Fc2Fg==", + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.4.tgz", + "integrity": "sha512-7nQOttdzVGth1iz57kxg9uCz57dxQLHWxopL6mYuYthohPKEK0vU0C3O21CcBK6KDlkYVcnDXY099HcCDXd9dA==", "cpu": [ "arm64" ], @@ -946,9 +947,9 @@ } }, "node_modules/@esbuild/linux-ia32": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.3.tgz", - "integrity": "sha512-yGlQYjdxtLdh0a3jHjuwOrxQjOZYD/C9PfdbgJJF3TIZWnm/tMd/RcNiLngiu4iwcBAOezdnSLAwQDPqTmtTYg==", + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.4.tgz", + "integrity": "sha512-oPtixtAIzgvzYcKBQM/qZ3R+9TEUd1aNJQu0HhGyqtx6oS7qTpvjheIWBbes4+qu1bNlo2V4cbkISr8q6gRBFA==", "cpu": [ "ia32" ], @@ -963,9 +964,9 @@ } }, "node_modules/@esbuild/linux-loong64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.3.tgz", - "integrity": "sha512-WO60Sn8ly3gtzhyjATDgieJNet/KqsDlX5nRC5Y3oTFcS1l0KWba+SEa9Ja1GfDqSF1z6hif/SkpQJbL63cgOA==", + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.4.tgz", + "integrity": "sha512-8mL/vh8qeCoRcFH2nM8wm5uJP+ZcVYGGayMavi8GmRJjuI3g1v6Z7Ni0JJKAJW+m0EtUuARb6Lmp4hMjzCBWzA==", "cpu": [ "loong64" ], @@ -980,9 +981,9 @@ } }, "node_modules/@esbuild/linux-mips64el": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.3.tgz", - "integrity": "sha512-APsymYA6sGcZ4pD6k+UxbDjOFSvPWyZhjaiPyl/f79xKxwTnrn5QUnXR5prvetuaSMsb4jgeHewIDCIWljrSxw==", + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.4.tgz", + "integrity": "sha512-1RdrWFFiiLIW7LQq9Q2NES+HiD4NyT8Itj9AUeCl0IVCA459WnPhREKgwrpaIfTOe+/2rdntisegiPWn/r/aAw==", "cpu": [ "mips64el" ], @@ -997,9 +998,9 @@ } }, "node_modules/@esbuild/linux-ppc64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.3.tgz", - "integrity": "sha512-eizBnTeBefojtDb9nSh4vvVQ3V9Qf9Df01PfawPcRzJH4gFSgrObw+LveUyDoKU3kxi5+9RJTCWlj4FjYXVPEA==", + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.4.tgz", + "integrity": "sha512-tLCwNG47l3sd9lpfyx9LAGEGItCUeRCWeAx6x2Jmbav65nAwoPXfewtAdtbtit/pJFLUWOhpv0FpS6GQAmPrHA==", "cpu": [ "ppc64" ], @@ -1014,9 +1015,9 @@ } }, "node_modules/@esbuild/linux-riscv64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.3.tgz", - "integrity": "sha512-3Emwh0r5wmfm3ssTWRQSyVhbOHvqegUDRd0WhmXKX2mkHJe1SFCMJhagUleMq+Uci34wLSipf8Lagt4LlpRFWQ==", + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.4.tgz", + "integrity": "sha512-BnASypppbUWyqjd1KIpU4AUBiIhVr6YlHx/cnPgqEkNoVOhHg+YiSVxM1RLfiy4t9cAulbRGTNCKOcqHrEQLIw==", "cpu": [ "riscv64" ], @@ -1031,9 +1032,9 @@ } }, "node_modules/@esbuild/linux-s390x": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.3.tgz", - "integrity": "sha512-pBHUx9LzXWBc7MFIEEL0yD/ZVtNgLytvx60gES28GcWMqil8ElCYR4kvbV2BDqsHOvVDRrOxGySBM9Fcv744hw==", + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.4.tgz", + "integrity": "sha512-+eUqgb/Z7vxVLezG8bVB9SfBie89gMueS+I0xYh2tJdw3vqA/0ImZJ2ROeWwVJN59ihBeZ7Tu92dF/5dy5FttA==", "cpu": [ "s390x" ], @@ -1048,9 +1049,9 @@ } }, "node_modules/@esbuild/linux-x64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.3.tgz", - "integrity": "sha512-Czi8yzXUWIQYAtL/2y6vogER8pvcsOsk5cpwL4Gk5nJqH5UZiVByIY8Eorm5R13gq+DQKYg0+JyQoytLQas4dA==", + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.4.tgz", + "integrity": "sha512-S5qOXrKV8BQEzJPVxAwnryi2+Iq5pB40gTEIT69BQONqR7JH1EPIcQ/Uiv9mCnn05jff9umq/5nqzxlqTOg9NA==", "cpu": [ "x64" ], @@ -1065,9 +1066,9 @@ } }, "node_modules/@esbuild/netbsd-arm64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.3.tgz", - "integrity": "sha512-sDpk0RgmTCR/5HguIZa9n9u+HVKf40fbEUt+iTzSnCaGvY9kFP0YKBWZtJaraonFnqef5SlJ8/TiPAxzyS+UoA==", + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.4.tgz", + "integrity": "sha512-xHT8X4sb0GS8qTqiwzHqpY00C95DPAq7nAwX35Ie/s+LO9830hrMd3oX0ZMKLvy7vsonee73x0lmcdOVXFzd6Q==", "cpu": [ "arm64" ], @@ -1082,9 +1083,9 @@ } }, "node_modules/@esbuild/netbsd-x64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.3.tgz", - "integrity": "sha512-P14lFKJl/DdaE00LItAukUdZO5iqNH7+PjoBm+fLQjtxfcfFE20Xf5CrLsmZdq5LFFZzb5JMZ9grUwvtVYzjiA==", + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.4.tgz", + "integrity": "sha512-RugOvOdXfdyi5Tyv40kgQnI0byv66BFgAqjdgtAKqHoZTbTF2QqfQrFwa7cHEORJf6X2ht+l9ABLMP0dnKYsgg==", "cpu": [ "x64" ], @@ -1099,9 +1100,9 @@ } }, "node_modules/@esbuild/openbsd-arm64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.3.tgz", - "integrity": "sha512-AIcMP77AvirGbRl/UZFTq5hjXK+2wC7qFRGoHSDrZ5v5b8DK/GYpXW3CPRL53NkvDqb9D+alBiC/dV0Fb7eJcw==", + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.4.tgz", + "integrity": "sha512-2MyL3IAaTX+1/qP0O1SwskwcwCoOI4kV2IBX1xYnDDqthmq5ArrW94qSIKCAuRraMgPOmG0RDTA74mzYNQA9ow==", "cpu": [ "arm64" ], @@ -1116,9 +1117,9 @@ } }, "node_modules/@esbuild/openbsd-x64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.3.tgz", - "integrity": "sha512-DnW2sRrBzA+YnE70LKqnM3P+z8vehfJWHXECbwBmH/CU51z6FiqTQTHFenPlHmo3a8UgpLyH3PT+87OViOh1AQ==", + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.4.tgz", + "integrity": "sha512-u8fg/jQ5aQDfsnIV6+KwLOf1CmJnfu1ShpwqdwC0uA7ZPwFws55Ngc12vBdeUdnuWoQYx/SOQLGDcdlfXhYmXQ==", "cpu": [ "x64" ], @@ -1133,9 +1134,9 @@ } }, "node_modules/@esbuild/openharmony-arm64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.3.tgz", - "integrity": "sha512-NinAEgr/etERPTsZJ7aEZQvvg/A6IsZG/LgZy+81wON2huV7SrK3e63dU0XhyZP4RKGyTm7aOgmQk0bGp0fy2g==", + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.4.tgz", + "integrity": "sha512-JkTZrl6VbyO8lDQO3yv26nNr2RM2yZzNrNHEsj9bm6dOwwu9OYN28CjzZkH57bh4w0I2F7IodpQvUAEd1mbWXg==", "cpu": [ "arm64" ], @@ -1150,9 +1151,9 @@ } }, "node_modules/@esbuild/sunos-x64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.3.tgz", - "integrity": "sha512-PanZ+nEz+eWoBJ8/f8HKxTTD172SKwdXebZ0ndd953gt1HRBbhMsaNqjTyYLGLPdoWHy4zLU7bDVJztF5f3BHA==", + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.4.tgz", + "integrity": "sha512-/gOzgaewZJfeJTlsWhvUEmUG4tWEY2Spp5M20INYRg2ZKl9QPO3QEEgPeRtLjEWSW8FilRNacPOg8R1uaYkA6g==", "cpu": [ "x64" ], @@ -1167,9 +1168,9 @@ } }, "node_modules/@esbuild/win32-arm64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.3.tgz", - "integrity": "sha512-B2t59lWWYrbRDw/tjiWOuzSsFh1Y/E95ofKz7rIVYSQkUYBjfSgf6oeYPNWHToFRr2zx52JKApIcAS/D5TUBnA==", + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.4.tgz", + "integrity": "sha512-Z9SExBg2y32smoDQdf1HRwHRt6vAHLXcxD2uGgO/v2jK7Y718Ix4ndsbNMU/+1Qiem9OiOdaqitioZwxivhXYg==", "cpu": [ "arm64" ], @@ -1184,9 +1185,9 @@ } }, "node_modules/@esbuild/win32-ia32": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.3.tgz", - "integrity": "sha512-QLKSFeXNS8+tHW7tZpMtjlNb7HKau0QDpwm49u0vUp9y1WOF+PEzkU84y9GqYaAVW8aH8f3GcBck26jh54cX4Q==", + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.4.tgz", + "integrity": "sha512-DAyGLS0Jz5G5iixEbMHi5KdiApqHBWMGzTtMiJ72ZOLhbu/bzxgAe8Ue8CTS3n3HbIUHQz/L51yMdGMeoxXNJw==", "cpu": [ "ia32" ], @@ -1201,9 +1202,9 @@ } }, "node_modules/@esbuild/win32-x64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.3.tgz", - "integrity": "sha512-4uJGhsxuptu3OcpVAzli+/gWusVGwZZHTlS63hh++ehExkVT8SgiEf7/uC/PclrPPkLhZqGgCTjd0VWLo6xMqA==", + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.4.tgz", + "integrity": "sha512-+knoa0BDoeXgkNvvV1vvbZX4+hizelrkwmGJBdT17t8FNPwG2lKemmuMZlmaNQ3ws3DKKCxpb4zRZEIp3UxFCg==", "cpu": [ "x64" ], @@ -5193,9 +5194,9 @@ "optional": true }, "node_modules/esbuild": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.3.tgz", - "integrity": "sha512-8VwMnyGCONIs6cWue2IdpHxHnAjzxnw2Zr7MkVxB2vjmQ2ivqGFb4LEG3SMnv0Gb2F/G/2yA8zUaiL1gywDCCg==", + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.4.tgz", + "integrity": "sha512-Rq4vbHnYkK5fws5NF7MYTU68FPRE1ajX7heQ/8QXXWqNgqqJ/GkmmyxIzUnf2Sr/bakf8l54716CcMGHYhMrrQ==", "dev": true, "hasInstallScript": true, "license": "MIT", @@ -5206,32 +5207,32 @@ "node": ">=18" }, "optionalDependencies": { - "@esbuild/aix-ppc64": "0.27.3", - "@esbuild/android-arm": "0.27.3", - "@esbuild/android-arm64": "0.27.3", - "@esbuild/android-x64": "0.27.3", - "@esbuild/darwin-arm64": "0.27.3", - "@esbuild/darwin-x64": "0.27.3", - "@esbuild/freebsd-arm64": "0.27.3", - "@esbuild/freebsd-x64": "0.27.3", - "@esbuild/linux-arm": "0.27.3", - "@esbuild/linux-arm64": "0.27.3", - "@esbuild/linux-ia32": "0.27.3", - "@esbuild/linux-loong64": "0.27.3", - "@esbuild/linux-mips64el": "0.27.3", - "@esbuild/linux-ppc64": "0.27.3", - "@esbuild/linux-riscv64": "0.27.3", - "@esbuild/linux-s390x": "0.27.3", - "@esbuild/linux-x64": "0.27.3", - "@esbuild/netbsd-arm64": "0.27.3", - "@esbuild/netbsd-x64": "0.27.3", - "@esbuild/openbsd-arm64": "0.27.3", - "@esbuild/openbsd-x64": "0.27.3", - "@esbuild/openharmony-arm64": "0.27.3", - "@esbuild/sunos-x64": "0.27.3", - "@esbuild/win32-arm64": "0.27.3", - "@esbuild/win32-ia32": "0.27.3", - "@esbuild/win32-x64": "0.27.3" + "@esbuild/aix-ppc64": "0.27.4", + "@esbuild/android-arm": "0.27.4", + "@esbuild/android-arm64": "0.27.4", + "@esbuild/android-x64": "0.27.4", + "@esbuild/darwin-arm64": "0.27.4", + "@esbuild/darwin-x64": "0.27.4", + "@esbuild/freebsd-arm64": "0.27.4", + "@esbuild/freebsd-x64": "0.27.4", + "@esbuild/linux-arm": "0.27.4", + "@esbuild/linux-arm64": "0.27.4", + "@esbuild/linux-ia32": "0.27.4", + "@esbuild/linux-loong64": "0.27.4", + "@esbuild/linux-mips64el": "0.27.4", + "@esbuild/linux-ppc64": "0.27.4", + "@esbuild/linux-riscv64": "0.27.4", + "@esbuild/linux-s390x": "0.27.4", + "@esbuild/linux-x64": "0.27.4", + "@esbuild/netbsd-arm64": "0.27.4", + "@esbuild/netbsd-x64": "0.27.4", + "@esbuild/openbsd-arm64": "0.27.4", + "@esbuild/openbsd-x64": "0.27.4", + "@esbuild/openharmony-arm64": "0.27.4", + "@esbuild/sunos-x64": "0.27.4", + "@esbuild/win32-arm64": "0.27.4", + "@esbuild/win32-ia32": "0.27.4", + "@esbuild/win32-x64": "0.27.4" } }, "node_modules/escalade": { diff --git a/package.json b/package.json index 9e23de4e..136163bb 100644 --- a/package.json +++ b/package.json @@ -9,12 +9,13 @@ "main": "dist-electron/main.js", "type": "module", "scripts": { - "dev": "npm run compile && concurrently -k \"vite --config electron/vite.config.electron.ts\" \"wait-on http://localhost:1421 && VITE_DEV_SERVER_URL=http://localhost:1421 electron --no-sandbox dist-electron/main.js\"", + "dev": "npm run compile && npm run build:mcp && concurrently -k \"vite --config electron/vite.config.electron.ts\" \"wait-on http://localhost:1421 && VITE_DEV_SERVER_URL=http://localhost:1421 electron --no-sandbox dist-electron/main.js\"", "typecheck": "tsc --noEmit", "compile": "tsc -p electron/tsconfig.json", + "build:mcp": "esbuild dist-electron/mcp/server.js --bundle --platform=node --format=cjs --outfile=dist-electron/mcp-server.cjs", "build:frontend": "vite build --config electron/vite.config.electron.ts", "build:remote": "vite build --config src/remote/vite.config.ts", - "build": "npm run build:frontend && npm run build:remote && npm run compile && electron-builder", + "build": "npm run build:frontend && npm run build:remote && npm run compile && npm run build:mcp && electron-builder", "serve": "vite preview --config electron/vite.config.electron.ts", "lint": "eslint . --max-warnings 0", "lint:fix": "eslint . --fix", @@ -49,6 +50,7 @@ "concurrently": "^9.2.1", "electron": "^40.6.0", "electron-builder": "^26.8.1", + "esbuild": "^0.27.4", "eslint": "^9.39.3", "eslint-config-prettier": "^10.1.8", "eslint-plugin-solid": "^0.14.5", @@ -83,7 +85,8 @@ "electron/preload.cjs" ], "asarUnpack": [ - "**/node-pty/**" + "**/node-pty/**", + "dist-electron/mcp-server.cjs" ], "extraResources": [ { diff --git a/src/components/CloseTaskDialog.tsx b/src/components/CloseTaskDialog.tsx index 97acc609..643657a2 100644 --- a/src/components/CloseTaskDialog.tsx +++ b/src/components/CloseTaskDialog.tsx @@ -1,7 +1,7 @@ import { Show, createResource } from 'solid-js'; import { invoke } from '../lib/ipc'; import { IPC } from '../../electron/ipc/channels'; -import { closeTask, getProject } from '../store/store'; +import { closeTask, getProject, getCoordinatorCloseWarning } from '../store/store'; import { ConfirmDialog } from './ConfirmDialog'; import { theme } from '../lib/theme'; import type { Task } from '../store/types'; @@ -25,6 +25,24 @@ export function CloseTaskDialog(props: CloseTaskDialogProps) { title="Close Task" message={
+ + {(warning) => ( +
+ {warning()} +
+ )} +

This will stop all running agents and shells for this task. No git operations will be diff --git a/src/components/Sidebar.tsx b/src/components/Sidebar.tsx index 05581837..c8f1ad36 100644 --- a/src/components/Sidebar.tsx +++ b/src/components/Sidebar.tsx @@ -22,7 +22,11 @@ import { isProjectMissing, } from '../store/store'; import type { Project } from '../store/types'; -import { computeGroupedTasks } from '../store/sidebar-order'; +import { + computeGroupedTasks, + getCoordinatorChildren, + isCoordinatedChild, +} from '../store/sidebar-order'; import { ConnectPhoneModal } from './ConnectPhoneModal'; import { ConfirmDialog } from './ConfirmDialog'; import { EditProjectDialog } from './EditProjectDialog'; @@ -39,6 +43,21 @@ const SIDEBAR_MIN_WIDTH = 160; const SIDEBAR_MAX_WIDTH = 480; const SIDEBAR_SIZE_KEY = 'sidebar:width'; +/** Small bot/coordinator icon (16x16 SVG). */ +function CoordinatorIcon() { + return ( + + + + ); +} + export function Sidebar() { const [confirmRemove, setConfirmRemove] = createSignal(null); const [editingProject, setEditingProject] = createSignal(null); @@ -49,12 +68,15 @@ export function Sidebar() { let taskListRef: HTMLDivElement | undefined; const sidebarWidth = () => getPanelSize(SIDEBAR_SIZE_KEY) ?? SIDEBAR_DEFAULT_WIDTH; + const taskIndexById = createMemo(() => { const map = new Map(); store.taskOrder.forEach((taskId, idx) => map.set(taskId, idx)); return map; }); + const groupedTasks = createMemo(() => computeGroupedTasks()); + function handleResizeMouseDown(e: MouseEvent) { e.preventDefault(); setResizing(true); @@ -80,7 +102,6 @@ export function Sidebar() { } onMount(() => { - // Attach mousedown on task list container via native listener const el = taskListRef; if (el) { const handler = (e: MouseEvent) => { @@ -89,18 +110,18 @@ export function Sidebar() { const index = Number(target.dataset.taskIndex); const taskId = store.taskOrder[index]; if (taskId === undefined || taskId === null) return; + // Don't allow dragging coordinated children + if (isCoordinatedChild(taskId)) return; handleTaskMouseDown(e, taskId, index); }; el.addEventListener('mousedown', handler); onCleanup(() => el.removeEventListener('mousedown', handler)); } - // Register sidebar focus registerFocusFn('sidebar', () => taskListRef?.focus()); onCleanup(() => unregisterFocusFn('sidebar')); }); - // When sidebarFocused changes, trigger focus createEffect(() => { if (store.sidebarFocused) { taskListRef?.focus(); @@ -119,7 +140,6 @@ export function Sidebar() { el?.scrollIntoView({ block: 'nearest', behavior: 'instant' }); }); - // Scroll the focused task into view when navigating via keyboard createEffect(() => { const focusedId = store.sidebarFocusedTaskId; if (!focusedId || !taskListRef) return; @@ -134,7 +154,6 @@ export function Sidebar() { el.scrollIntoView({ block: 'nearest', behavior: 'instant' }); }); - // Scroll the focused project into view when it changes createEffect(() => { const projectId = store.sidebarFocusedProjectId; if (!projectId) return; @@ -183,8 +202,7 @@ export function Sidebar() { document.body.classList.add('dragging-task'); } - const dropIdx = computeDropIndex(ev.clientY, index); - setDropTargetIndex(dropIdx); + setDropTargetIndex(computeDropIndex(ev.clientY, index)); } function onUp() { @@ -213,7 +231,6 @@ export function Sidebar() { } function abbreviatePath(path: string): string { - // Handle Linux /home/user/... and macOS /Users/user/... const prefixes = ['/home/', '/Users/']; for (const prefix of prefixes) { if (path.startsWith(prefix)) { @@ -226,7 +243,6 @@ export function Sidebar() { return path; } - // Compute the global taskOrder index for a given task function globalIndex(taskId: string): number { return taskIndexById().get(taskId) ?? -1; } @@ -567,7 +583,7 @@ export function Sidebar() { {(taskId) => ( - - {(taskId) => } + {(taskId) => ( + + )} ); @@ -605,7 +623,7 @@ export function Sidebar() { {(taskId) => ( - - {(taskId) => } + {(taskId) => } @@ -668,7 +686,6 @@ export function Sidebar() { setShowConnectPhone(false)} /> - {/* Edit project dialog */} setEditingProject(null)} /> {/* Confirm remove project dialog */} @@ -714,130 +731,282 @@ export function Sidebar() { ); } -function CollapsedTaskRow(props: { taskId: string }) { +// Coordinator children always render inline under their coordinator regardless of +// their position in taskOrder (they're filtered out of computeGroupedTasks). +// So moving the coordinator itself is sufficient — children follow visually. + +// --- Task entry: renders a task row OR a coordinator folder with nested children --- + +interface TaskEntryProps { + taskId: string; + globalIndex: (taskId: string) => number; + dragFromIndex: () => number | null; + dropTargetIndex: () => number | null; +} + +function TaskEntry(props: TaskEntryProps) { const task = () => store.tasks[props.taskId]; - const coordinatorName = () => { - const t = task(); - if (!t?.coordinatedBy) return null; - return store.tasks[t.coordinatedBy]?.name ?? null; - }; + const isCoordinator = () => task()?.coordinatorMode ?? false; + + return ( + + + } + > + + + + ); +} + +// --- Coordinator folder: coordinator row + indented children --- + +function CoordinatorFolder(props: TaskEntryProps) { + const task = () => store.tasks[props.taskId]; + const children = createMemo(() => getCoordinatorChildren(props.taskId)); + const childCount = createMemo(() => children().active.length + children().collapsed.length); + const idx = () => props.globalIndex(props.taskId); + return ( {(t) => ( -

uncollapseTask(props.taskId)} - onKeyDown={(e) => { - if (e.key === 'Enter' || e.key === ' ') { - e.preventDefault(); - uncollapseTask(props.taskId); - } - }} - title="Click to restore" - style={{ - padding: '7px 10px', - 'border-radius': '6px', - background: 'transparent', - color: theme.fgSubtle, - 'font-size': sf(12), - 'font-weight': '400', - cursor: 'pointer', - 'white-space': 'nowrap', - overflow: 'hidden', - 'text-overflow': 'ellipsis', - opacity: '0.6', - display: 'flex', - 'flex-direction': 'column', - gap: '1px', - border: - store.sidebarFocused && store.sidebarFocusedTaskId === props.taskId - ? `1.5px solid var(--border-focus)` - : '1.5px solid transparent', - }} - > -
- - - - {t().branchName} + <> + +
+ + {/* Coordinator row */} +
{ + setActiveTask(props.taskId); + focusSidebar(); + }} + style={{ + padding: '7px 10px', + 'border-radius': '6px', + background: 'transparent', + color: store.activeTaskId === props.taskId ? theme.fg : theme.fgMuted, + 'font-size': sf(12), + 'font-weight': store.activeTaskId === props.taskId ? '500' : '400', + cursor: props.dragFromIndex() !== null ? 'grabbing' : 'pointer', + 'white-space': 'nowrap', + overflow: 'hidden', + 'text-overflow': 'ellipsis', + opacity: props.dragFromIndex() === idx() ? '0.4' : '1', + display: 'flex', + 'flex-direction': 'column', + gap: '1px', + border: + store.sidebarFocused && store.sidebarFocusedTaskId === props.taskId + ? `1.5px solid var(--border-focus)` + : '1.5px solid transparent', + }} + > +
+ + + + {t().name} - - {t().name} + 0}> + + {childCount()} + + +
- - {(name) => ( - - via {name()} - + + {/* Indented active children */} + + {(childId) => ( + )} + + + {/* Indented collapsed children */} + + {(childId) => } + + + )} + + ); +} + +// --- Collapsed task entry: also handles coordinator folders in collapsed state --- + +function CollapsedTaskEntry(props: { taskId: string; indented?: boolean }) { + const task = () => store.tasks[props.taskId]; + // Only top-level coordinators render children — indented entries never recurse + const isCoordinator = () => !props.indented && (task()?.coordinatorMode ?? false); + const children = createMemo(() => + isCoordinator() ? getCoordinatorChildren(props.taskId) : { active: [], collapsed: [] }, + ); + const childCount = createMemo(() => children().active.length + children().collapsed.length); + + return ( + + {(t) => ( + <> +
uncollapseTask(props.taskId)} + onKeyDown={(e) => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + uncollapseTask(props.taskId); + } + }} + title="Click to restore" + style={{ + padding: '7px 10px', + 'padding-left': props.indented ? '22px' : '10px', + 'border-radius': '6px', + background: 'transparent', + color: theme.fgSubtle, + 'font-size': sf(12), + 'font-weight': '400', + cursor: 'pointer', + 'white-space': 'nowrap', + overflow: 'hidden', + 'text-overflow': 'ellipsis', + opacity: '0.6', + display: 'flex', + 'flex-direction': 'column', + gap: '1px', + border: + store.sidebarFocused && store.sidebarFocusedTaskId === props.taskId + ? `1.5px solid var(--border-focus)` + : '1.5px solid transparent', + }} + > +
+ + + + + + + {t().branchName} + + + {t().name} + 0}> + + {childCount()} + + +
+
+ + {/* If collapsed coordinator, still show children nested */} + + + {(childId) => ( + + )} + + + {(childId) => } + -
+ )}
); } +// --- Individual task row --- + interface TaskRowProps { taskId: string; globalIndex: (taskId: string) => number; dragFromIndex: () => number | null; dropTargetIndex: () => number | null; + indented: boolean; } function TaskRow(props: TaskRowProps) { const task = () => store.tasks[props.taskId]; const idx = () => props.globalIndex(props.taskId); - const coordinatorName = () => { - const t = task(); - if (!t?.coordinatedBy) return null; - return store.tasks[t.coordinatedBy]?.name ?? null; - }; return ( {(t) => ( <> - +
{ setActiveTask(props.taskId); focusSidebar(); }} style={{ padding: '7px 10px', + 'padding-left': props.indented ? '22px' : '10px', 'border-radius': '6px', background: 'transparent', color: store.activeTaskId === props.taskId ? theme.fg : theme.fgMuted, 'font-size': sf(12), 'font-weight': store.activeTaskId === props.taskId ? '500' : '400', - cursor: props.dragFromIndex() !== null ? 'grabbing' : 'pointer', + cursor: props.indented + ? 'pointer' + : props.dragFromIndex() !== null + ? 'grabbing' + : 'pointer', 'white-space': 'nowrap', overflow: 'hidden', 'text-overflow': 'ellipsis', - opacity: props.dragFromIndex() === idx() ? '0.4' : '1', + opacity: !props.indented && props.dragFromIndex() === idx() ? '0.4' : '1', display: 'flex', 'flex-direction': 'column', gap: '1px', @@ -867,25 +1036,6 @@ function TaskRow(props: TaskRowProps) { {t().name}
- - {(name) => ( - { - e.stopPropagation(); - const coordId = t().coordinatedBy; - if (coordId) setActiveTask(coordId); - }} - > - via {name()} - - )} -
)} diff --git a/src/components/SidebarFooter.tsx b/src/components/SidebarFooter.tsx index 4a9cccef..fd3d7c04 100644 --- a/src/components/SidebarFooter.tsx +++ b/src/components/SidebarFooter.tsx @@ -1,9 +1,13 @@ -import { createMemo } from 'solid-js'; +import { createMemo, createEffect, onCleanup, Show } from 'solid-js'; import { + store, getCompletedTasksTodayCount, getMergedLineTotals, toggleHelpDialog, toggleArena, + hasAnyCoordinatorTask, + startMCPStatusPolling, + stopMCPStatusPolling, } from '../store/store'; import { theme } from '../lib/theme'; import { sf } from '../lib/fontScale'; @@ -12,9 +16,53 @@ import { alt, mod } from '../lib/platform'; export function SidebarFooter() { const completedTasksToday = createMemo(() => getCompletedTasksTodayCount()); const mergedLines = createMemo(() => getMergedLineTotals()); + const hasCoordinator = createMemo(() => hasAnyCoordinatorTask()); + + createEffect(() => { + if (hasCoordinator()) { + startMCPStatusPolling(); + } else { + stopMCPStatusPolling(); + } + }); + + onCleanup(() => stopMCPStatusPolling()); + + const mcpOk = () => store.mcpStatus.remoteRunning; return ( <> + +
+
+ + MCP {mcpOk() ? 'Connected' : 'Disconnected'} + +
+ +
({ tailscaleUrl: null, connectedClients: 0, }, + mcpStatus: { + mcpRunning: false, + remoteRunning: false, + }, showArena: false, }); diff --git a/src/store/mcpStatus.ts b/src/store/mcpStatus.ts new file mode 100644 index 00000000..19d77ac2 --- /dev/null +++ b/src/store/mcpStatus.ts @@ -0,0 +1,38 @@ +import { store, setStore } from './core'; +import { invoke } from '../lib/ipc'; +import { IPC } from '../../electron/ipc/channels'; + +let pollTimer: ReturnType | null = null; +const POLL_INTERVAL_MS = 3_000; + +export function hasAnyCoordinatorTask(): boolean { + for (const id of store.taskOrder) { + if (store.tasks[id]?.coordinatorMode) return true; + } + return false; +} + +export async function refreshMCPStatus(): Promise { + try { + const result = await invoke<{ mcpRunning: boolean; remoteRunning: boolean }>( + IPC.GetMCPStatus, + ); + setStore('mcpStatus', result); + } catch { + setStore('mcpStatus', { mcpRunning: false, remoteRunning: false }); + } +} + +export function startMCPStatusPolling(): void { + if (pollTimer) return; + refreshMCPStatus(); + pollTimer = setInterval(refreshMCPStatus, POLL_INTERVAL_MS); +} + +export function stopMCPStatusPolling(): void { + if (pollTimer) { + clearInterval(pollTimer); + pollTimer = null; + } + setStore('mcpStatus', { mcpRunning: false, remoteRunning: false }); +} diff --git a/src/store/sidebar-order.ts b/src/store/sidebar-order.ts index a5f849ba..21c52da4 100644 --- a/src/store/sidebar-order.ts +++ b/src/store/sidebar-order.ts @@ -6,7 +6,42 @@ export interface GroupedSidebarTasks { orphanedCollapsed: string[]; } -/** Group tasks by project: active first, then collapsed. Tasks without a valid project go to orphans. */ +/** + * Get ordered child task IDs for a coordinator, preserving taskOrder ordering. + * Returns both active and collapsed children separately. + */ +export function getCoordinatorChildren(coordinatorId: string): { + active: string[]; + collapsed: string[]; +} { + const active: string[] = []; + const collapsed: string[] = []; + for (const taskId of store.taskOrder) { + if (store.tasks[taskId]?.coordinatedBy === coordinatorId) { + active.push(taskId); + } + } + for (const taskId of store.collapsedTaskOrder) { + const task = store.tasks[taskId]; + if (task?.collapsed && task.coordinatedBy === coordinatorId) { + collapsed.push(taskId); + } + } + return { active, collapsed }; +} + +/** + * Check if a task is a child of any coordinator. + */ +export function isCoordinatedChild(taskId: string): boolean { + const task = store.tasks[taskId]; + if (!task?.coordinatedBy) return false; + // Only treat as child if the coordinator still exists + return !!store.tasks[task.coordinatedBy]; +} + +/** Group tasks by project: active first, then collapsed. Tasks without a valid project go to orphans. + * Coordinated children are excluded from the flat list — they render nested under their coordinator. */ export function computeGroupedTasks(): GroupedSidebarTasks { const grouped: Record = {}; const orphanedActive: string[] = []; @@ -16,6 +51,8 @@ export function computeGroupedTasks(): GroupedSidebarTasks { for (const taskId of store.taskOrder) { const task = store.tasks[taskId]; if (!task) continue; + // Skip coordinated children — they'll be rendered nested under their coordinator + if (isCoordinatedChild(taskId)) continue; if (task.projectId && projectIds.has(task.projectId)) { (grouped[task.projectId] ??= { active: [], collapsed: [] }).active.push(taskId); } else { @@ -26,6 +63,8 @@ export function computeGroupedTasks(): GroupedSidebarTasks { for (const taskId of store.collapsedTaskOrder) { const task = store.tasks[taskId]; if (!task?.collapsed) continue; + // Skip coordinated children + if (isCoordinatedChild(taskId)) continue; if (task.projectId && projectIds.has(task.projectId)) { (grouped[task.projectId] ??= { active: [], collapsed: [] }).collapsed.push(taskId); } else { diff --git a/src/store/store.ts b/src/store/store.ts index 86b84324..940f3ba8 100644 --- a/src/store/store.ts +++ b/src/store/store.ts @@ -50,6 +50,7 @@ export { setNewTaskPrefillPrompt, setPlanContent, initMCPListeners, + getCoordinatorCloseWarning, } from './tasks'; export { setActiveTask, @@ -129,3 +130,9 @@ export { syncTerminalCounter, } from './terminals'; export { startRemoteAccess, stopRemoteAccess, refreshRemoteStatus } from './remote'; +export { + hasAnyCoordinatorTask, + refreshMCPStatus, + startMCPStatusPolling, + stopMCPStatusPolling, +} from './mcpStatus'; diff --git a/src/store/tasks.ts b/src/store/tasks.ts index d536cf07..367ae508 100644 --- a/src/store/tasks.ts +++ b/src/store/tasks.ts @@ -16,6 +16,8 @@ import { recordMergedLines, recordTaskCompleted } from './completion'; import type { AgentDef, CreateTaskResult, MergeResult } from '../ipc/types'; import { parseGitHubUrl, taskNameFromGitHubUrl } from '../lib/github-url'; import type { Agent, Task } from './types'; +import { COORDINATOR_PREAMBLE } from './coordinator-preamble'; +import { getCoordinatorChildren } from './sidebar-order'; const AGENT_WRITE_READY_TIMEOUT_MS = 8_000; const AGENT_WRITE_RETRY_MS = 50; @@ -82,6 +84,25 @@ export async function createTask(opts: CreateTaskOptions): Promise { branchPrefix, }); + // Start MCP server BEFORE adding task to store — the store update triggers + // a reactive render of TerminalView which spawns the PTY immediately. + // If mcpConfigPath isn't set yet, the --mcp-config arg is missing. + let mcpConfigPath: string | undefined; + if (opts.coordinatorMode) { + try { + const mcpResult = await invoke<{ configPath: string }>(IPC.StartMCPServer, { + coordinatorTaskId: result.id, + projectId, + projectRoot, + worktreePath: result.worktree_path, + }); + mcpConfigPath = mcpResult.configPath; + console.log('[MCP] Coordinator config path:', mcpConfigPath); + } catch (err) { + console.warn('[MCP] Failed to start MCP server for coordinator:', err); + } + } + const agentId = crypto.randomUUID(); const task: Task = { id: result.id, @@ -93,11 +114,14 @@ export async function createTask(opts: CreateTaskOptions): Promise { shellAgentIds: [], notes: '', lastPrompt: '', - initialPrompt: initialPrompt || undefined, + initialPrompt: (opts.coordinatorMode && initialPrompt + ? COORDINATOR_PREAMBLE + initialPrompt + : initialPrompt) || undefined, skipPermissions: skipPermissions || undefined, githubUrl, savedInitialPrompt: initialPrompt || undefined, coordinatorMode: opts.coordinatorMode || undefined, + mcpConfigPath, }; const agent: Agent = { @@ -129,20 +153,6 @@ export async function createTask(opts: CreateTaskOptions): Promise { rescheduleTaskStatusPolling(); updateWindowTitle(name); - // Start MCP server for coordinator tasks - if (opts.coordinatorMode) { - try { - const mcpResult = await invoke<{ configPath: string }>(IPC.StartMCPServer, { - coordinatorTaskId: result.id, - projectId, - projectRoot, - }); - setStore('tasks', result.id, 'mcpConfigPath', mcpResult.configPath); - } catch (err) { - console.warn('Failed to start MCP server for coordinator:', err); - } - } - return result.id; } @@ -215,10 +225,40 @@ export async function createDirectTask(opts: CreateDirectTaskOptions): Promise { const task = store.tasks[taskId]; if (!task || task.closingStatus === 'closing' || task.closingStatus === 'removing') return; + // If this is a coordinator, unparent all children first + if (task.coordinatorMode) { + const children = getCoordinatorChildren(taskId); + const allChildIds = [...children.active, ...children.collapsed]; + if (allChildIds.length > 0) { + setStore( + produce((s) => { + for (const childId of allChildIds) { + if (s.tasks[childId]) { + s.tasks[childId].coordinatedBy = undefined; + } + } + }), + ); + } + } + const agentIds = [...task.agentIds]; const shellAgentIds = [...task.shellAgentIds]; const branchName = task.branchName; @@ -628,6 +668,7 @@ interface MCPTaskCreatedEvent { worktreePath: string; agentId: string; coordinatorTaskId: string; + prompt?: string; } /** Call once during app initialization to listen for orchestrator events. */ @@ -648,6 +689,9 @@ export function initMCPListeners(): () => void { notes: '', lastPrompt: '', coordinatedBy: evt.coordinatorTaskId, + // Use the same initialPrompt path as manually created tasks — + // PromptInput auto-delivers it with stability checks + quiescence. + initialPrompt: evt.prompt, }; const agent: Agent = { diff --git a/src/store/types.ts b/src/store/types.ts index e56dac56..d486cc9a 100644 --- a/src/store/types.ts +++ b/src/store/types.ts @@ -144,10 +144,17 @@ export interface RemoteAccess { connectedClients: number; } +export interface MCPStatus { + mcpRunning: boolean; + remoteRunning: boolean; +} + export interface AppStore { projects: Project[]; lastProjectId: string | null; lastAgentId: string | null; + /** Ordered active task IDs. Coordinated children are present here but filtered + * out of the sidebar's flat list — they render nested under their coordinator. */ taskOrder: string[]; collapsedTaskOrder: string[]; tasks: Record; @@ -189,5 +196,6 @@ export interface AppStore { newTaskPrefillPrompt: { prompt: string; projectId: string | null } | null; missingProjectIds: Record; remoteAccess: RemoteAccess; + mcpStatus: MCPStatus; showArena: boolean; } From befe5639716a16c10daa528554cc963a82ec13f4 Mon Sep 17 00:00:00 2001 From: Croissant Le Doux Date: Tue, 24 Mar 2026 11:00:49 -0400 Subject: [PATCH 3/3] bring branch up to main --- .prettierignore | 1 + build/icon.icns | Bin 65375 -> 94333 bytes docker/Dockerfile | 67 ++ electron/ipc/channels.ts | 11 + electron/ipc/git.ts | 336 +++---- electron/ipc/persistence.ts | 40 +- electron/ipc/plans.ts | 2 +- electron/ipc/pty.ts | 422 ++++++++- electron/ipc/register.ts | 210 +++-- electron/ipc/system-fonts.ts | 35 + electron/ipc/tasks.ts | 29 +- electron/preload.cjs | 13 +- package-lock.json | 54 +- package.json | 12 +- src/App.tsx | 7 + src/components/ChangedFilesList.tsx | 28 +- src/components/CloseTaskDialog.tsx | 24 +- src/components/ConnectPhoneModal.tsx | 431 ++++----- src/components/DiffViewerDialog.tsx | 8 +- src/components/EditProjectDialog.tsx | 111 +-- src/components/MergeDialog.tsx | 72 +- src/components/NewTaskDialog.tsx | 471 +++++++--- src/components/PromptInput.tsx | 47 +- src/components/PushDialog.tsx | 8 +- src/components/ScrollingDiffView.tsx | 303 +++++-- src/components/SegmentedButtons.tsx | 49 + src/components/SettingsDialog.tsx | 129 ++- src/components/Sidebar.tsx | 44 +- src/components/TaskAITerminal.tsx | 304 +++++++ src/components/TaskBranchInfoBar.tsx | 148 +++ src/components/TaskClosingOverlay.tsx | 67 ++ src/components/TaskNotesPanel.tsx | 275 ++++++ src/components/TaskPanel.tsx | 1207 +------------------------ src/components/TaskShellSection.tsx | 275 ++++++ src/components/TaskTitleBar.tsx | 177 ++++ src/components/TerminalView.tsx | 8 +- src/components/TilingLayout.tsx | 9 +- src/lib/focus-registration.ts | 11 + src/lib/fonts.ts | 87 +- src/lib/ipc.ts | 7 +- src/lib/terminalFitManager.ts | 13 +- src/lib/theme.ts | 19 + src/store/autosave.ts | 4 +- src/store/core.ts | 8 +- src/store/desktopNotifications.ts | 49 +- src/store/focus.ts | 45 +- src/store/navigation.ts | 6 +- src/store/persistence.ts | 76 +- src/store/projects.ts | 27 +- src/store/store.ts | 5 +- src/store/taskStatus.ts | 251 +++-- src/store/tasks.ts | 234 +++-- src/store/terminals.ts | 12 +- src/store/types.ts | 23 +- src/store/ui.ts | 11 +- 55 files changed, 3844 insertions(+), 2478 deletions(-) create mode 100644 docker/Dockerfile create mode 100644 electron/ipc/system-fonts.ts create mode 100644 src/components/SegmentedButtons.tsx create mode 100644 src/components/TaskAITerminal.tsx create mode 100644 src/components/TaskBranchInfoBar.tsx create mode 100644 src/components/TaskClosingOverlay.tsx create mode 100644 src/components/TaskNotesPanel.tsx create mode 100644 src/components/TaskShellSection.tsx create mode 100644 src/components/TaskTitleBar.tsx create mode 100644 src/lib/focus-registration.ts diff --git a/.prettierignore b/.prettierignore index 22811590..d8a963a2 100644 --- a/.prettierignore +++ b/.prettierignore @@ -5,6 +5,7 @@ release/ node_modules/ .worktrees/ .claude/ +.letta/ package-lock.json *.AppImage *.deb diff --git a/build/icon.icns b/build/icon.icns index 732bdd488805505a1b9022c7ebaeaf7e62a9aadc..847611d197ebc4009ad2090aa021457aa30ea087 100644 GIT binary patch literal 94333 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 Hzx;mziQsh~ literal 65375 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 T1*x+rNQ*yWKYsoEfAs$WAXm%p diff --git a/docker/Dockerfile b/docker/Dockerfile new file mode 100644 index 00000000..a717cbb8 --- /dev/null +++ b/docker/Dockerfile @@ -0,0 +1,67 @@ +# Parallel Code — Docker agent image +# Pre-installs common dev tools so agents don't waste time on setup. +# Build: docker build -t parallel-code-agent:latest docker/ +# Size target: ~600 MB (multi-stage not needed; one fat layer is fine for a dev image) + +FROM ubuntu:22.04 + +ENV DEBIAN_FRONTEND=noninteractive \ + LANG=C.UTF-8 \ + LC_ALL=C.UTF-8 + +# Core build tools + languages +RUN apt-get update && apt-get install -y --no-install-recommends \ + # Basics + ca-certificates curl wget git openssh-client gnupg \ + # Build essentials + build-essential pkg-config \ + # Python + python3 python3-pip python3-venv \ + # Shells & utilities + bash zsh jq ripgrep fd-find fzf tree unzip less \ + # Process inspection + procps \ + # Networking + dnsutils iputils-ping netcat-openbsd \ + # Editor (agents sometimes need a $EDITOR) + nano \ + && rm -rf /var/lib/apt/lists/* + +# Add apt repos: NodeSource (Node 22 LTS) and GitHub CLI +RUN mkdir -p /etc/apt/keyrings \ + && curl -fsSL https://deb.nodesource.com/gpgkey/nodesource-repo.gpg.key \ + | gpg --dearmor -o /etc/apt/keyrings/nodesource.gpg \ + && echo "deb [signed-by=/etc/apt/keyrings/nodesource.gpg] https://deb.nodesource.com/node_22.x nodistro main" \ + > /etc/apt/sources.list.d/nodesource.list \ + && curl -fsSL https://cli.github.com/packages/githubcli-archive-keyring.gpg \ + | dd of=/usr/share/keyrings/githubcli-archive-keyring.gpg \ + && echo "deb [arch=$(dpkg --print-architecture) signed-by=/usr/share/keyrings/githubcli-archive-keyring.gpg] https://cli.github.com/packages stable main" \ + > /etc/apt/sources.list.d/github-cli.list + +# Install Node.js, npm, and GitHub CLI in one layer +RUN apt-get update && apt-get install -y --no-install-recommends \ + nodejs \ + git-lfs \ + gh \ + && rm -rf /var/lib/apt/lists/* \ + && npm install -g npm@10 + +# Make fd and fdfind available as 'fd' +RUN ln -sf "$(command -v fdfind)" /usr/local/bin/fd 2>/dev/null || true + +# AI agent CLIs — must be present so Docker-mode tasks can execute them +RUN npm install -g @anthropic-ai/claude-code @openai/codex @google/gemini-cli opencode-ai + +# Default git config so commits work out of the box inside containers +RUN git config --system init.defaultBranch main \ + && git config --system advice.detachedHead false + +# Non-root user — use --user 1000:1000 in docker run as needed +RUN groupadd --gid 1000 agent \ + && useradd --uid 1000 --gid agent --shell /bin/bash --create-home agent + +# Set a reasonable default shell +ENV SHELL=/bin/bash +WORKDIR /app + +CMD ["bash"] diff --git a/electron/ipc/channels.ts b/electron/ipc/channels.ts index b6301e41..ab9449b4 100644 --- a/electron/ipc/channels.ts +++ b/electron/ipc/channels.ts @@ -30,6 +30,8 @@ export enum IPC { RebaseTask = 'rebase_task', GetMainBranch = 'get_main_branch', GetCurrentBranch = 'get_current_branch', + GetBranches = 'get_branches', + CheckIsGitRepo = 'check_is_git_repo', CommitAll = 'commit_all', DiscardUncommitted = 'discard_uncommitted', @@ -81,11 +83,20 @@ export enum IPC { // Plan PlanContent = 'plan_content', ReadPlanContent = 'read_plan_content', + StopPlanWatcher = 'stop_plan_watcher', // Ask about code AskAboutCode = 'ask_about_code', CancelAskAboutCode = 'cancel_ask_about_code', + // Docker + CheckDockerAvailable = 'check_docker_available', + CheckDockerImageExists = 'check_docker_image_exists', + BuildDockerImage = 'build_docker_image', + + // System + GetSystemFonts = 'get_system_fonts', + // Notifications ShowNotification = 'show_notification', NotificationClicked = 'notification_clicked', diff --git a/electron/ipc/git.ts b/electron/ipc/git.ts index e633324f..0e650f76 100644 --- a/electron/ipc/git.ts +++ b/electron/ipc/git.ts @@ -6,6 +6,17 @@ import type { BrowserWindow } from 'electron'; const exec = promisify(execFile); +// --- Types --- + +/** A file entry from a git diff with status and line counts. */ +export interface ChangedFile { + path: string; + lines_added: number; + lines_removed: number; + status: string; + committed: boolean; +} + // --- TTL Caches --- interface CacheEntry { @@ -18,6 +29,20 @@ const mergeBaseCache = new Map(); const MAIN_BRANCH_TTL = 60_000; // 60s const MERGE_BASE_TTL = 30_000; // 30s const MAX_BUFFER = 10 * 1024 * 1024; // 10MB +const STDERR_CAP = 4096; // cap for stderr buffers in spawned git processes + +// Sweep expired cache entries periodically so stale entries from repos that +// are no longer queried don't accumulate (lazy deletion alone isn't enough). +const CACHE_SWEEP_INTERVAL = 5 * 60_000; // 5 min +setInterval(() => { + const now = Date.now(); + for (const [k, v] of mainBranchCache) { + if (v.expiresAt <= now) mainBranchCache.delete(k); + } + for (const [k, v] of mergeBaseCache) { + if (v.expiresAt <= now) mergeBaseCache.delete(k); + } +}, CACHE_SWEEP_INTERVAL).unref(); /** Check if a file is binary by looking for null bytes in the first 8KB (same heuristic as git). */ async function isBinaryFile(filePath: string): Promise { @@ -167,15 +192,19 @@ async function getCurrentBranchName(repoRoot: string): Promise { return stdout.trim(); } -async function detectMergeBase(repoRoot: string, head?: string): Promise { - const key = cacheKey(repoRoot); +async function detectMergeBase( + repoRoot: string, + head?: string, + baseBranch?: string, +): Promise { + const mainBranch = baseBranch ?? (await detectMainBranch(repoRoot)); + const key = `${cacheKey(repoRoot)}:${mainBranch}`; const cached = mergeBaseCache.get(key); if (cached) { if (cached.expiresAt > Date.now()) return cached.value; mergeBaseCache.delete(key); } - const mainBranch = await detectMainBranch(repoRoot); let result: string; try { const { stdout } = await exec('git', ['merge-base', mainBranch, head ?? 'HEAD'], { @@ -205,7 +234,7 @@ async function detectRepoLockKey(p: string): Promise { const commonDir = stdout.trim(); const commonPath = path.isAbsolute(commonDir) ? commonDir : path.join(p, commonDir); try { - return fs.realpathSync(commonPath); + return await fs.promises.realpath(commonPath); } catch { return commonPath; } @@ -216,7 +245,7 @@ function normalizeStatusPath(raw: string): string { if (!trimmed) return ''; // Handle rename/copy "old -> new" const destination = trimmed.split(' -> ').pop()?.trim() ?? trimmed; - return destination.replace(/^"|"$/g, ''); + return destination.replace(/^"|"$/g, '').replace(/\\(.)/g, '$1'); } /** Parse combined `git diff --raw --numstat` output into status and numstat maps. */ @@ -288,7 +317,7 @@ async function computeBranchDiffStats( mainBranch: string, branchName: string, ): Promise<{ linesAdded: number; linesRemoved: number }> { - const { stdout } = await exec('git', ['diff', '--numstat', `${mainBranch}..${branchName}`], { + const { stdout } = await exec('git', ['diff', '--numstat', `${mainBranch}...${branchName}`], { cwd: projectRoot, maxBuffer: MAX_BUFFER, }); @@ -324,8 +353,8 @@ function shallowSymlinkDir(source: string, target: string, exclude: Set) if (!fs.existsSync(dst)) { fs.symlinkSync(src, dst); } - } catch { - /* ignore */ + } catch (err) { + console.warn(`Failed to symlink ${src} -> ${dst}:`, err); } } } @@ -336,6 +365,7 @@ export async function createWorktree( repoRoot: string, branchName: string, symlinkDirs: string[], + baseBranch?: string, forceClean = false, ): Promise<{ path: string; branch: string }> { const worktreePath = `${repoRoot}/.worktrees/${branchName}`; @@ -362,7 +392,9 @@ export async function createWorktree( } // Create fresh worktree with new branch - await exec('git', ['worktree', 'add', '-b', branchName, worktreePath], { cwd: repoRoot }); + const worktreeArgs = ['worktree', 'add', '-b', branchName, worktreePath]; + if (baseBranch) worktreeArgs.push(baseBranch); + await exec('git', worktreeArgs, { cwd: repoRoot }); // Symlink selected directories for (const name of symlinkDirs) { @@ -380,8 +412,8 @@ export async function createWorktree( } else { fs.symlinkSync(source, target); } - } catch { - /* ignore */ + } catch (err) { + console.warn(`Failed to symlink directory '${name}' into worktree:`, err); } } @@ -430,7 +462,7 @@ export async function getGitIgnoredDirs(projectRoot: string): Promise for (const name of SYMLINK_CANDIDATES) { const dirPath = path.join(projectRoot, name); try { - fs.statSync(dirPath); // throws if entry doesn't exist + await fs.promises.stat(dirPath); // throws if entry doesn't exist } catch { continue; } @@ -452,18 +484,23 @@ export async function getCurrentBranch(projectRoot: string): Promise { return getCurrentBranchName(projectRoot); } -export async function getChangedFiles(worktreePath: string): Promise< - Array<{ - path: string; - lines_added: number; - lines_removed: number; - status: string; - committed: boolean; - }> -> { +export async function getBranches(projectRoot: string): Promise { + const { stdout } = await exec('git', ['branch', '--list', '--format=%(refname:short)'], { + cwd: projectRoot, + }); + return stdout + .split('\n') + .map((b) => b.trim()) + .filter(Boolean); +} + +export async function getChangedFiles( + worktreePath: string, + baseBranch?: string, +): Promise { // Pin HEAD first so merge-base and diff use the same immutable commit const headHash = await pinHead(worktreePath); - const base = await detectMergeBase(worktreePath, headHash).catch(() => headHash); + const base = await detectMergeBase(worktreePath, headHash, baseBranch).catch(() => headHash); // git diff --raw --numstat — committed changes only (immutable) let diffStr = ''; @@ -480,115 +517,84 @@ export async function getChangedFiles(worktreePath: string): Promise< const { statusMap: committedStatusMap, numstatMap: committedNumstatMap } = parseDiffRawNumstat(diffStr); - // git status --porcelain for uncommitted/untracked paths - let statusStr = ''; - try { - const { stdout } = await exec('git', ['status', '--porcelain'], { + // git diff --raw --numstat — tracked uncommitted changes (HEAD vs working tree). + // Compares HEAD tree directly to the working tree, so it does not need the index + // write lock and works reliably even while an agent holds it. + // git ls-files --others --exclude-standard — untracked files (no index lock needed). + // Both commands run in parallel since they are independent. + const [uncommittedResult, untrackedResult] = await Promise.all([ + exec('git', ['diff', '--raw', '--numstat', headHash], { cwd: worktreePath, maxBuffer: MAX_BUFFER, - }); - statusStr = stdout; - } catch { - /* empty */ - } + }).catch(() => ({ stdout: '' })), + exec('git', ['ls-files', '--others', '--exclude-standard'], { + cwd: worktreePath, + maxBuffer: MAX_BUFFER, + }).catch(() => ({ stdout: '' })), + ]); + + const { statusMap: uncommittedStatusMap, numstatMap: uncommittedNumstatMap } = + parseDiffRawNumstat(uncommittedResult.stdout); - const uncommittedPaths = new Map(); // path -> status letter const untrackedPaths = new Set(); - for (const line of statusStr.split('\n')) { - if (line.length < 3) continue; - const p = normalizeStatusPath(line.slice(3)); - if (!p) continue; - if (line.startsWith('??')) { - untrackedPaths.add(p); - uncommittedPaths.set(p, '?'); - } else { - // Prefer working tree status, fall back to index status - const wtStatus = line[1]; - const indexStatus = line[0]; - uncommittedPaths.set(p, wtStatus !== ' ' ? wtStatus : indexStatus); - } + for (const line of untrackedResult.stdout.split('\n')) { + const p = normalizeStatusPath(line); + if (p) untrackedPaths.add(p); } - const files: Array<{ - path: string; - lines_added: number; - lines_removed: number; - status: string; - committed: boolean; - }> = []; + const files: ChangedFile[] = []; const seen = new Set(); // Committed files from diff base..HEAD for (const [p, [added, removed]] of committedNumstatMap) { const status = committedStatusMap.get(p) ?? 'M'; - // If also in uncommitted paths, mark as uncommitted (has local changes on top) - const committed = !uncommittedPaths.has(p); + // If also in uncommitted diff, mark as uncommitted (has local changes on top) + const committed = + !uncommittedNumstatMap.has(p) && !uncommittedStatusMap.has(p) && !untrackedPaths.has(p); seen.add(p); files.push({ path: p, lines_added: added, lines_removed: removed, status, committed }); } - // Uncommitted-only files (in status but not in committed diff) - // Use git diff --numstat HEAD for tracked files to get actual changed line counts - const uncommittedNumstat = new Map(); - const hasTrackedUncommitted = [...uncommittedPaths.keys()].some( - (p) => !seen.has(p) && !untrackedPaths.has(p), - ); - if (hasTrackedUncommitted) { - try { - const { stdout } = await exec('git', ['diff', '--numstat', 'HEAD'], { - cwd: worktreePath, - maxBuffer: MAX_BUFFER, - }); - for (const line of stdout.split('\n')) { - const parts = line.split('\t'); - if (parts.length >= 3) { - const a = parseInt(parts[0], 10); - const r = parseInt(parts[1], 10); - if (!isNaN(a) && !isNaN(r)) { - const rawPath = parts[parts.length - 1]; - const np = normalizeStatusPath(rawPath); - if (np) uncommittedNumstat.set(np, [a, r]); - } - } - } - } catch { - /* ignore */ - } + // Committed binary/special files (in statusMap but not numstatMap) + for (const [p, status] of committedStatusMap) { + if (seen.has(p)) continue; + const committed = + !uncommittedNumstatMap.has(p) && !uncommittedStatusMap.has(p) && !untrackedPaths.has(p); + seen.add(p); + files.push({ path: p, lines_added: 0, lines_removed: 0, status, committed }); } - for (const [p, statusLetter] of uncommittedPaths) { + // Tracked uncommitted files not in committed diff + for (const [p, [added, removed]] of uncommittedNumstatMap) { if (seen.has(p)) continue; - let added = 0; - let removed = 0; + const status = uncommittedStatusMap.get(p) ?? 'M'; + seen.add(p); + files.push({ path: p, lines_added: added, lines_removed: removed, status, committed: false }); + } - if (untrackedPaths.has(p)) { - // Untracked (new) files: count all lines as added - const fullPath = path.join(worktreePath, p); - try { - const stat = await fs.promises.stat(fullPath); - if (stat.isFile() && stat.size < MAX_BUFFER) { - const content = await fs.promises.readFile(fullPath, 'utf8'); - const lines = content.split('\n'); - added = content.endsWith('\n') ? lines.length - 1 : lines.length; - } - } catch { - /* ignore */ - } - } else { - // Tracked files: use actual diff stats - const stats = uncommittedNumstat.get(p); - if (stats) { - [added, removed] = stats; + // Uncommitted binary/special files (in statusMap but not numstatMap) + for (const [p, status] of uncommittedStatusMap) { + if (seen.has(p) || uncommittedNumstatMap.has(p)) continue; + seen.add(p); + files.push({ path: p, lines_added: 0, lines_removed: 0, status, committed: false }); + } + + // Untracked (new) files: count all lines as added + for (const p of untrackedPaths) { + if (seen.has(p)) continue; + let added = 0; + const fullPath = path.join(worktreePath, p); + try { + const stat = await fs.promises.stat(fullPath); + if (stat.isFile() && stat.size < MAX_BUFFER) { + const content = await fs.promises.readFile(fullPath, 'utf8'); + const lines = content.split('\n'); + added = content.endsWith('\n') ? lines.length - 1 : lines.length; } + } catch { + /* ignore */ } - - files.push({ - path: p, - lines_added: added, - lines_removed: removed, - status: statusLetter, - committed: false, - }); + files.push({ path: p, lines_added: added, lines_removed: 0, status: '?', committed: false }); } files.sort((a, b) => { @@ -599,9 +605,9 @@ export async function getChangedFiles(worktreePath: string): Promise< return files; } -export async function getAllFileDiffs(worktreePath: string): Promise { +export async function getAllFileDiffs(worktreePath: string, baseBranch?: string): Promise { const headHash = await pinHead(worktreePath); - const base = await detectMergeBase(worktreePath, headHash).catch(() => headHash); + const base = await detectMergeBase(worktreePath, headHash, baseBranch).catch(() => headHash); // Single combined diff: merge-base to working tree. // This avoids duplicate entries when a file has both committed and uncommitted changes. @@ -617,7 +623,7 @@ export async function getAllFileDiffs(worktreePath: string): Promise { } // Untracked files: build pseudo-diffs - let untrackedDiff = ''; + const untrackedParts: string[] = []; try { const { stdout } = await exec('git', ['status', '--porcelain'], { cwd: worktreePath, @@ -632,17 +638,24 @@ export async function getAllFileDiffs(worktreePath: string): Promise { const stat = await fs.promises.stat(fullPath); if (!stat.isFile() || stat.size >= MAX_BUFFER) continue; if (await isBinaryFile(fullPath)) { - untrackedDiff += `diff --git a/${filePath} b/${filePath}\nnew file mode 100644\nBinary files /dev/null and b/${filePath} differ\n`; + untrackedParts.push( + `diff --git a/${filePath} b/${filePath}\nnew file mode 100644\nBinary files /dev/null and b/${filePath} differ\n`, + ); continue; } const content = await fs.promises.readFile(fullPath, 'utf8'); const lines = content.split('\n'); const lineCount = content.endsWith('\n') ? lines.length - 1 : lines.length; - let pseudo = `diff --git a/${filePath} b/${filePath}\nnew file mode 100644\n--- /dev/null\n+++ b/${filePath}\n@@ -0,0 +1,${lineCount} @@\n`; + const pseudoLines: string[] = []; + pseudoLines.push(`diff --git a/${filePath} b/${filePath}`); + pseudoLines.push('new file mode 100644'); + pseudoLines.push('--- /dev/null'); + pseudoLines.push(`+++ b/${filePath}`); + pseudoLines.push(`@@ -0,0 +1,${lineCount} @@`); for (let i = 0; i < lineCount; i++) { - pseudo += `+${lines[i]}\n`; + pseudoLines.push(`+${lines[i]}`); } - untrackedDiff += pseudo; + untrackedParts.push(pseudoLines.join('\n') + '\n'); } catch { /* skip unreadable files */ } @@ -651,15 +664,16 @@ export async function getAllFileDiffs(worktreePath: string): Promise { /* empty */ } - const parts = [combinedDiff, untrackedDiff].filter((p) => p.length > 0); + const parts = [combinedDiff, untrackedParts.join('')].filter((p) => p.length > 0); return parts.join('\n'); } export async function getAllFileDiffsFromBranch( projectRoot: string, branchName: string, + baseBranch?: string, ): Promise { - const mainBranch = await detectMainBranch(projectRoot); + const mainBranch = baseBranch ?? (await detectMainBranch(projectRoot)); try { const { stdout } = await exec('git', ['diff', '-U3', `${mainBranch}...${branchName}`], { cwd: projectRoot, @@ -677,10 +691,14 @@ interface FileDiffResult { newContent: string; } -export async function getFileDiff(worktreePath: string, filePath: string): Promise { +export async function getFileDiff( + worktreePath: string, + filePath: string, + baseBranch?: string, +): Promise { // Pin HEAD first so merge-base and all reads use the same immutable commit const headHash = await pinHead(worktreePath); - const base = await detectMergeBase(worktreePath, headHash).catch(() => headHash); + const base = await detectMergeBase(worktreePath, headHash, baseBranch).catch(() => headHash); // Old content from merge base let oldContent = ''; @@ -766,22 +784,28 @@ export async function getFileDiff(worktreePath: string, filePath: string): Promi diff = `Binary files /dev/null and b/${filePath} differ`; } else { const lines = newContent.split('\n'); - let pseudo = `--- /dev/null\n+++ b/${filePath}\n@@ -0,0 +1,${lines.length} @@\n`; + const pseudoLines: string[] = []; + pseudoLines.push(`--- /dev/null`); + pseudoLines.push(`+++ b/${filePath}`); + pseudoLines.push(`@@ -0,0 +1,${lines.length} @@`); for (const line of lines) { - pseudo += `+${line}\n`; + pseudoLines.push(`+${line}`); } - diff = pseudo; + diff = pseudoLines.join('\n') + '\n'; } } // Uncommitted deletion with no committed diff — build deletion pseudo-diff if (!diff && isUncommittedDeletion && oldContent) { const lines = oldContent.split('\n'); - let pseudo = `--- a/${filePath}\n+++ /dev/null\n@@ -1,${lines.length} +0,0 @@\n`; + const pseudoLines: string[] = []; + pseudoLines.push(`--- a/${filePath}`); + pseudoLines.push(`+++ /dev/null`); + pseudoLines.push(`@@ -1,${lines.length} +0,0 @@`); for (const line of lines) { - pseudo += `-${line}\n`; + pseudoLines.push(`-${line}`); } - diff = pseudo; + diff = pseudoLines.join('\n') + '\n'; } return { diff, oldContent, newContent }; @@ -789,6 +813,7 @@ export async function getFileDiff(worktreePath: string, filePath: string): Promi export async function getWorktreeStatus( worktreePath: string, + baseBranch?: string, ): Promise<{ has_committed_changes: boolean; has_uncommitted_changes: boolean }> { const { stdout: statusOut } = await exec('git', ['status', '--porcelain'], { cwd: worktreePath, @@ -796,7 +821,7 @@ export async function getWorktreeStatus( }); const hasUncommittedChanges = statusOut.trim().length > 0; - const mainBranch = await detectMainBranch(worktreePath).catch(() => 'HEAD'); + const mainBranch = baseBranch ?? (await detectMainBranch(worktreePath).catch(() => 'HEAD')); let hasCommittedChanges = false; try { const { stdout: logOut } = await exec('git', ['log', `${mainBranch}..HEAD`, '--oneline'], { @@ -827,8 +852,9 @@ export async function discardUncommitted(worktreePath: string): Promise { export async function checkMergeStatus( worktreePath: string, + baseBranch?: string, ): Promise<{ main_ahead_count: number; conflicting_files: string[] }> { - const mainBranch = await detectMainBranch(worktreePath); + const mainBranch = baseBranch ?? (await detectMainBranch(worktreePath)); let mainAheadCount = 0; try { @@ -863,11 +889,12 @@ export async function mergeTask( squash: boolean, message: string | null, cleanup: boolean, + baseBranch?: string, ): Promise<{ main_branch: string; lines_added: number; lines_removed: number }> { const lockKey = await detectRepoLockKey(projectRoot).catch(() => projectRoot); return withWorktreeLock(lockKey, async () => { - const mainBranch = await detectMainBranch(projectRoot); + const mainBranch = baseBranch ?? (await detectMainBranch(projectRoot)); const { linesAdded, linesRemoved } = await computeBranchDiffStats( projectRoot, mainBranch, @@ -942,8 +969,8 @@ export async function mergeTask( }); } -export async function getBranchLog(worktreePath: string): Promise { - const mainBranch = await detectMainBranch(worktreePath).catch(() => 'HEAD'); +export async function getBranchLog(worktreePath: string, baseBranch?: string): Promise { + const mainBranch = baseBranch ?? (await detectMainBranch(worktreePath).catch(() => 'HEAD')); try { const { stdout } = await exec( 'git', @@ -962,16 +989,9 @@ export async function getBranchLog(worktreePath: string): Promise { export async function getChangedFilesFromBranch( projectRoot: string, branchName: string, -): Promise< - Array<{ - path: string; - lines_added: number; - lines_removed: number; - status: string; - committed: boolean; - }> -> { - const mainBranch = await detectMainBranch(projectRoot); + baseBranch?: string, +): Promise { + const mainBranch = baseBranch ?? (await detectMainBranch(projectRoot)); let diffStr = ''; try { @@ -987,13 +1007,7 @@ export async function getChangedFilesFromBranch( const { statusMap, numstatMap } = parseDiffRawNumstat(diffStr); - const files: Array<{ - path: string; - lines_added: number; - lines_removed: number; - status: string; - committed: boolean; - }> = []; + const files: ChangedFile[] = []; for (const [p, [added, removed]] of numstatMap) { const status = statusMap.get(p) ?? 'M'; @@ -1014,8 +1028,9 @@ export async function getFileDiffFromBranch( projectRoot: string, branchName: string, filePath: string, + baseBranch?: string, ): Promise { - const mainBranch = await detectMainBranch(projectRoot); + const mainBranch = baseBranch ?? (await detectMainBranch(projectRoot)); let diff = ''; try { @@ -1087,10 +1102,15 @@ export function pushTask( send(chunk.toString('utf8')); }); + // Only the last line is used for error messages — cap the buffer to avoid + // unbounded growth from verbose git push output (progress, LFS, etc.). let stderrBuf = ''; proc.stderr?.on('data', (chunk: Buffer) => { const text = chunk.toString('utf8'); stderrBuf += text; + if (stderrBuf.length > STDERR_CAP) { + stderrBuf = stderrBuf.slice(-STDERR_CAP); + } send(text); }); @@ -1117,11 +1137,11 @@ export function pushTask( }); } -export async function rebaseTask(worktreePath: string): Promise { +export async function rebaseTask(worktreePath: string, baseBranch?: string): Promise { const lockKey = await detectRepoLockKey(worktreePath).catch(() => worktreePath); return withWorktreeLock(lockKey, async () => { - const mainBranch = await detectMainBranch(worktreePath); + const mainBranch = baseBranch ?? (await detectMainBranch(worktreePath)); try { await exec('git', ['rebase', mainBranch], { cwd: worktreePath }); } catch (e) { @@ -1133,3 +1153,15 @@ export async function rebaseTask(worktreePath: string): Promise { invalidateMergeBaseCache(); }); } + +/** Check whether a directory is the root of a git repository. */ +export async function isGitRepo(dirPath: string): Promise { + try { + const { stdout } = await exec('git', ['rev-parse', '--show-toplevel'], { cwd: dirPath }); + const toplevel = await fs.promises.realpath(stdout.trim()); + const resolved = await fs.promises.realpath(dirPath); + return toplevel === resolved; + } catch { + return false; + } +} diff --git a/electron/ipc/persistence.ts b/electron/ipc/persistence.ts index 0dcd1f2a..3a92c4fe 100644 --- a/electron/ipc/persistence.ts +++ b/electron/ipc/persistence.ts @@ -26,19 +26,29 @@ export function saveAppState(json: string): void { // Atomic write: write to temp, then rename const tmpPath = statePath + '.tmp'; - fs.writeFileSync(tmpPath, json, 'utf8'); + try { + fs.writeFileSync(tmpPath, json, 'utf8'); - // Keep one backup (copy so statePath is never missing during the operation) - if (fs.existsSync(statePath)) { - const bakPath = statePath + '.bak'; + // Keep one backup (copy so statePath is never missing during the operation) + if (fs.existsSync(statePath)) { + const bakPath = statePath + '.bak'; + try { + fs.copyFileSync(statePath, bakPath); + } catch { + /* ignore */ + } + } + + fs.renameSync(tmpPath, statePath); + } catch (err) { + // Clean up orphaned temp file on failure try { - fs.copyFileSync(statePath, bakPath); + fs.unlinkSync(tmpPath); } catch { - /* ignore */ + /* temp file may not exist */ } + throw err; } - - fs.renameSync(tmpPath, statePath); } export function loadAppState(): string | null { @@ -48,19 +58,25 @@ export function loadAppState(): string | null { try { if (fs.existsSync(statePath)) { const content = fs.readFileSync(statePath, 'utf8'); - if (content.trim()) return content; + if (content.trim()) { + JSON.parse(content); // validate JSON; falls through to backup on invalid + return content; + } } } catch { - // Primary state file unreadable — try backup + // Primary state file unreadable or invalid JSON — try backup } try { if (fs.existsSync(bakPath)) { const content = fs.readFileSync(bakPath, 'utf8'); - if (content.trim()) return content; + if (content.trim()) { + JSON.parse(content); // validate JSON + return content; + } } } catch { - // Backup also unreadable + // Backup also unreadable or invalid JSON } return null; diff --git a/electron/ipc/plans.ts b/electron/ipc/plans.ts index e79fc78b..bc760ee7 100644 --- a/electron/ipc/plans.ts +++ b/electron/ipc/plans.ts @@ -209,8 +209,8 @@ export function startPlanWatcher(win: BrowserWindow, taskId: string, worktreePat } } - startDirPolling(taskId, entry, onChange); watchers.set(taskId, entry); + startDirPolling(taskId, entry, onChange); } /** Stops and removes the plan watcher for a given task. */ diff --git a/electron/ipc/pty.ts b/electron/ipc/pty.ts index e19c6249..f39ce2c9 100644 --- a/electron/ipc/pty.ts +++ b/electron/ipc/pty.ts @@ -1,9 +1,15 @@ import * as pty from 'node-pty'; -import { execFileSync } from 'child_process'; +import { execFileSync, execFile, spawn as cpSpawn } from 'child_process'; +import crypto from 'crypto'; import fs from 'fs'; +import path from 'path'; +import { fileURLToPath } from 'url'; import type { BrowserWindow } from 'electron'; import { RingBuffer } from '../remote/ring-buffer.js'; +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); + interface PtySession { proc: pty.IPty; channelId: string; @@ -13,6 +19,8 @@ interface PtySession { flushTimer: ReturnType | null; subscribers: Set<(encoded: string) => void>; scrollback: RingBuffer; + /** Assigned container name when running in Docker mode, null otherwise. */ + containerName: string | null; } const sessions = new Map(); @@ -88,6 +96,8 @@ export function spawnAgent( cols: number; rows: number; isShell?: boolean; + dockerMode?: boolean; + dockerImage?: string; onOutput: { __CHANNEL_ID__: string }; }, ): void { @@ -102,7 +112,12 @@ export function spawnAgent( throw new Error(`Command contains disallowed characters: ${command}`); } - validateCommand(command); + // In Docker mode, we validate `docker` exists rather than the inner command + if (!args.dockerMode) { + validateCommand(command); + } else { + validateCommand('docker'); + } // Kill any existing session with the same agentId to prevent PTY leaks const existing = sessions.get(args.agentId); @@ -148,12 +163,66 @@ export function spawnAgent( delete spawnEnv.CLAUDE_CODE_SESSION; delete spawnEnv.CLAUDE_CODE_ENTRYPOINT; - const proc = pty.spawn(command, args.args, { + let spawnCommand: string; + let spawnArgs: string[]; + + // Derive a predictable, unique container name from the agentId so we can + // reliably stop it later without having to parse docker inspect output. + const containerName = args.dockerMode ? `parallel-code-${args.agentId.slice(0, 12)}` : null; + + if (args.dockerMode) { + const name = containerName as string; + const image = args.dockerImage || DOCKER_DEFAULT_IMAGE; + spawnCommand = 'docker'; + spawnArgs = [ + 'run', + '--rm', + '-it', + // Predictable name so we can stop the container on kill + '--name', + name, + // Label so we can identify all containers owned by this app + '--label', + 'parallel-code=true', + // Host networking — agents need internet access for API calls and package installs. + // Filesystem isolation (volume mounts) is the primary safety goal, not network isolation. + '--network', + 'host', + // Resource limits to prevent runaway containers + '--memory', + '8g', + '--pids-limit', + '512', + // Run as host user so container files are owned by the host user + '--user', + `${process.getuid?.() ?? 1000}:${process.getgid?.() ?? 1000}`, + // Mount the project directory as the only writable volume + '-v', + `${cwd}:${cwd}`, + '-w', + cwd, + // Forward env vars the agent needs (API keys, git config, etc.) + ...buildDockerEnvFlags(spawnEnv), + // Writable HOME for agent config files (host HOME is blocked above) + '-e', + `HOME=${DOCKER_CONTAINER_HOME}`, + // Mount SSH and git config read-only for git operations + ...buildDockerCredentialMounts(), + image, + command, + ...args.args, + ]; + } else { + spawnCommand = command; + spawnArgs = args.args; + } + + const proc = pty.spawn(spawnCommand, spawnArgs, { name: 'xterm-256color', cols: args.cols, rows: args.rows, - cwd, - env: spawnEnv, + cwd: args.dockerMode ? undefined : cwd, + env: args.dockerMode ? filteredEnv : spawnEnv, }); const session: PtySession = { @@ -165,12 +234,15 @@ export function spawnAgent( flushTimer: null, subscribers: new Set(), scrollback: new RingBuffer(), + containerName, }; sessions.set(args.agentId, session); // Batching strategy matching the Rust implementation - let batch = Buffer.alloc(0); - let tailBuf = Buffer.alloc(0); + let batchChunks: Buffer[] = []; + let batchSize = 0; + let tailChunks: Buffer[] = []; + let tailSize = 0; const send = (msg: unknown) => { if (!win.isDestroyed()) { @@ -178,15 +250,31 @@ export function spawnAgent( } }; + // In Docker mode, write a diagnostic banner to the terminal so the user + // can see what command is being run (and debug when nothing else appears). + if (args.dockerMode) { + const image = args.dockerImage || DOCKER_DEFAULT_IMAGE; + const innerCmd = [command, ...args.args].join(' '); + const banner = + `\x1b[2m[docker] container: ${containerName}\r\n` + + `[docker] image: ${image}\r\n` + + `[docker] command: ${innerCmd}\r\n` + + `[docker] waiting for container to start…\x1b[0m\r\n\r\n`; + console.warn(`[docker] spawning container ${containerName} — image=${image} cmd=${innerCmd}`); + send({ type: 'Data', data: Buffer.from(banner, 'utf8').toString('base64') }); + } + const flush = () => { - if (batch.length === 0) return; + if (batchSize === 0) return; + const batch = Buffer.concat(batchChunks); const encoded = batch.toString('base64'); send({ type: 'Data', data: encoded }); session.scrollback.write(batch); for (const sub of session.subscribers) { sub(encoded); } - batch = Buffer.alloc(0); + batchChunks = []; + batchSize = 0; if (session.flushTimer) { clearTimeout(session.flushTimer); session.flushTimer = null; @@ -197,15 +285,20 @@ export function spawnAgent( const chunk = Buffer.from(data, 'utf8'); // Maintain tail buffer for exit diagnostics - tailBuf = Buffer.concat([tailBuf, chunk]); - if (tailBuf.length > TAIL_CAP) { - tailBuf = tailBuf.subarray(tailBuf.length - TAIL_CAP); + tailChunks.push(chunk); + tailSize += chunk.length; + if (tailSize > TAIL_CAP) { + const combined = Buffer.concat(tailChunks); + const trimmed = combined.subarray(combined.length - TAIL_CAP); + tailChunks = [trimmed]; + tailSize = trimmed.length; } - batch = Buffer.concat([batch, chunk]); + batchChunks.push(chunk); + batchSize += chunk.length; // Flush large batches immediately - if (batch.length >= BATCH_MAX) { + if (batchSize >= BATCH_MAX) { flush(); return; } @@ -227,10 +320,17 @@ export function spawnAgent( // skip cleanup — the new session owns the map entry now. if (sessions.get(args.agentId) !== session) return; + if (containerName) { + console.warn( + `[docker] container ${containerName} exited — code=${exitCode} signal=${signal ?? 'none'}`, + ); + } + // Flush any remaining buffered data flush(); // Parse tail buffer into last N lines for exit diagnostics + const tailBuf = Buffer.concat(tailChunks); const tailStr = tailBuf.toString('utf8'); const lines = tailStr .split('\n') @@ -289,6 +389,12 @@ export function killAgent(agentId: string): void { // notify stale listeners. Let onExit handle sessions.delete // and emitPtyEvent to avoid the race condition. session.subscribers.clear(); + // Stop the Docker container first so it doesn't keep running after the + // local PTY process (docker run) is killed. Fire-and-forget; the PTY kill + // below is the authoritative termination signal. + if (session.containerName) { + stopDockerContainer(session.containerName); + } session.proc.kill(); } } @@ -301,6 +407,16 @@ export function killAllAgents(): void { for (const [, session] of sessions) { if (session.flushTimer) clearTimeout(session.flushTimer); session.subscribers.clear(); + if (session.containerName) { + // Use synchronous docker kill with a short timeout so containers are + // terminated before the Electron process exits. Errors are ignored + // (container may already be gone). + try { + execFileSync('docker', ['kill', session.containerName], { timeout: 3000, stdio: 'pipe' }); + } catch { + // Intentionally ignore: container may not exist or may have already stopped. + } + } session.proc.kill(); } // Let onExit handlers clean up sessions individually @@ -344,3 +460,281 @@ export function getAgentCols(agentId: string): number { const s = sessions.get(agentId); return s ? s.proc.cols : 80; } + +// --- Docker mode helpers --- + +/** + * Env vars that are desktop/host-specific and must NOT be forwarded into the + * container. Everything else is forwarded so agents can use arbitrary vars + * (custom API keys, feature flags, tool config, etc.) without needing an + * ever-growing allowlist. + */ +/** Home directory inside the Docker container (writable by uid 1000). */ +const DOCKER_CONTAINER_HOME = '/home/agent'; + +const DOCKER_ENV_BLOCK_LIST = new Set([ + // Host PATH must not override the container's PATH — agent CLIs like + // `claude` are installed at /usr/local/bin inside the image and won't be + // found if the host PATH (pointing at host-only dirs) is forwarded. + 'PATH', + // Host HOME points to a non-writable directory inside the container + // (created by Docker for read-only credential mounts). Agents need a + // writable HOME for config files — we set HOME=/home/agent explicitly. + 'HOME', + // Display / desktop session + 'DISPLAY', + 'WAYLAND_DISPLAY', + 'DBUS_SESSION_BUS_ADDRESS', + 'DBUS_SYSTEM_BUS_ADDRESS', + 'DESKTOP_SESSION', + 'XDG_CURRENT_DESKTOP', + 'XDG_RUNTIME_DIR', + 'XDG_SESSION_CLASS', + 'XDG_SESSION_ID', + 'XDG_SESSION_TYPE', + 'XDG_VTNR', + 'WINDOWID', + 'XAUTHORITY', + // Electron / Node host internals + 'ELECTRON_RUN_AS_NODE', + 'ELECTRON_NO_ATTACH_CONSOLE', + 'ELECTRON_ENABLE_LOGGING', + 'ELECTRON_ENABLE_STACK_DUMPING', + // Host-specific paths / linker + 'LD_PRELOAD', + 'LD_LIBRARY_PATH', + 'DYLD_INSERT_LIBRARIES', + 'DYLD_LIBRARY_PATH', + // Session / PAM + 'LOGNAME', + 'MAIL', + 'XDG_DATA_DIRS', + 'XDG_CONFIG_DIRS', + // Active Claude Code session markers (prevent nested session confusion) + 'CLAUDECODE', + 'CLAUDE_CODE_SESSION', + 'CLAUDE_CODE_ENTRYPOINT', + // SSH / GPG / k8s — agent sockets and credentials must not leak into container + 'SSH_AUTH_SOCK', + 'GPG_AGENT_INFO', + 'KUBECONFIG', +]); + +/** Returns true for env var names that should be blocked from Docker forwarding. */ +function isBlockedDockerEnvKey(key: string): boolean { + if (DOCKER_ENV_BLOCK_LIST.has(key)) return true; + // Block all remaining XDG_* vars not explicitly listed above + if (key.startsWith('XDG_')) return true; + // Block all ELECTRON_* vars not explicitly listed above + if (key.startsWith('ELECTRON_')) return true; + // Block all SUDO_* vars (e.g. SUDO_USER, SUDO_UID) — host privilege context + if (key.startsWith('SUDO_')) return true; + return false; +} + +function buildDockerEnvFlags(env: Record): string[] { + const flags: string[] = []; + for (const [key, value] of Object.entries(env)) { + if (!isBlockedDockerEnvKey(key) && value !== undefined) { + flags.push('-e', `${key}=${value}`); + } + } + return flags; +} + +function buildDockerCredentialMounts(): string[] { + const mounts: string[] = []; + const home = process.env.HOME; + if (!home) return mounts; + + const containerHome = DOCKER_CONTAINER_HOME; + + /** Mount a host path read-only into the container home. Skips if absent. */ + const mountIfExists = (hostPath: string, containerPath: string): void => { + try { + fs.accessSync(hostPath, fs.constants.R_OK); + mounts.push('-v', `${hostPath}:${containerPath}:ro`); + } catch { + // Path absent or unreadable — skip + } + }; + + // SSH keys for git push/pull + mountIfExists(`${home}/.ssh`, `${containerHome}/.ssh`); + + // Git identity / config + mountIfExists(`${home}/.gitconfig`, `${containerHome}/.gitconfig`); + + // GitHub CLI auth tokens (~/.config/gh/) + mountIfExists(`${home}/.config/gh`, `${containerHome}/.config/gh`); + + // npm auth token + mountIfExists(`${home}/.npmrc`, `${containerHome}/.npmrc`); + + // General HTTP/git HTTPS credentials (used by git credential helper) + mountIfExists(`${home}/.netrc`, `${containerHome}/.netrc`); + + // Google Application Credentials file (for Vertex AI / gcloud) — mounted + // at its original path since the env var points there. + const googleCredsFile = process.env.GOOGLE_APPLICATION_CREDENTIALS; + if (googleCredsFile) { + mountIfExists(googleCredsFile, googleCredsFile); + } + + return mounts; +} + +/** + * Asynchronously stop a Docker container by name. Fire-and-forget — errors are + * silently swallowed because the container may have already exited by the time + * this is called. + */ +function stopDockerContainer(name: string): void { + execFile('docker', ['stop', name], { timeout: 10_000 }, () => { + // Intentionally ignore errors: container may not exist or may have already stopped. + }); +} + +/** Check if Docker is available on the system. */ +export async function isDockerAvailable(): Promise { + return new Promise((resolve) => { + execFile('docker', ['info'], { encoding: 'utf8', timeout: 5000 }, (err) => { + resolve(!err); + }); + }); +} + +/** The default image name for Docker-isolated tasks. */ +export const DOCKER_DEFAULT_IMAGE = 'parallel-code-agent:latest'; + +/** Label key used to stamp the Dockerfile content hash on built images. */ +const DOCKERFILE_HASH_LABEL = 'parallel-code-dockerfile-hash'; + +/** + * Resolve the path to the bundled Dockerfile. + * In dev mode it lives at `/docker/Dockerfile`; + * in production it's inside the asar resources directory. + */ +function resolveDockerfilePath(): string | null { + const devDockerDir = path.join(__dirname, '..', '..', 'docker'); + const prodDockerDir = path.join(process.resourcesPath ?? '', 'docker'); + const dockerDir = fs.existsSync(path.join(devDockerDir, 'Dockerfile')) + ? devDockerDir + : prodDockerDir; + const p = path.join(dockerDir, 'Dockerfile'); + return fs.existsSync(p) ? p : null; +} + +/** SHA-256 hex digest of the current Dockerfile, or null if not found. */ +function getDockerfileHash(): string | null { + const p = resolveDockerfilePath(); + if (!p) return null; + return crypto.createHash('sha256').update(fs.readFileSync(p)).digest('hex'); +} + +/** + * Check if a Docker image exists locally **and** matches the current Dockerfile. + * Returns false when the image is missing or was built from a different Dockerfile, + * so the UI will prompt the user to (re)build. + */ +export async function dockerImageExists(image: string): Promise { + const expectedHash = getDockerfileHash(); + return new Promise((resolve) => { + execFile( + 'docker', + [ + 'image', + 'inspect', + '--format', + `{{index .Config.Labels "${DOCKERFILE_HASH_LABEL}"}}`, + image, + ], + { encoding: 'utf8', timeout: 5000 }, + (err, stdout) => { + if (err) { + resolve(false); + return; + } + // If we can't compute the expected hash (Dockerfile missing), accept any existing image + if (!expectedHash) { + resolve(true); + return; + } + resolve(stdout.trim() === expectedHash); + }, + ); + }); +} + +/** Deduplicates concurrent calls to buildDockerImage. Null when no build is in progress. */ +let activeBuild: Promise<{ ok: boolean; error?: string }> | null = null; + +/** + * Build the bundled Dockerfile into parallel-code-agent:latest. + * Streams build output to the renderer via an IPC channel so the user can see progress. + * Returns a promise that resolves on success, rejects on failure. + * Concurrent calls return the same in-flight promise. + */ +export function buildDockerImage( + win: BrowserWindow, + onOutputChannel: string, +): Promise<{ ok: boolean; error?: string }> { + if (activeBuild !== null) { + return activeBuild; + } + + activeBuild = new Promise((resolve) => { + const finish = (result: { ok: boolean; error?: string }) => { + activeBuild = null; + resolve(result); + }; + + const dockerfilePath = resolveDockerfilePath(); + if (!dockerfilePath) { + finish({ ok: false, error: 'Dockerfile not found' }); + return; + } + const dockerDir = path.dirname(dockerfilePath); + const hash = getDockerfileHash() ?? 'unknown'; + + const send = (text: string) => { + if (!win.isDestroyed()) { + win.webContents.send(onOutputChannel, text); + } + }; + + const proc = cpSpawn( + 'docker', + [ + 'build', + '-t', + DOCKER_DEFAULT_IMAGE, + '--label', + `${DOCKERFILE_HASH_LABEL}=${hash}`, + '-f', + dockerfilePath, + dockerDir, + ], + { + stdio: ['ignore', 'pipe', 'pipe'], + }, + ); + + proc.stdout?.on('data', (chunk: Buffer) => send(chunk.toString('utf8'))); + proc.stderr?.on('data', (chunk: Buffer) => send(chunk.toString('utf8'))); + + proc.on('error', (err) => { + finish({ ok: false, error: err.message }); + }); + + proc.on('close', (code) => { + if (code === 0) { + finish({ ok: true }); + } else { + finish({ ok: false, error: `docker build exited with code ${code}` }); + } + }); + }); + + return activeBuild; +} diff --git a/electron/ipc/register.ts b/electron/ipc/register.ts index 6635384c..53f14afc 100644 --- a/electron/ipc/register.ts +++ b/electron/ipc/register.ts @@ -12,8 +12,16 @@ import { countRunningAgents, killAllAgents, getAgentMeta, + isDockerAvailable, + dockerImageExists, + buildDockerImage, } from './pty.js'; -import { ensurePlansDirectory, startPlanWatcher, readPlanForWorktree } from './plans.js'; +import { + ensurePlansDirectory, + startPlanWatcher, + stopPlanWatcher, + readPlanForWorktree, +} from './plans.js'; import { startRemoteServer, getMCPLogs } from '../remote/server.js'; import { Orchestrator } from '../mcp/orchestrator.js'; import { @@ -36,12 +44,15 @@ import { rebaseTask, createWorktree, removeWorktree, + isGitRepo, + getBranches, } from './git.js'; import { createTask, deleteTask } from './tasks.js'; import { listAgents } from './agents.js'; import { saveAppState, loadAppState } from './persistence.js'; import { spawn } from 'child_process'; import { askAboutCode, cancelAskAboutCode } from './ask-code.js'; +import { getSystemMonospaceFonts } from './system-fonts.js'; import path from 'path'; import { assertString, @@ -72,6 +83,36 @@ function validateBranchName(name: unknown, label: string): void { if (name.startsWith('-')) throw new Error(`${label} must not start with "-"`); } +/** + * Create a leading+trailing throttled event forwarder. + * Fires immediately, suppresses for `intervalMs`, then fires once more + * if events arrived during suppression (ensures the final state is always forwarded). + */ +function createThrottledForwarder( + win: BrowserWindow, + channel: string, + intervalMs: number, +): () => void { + let throttled = false; + let pending = false; + return () => { + if (win.isDestroyed()) return; + if (throttled) { + pending = true; + return; + } + throttled = true; + win.webContents.send(channel); + setTimeout(() => { + throttled = false; + if (pending) { + pending = false; + if (!win.isDestroyed()) win.webContents.send(channel); + } + }, intervalMs); + }; +} + export function registerAllHandlers(win: BrowserWindow): void { // --- Remote access state --- let remoteServer: ReturnType | null = null; @@ -83,6 +124,14 @@ export function registerAllHandlers(win: BrowserWindow): void { // --- PTY commands --- ipcMain.handle(IPC.SpawnAgent, (_e, args) => { + assertString(args.command, 'command'); + assertStringArray(args.args, 'args'); + assertString(args.taskId, 'taskId'); + assertString(args.agentId, 'agentId'); + assertInt(args.cols, 'cols'); + assertInt(args.rows, 'rows'); + assertOptionalBoolean(args.dockerMode, 'dockerMode'); + assertOptionalString(args.dockerImage, 'dockerImage'); if (args.cwd) validatePath(args.cwd, 'cwd'); if (!args.isShell && args.cwd) { try { @@ -129,6 +178,15 @@ export function registerAllHandlers(win: BrowserWindow): void { // --- Agent commands --- ipcMain.handle(IPC.ListAgents, () => listAgents()); + ipcMain.handle(IPC.CheckDockerAvailable, () => isDockerAvailable()); + ipcMain.handle(IPC.CheckDockerImageExists, (_e, args) => { + assertString(args.image, 'image'); + return dockerImageExists(args.image); + }); + ipcMain.handle(IPC.BuildDockerImage, (_e, args) => { + assertString(args.onOutputChannel, 'onOutputChannel'); + return buildDockerImage(win, args.onOutputChannel); + }); // --- Task commands --- ipcMain.handle(IPC.CreateTask, (_e, args) => { @@ -136,7 +194,16 @@ export function registerAllHandlers(win: BrowserWindow): void { validatePath(args.projectRoot, 'projectRoot'); assertStringArray(args.symlinkDirs, 'symlinkDirs'); assertOptionalString(args.branchPrefix, 'branchPrefix'); - const result = createTask(args.name, args.projectRoot, args.symlinkDirs, args.branchPrefix); + assertOptionalString(args.baseBranch, 'baseBranch'); + const baseBranch = args.baseBranch || undefined; + if (baseBranch) validateBranchName(baseBranch, 'baseBranch'); + const result = createTask( + args.name, + args.projectRoot, + args.symlinkDirs, + args.branchPrefix ?? 'task', + baseBranch, + ); result.then((r: { id: string }) => taskNames.set(r.id, args.name)).catch(() => {}); return result; }); @@ -145,38 +212,57 @@ export function registerAllHandlers(win: BrowserWindow): void { validatePath(args.projectRoot, 'projectRoot'); validateBranchName(args.branchName, 'branchName'); assertBoolean(args.deleteBranch, 'deleteBranch'); - return deleteTask(args.agentIds, args.branchName, args.deleteBranch, args.projectRoot); + assertOptionalString(args.taskId, 'taskId'); + return deleteTask({ + taskId: args.taskId, + agentIds: args.agentIds, + branchName: args.branchName, + deleteBranch: args.deleteBranch, + projectRoot: args.projectRoot, + }); }); // --- Git commands --- ipcMain.handle(IPC.GetChangedFiles, (_e, args) => { validatePath(args.worktreePath, 'worktreePath'); - return getChangedFiles(args.worktreePath); + const baseBranch = args.baseBranch || undefined; + if (baseBranch) validateBranchName(baseBranch, 'baseBranch'); + return getChangedFiles(args.worktreePath, baseBranch); }); ipcMain.handle(IPC.GetChangedFilesFromBranch, (_e, args) => { validatePath(args.projectRoot, 'projectRoot'); validateBranchName(args.branchName, 'branchName'); - return getChangedFilesFromBranch(args.projectRoot, args.branchName); + const baseBranch = args.baseBranch || undefined; + if (baseBranch) validateBranchName(baseBranch, 'baseBranch'); + return getChangedFilesFromBranch(args.projectRoot, args.branchName, baseBranch); }); ipcMain.handle(IPC.GetAllFileDiffs, (_e, args) => { validatePath(args.worktreePath, 'worktreePath'); - return getAllFileDiffs(args.worktreePath); + const baseBranch = args.baseBranch || undefined; + if (baseBranch) validateBranchName(baseBranch, 'baseBranch'); + return getAllFileDiffs(args.worktreePath, baseBranch); }); ipcMain.handle(IPC.GetAllFileDiffsFromBranch, (_e, args) => { validatePath(args.projectRoot, 'projectRoot'); validateBranchName(args.branchName, 'branchName'); - return getAllFileDiffsFromBranch(args.projectRoot, args.branchName); + const baseBranch = args.baseBranch || undefined; + if (baseBranch) validateBranchName(baseBranch, 'baseBranch'); + return getAllFileDiffsFromBranch(args.projectRoot, args.branchName, baseBranch); }); ipcMain.handle(IPC.GetFileDiff, (_e, args) => { validatePath(args.worktreePath, 'worktreePath'); validateRelativePath(args.filePath, 'filePath'); - return getFileDiff(args.worktreePath, args.filePath); + const baseBranch = args.baseBranch || undefined; + if (baseBranch) validateBranchName(baseBranch, 'baseBranch'); + return getFileDiff(args.worktreePath, args.filePath, baseBranch); }); ipcMain.handle(IPC.GetFileDiffFromBranch, (_e, args) => { validatePath(args.projectRoot, 'projectRoot'); validateBranchName(args.branchName, 'branchName'); validateRelativePath(args.filePath, 'filePath'); - return getFileDiffFromBranch(args.projectRoot, args.branchName, args.filePath); + const baseBranch = args.baseBranch || undefined; + if (baseBranch) validateBranchName(baseBranch, 'baseBranch'); + return getFileDiffFromBranch(args.projectRoot, args.branchName, args.filePath, baseBranch); }); ipcMain.handle(IPC.GetGitignoredDirs, (_e, args) => { validatePath(args.projectRoot, 'projectRoot'); @@ -184,7 +270,9 @@ export function registerAllHandlers(win: BrowserWindow): void { }); ipcMain.handle(IPC.GetWorktreeStatus, (_e, args) => { validatePath(args.worktreePath, 'worktreePath'); - return getWorktreeStatus(args.worktreePath); + const baseBranch = args.baseBranch || undefined; + if (baseBranch) validateBranchName(baseBranch, 'baseBranch'); + return getWorktreeStatus(args.worktreePath, baseBranch); }); ipcMain.handle(IPC.CommitAll, (_e, args) => { validatePath(args.worktreePath, 'worktreePath'); @@ -197,7 +285,9 @@ export function registerAllHandlers(win: BrowserWindow): void { }); ipcMain.handle(IPC.CheckMergeStatus, (_e, args) => { validatePath(args.worktreePath, 'worktreePath'); - return checkMergeStatus(args.worktreePath); + const baseBranch = args.baseBranch || undefined; + if (baseBranch) validateBranchName(baseBranch, 'baseBranch'); + return checkMergeStatus(args.worktreePath, baseBranch); }); ipcMain.handle(IPC.MergeTask, (_e, args) => { validatePath(args.projectRoot, 'projectRoot'); @@ -205,11 +295,22 @@ export function registerAllHandlers(win: BrowserWindow): void { assertBoolean(args.squash, 'squash'); assertOptionalString(args.message, 'message'); assertOptionalBoolean(args.cleanup, 'cleanup'); - return mergeTask(args.projectRoot, args.branchName, args.squash, args.message, args.cleanup); + const baseBranch = args.baseBranch || undefined; + if (baseBranch) validateBranchName(baseBranch, 'baseBranch'); + return mergeTask( + args.projectRoot, + args.branchName, + args.squash, + args.message ?? null, + args.cleanup ?? false, + baseBranch, + ); }); ipcMain.handle(IPC.GetBranchLog, (_e, args) => { validatePath(args.worktreePath, 'worktreePath'); - return getBranchLog(args.worktreePath); + const baseBranch = args.baseBranch || undefined; + if (baseBranch) validateBranchName(baseBranch, 'baseBranch'); + return getBranchLog(args.worktreePath, baseBranch); }); ipcMain.handle(IPC.PushTask, (_e, args) => { validatePath(args.projectRoot, 'projectRoot'); @@ -219,7 +320,9 @@ export function registerAllHandlers(win: BrowserWindow): void { }); ipcMain.handle(IPC.RebaseTask, (_e, args) => { validatePath(args.worktreePath, 'worktreePath'); - return rebaseTask(args.worktreePath); + const baseBranch = args.baseBranch || undefined; + if (baseBranch) validateBranchName(baseBranch, 'baseBranch'); + return rebaseTask(args.worktreePath, baseBranch); }); ipcMain.handle(IPC.GetMainBranch, (_e, args) => { validatePath(args.projectRoot, 'projectRoot'); @@ -229,6 +332,14 @@ export function registerAllHandlers(win: BrowserWindow): void { validatePath(args.projectRoot, 'projectRoot'); return getCurrentBranch(args.projectRoot); }); + ipcMain.handle(IPC.CheckIsGitRepo, (_e, args) => { + validatePath(args.path, 'path'); + return isGitRepo(args.path); + }); + ipcMain.handle(IPC.GetBranches, (_e, args) => { + validatePath(args.projectRoot, 'projectRoot'); + return getBranches(args.projectRoot); + }); // --- Persistence --- // Extract task names from persisted state so the remote server can @@ -287,7 +398,13 @@ export function registerAllHandlers(win: BrowserWindow): void { ipcMain.handle(IPC.CreateArenaWorktree, (_e, args) => { validatePath(args.projectRoot, 'projectRoot'); validateBranchName(args.branchName, 'branchName'); - return createWorktree(args.projectRoot, args.branchName, args.symlinkDirs ?? [], true); + return createWorktree( + args.projectRoot, + args.branchName, + args.symlinkDirs ?? [], + undefined, + true, + ); }); ipcMain.handle(IPC.RemoveArenaWorktree, (_e, args) => { @@ -301,6 +418,12 @@ export function registerAllHandlers(win: BrowserWindow): void { return fs.existsSync(args.path); }); + // --- Plan watcher cleanup --- + ipcMain.handle(IPC.StopPlanWatcher, (_e, args) => { + assertString(args.taskId, 'taskId'); + stopPlanWatcher(args.taskId); + }); + // --- Plan content (one-shot read) --- ipcMain.handle(IPC.ReadPlanContent, (_e, args) => { validatePath(args.worktreePath, 'worktreePath'); @@ -328,9 +451,14 @@ export function registerAllHandlers(win: BrowserWindow): void { cancelAskAboutCode(args.requestId); }); + // --- System --- + ipcMain.handle(IPC.GetSystemFonts, () => getSystemMonospaceFonts()); + // --- Notifications (fire-and-forget via ipcMain.on) --- + const activeNotifications = new Set(); ipcMain.on(IPC.ShowNotification, (_e, args) => { try { + if (!Notification.isSupported()) return; assertString(args.title, 'title'); assertString(args.body, 'body'); assertStringArray(args.taskIds, 'taskIds'); @@ -338,14 +466,26 @@ export function registerAllHandlers(win: BrowserWindow): void { title: args.title, body: args.body, }); + activeNotifications.add(notification); + const release = () => activeNotifications.delete(notification); notification.on('click', () => { + release(); if (!win.isDestroyed()) { win.show(); win.focus(); win.webContents.send(IPC.NotificationClicked, { taskIds: args.taskIds }); } }); + notification.on('close', release); notification.show(); + // On Linux, notifications may not auto-dismiss. Close after 30 seconds + // to prevent accumulation in the notification tray. + if (process.platform === 'linux') { + setTimeout(() => { + notification.close(); + release(); + }, 30_000); + } } catch (err) { console.warn('ShowNotification failed:', err); } @@ -643,44 +783,8 @@ export function registerAllHandlers(win: BrowserWindow): void { win.on('blur', () => { if (!win.isDestroyed()) win.webContents.send(IPC.WindowBlur); }); - // Leading+trailing throttle: fire immediately, suppress for 100ms, then fire once more - // if events arrived during suppression (ensures the final state is always forwarded). - let resizeThrottled = false; - let resizePending = false; - win.on('resize', () => { - if (win.isDestroyed()) return; - if (resizeThrottled) { - resizePending = true; - return; - } - resizeThrottled = true; - win.webContents.send(IPC.WindowResized); - setTimeout(() => { - resizeThrottled = false; - if (resizePending) { - resizePending = false; - if (!win.isDestroyed()) win.webContents.send(IPC.WindowResized); - } - }, 100); - }); - let moveThrottled = false; - let movePending = false; - win.on('move', () => { - if (win.isDestroyed()) return; - if (moveThrottled) { - movePending = true; - return; - } - moveThrottled = true; - win.webContents.send(IPC.WindowMoved); - setTimeout(() => { - moveThrottled = false; - if (movePending) { - movePending = false; - if (!win.isDestroyed()) win.webContents.send(IPC.WindowMoved); - } - }, 100); - }); + win.on('resize', createThrottledForwarder(win, IPC.WindowResized, 100)); + win.on('move', createThrottledForwarder(win, IPC.WindowMoved, 100)); win.on('close', (e) => { e.preventDefault(); if (!win.isDestroyed()) { diff --git a/electron/ipc/system-fonts.ts b/electron/ipc/system-fonts.ts new file mode 100644 index 00000000..19f0d429 --- /dev/null +++ b/electron/ipc/system-fonts.ts @@ -0,0 +1,35 @@ +import { execFile } from 'child_process'; + +let cachedFonts: string[] | null = null; + +export async function getSystemMonospaceFonts(): Promise { + if (cachedFonts) return cachedFonts; + + try { + const fonts = await queryFcList(); + cachedFonts = fonts; + return fonts; + } catch { + cachedFonts = []; + return []; + } +} + +function queryFcList(): Promise { + return new Promise((resolve, reject) => { + execFile('fc-list', [':spacing=mono', 'family'], { timeout: 5000 }, (err, stdout) => { + if (err) return reject(err); + const families = new Set(); + for (const line of stdout.split('\n')) { + const trimmed = line.trim(); + if (!trimmed) continue; + // fc-list outputs comma-separated names: primary family first, then aliases + // for weight variants (e.g. "BlexMono Nerd Font,BlexMono Nerd Font Light"). + // Taking only the first name collapses all weight variants into one entry. + const primary = trimmed.split(',')[0].trim(); + if (primary && !primary.startsWith('.')) families.add(primary); + } + resolve([...families].sort((a, b) => a.localeCompare(b))); + }); + }); +} diff --git a/electron/ipc/tasks.ts b/electron/ipc/tasks.ts index c6198099..b46d6776 100644 --- a/electron/ipc/tasks.ts +++ b/electron/ipc/tasks.ts @@ -1,6 +1,7 @@ import { randomUUID } from 'crypto'; import { createWorktree, removeWorktree } from './git.js'; import { killAgent, notifyAgentListChanged } from './pty.js'; +import { stopPlanWatcher } from './plans.js'; const MAX_SLUG_LEN = 72; @@ -33,30 +34,36 @@ export async function createTask( projectRoot: string, symlinkDirs: string[], branchPrefix: string, + baseBranch?: string, ): Promise<{ id: string; branch_name: string; worktree_path: string }> { + const id = randomUUID(); const prefix = sanitizeBranchPrefix(branchPrefix); - const branchName = `${prefix}/${slug(name)}`; - const worktree = await createWorktree(projectRoot, branchName, symlinkDirs); + const branchName = `${prefix}/${slug(name)}-${id.slice(0, 6)}`; + const worktree = await createWorktree(projectRoot, branchName, symlinkDirs, baseBranch); return { - id: randomUUID(), + id, branch_name: worktree.branch, worktree_path: worktree.path, }; } -export async function deleteTask( - agentIds: string[], - branchName: string, - deleteBranch: boolean, - projectRoot: string, -): Promise { - for (const agentId of agentIds) { +interface DeleteTaskOpts { + taskId?: string; + agentIds: string[]; + branchName: string; + deleteBranch: boolean; + projectRoot: string; +} + +export async function deleteTask(opts: DeleteTaskOpts): Promise { + if (opts.taskId) stopPlanWatcher(opts.taskId); + for (const agentId of opts.agentIds) { try { killAgent(agentId); } catch { /* already dead */ } } - await removeWorktree(projectRoot, branchName, deleteBranch); + await removeWorktree(opts.projectRoot, opts.branchName, opts.deleteBranch); notifyAgentListChanged(); } diff --git a/electron/preload.cjs b/electron/preload.cjs index 95ff6bb6..b579ba2f 100644 --- a/electron/preload.cjs +++ b/electron/preload.cjs @@ -35,6 +35,8 @@ const ALLOWED_CHANNELS = new Set([ 'rebase_task', 'get_main_branch', 'get_current_branch', + 'get_branches', + 'check_is_git_repo', // Persistence 'save_app_state', 'load_app_state', @@ -77,9 +79,16 @@ const ALLOWED_CHANNELS = new Set([ // Plan 'plan_content', 'read_plan_content', + 'stop_plan_watcher', + // Docker + 'check_docker_available', + 'check_docker_image_exists', + 'build_docker_image', // Ask about code 'ask_about_code', 'cancel_ask_about_code', + // System + 'get_system_fonts', // Notifications 'show_notification', 'notification_clicked', @@ -103,10 +112,6 @@ contextBridge.exposeInMainWorld('electron', { if (!isAllowedChannel(channel)) throw new Error(`Blocked IPC channel: ${channel}`); return ipcRenderer.invoke(channel, ...args); }, - send: (channel, ...args) => { - if (!isAllowedChannel(channel)) throw new Error(`Blocked IPC channel: ${channel}`); - ipcRenderer.send(channel, ...args); - }, on: (channel, listener) => { if (!isAllowedChannel(channel)) throw new Error(`Blocked IPC channel: ${channel}`); const wrapped = (_event, ...eventArgs) => listener(...eventArgs); diff --git a/package-lock.json b/package-lock.json index cb27df31..1933819c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,10 +10,10 @@ "license": "MIT", "dependencies": { "@modelcontextprotocol/sdk": "^1.27.1", - "@xterm/addon-fit": "^0.11.0", - "@xterm/addon-web-links": "^0.12.0", - "@xterm/addon-webgl": "^0.19.0", - "@xterm/xterm": "^6.0.0", + "@xterm/addon-fit": "^0.12.0-beta.195", + "@xterm/addon-web-links": "^0.13.0-beta.195", + "@xterm/addon-webgl": "^0.20.0-beta.194", + "@xterm/xterm": "^6.1.0-beta.195", "marked": "^17.0.3", "monaco-editor": "^0.55.1", "node-pty": "^1.1.0", @@ -3033,28 +3033,38 @@ } }, "node_modules/@xterm/addon-fit": { - "version": "0.11.0", - "resolved": "https://registry.npmjs.org/@xterm/addon-fit/-/addon-fit-0.11.0.tgz", - "integrity": "sha512-jYcgT6xtVYhnhgxh3QgYDnnNMYTcf8ElbxxFzX0IZo+vabQqSPAjC3c1wJrKB5E19VwQei89QCiZZP86DCPF7g==", - "license": "MIT" + "version": "0.12.0-beta.195", + "resolved": "https://registry.npmjs.org/@xterm/addon-fit/-/addon-fit-0.12.0-beta.195.tgz", + "integrity": "sha512-Ihc+azRK3HFB2NVBEoWRkEUGYVxoojK2X4Jx6YxiRKdAu6bYzDTzTImE/0EDOjjz2AUUqddRwCUdSbz2/WvYfA==", + "license": "MIT", + "peerDependencies": { + "@xterm/xterm": "^6.1.0-beta.195" + } }, "node_modules/@xterm/addon-web-links": { - "version": "0.12.0", - "resolved": "https://registry.npmjs.org/@xterm/addon-web-links/-/addon-web-links-0.12.0.tgz", - "integrity": "sha512-4Smom3RPyVp7ZMYOYDoC/9eGJJJqYhnPLGGqJ6wOBfB8VxPViJNSKdgRYb8NpaM6YSelEKbA2SStD7lGyqaobw==", - "license": "MIT" + "version": "0.13.0-beta.195", + "resolved": "https://registry.npmjs.org/@xterm/addon-web-links/-/addon-web-links-0.13.0-beta.195.tgz", + "integrity": "sha512-3x1jjud/TAVCSd3Jn7E9ielvPUwHAqvtqL1AKeH9sD5RKUuNr+Z4B+vhkvR0XF2BAk9af6ErgyygdAMnOL2Rwg==", + "license": "MIT", + "peerDependencies": { + "@xterm/xterm": "^6.1.0-beta.195" + } }, "node_modules/@xterm/addon-webgl": { - "version": "0.19.0", - "resolved": "https://registry.npmjs.org/@xterm/addon-webgl/-/addon-webgl-0.19.0.tgz", - "integrity": "sha512-b3fMOsyLVuCeNJWxolACEUED0vm7qC0cy4wRvf3oURSzDTYVQiGPhTnhWZwIHdvC48Y+oLhvYXnY4XDXPoJo6A==", - "license": "MIT" + "version": "0.20.0-beta.194", + "resolved": "https://registry.npmjs.org/@xterm/addon-webgl/-/addon-webgl-0.20.0-beta.194.tgz", + "integrity": "sha512-aX4yGkHyoJVmxh3ZVMha7CYdTFu7tuzTJ0ljyXKAVFrdO+Wve4luK8w3wLmxuvqa9LWA9muMx/bGeEWtwD/Nlg==", + "license": "MIT", + "peerDependencies": { + "@xterm/xterm": "^6.1.0-beta.195" + } }, "node_modules/@xterm/xterm": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/@xterm/xterm/-/xterm-6.0.0.tgz", - "integrity": "sha512-TQwDdQGtwwDt+2cgKDLn0IRaSxYu1tSUjgKarSDkUM0ZNiSRXFpjxEsvc/Zgc5kq5omJ+V0a8/kIM2WD3sMOYg==", + "version": "6.1.0-beta.195", + "resolved": "https://registry.npmjs.org/@xterm/xterm/-/xterm-6.1.0-beta.195.tgz", + "integrity": "sha512-lLVfI3T4pX4W4qrbf2Qhdq5Pa00FkOOUz9vlOm6f1r5wel1mUafeJL8zacfsUVdc03MsCKHRyZkLubmDEnabcw==", "license": "MIT", + "peer": true, "workspaces": [ "addons/*" ] @@ -6391,9 +6401,9 @@ } }, "node_modules/hono": { - "version": "4.12.8", - "resolved": "https://registry.npmjs.org/hono/-/hono-4.12.8.tgz", - "integrity": "sha512-VJCEvtrezO1IAR+kqEYnxUOoStaQPGrCmX3j4wDTNOcD1uRPFpGlwQUIW8niPuvHXaTUxeOUl5MMDGrl+tmO9A==", + "version": "4.12.9", + "resolved": "https://registry.npmjs.org/hono/-/hono-4.12.9.tgz", + "integrity": "sha512-wy3T8Zm2bsEvxKZM5w21VdHDDcwVS1yUFFY6i8UobSsKfFceT7TOwhbhfKsDyx7tYQlmRM5FLpIuYvNFyjctiA==", "license": "MIT", "peer": true, "engines": { diff --git a/package.json b/package.json index 136163bb..1d833a5d 100644 --- a/package.json +++ b/package.json @@ -29,10 +29,10 @@ "license": "MIT", "dependencies": { "@modelcontextprotocol/sdk": "^1.27.1", - "@xterm/addon-fit": "^0.11.0", - "@xterm/addon-web-links": "^0.12.0", - "@xterm/addon-webgl": "^0.19.0", - "@xterm/xterm": "^6.0.0", + "@xterm/addon-fit": "^0.12.0-beta.195", + "@xterm/addon-web-links": "^0.13.0-beta.195", + "@xterm/addon-webgl": "^0.20.0-beta.194", + "@xterm/xterm": "^6.1.0-beta.195", "marked": "^17.0.3", "monaco-editor": "^0.55.1", "node-pty": "^1.1.0", @@ -92,6 +92,10 @@ { "from": "build/icon.png", "to": "icon.png" + }, + { + "from": "docker/", + "to": "docker/" } ], "linux": { diff --git a/src/App.tsx b/src/App.tsx index c3ec8d98..5c2f56ff 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -44,6 +44,7 @@ import { validateProjectPaths, setPlanContent, initMCPListeners, + setDockerAvailable, } from './store/store'; import { isGitHubUrl } from './lib/github-url'; import type { PersistedWindowState } from './store/types'; @@ -288,6 +289,10 @@ function App() { })(); await loadAgents(); + invoke(IPC.CheckDockerAvailable).then( + (available) => setDockerAvailable(available), + () => setDockerAvailable(false), + ); await loadState(); // Restore plan content for tasks that had a plan file before restart @@ -316,6 +321,7 @@ function App() { // Listen for plan content pushed from backend plan watcher const offPlanContent = window.electron.ipcRenderer.on(IPC.PlanContent, (data: unknown) => { + if (!data || typeof data !== 'object') return; const msg = data as { taskId: string; content: string | null; fileName: string | null }; if (msg.taskId && store.tasks[msg.taskId]) { setPlanContent(msg.taskId, msg.content, msg.fileName); @@ -564,6 +570,7 @@ function App() { registerShortcut({ key: '0', cmdOrCtrl: true, + global: true, handler: () => { const taskId = store.activeTaskId; if (taskId) resetFontScale(taskId); diff --git a/src/components/ChangedFilesList.tsx b/src/components/ChangedFilesList.tsx index ef6c4a10..2a6f9373 100644 --- a/src/components/ChangedFilesList.tsx +++ b/src/components/ChangedFilesList.tsx @@ -15,11 +15,27 @@ interface ChangedFilesListProps { projectRoot?: string; /** Branch name for branch-based fallback when worktree doesn't exist */ branchName?: string | null; + /** Base branch for diff comparison (e.g. 'main', 'develop'). Undefined = auto-detect. */ + baseBranch?: string; } export function ChangedFilesList(props: ChangedFilesListProps) { const [files, setFiles] = createSignal([]); const [selectedIndex, setSelectedIndex] = createSignal(-1); + const rowRefs: HTMLDivElement[] = []; + + // Scroll selected item into view reactively + createEffect(() => { + const idx = selectedIndex(); + if (idx >= 0) rowRefs[idx]?.scrollIntoView({ block: 'nearest', behavior: 'instant' }); + }); + + // Trim stale refs and clamp selection when file list shrinks + createEffect(() => { + const len = files().length; + rowRefs.length = len; + setSelectedIndex((i) => (i >= len ? len - 1 : i)); + }); function handleKeyDown(e: KeyboardEvent) { const list = files(); @@ -46,6 +62,7 @@ export function ChangedFilesList(props: ChangedFilesListProps) { const path = props.worktreePath; const projectRoot = props.projectRoot; const branchName = props.branchName; + const baseBranch = props.baseBranch; if (!props.isActive) return; let cancelled = false; let inFlight = false; @@ -60,6 +77,7 @@ export function ChangedFilesList(props: ChangedFilesListProps) { try { const result = await invoke(IPC.GetChangedFiles, { worktreePath: path, + baseBranch, }); if (!cancelled) setFiles(result); return; @@ -75,6 +93,7 @@ export function ChangedFilesList(props: ChangedFilesListProps) { const result = await invoke(IPC.GetChangedFilesFromBranch, { projectRoot, branchName, + baseBranch, }); if (!cancelled) setFiles(result); } catch { @@ -96,8 +115,12 @@ export function ChangedFilesList(props: ChangedFilesListProps) { }); }); - const totalAdded = createMemo(() => files().reduce((s, f) => s + f.lines_added, 0)); - const totalRemoved = createMemo(() => files().reduce((s, f) => s + f.lines_removed, 0)); + const totalAdded = createMemo(() => + files().reduce((s, f) => s + (f.committed ? f.lines_added : 0), 0), + ); + const totalRemoved = createMemo(() => + files().reduce((s, f) => s + (f.committed ? f.lines_removed : 0), 0), + ); const uncommittedCount = createMemo(() => files().filter((f) => !f.committed).length); /** For each file, compute the display filename and an optional disambiguating directory. */ @@ -159,6 +182,7 @@ export function ChangedFilesList(props: ChangedFilesListProps) { {(file, i) => (
(rowRefs[i()] = el)} class="file-row" style={{ display: 'flex', diff --git a/src/components/CloseTaskDialog.tsx b/src/components/CloseTaskDialog.tsx index 643657a2..06b28d7a 100644 --- a/src/components/CloseTaskDialog.tsx +++ b/src/components/CloseTaskDialog.tsx @@ -3,7 +3,7 @@ import { invoke } from '../lib/ipc'; import { IPC } from '../../electron/ipc/channels'; import { closeTask, getProject, getCoordinatorCloseWarning } from '../store/store'; import { ConfirmDialog } from './ConfirmDialog'; -import { theme } from '../lib/theme'; +import { theme, bannerStyle } from '../lib/theme'; import type { Task } from '../store/types'; import type { WorktreeStatus } from '../ipc/types'; @@ -15,7 +15,7 @@ interface CloseTaskDialogProps { export function CloseTaskDialog(props: CloseTaskDialogProps) { const [worktreeStatus] = createResource( - () => (props.open && !props.task.directMode ? props.task.worktreePath : null), + () => (props.open && props.task.gitIsolation === 'worktree' ? props.task.worktreePath : null), (path) => invoke(IPC.GetWorktreeStatus, { worktreePath: path }), ); @@ -43,13 +43,13 @@ export function CloseTaskDialog(props: CloseTaskDialogProps) {
)} - +

This will stop all running agents and shells for this task. No git operations will be performed.

- +
@@ -81,12 +77,8 @@ export function CloseTaskDialog(props: CloseTaskDialogProps) {
@@ -134,8 +126,8 @@ export function CloseTaskDialog(props: CloseTaskDialogProps) {
} - confirmLabel={props.task.directMode ? 'Close' : 'Delete'} - danger={!props.task.directMode} + confirmLabel={props.task.gitIsolation === 'direct' ? 'Close' : 'Delete'} + danger={props.task.gitIsolation === 'worktree'} onConfirm={() => { props.onDone(); closeTask(props.task.id); diff --git a/src/components/ConnectPhoneModal.tsx b/src/components/ConnectPhoneModal.tsx index c11cc5c0..c495c685 100644 --- a/src/components/ConnectPhoneModal.tsx +++ b/src/components/ConnectPhoneModal.tsx @@ -1,8 +1,7 @@ // src/components/ConnectPhoneModal.tsx import { Show, createSignal, createEffect, onCleanup, createMemo, untrack } from 'solid-js'; -import { Portal } from 'solid-js/web'; -import { createFocusRestore } from '../lib/focus-restore'; +import { Dialog } from './Dialog'; import { store } from '../store/core'; import { startRemoteAccess, stopRemoteAccess, refreshRemoteStatus } from '../store/remote'; import { theme } from '../lib/theme'; @@ -20,7 +19,6 @@ export function ConnectPhoneModal(props: ConnectPhoneModalProps) { const [error, setError] = createSignal(null); const [copied, setCopied] = createSignal(false); const [mode, setMode] = createSignal('wifi'); - let dialogRef: HTMLDivElement | undefined; let stopPolling: (() => void) | undefined; let copiedTimer: ReturnType | undefined; onCleanup(() => { @@ -32,18 +30,19 @@ export function ConnectPhoneModal(props: ConnectPhoneModalProps) { return mode() === 'tailscale' ? store.remoteAccess.tailscaleUrl : store.remoteAccess.wifiUrl; }); - createFocusRestore(() => props.open); - async function generateQr(url: string) { try { - const QRCode = await import('qrcode'); + const mod = await import('qrcode'); + // qrcode is CJS — Vite dev wraps it as .default only, prod adds named re-exports + const QRCode = mod.default ?? mod; const dataUrl = await QRCode.toDataURL(url, { width: 256, margin: 2, color: { dark: '#000000', light: '#ffffff' }, }); setQrDataUrl(dataUrl); - } catch { + } catch (err) { + console.error('[ConnectPhoneModal] QR generation failed:', err); setQrDataUrl(null); } } @@ -57,17 +56,18 @@ export function ConnectPhoneModal(props: ConnectPhoneModalProps) { } }); - // Start server when modal opens + // Focus the dialog panel when it opens (Dialog doesn't auto-focus) createEffect(() => { if (!props.open) return; + requestAnimationFrame(() => { + const panel = document.querySelector('.dialog-panel'); + panel?.focus(); + }); + }); - requestAnimationFrame(() => dialogRef?.focus()); - - const handler = (e: KeyboardEvent) => { - if (e.key === 'Escape') props.onClose(); - }; - document.addEventListener('keydown', handler); - onCleanup(() => document.removeEventListener('keydown', handler)); + // Start server when modal opens + createEffect(() => { + if (!props.open) return; if (!store.remoteAccess.enabled && !untrack(starting)) { setStarting(true); @@ -143,247 +143,210 @@ export function ConnectPhoneModal(props: ConnectPhoneModalProps) { }); return ( - - + +
+

+ Connect Phone +

+ Experimental +
+ + +
Starting server...
+
+ + +
+ {error()} +
+
+ + + {/* Network mode toggle */}
{ - if (e.target === e.currentTarget) props.onClose(); + gap: '4px', + background: theme.bgInput, + 'border-radius': '8px', + padding: '3px', }} >
e.stopPropagation()} > -
-

- Connect Phone -

- Experimental -
- - -
Starting server...
+ + + Not detected - - -
- {error()} -
+
+
+ + + Not detected +
+
- - {/* Network mode toggle */} -
-
- - - Not detected - -
-
- - - Not detected - -
-
- - {/* QR Code */} - - {(url) => ( - Connection QR code - )} - - - {/* URL */} -
- {activeUrl() ?? store.remoteAccess.url} -
+ {/* QR Code */} + + {(url) => ( + Connection QR code + )} + - - Copied! - + {/* URL */} +
+ {activeUrl() ?? store.remoteAccess.url} +
- {/* Instructions */} -

- Scan the QR code or copy the URL to monitor and interact with your agent terminals - from your phone. - Your phone and this computer must be on the same WiFi network.} - > - <> Your phone and this computer must be on the same Tailscale network. - -

+ + Copied! + - {/* Connected clients */} - 0} - fallback={ -
-
- Waiting for connection... -
- } - > -
- - - - - {store.remoteAccess.connectedClients} client(s) connected - -
- + {/* Instructions */} +

+ Scan the QR code or copy the URL to monitor and interact with your agent terminals from + your phone. + Your phone and this computer must be on the same WiFi network.} + > + <> Your phone and this computer must be on the same Tailscale network. + +

- {/* Disconnect — always available when server is running */} - - + /> + Waiting for connection... +
+ } + > +
+ + + + + {store.remoteAccess.connectedClients} client(s) connected +
-
+
+ + {/* Disconnect — always available when server is running */} +
- + ); } diff --git a/src/components/DiffViewerDialog.tsx b/src/components/DiffViewerDialog.tsx index 0b7644cf..66221271 100644 --- a/src/components/DiffViewerDialog.tsx +++ b/src/components/DiffViewerDialog.tsx @@ -22,6 +22,8 @@ interface DiffViewerDialogProps { projectRoot?: string; /** Branch name for branch-based fallback when worktree doesn't exist */ branchName?: string | null; + /** Base branch for diff comparison (e.g. 'main', 'develop'). Undefined = auto-detect. */ + baseBranch?: string; taskId?: string; agentId?: string; } @@ -67,6 +69,7 @@ export function DiffViewerDialog(props: DiffViewerDialogProps) { onClose={props.onClose} projectRoot={props.projectRoot} branchName={props.branchName} + baseBranch={props.baseBranch} taskId={props.taskId} agentId={props.agentId} /> @@ -114,6 +117,7 @@ function DiffViewerContent(props: DiffViewerDialogProps) { const worktreePath = props.worktreePath; const projectRoot = props.projectRoot; const branchName = props.branchName; + const baseBranch = props.baseBranch; const thisGen = ++fetchGeneration; setSearchQuery(''); @@ -122,7 +126,7 @@ function DiffViewerContent(props: DiffViewerDialogProps) { setParsedFiles([]); const worktreePromise = worktreePath - ? invoke(IPC.GetAllFileDiffs, { worktreePath }) + ? invoke(IPC.GetAllFileDiffs, { worktreePath, baseBranch }) : Promise.reject(new Error('no worktree')); worktreePromise @@ -131,6 +135,7 @@ function DiffViewerContent(props: DiffViewerDialogProps) { return invoke(IPC.GetAllFileDiffsFromBranch, { projectRoot, branchName, + baseBranch, }); } const msg = err instanceof Error ? err.message : String(err); @@ -307,6 +312,7 @@ function DiffViewerContent(props: DiffViewerDialogProps) { files={parsedFiles()} scrollToPath={props.scrollToFile} worktreePath={props.worktreePath} + baseBranch={props.baseBranch} searchQuery={searchQuery()} reviewAnnotations={review.annotations()} onAnnotationAdd={review.addAnnotation} diff --git a/src/components/EditProjectDialog.tsx b/src/components/EditProjectDialog.tsx index 350d9688..cee1303d 100644 --- a/src/components/EditProjectDialog.tsx +++ b/src/components/EditProjectDialog.tsx @@ -8,8 +8,9 @@ import { removeProjectWithTasks, } from '../store/store'; import { sanitizeBranchPrefix, toBranchName } from '../lib/branch-name'; -import { theme } from '../lib/theme'; -import type { Project, TerminalBookmark } from '../store/types'; +import { theme, sectionLabelStyle } from '../lib/theme'; +import type { Project, TerminalBookmark, GitIsolationMode } from '../store/types'; +import { SegmentedButtons } from './SegmentedButtons'; interface EditProjectDialogProps { project: Project | null; @@ -26,7 +27,8 @@ export function EditProjectDialog(props: EditProjectDialogProps) { const [selectedHue, setSelectedHue] = createSignal(0); const [branchPrefix, setBranchPrefix] = createSignal('task'); const [deleteBranchOnClose, setDeleteBranchOnClose] = createSignal(true); - const [defaultDirectMode, setDefaultDirectMode] = createSignal(false); + const [defaultGitIsolation, setDefaultGitIsolation] = createSignal('worktree'); + const [defaultBaseBranch, setDefaultBaseBranch] = createSignal(''); const [bookmarks, setBookmarks] = createSignal([]); const [newCommand, setNewCommand] = createSignal(''); let nameRef!: HTMLInputElement; @@ -39,7 +41,8 @@ export function EditProjectDialog(props: EditProjectDialogProps) { setSelectedHue(hueFromColor(p.color)); setBranchPrefix(sanitizeBranchPrefix(p.branchPrefix ?? 'task')); setDeleteBranchOnClose(p.deleteBranchOnClose ?? true); - setDefaultDirectMode(p.defaultDirectMode ?? false); + setDefaultGitIsolation(p.defaultGitIsolation ?? 'worktree'); + setDefaultBaseBranch(p.defaultBaseBranch ?? ''); setBookmarks(p.terminalBookmarks ? [...p.terminalBookmarks] : []); setNewCommand(''); requestAnimationFrame(() => nameRef?.focus()); @@ -71,7 +74,8 @@ export function EditProjectDialog(props: EditProjectDialogProps) { color: `hsl(${selectedHue()}, 70%, 75%)`, branchPrefix: sanitizedPrefix, deleteBranchOnClose: deleteBranchOnClose(), - defaultDirectMode: defaultDirectMode(), + defaultGitIsolation: defaultGitIsolation(), + defaultBaseBranch: defaultBaseBranch() || undefined, terminalBookmarks: bookmarks(), }); props.onClose(); @@ -196,16 +200,7 @@ export function EditProjectDialog(props: EditProjectDialogProps) { {/* Name */}
- + - + - +
{(hue) => { @@ -346,38 +323,48 @@ export function EditProjectDialog(props: EditProjectDialogProps) { Always delete branch and worklog on merge - {/* Default direct mode preference */} -