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/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/build/icon.icns b/build/icon.icns index 732bdd48..847611d1 100644 Binary files a/build/icon.icns and b/build/icon.icns differ 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 d36e57a7..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,12 +83,30 @@ 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', + + // MCP / Coordinating agent + 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/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 b12ce0aa..53f14afc 100644 --- a/electron/ipc/register.ts +++ b/electron/ipc/register.ts @@ -12,9 +12,18 @@ import { countRunningAgents, killAllAgents, getAgentMeta, + isDockerAvailable, + dockerImageExists, + buildDockerImage, } from './pty.js'; -import { ensurePlansDirectory, startPlanWatcher, readPlanForWorktree } from './plans.js'; -import { startRemoteServer } from '../remote/server.js'; +import { + ensurePlansDirectory, + startPlanWatcher, + stopPlanWatcher, + readPlanForWorktree, +} from './plans.js'; +import { startRemoteServer, getMCPLogs } from '../remote/server.js'; +import { Orchestrator } from '../mcp/orchestrator.js'; import { getGitIgnoredDirs, getMainBranch, @@ -35,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, @@ -71,13 +83,55 @@ 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; const taskNames = new Map(); + // --- MCP orchestrator --- + const orchestrator = new Orchestrator(); + orchestrator.setWindow(win); + // --- 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 { @@ -124,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) => { @@ -131,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; }); @@ -140,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'); @@ -179,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'); @@ -192,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'); @@ -200,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'); @@ -214,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'); @@ -224,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 @@ -282,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) => { @@ -296,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'); @@ -323,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'); @@ -333,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); } @@ -469,6 +614,7 @@ export function registerAllHandlers(win: BrowserWindow): void { lastLine: '', }; }, + orchestrator, }); return { url: remoteServer.url, @@ -499,6 +645,137 @@ export function registerAllHandlers(win: BrowserWindow): void { }; }); + // --- MCP server management --- + ipcMain.handle( + IPC.StartMCPServer, + async ( + _e, + args: { + coordinatorTaskId: string; + projectId: string; + projectRoot: string; + worktreePath?: string; + }, + ) => { + // Set orchestrator's default project + coordinator task ID + orchestrator.setDefaultProject(args.projectId, args.projectRoot, args.coordinatorTaskId); + + // 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 — use the bundled single-file MCP server + // (built by esbuild, no external deps needed at runtime) + const thisDir = path.dirname(fileURLToPath(import.meta.url)); + 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, 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, + serverUrl, + token: remoteServer.token, + port: remoteServer.port, + }; + }, + ); + + ipcMain.handle(IPC.StopMCPServer, async () => { + // 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: 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); @@ -506,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/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..e14f1f75 --- /dev/null +++ b/electron/mcp/orchestrator.ts @@ -0,0 +1,374 @@ +// 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, + 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; + +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; + 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, coordinatorTaskId?: string): void { + this.projectId = projectId; + this.projectRoot = projectRoot; + if (coordinatorTaskId) this.defaultCoordinatorTaskId = coordinatorTaskId; + } + + 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 coordinatorId = opts.coordinatorTaskId !== 'api' + ? opts.coordinatorTaskId + : (this.defaultCoordinatorTaskId ?? opts.coordinatorTaskId); + + const agentId = randomUUID(); + const task: OrchestratedTask = { + id: result.id, + name: opts.name, + projectId: projId, + branchName: result.branch_name, + worktreePath: result.worktree_path, + agentId, + coordinatorTaskId: coordinatorId, + 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'; + + // 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, + branchName: task.branchName, + worktreePath: task.worktreePath, + agentId: task.agentId, + coordinatorTaskId: task.coordinatorTaskId, + prompt: 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, + pendingPrompt: task.pendingPrompt, + }; + } + + 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'; + task.pendingPrompt = undefined; + } + + 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(IPC.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..3695f83a --- /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 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 once it finishes starting up.', + }, + 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..a1ec8f66 --- /dev/null +++ b/electron/mcp/types.ts @@ -0,0 +1,92 @@ +// 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' | 'error'; + exitCode: number | null; + pendingPrompt?: string; +} + +// --- 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; + pendingPrompt?: string; +} + +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/preload.cjs b/electron/preload.cjs index e2c8baf9..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', @@ -76,12 +78,28 @@ const ALLOWED_CHANNELS = new Set([ 'get_remote_status', // 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', + // 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) { @@ -94,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/electron/remote/server.ts b/electron/remote/server.ts index f9c99fbd..ffdc26ec 100644 --- a/electron/remote/server.ts +++ b/electron/remote/server.ts @@ -19,6 +19,28 @@ import { onPtyEvent, } from '../ipc/pty.js'; 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', @@ -102,6 +124,7 @@ export function startRemoteServer(opts: { exitCode: number | null; lastLine: string; }; + orchestrator?: Orchestrator; }): RemoteServer { const token = randomBytes(24).toString('base64url'); const ips = getNetworkIps(); @@ -169,6 +192,170 @@ 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) => { + 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) => { + 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 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 { + jsonReply(200, detail); + } + return; + } + + if (taskIdMatch && taskIdMatch[2] === 'prompt' && req.method === 'POST') { + readBody() + .then(async (body) => { + 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) => { + 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) => { + 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) => { + 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(taskId) + .then((result) => jsonReply(200, result)) + .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(taskId); + jsonReply(200, { output }); + } catch (err) { + mcpLog('error', `get_task_output FAIL: ${String(err)}`); + jsonReply(500, { error: String(err) }); + } + return; + } + + if (taskIdMatch && taskIdMatch[2] === 'merge' && req.method === 'POST') { + readBody() + .then(async (body) => { + 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) => { + 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(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; + } + } + 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..1933819c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,10 +9,11 @@ "version": "1.1.1", "license": "MIT", "dependencies": { - "@xterm/addon-fit": "^0.11.0", - "@xterm/addon-web-links": "^0.12.0", - "@xterm/addon-webgl": "^0.19.0", - "@xterm/xterm": "^6.0.0", + "@modelcontextprotocol/sdk": "^1.27.1", + "@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", @@ -30,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", @@ -75,6 +77,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 +721,6 @@ "dev": true, "license": "BSD-2-Clause", "optional": true, - "peer": true, "dependencies": { "cross-dirname": "^0.1.0", "debug": "^4.3.4", @@ -740,7 +742,6 @@ "dev": true, "license": "MIT", "optional": true, - "peer": true, "dependencies": { "graceful-fs": "^4.2.0", "jsonfile": "^6.0.1", @@ -757,7 +758,6 @@ "dev": true, "license": "MIT", "optional": true, - "peer": true, "dependencies": { "universalify": "^2.0.0" }, @@ -772,15 +772,14 @@ "dev": true, "license": "MIT", "optional": true, - "peer": true, "engines": { "node": ">= 10.0.0" } }, "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" ], @@ -795,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" ], @@ -812,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" ], @@ -829,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" ], @@ -846,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" ], @@ -863,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" ], @@ -880,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" ], @@ -897,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" ], @@ -914,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" ], @@ -931,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" ], @@ -948,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" ], @@ -965,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" ], @@ -982,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" ], @@ -999,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" ], @@ -1016,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" ], @@ -1033,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" ], @@ -1050,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" ], @@ -1067,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" ], @@ -1084,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" ], @@ -1101,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" ], @@ -1118,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" ], @@ -1135,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" ], @@ -1152,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" ], @@ -1169,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" ], @@ -1186,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" ], @@ -1203,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" ], @@ -1479,6 +1478,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 +1786,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 +2673,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", @@ -2959,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/*" ] @@ -3002,12 +3086,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 +3164,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 +3176,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 +3628,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 +3723,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", @@ -3653,6 +3857,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 +3985,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 +3994,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 +4335,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 +4364,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 +4390,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 +4424,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 +4444,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 +4471,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 +4600,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 +4703,7 @@ "integrity": "sha512-glMJgnTreo8CFINujtAhCgN96QAqApDMZ8Vl1r8f0QT8QprvC1UCltV4CcWj20YoIyLZx6IUskaJZ0NV8fokcg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "app-builder-lib": "26.8.1", "builder-util": "26.8.1", @@ -4523,7 +4813,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 +4830,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 +5017,6 @@ "dev": true, "hasInstallScript": true, "license": "MIT", - "peer": true, "dependencies": { "@electron/asar": "^3.2.1", "debug": "^4.1.1", @@ -4743,7 +5037,6 @@ "integrity": "sha512-YJDaCJZEnBmcbw13fvdAM9AwNOJwOzrE4pqMqBq5nFiEqXUqHwlK4B+3pUw6JNvfSPtX05xFHtYy/1ni01eGCw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "graceful-fs": "^4.1.2", "jsonfile": "^4.0.0", @@ -4776,6 +5069,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 +5146,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 +5155,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 +5171,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" @@ -4905,9 +5204,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", @@ -4918,32 +5217,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": { @@ -4956,6 +5255,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 +5280,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 +5534,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 +5550,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,34 +5588,120 @@ "dev": true, "license": "Apache-2.0" }, - "node_modules/extract-zip": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/extract-zip/-/extract-zip-2.0.1.tgz", - "integrity": "sha512-GDhU9ntwuKyGXdZBUgTIe+vXnWj0fppUEtMDL0+idd5Sta8TGpHssn/eusA9mrPr9qNDym6SxAYZjNvCn/9RBg==", - "dev": true, - "license": "BSD-2-Clause", - "dependencies": { - "debug": "^4.1.1", - "get-stream": "^5.1.0", - "yauzl": "^2.10.0" - }, - "bin": { - "extract-zip": "cli.js" + "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": ">= 10.17.0" + "node": ">= 18" }, - "optionalDependencies": { - "@types/yauzl": "^2.9.1" + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" } }, - "node_modules/extsprintf": { - "version": "1.4.1", - "resolved": "https://registry.npmjs.org/extsprintf/-/extsprintf-1.4.1.tgz", - "integrity": "sha512-Wrk35e8ydCKDj/ArClo1VrPVmN8zph5V4AtHwIuHhvMXsKf73UT3BOD+azBIW+3wOJ4FhEH7zyaJCFvChjYvMA==", - "dev": true, - "engines": [ - "node >=0.6.0" + "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", + "integrity": "sha512-GDhU9ntwuKyGXdZBUgTIe+vXnWj0fppUEtMDL0+idd5Sta8TGpHssn/eusA9mrPr9qNDym6SxAYZjNvCn/9RBg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "debug": "^4.1.1", + "get-stream": "^5.1.0", + "yauzl": "^2.10.0" + }, + "bin": { + "extract-zip": "cli.js" + }, + "engines": { + "node": ">= 10.17.0" + }, + "optionalDependencies": { + "@types/yauzl": "^2.9.1" + } + }, + "node_modules/extsprintf": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/extsprintf/-/extsprintf-1.4.1.tgz", + "integrity": "sha512-Wrk35e8ydCKDj/ArClo1VrPVmN8zph5V4AtHwIuHhvMXsKf73UT3BOD+azBIW+3wOJ4FhEH7zyaJCFvChjYvMA==", + "dev": true, + "engines": [ + "node >=0.6.0" ], "license": "MIT", "optional": true @@ -5288,7 +5710,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,6 +5726,22 @@ "dev": true, "license": "MIT" }, + "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", @@ -5399,6 +5836,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 +5963,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 +6035,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 +6076,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 +6100,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 +6259,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 +6328,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 +6356,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 +6400,16 @@ "url": "https://opencollective.com/unified" } }, + "node_modules/hono": { + "version": "4.12.9", + "resolved": "https://registry.npmjs.org/hono/-/hono-4.12.9.tgz", + "integrity": "sha512-wy3T8Zm2bsEvxKZM5w21VdHDDcwVS1yUFFY6i8UobSsKfFceT7TOwhbhfKsDyx7tYQlmRM5FLpIuYvNFyjctiA==", + "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 +6480,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 +6663,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 +6676,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 +6755,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 +6873,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 +6929,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 +7553,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 +7579,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 +7604,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 +7986,6 @@ "integrity": "sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "minimist": "^1.2.6" }, @@ -7481,7 +8028,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 +8073,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 +8225,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 +8268,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 +8461,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 +8493,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 +8522,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 +8574,7 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -7999,6 +8595,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 +8664,6 @@ "dev": true, "license": "MIT", "optional": true, - "peer": true, "dependencies": { "commander": "^9.4.0" }, @@ -8077,7 +8681,6 @@ "dev": true, "license": "MIT", "optional": true, - "peer": true, "engines": { "node": "^12.20.0 || >=14" } @@ -8164,6 +8767,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 +8943,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 +8971,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 +9072,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 +9173,6 @@ "deprecated": "Rimraf versions prior to v4 are no longer supported", "dev": true, "license": "ISC", - "peer": true, "dependencies": { "glob": "^7.1.3" }, @@ -8565,6 +9244,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 +9295,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 +9335,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 +9408,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 +9425,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 +9472,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 +9509,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 +9683,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 +9784,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 +9975,6 @@ "integrity": "sha512-yYrrsWnrXMcdsnu/7YMYAofM1ktpL5By7vZhf15CrXijWWrEYZks5AXBudalfSWJLlnen/QUJUB5aoB0kqZUGA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "mkdirp": "^0.5.1", "rimraf": "~2.6.2" @@ -9279,6 +10129,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 +10215,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 +10404,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 +10468,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 +10527,7 @@ "integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "esbuild": "^0.27.0", "fdir": "^6.5.0", @@ -9922,7 +10840,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 +10959,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..1d833a5d 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", @@ -27,10 +28,11 @@ }, "license": "MIT", "dependencies": { - "@xterm/addon-fit": "^0.11.0", - "@xterm/addon-web-links": "^0.12.0", - "@xterm/addon-webgl": "^0.19.0", - "@xterm/xterm": "^6.0.0", + "@modelcontextprotocol/sdk": "^1.27.1", + "@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", @@ -48,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", @@ -69,8 +72,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" @@ -82,12 +85,17 @@ "electron/preload.cjs" ], "asarUnpack": [ - "**/node-pty/**" + "**/node-pty/**", + "dist-electron/mcp-server.cjs" ], "extraResources": [ { "from": "build/icon.png", "to": "icon.png" + }, + { + "from": "docker/", + "to": "docker/" } ], "linux": { diff --git a/src/App.tsx b/src/App.tsx index 227a6fbf..5c2f56ff 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -43,6 +43,8 @@ import { setNewTaskDropUrl, validateProjectPaths, setPlanContent, + initMCPListeners, + setDockerAvailable, } from './store/store'; import { isGitHubUrl } from './lib/github-url'; import type { PersistedWindowState } from './store/types'; @@ -287,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 @@ -310,10 +316,12 @@ function App() { await captureWindowState(); setupAutosave(); startTaskStatusPolling(); + const stopMCPListeners = initMCPListeners(); const stopNotificationWatcher = startDesktopNotificationWatcher(windowFocused); // 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); @@ -562,6 +570,7 @@ function App() { registerShortcut({ key: '0', cmdOrCtrl: true, + global: true, handler: () => { const taskId = store.activeTaskId; if (taskId) resetFontScale(taskId); @@ -575,6 +584,7 @@ function App() { unlistenCloseRequested(); cleanupShortcuts(); stopTaskStatusPolling(); + stopMCPListeners(); stopNotificationWatcher(); offPlanContent(); unlistenFocusChanged?.(); 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 97acc609..06b28d7a 100644 --- a/src/components/CloseTaskDialog.tsx +++ b/src/components/CloseTaskDialog.tsx @@ -1,9 +1,9 @@ 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 { 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 }), ); @@ -25,13 +25,31 @@ 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 performed.

- +
@@ -63,12 +77,8 @@ export function CloseTaskDialog(props: CloseTaskDialogProps) {
@@ -116,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 */} -