diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml index d2a7eba3a..7e4f620d2 100644 --- a/.github/FUNDING.yml +++ b/.github/FUNDING.yml @@ -1,6 +1,6 @@ # These are supported funding model platforms -github: [johannesjo] # Replace with up to 4 GitHub Sponsors-enabled usernames e.g., [user1, user2] +github: [thunderock] # Replace with up to 4 GitHub Sponsors-enabled usernames e.g., [user1, user2] patreon: # Replace with a single Patreon username open_collective: # Replace with a single Open Collective username ko_fi: # Replace with a single Ko-fi username diff --git a/.github/workflows/docker-image.yml b/.github/workflows/docker-image.yml new file mode 100644 index 000000000..c5c5f9cbe --- /dev/null +++ b/.github/workflows/docker-image.yml @@ -0,0 +1,107 @@ +name: Docker image + +# Builds docker/ and publishes the agent image to Docker Hub. +# - Pull request touching docker/**: build (amd64) to VERIFY it compiles — no push. +# - Push to main touching docker/** (or manual dispatch): build multi-arch on +# native runners and push thunderockforge/forge-agent:latest. +# +# Requires repo secrets: DOCKERHUB_USERNAME, DOCKERHUB_TOKEN (a Docker Hub access token). +# Action versions use major tags for readability; pin to SHAs to match release.yml if desired. + +on: + push: + branches: [main] + paths: + - 'docker/**' + - '.github/workflows/docker-image.yml' + pull_request: + paths: + - 'docker/**' + - '.github/workflows/docker-image.yml' + workflow_dispatch: + +env: + IMAGE: docker.io/thunderockforge/forge-agent + +jobs: + # ---- PR verification: prove the image builds, don't push ---- + verify: + if: github.event_name == 'pull_request' + runs-on: ubuntu-24.04 + steps: + - uses: actions/checkout@v4 + - uses: docker/setup-buildx-action@v3 + - name: Build (amd64, no push) + uses: docker/build-push-action@v6 + with: + context: ./docker + platforms: linux/amd64 + push: false + + # ---- main / manual: build each arch on its native runner, push by digest ---- + build: + if: github.event_name != 'pull_request' + strategy: + fail-fast: false + matrix: + include: + - platform: linux/amd64 + arch: amd64 + runner: ubuntu-24.04 + - platform: linux/arm64 + arch: arm64 + runner: ubuntu-24.04-arm + runs-on: ${{ matrix.runner }} + steps: + - uses: actions/checkout@v4 + - uses: docker/setup-buildx-action@v3 + - name: Log in to Docker Hub + uses: docker/login-action@v3 + with: + username: ${{ secrets.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_TOKEN }} + - name: Build and push by digest + id: build + uses: docker/build-push-action@v6 + with: + context: ./docker + platforms: ${{ matrix.platform }} + outputs: type=image,name=${{ env.IMAGE }},push-by-digest=true,name-canonical=true,push=true + - name: Export digest + run: | + mkdir -p "${RUNNER_TEMP}/digests" + digest="${{ steps.build.outputs.digest }}" + touch "${RUNNER_TEMP}/digests/${digest#sha256:}" + - name: Upload digest + uses: actions/upload-artifact@v4 + with: + name: digest-${{ matrix.arch }} + path: ${{ runner.temp }}/digests/* + if-no-files-found: error + retention-days: 1 + + # ---- combine per-arch digests into one multi-arch :latest tag ---- + merge: + if: github.event_name != 'pull_request' + needs: build + runs-on: ubuntu-24.04 + steps: + - name: Download digests + uses: actions/download-artifact@v4 + with: + path: ${{ runner.temp }}/digests + pattern: digest-* + merge-multiple: true + - uses: docker/setup-buildx-action@v3 + - name: Log in to Docker Hub + uses: docker/login-action@v3 + with: + username: ${{ secrets.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_TOKEN }} + - name: Create and push manifest list (:latest) + working-directory: ${{ runner.temp }}/digests + run: | + docker buildx imagetools create -t ${{ env.IMAGE }}:latest \ + $(printf '${{ env.IMAGE }}@sha256:%s ' *) + - name: Inspect + run: docker buildx imagetools inspect ${{ env.IMAGE }}:latest diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 6223d562f..ce9535ca1 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -52,50 +52,51 @@ jobs: node-version: '22' cache: npm - - name: Import code signing certificate - env: - DMG_CERT: ${{ secrets.DMG_CERT }} - DMG_CERT_PWD: ${{ secrets.DMG_CERT_PWD }} - run: | - CERTIFICATE_PATH="$RUNNER_TEMP/build_certificate.p12" - KEYCHAIN_PATH="$RUNNER_TEMP/app-signing.keychain-db" - KEYCHAIN_PWD=$(openssl rand -base64 32) - - echo -n "$DMG_CERT" | base64 --decode -o "$CERTIFICATE_PATH" - - security create-keychain -p "$KEYCHAIN_PWD" "$KEYCHAIN_PATH" - security set-keychain-settings -lut 3600 "$KEYCHAIN_PATH" - security unlock-keychain -p "$KEYCHAIN_PWD" "$KEYCHAIN_PATH" - - security import "$CERTIFICATE_PATH" -P "$DMG_CERT_PWD" \ - -A -t cert -f pkcs12 -k "$KEYCHAIN_PATH" - - security set-key-partition-list -S apple-tool:,apple:,codesign: \ - -s -k "$KEYCHAIN_PWD" "$KEYCHAIN_PATH" - - security list-keychains -d user -s "$KEYCHAIN_PATH" - - - name: Prepare API key for notarization - env: - APPLE_API_KEY_B64: ${{ secrets.APPLE_API_KEY }} - run: | - mkdir -p "$RUNNER_TEMP/private_keys" - echo -n "$APPLE_API_KEY_B64" | base64 --decode > "$RUNNER_TEMP/private_keys/AuthKey.p8" - - run: npm ci - - name: Build, sign, and publish + # Unsigned test build — no Apple Developer cert or notarization required. + # To restore signed + notarized releases: re-add the cert-import and + # notarization-key steps, set APPLE_API_KEY/APPLE_API_KEY_ID/APPLE_API_ISSUER, + # and drop the CSC_IDENTITY_AUTO_DISCOVERY + -c.mac.notarize=false overrides. + - name: Build (unsigned) and publish env: NODE_OPTIONS: '--max-old-space-size=4096' - APPLE_API_KEY: ${{ runner.temp }}/private_keys/AuthKey.p8 - APPLE_API_KEY_ID: ${{ secrets.APPLE_API_KEY_ID }} - APPLE_API_ISSUER: ${{ secrets.APPLE_API_ISSUER }} + CSC_IDENTITY_AUTO_DISCOVERY: 'false' GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - run: npm run build -- --universal --publish always + run: npm run build -- --universal -c.mac.notarize=false --publish always + + # Attach the agent image as a per-arch tarball to the release, so the app can fetch + # it host-side and `docker load` it when the Docker daemon can't reach a registry + # (e.g. it runs in a VM with no egress). Built on native runners — no QEMU. + agent-image: + needs: create-release + strategy: + fail-fast: false + matrix: + include: + - arch: amd64 + runner: ubuntu-24.04 + - arch: arm64 + runner: ubuntu-24.04-arm + runs-on: ${{ matrix.runner }} + steps: + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 - - name: Cleanup signing artifacts - if: always() + - name: Build agent image (${{ matrix.arch }}) + run: docker build -t thunderockforge/forge-agent:latest ./docker + + - name: Save + compress + checksum run: | - security delete-keychain "$RUNNER_TEMP/app-signing.keychain-db" 2>/dev/null || true - rm -f "$RUNNER_TEMP/private_keys/AuthKey.p8" 2>/dev/null || true - rm -f "$RUNNER_TEMP/build_certificate.p12" 2>/dev/null || true + set -euo pipefail + asset="forge-agent-${{ matrix.arch }}.tar.gz" + docker save thunderockforge/forge-agent:latest | gzip > "$asset" + sha256sum "$asset" > "$asset.sha256" + ls -lh "$asset" "$asset.sha256" + + - name: Upload image asset to the release + uses: softprops/action-gh-release@3bb12739c298aeb8a4eeaf626c5b8d85266b0e65 # v2 + with: + draft: true + files: | + forge-agent-${{ matrix.arch }}.tar.gz + forge-agent-${{ matrix.arch }}.tar.gz.sha256 diff --git a/.gitignore b/.gitignore index 30c4bd744..69b9769e4 100644 --- a/.gitignore +++ b/.gitignore @@ -7,7 +7,7 @@ coverage # Local auto-update test server payload (build artifacts, not source). update-test/ # Runtime data dir written by the app (MCP coordinator state, etc.) -.parallel-code/ +.forge/ .worktrees .idea .claude diff --git a/.gitleaks.toml b/.gitleaks.toml index 3ae13382b..25419f49e 100644 --- a/.gitleaks.toml +++ b/.gitleaks.toml @@ -1,14 +1,14 @@ -title = "Gitleaks config for Parallel Code" +title = "Gitleaks config for Forge" [extend] # Use the default Gitleaks ruleset as the base useDefault = true [[rules]] -id = "parallel-code-mcp-token" -description = "Parallel Code MCP bearer token" -regex = '''PARALLEL_CODE_MCP_TOKEN\s*[=:]\s*['"]?[A-Za-z0-9+/_-]{20,}['"]?''' -tags = ["token", "parallel-code"] +id = "forge-mcp-token" +description = "Forge MCP bearer token" +regex = '''FORGE_MCP_TOKEN\s*[=:]\s*['"]?[A-Za-z0-9+/_-]{20,}['"]?''' +tags = ["token", "forge"] [[rules]] id = "bearer-token-in-url" diff --git a/.prettierignore b/.prettierignore index 12dffc981..7ba78cf64 100644 --- a/.prettierignore +++ b/.prettierignore @@ -6,7 +6,7 @@ node_modules/ .worktrees/ .claude/ .remember/ -.parallel-code/ +.forge/ .enzyme/ .omc/ .planning/ diff --git a/CLAUDE.md b/CLAUDE.md index a088e724e..697febaaa 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,4 +1,4 @@ -# Parallel Code +# Forge Electron desktop app — SolidJS frontend, Node.js backend. Published for **macOS and Linux only** (no Windows). diff --git a/PRIVACY.md b/PRIVACY.md index 4226f6635..4bb693193 100644 --- a/PRIVACY.md +++ b/PRIVACY.md @@ -2,11 +2,11 @@ _Last updated: 2026-05-26_ -Parallel Code is an open-source desktop application that runs entirely on your local machine. This policy describes what data the application handles and how. +Forge is an open-source desktop application that runs entirely on your local machine. This policy describes what data the application handles and how. ## Summary -**The Parallel Code project operates no remote servers and does not collect, transmit, or store your data on any infrastructure we control.** +**The Forge project operates no remote servers and does not collect, transmit, or store your data on any infrastructure we control.** - No analytics - No telemetry @@ -16,9 +16,9 @@ Parallel Code is an open-source desktop application that runs entirely on your l - No remote logging - No advertising or third-party trackers -## What data Parallel Code handles +## What data Forge handles -Except where explicitly disclosed in the "Network activity initiated by Parallel Code itself" section below, data Parallel Code handles stays on your computer: +Except where explicitly disclosed in the "Network activity initiated by Forge itself" section below, data Forge handles stays on your computer: - **Your source code, git repositories, and worktrees** — read and written locally on your filesystem. - **Task metadata, notes, prompts, settings, and UI state** — stored locally in the application's data directory. @@ -28,7 +28,7 @@ Except where explicitly disclosed in the "Network activity initiated by Parallel ## Third-party AI coding CLIs -Parallel Code is a local interface for third-party AI coding CLIs and agent commands that you install and configure yourself, including: +Forge is a local interface for third-party AI coding CLIs and agent commands that you install and configure yourself, including: - [Claude Code](https://docs.anthropic.com/en/docs/claude-code) (Anthropic) - [Codex CLI](https://github.com/openai/codex) (OpenAI) @@ -37,78 +37,78 @@ Parallel Code is a local interface for third-party AI coding CLIs and agent comm - [OpenCode](https://opencode.ai/) (a multi-provider AI coding agent) - Arena presets such as Aider and any custom agent commands you add yourself -When you dispatch an agent, Parallel Code starts the selected CLI or command as a local subprocess on your machine, or inside a local Docker container when Docker mode is enabled. **That tool may then communicate with whatever vendor, model provider, repository host, package registry, or other service it is configured to use, under that service's own privacy policy and terms.** Parallel Code is not a party to those network communications and does not proxy them through infrastructure operated by the Parallel Code project. +When you dispatch an agent, Forge starts the selected CLI or command as a local subprocess on your machine, or inside a local Docker container when Docker mode is enabled. **That tool may then communicate with whatever vendor, model provider, repository host, package registry, or other service it is configured to use, under that service's own privacy policy and terms.** Forge is not a party to those network communications and does not proxy them through infrastructure operated by the Forge project. **You are responsible for reviewing the privacy policy and terms of each third-party tool you choose to use.** Data sent to those services — including prompts, source code, and conversation history — is governed by their policies, not this one. -For sub-tasks run under a coordinator, Parallel Code prepends a short Parallel-Code-authored system-prompt preamble to the agent's first turn (for example, instructing the sub-task to call the `signal_done` MCP tool when finished). For Claude Code specifically, this preamble is injected by writing an entry into `.claude/settings.local.json` in the worktree; other agents receive the preamble through their own mechanism. The CLI then sends that preamble to its vendor alongside your prompt. +For sub-tasks run under a coordinator, Forge prepends a short Forge-authored system-prompt preamble to the agent's first turn (for example, instructing the sub-task to call the `signal_done` MCP tool when finished). For Claude Code specifically, this preamble is injected by writing an entry into `.claude/settings.local.json` in the worktree; other agents receive the preamble through their own mechanism. The CLI then sends that preamble to its vendor alongside your prompt. -## Network activity initiated by Parallel Code itself +## Network activity initiated by Forge itself -Unlike the AI CLIs above, the following network activity is initiated by Parallel Code itself, either directly or by invoking local tooling on your machine: +Unlike the AI CLIs above, the following network activity is initiated by Forge itself, either directly or by invoking local tooling on your machine: -- **Application updates** — on packaged macOS and Linux AppImage builds, Parallel Code asks GitHub Releases whether a newer version exists about ten seconds after launch (and again if you click "Check for updates" in the UI). The request goes to the public GitHub Releases endpoint. In addition to your IP address, the underlying update library ([`electron-updater`](https://www.electron.build/auto-update)) sends two pieces of metadata that GitHub can see: a `User-Agent` header with the literal value `electron-builder`, and an `x-user-staging-id` header containing a random UUID generated locally on first launch and stored as `.updaterId` in the app's data directory. **This is a stable per-install identifier sent to GitHub on every update check** — GitHub can correlate the timing, frequency, and IP of those checks per install under its own policies. The Parallel Code project itself does not receive or store it. If you want a fresh identifier, delete `.updaterId`; the next check will generate a new UUID. Downloading an update only happens when you confirm it; once downloaded, the update is applied automatically the next time you quit the app. -- **GitHub PR status** — when a task is linked to a GitHub pull request, Parallel Code invokes your locally installed `gh` CLI to fetch the PR's state and check status. While the app window is visible, pending PRs are polled roughly every 30 seconds and already-settled PRs every five minutes. The `gh` CLI then contacts GitHub under your own existing authentication. +- **Application updates** — on packaged macOS and Linux AppImage builds, Forge asks GitHub Releases whether a newer version exists about ten seconds after launch (and again if you click "Check for updates" in the UI). The request goes to the public GitHub Releases endpoint. In addition to your IP address, the underlying update library ([`electron-updater`](https://www.electron.build/auto-update)) sends two pieces of metadata that GitHub can see: a `User-Agent` header with the literal value `electron-builder`, and an `x-user-staging-id` header containing a random UUID generated locally on first launch and stored as `.updaterId` in the app's data directory. **This is a stable per-install identifier sent to GitHub on every update check** — GitHub can correlate the timing, frequency, and IP of those checks per install under its own policies. The Forge project itself does not receive or store it. If you want a fresh identifier, delete `.updaterId`; the next check will generate a new UUID. Downloading an update only happens when you confirm it; once downloaded, the update is applied automatically the next time you quit the app. +- **GitHub PR status** — when a task is linked to a GitHub pull request, Forge invokes your locally installed `gh` CLI to fetch the PR's state and check status. While the app window is visible, pending PRs are polled roughly every 30 seconds and already-settled PRs every five minutes. The `gh` CLI then contacts GitHub under your own existing authentication. - **Other git operations** — `push`, `pull`, and `fetch` run only when you explicitly invoke them, using your local `git` tooling and its credentials. One implicit exception: when the app needs to know your repository's default branch and the cached remote-tracking ref is stale, it runs `git remote set-head origin --auto`, which contacts your configured remote. -- **Docker image builds** — when you click "Build Image" for Docker mode, Parallel Code invokes your local `docker` CLI / Docker daemon to run `docker build`. The bundled Dockerfile can contact Docker registries, Ubuntu package mirrors, NodeSource, GitHub CLI package repositories, and npm while building the image. If your project contains `.parallel-code/Dockerfile`, Parallel Code can build that Dockerfile instead using the project root as the Docker build context; if your Docker CLI is configured to use a remote Docker daemon or remote context, Docker may send that build context to that daemon according to your Docker configuration. -- **Inline code Q&A** — by default, the inline Q&A feature uses the Claude Code CLI as a local subprocess. The prompt sent to that CLI includes your question, a fixed instruction, and the selection you asked about — either a code snippet with file path and line range, or a markdown snippet from the in-app plan viewer with its source heading and an approximate position within the document. When you enable the optional [MiniMax](https://www.minimax.io/) provider in Settings, MiniMax is called directly by Parallel Code rather than as a subprocess: Parallel Code itself makes an HTTPS request to `api.minimax.io` using your API key, with the same kind of selected context in the request body. Your MiniMax API key is held in the main process in memory only and is not written to disk. Data sent to MiniMax is governed by MiniMax's own privacy policy, not this one. -- **Remote Access (mobile monitoring)** — when you start it from Settings, Parallel Code runs a local HTTP and WebSocket listener bound to all of your machine's network interfaces, so devices on your LAN — or any device that can reach an address shown by the app — can connect. No traffic is routed through infrastructure operated by the Parallel Code project. +- **Docker image builds** — when you click "Build Image" for Docker mode, Forge invokes your local `docker` CLI / Docker daemon to run `docker build`. The bundled Dockerfile can contact Docker registries, Ubuntu package mirrors, NodeSource, GitHub CLI package repositories, and npm while building the image. If your project contains `.forge/Dockerfile`, Forge can build that Dockerfile instead using the project root as the Docker build context; if your Docker CLI is configured to use a remote Docker daemon or remote context, Docker may send that build context to that daemon according to your Docker configuration. +- **Inline code Q&A** — by default, the inline Q&A feature uses the Claude Code CLI as a local subprocess. The prompt sent to that CLI includes your question, a fixed instruction, and the selection you asked about — either a code snippet with file path and line range, or a markdown snippet from the in-app plan viewer with its source heading and an approximate position within the document. When you enable the optional [MiniMax](https://www.minimax.io/) provider in Settings, MiniMax is called directly by Forge rather than as a subprocess: Forge itself makes an HTTPS request to `api.minimax.io` using your API key, with the same kind of selected context in the request body. Your MiniMax API key is held in the main process in memory only and is not written to disk. Data sent to MiniMax is governed by MiniMax's own privacy policy, not this one. +- **Remote Access (mobile monitoring)** — when you start it from Settings, Forge runs a local HTTP and WebSocket listener bound to all of your machine's network interfaces, so devices on your LAN — or any device that can reach an address shown by the app — can connect. No traffic is routed through infrastructure operated by the Forge project. - **Transport.** Traffic is unencrypted HTTP, and the access token is included in the URL (e.g. in the QR code). **Treat that URL as a credential.** Anything that captures the URL has the token until you restart Remote Access — including a photo of the QR code (which may be auto-backed up by your phone), mobile browser history or cross-device sync, clipboard managers, screen-sharing or screen-recording tools, and corporate TLS-inspection proxies or appliances that log HTTP URLs. Treat the network it runs on as trusted (a private LAN or a Tailscale tailnet), and stop the server when you're done. - **Tokens.** A fresh bearer token is generated each time the server starts and is not persisted by the desktop app; previous tokens become invalid on restart. The mobile client stores the token it receives in its browser `localStorage` so the same device can reconnect without rescanning; the token persists there in plaintext until you clear browser data on the device or restart Remote Access. - **Capabilities.** An authenticated mobile client is **read-only** — it can see the list of running agents and their recent terminal output, but cannot send input or stop agents. - - **"Tailscale-like" detection.** Parallel Code labels an interface as Tailscale-like when it finds a non-internal IPv4 address beginning with `100.`; addresses starting with `172.` (often used by Docker bridges) are excluded from the displayed "WiFi" URL. The check is heuristic and does not verify the interface is actually Tailscale, so only treat that option as Tailscale if you know the address belongs to your tailnet. If your host has a VPN, virtualisation bridge, or unusual NIC, the URL shown may be reachable from a wider network than your LAN. + - **"Tailscale-like" detection.** Forge labels an interface as Tailscale-like when it finds a non-internal IPv4 address beginning with `100.`; addresses starting with `172.` (often used by Docker bridges) are excluded from the displayed "WiFi" URL. The check is heuristic and does not verify the interface is actually Tailscale, so only treat that option as Tailscale if you know the address belongs to your tailnet. If your host has a VPN, virtualisation bridge, or unusual NIC, the URL shown may be reachable from a wider network than your LAN. - **Over Tailscale.** Traffic is carried by your tailnet — typically a direct WireGuard connection between your devices, but Tailscale's coordination service and (when direct connection is not possible) DERP relays may be involved per [Tailscale's network architecture](https://tailscale.com/kb/1257/connection-types). How Tailscale handles that traffic is governed by Tailscale's own policies, not this one. -- **Sub-task coordinator (MCP)** — when sub-tasks run under a coordinator agent, Parallel Code starts a local token-protected HTTP/WebSocket server so sub-task agents can call back into the app (e.g. to signal completion). No traffic from this feature passes through infrastructure operated by the Parallel Code project. +- **Sub-task coordinator (MCP)** — when sub-tasks run under a coordinator agent, Forge starts a local token-protected HTTP/WebSocket server so sub-task agents can call back into the app (e.g. to signal completion). No traffic from this feature passes through infrastructure operated by the Forge project. - **Bind address.** When MCP starts its own listener, it binds to `127.0.0.1` except on macOS Docker setups, where it binds to `0.0.0.0` so containers can reach it via `host.docker.internal` — this also makes the port reachable from other hosts on your LAN, though access still requires the token. If a Remote Access server is already running when a coordinator starts, the coordinator reuses that listener; because Remote Access binds to `0.0.0.0`, MCP routes inherit that LAN reach on any platform (including Linux), though access still requires the MCP token. - - **Where the token can land.** Token-bearing MCP data is written or passed in several places: a worktree `.mcp.json` when a worktree path is available, or a project-root `.mcp.json` otherwise, so the coordinator agent can auto-discover the server (Parallel Code also adds `.mcp.json` to your `.git/info/exclude` so it is not committed); a non-Docker coordinator config in your OS temp directory named `parallel-code-mcp-.json`; per-sub-task configs in your OS temp directory for host-mode sub-tasks (`parallel-code-subtask-.json`) or under the coordinator's `.parallel-code/` directory for Docker sub-tasks (`subtask-.json`); and short-lived `.parallel-code-atomic-.tmp` files written next to these configs during atomic-rename steps. These files are written with `0600` permissions where the platform supports it. - - **Codex token in the command line.** For Codex specifically, the MCP token is passed as a literal command-line argument (`--config mcp_servers.parallel-code={... env = { PARALLEL_CODE_MCP_TOKEN = "..." }}`). Process command lines are visible to other processes — via `/proc//cmdline` on Linux or `ps` on macOS — so any local process that runs concurrently with a Codex sub-task can read that token and call back into the coordinator under its authority until the coordinator exits. Other agents receive the token through a token-protected file or env var instead. + - **Where the token can land.** Token-bearing MCP data is written or passed in several places: a worktree `.mcp.json` when a worktree path is available, or a project-root `.mcp.json` otherwise, so the coordinator agent can auto-discover the server (Forge also adds `.mcp.json` to your `.git/info/exclude` so it is not committed); a non-Docker coordinator config in your OS temp directory named `forge-mcp-.json`; per-sub-task configs in your OS temp directory for host-mode sub-tasks (`forge-subtask-.json`) or under the coordinator's `.forge/` directory for Docker sub-tasks (`subtask-.json`); and short-lived `.forge-atomic-.tmp` files written next to these configs during atomic-rename steps. These files are written with `0600` permissions where the platform supports it. + - **Codex token in the command line.** For Codex specifically, the MCP token is passed as a literal command-line argument (`--config mcp_servers.forge={... env = { FORGE_MCP_TOKEN = "..." }}`). Process command lines are visible to other processes — via `/proc//cmdline` on Linux or `ps` on macOS — so any local process that runs concurrently with a Codex sub-task can read that token and call back into the coordinator under its authority until the coordinator exits. Other agents receive the token through a token-protected file or env var instead. - **Docker task isolation** — when you enable Docker mode for a task, or when you opt coordinator sub-tasks into Docker-isolated mode, the agent launches in a container via `docker run --network host`. **Docker mode is not a security boundary.** It isolates the filesystem against the worktree, but does not isolate the network or credentials from the agent. - **Network.** `--network host` means the container shares the host's network namespace; its outbound reachability is the same as your host's, including loopback services and any LAN address your host can reach. - - **Environment.** Most of your shell environment is forwarded into the container with `-e`. A blocklist strips host-specific variables — `PATH`, `HOME`, all temp-dir vars (`TMPDIR`/`TEMPDIR`/`TMP`/`TEMP`), display/desktop session vars (`DISPLAY`, `WAYLAND_DISPLAY`, `DBUS_*`, `XAUTHORITY`), linker overrides (`LD_PRELOAD`, `LD_LIBRARY_PATH`, `DYLD_*`), session vars (`LOGNAME`, `MAIL`), agent socket / k8s vars (`SSH_AUTH_SOCK`, `GPG_AGENT_INFO`, `KUBECONFIG`), and the `XDG_*`/`ELECTRON_*`/`SUDO_*`/`CLAUDE_CODE_*` families (the authoritative list is `DOCKER_ENV_BLOCK_LIST` in `electron/ipc/pty.ts`). **API keys and tokens in your shell are not on that blocklist** — for example `ANTHROPIC_API_KEY`, `OPENAI_API_KEY`, `GITHUB_TOKEN`/`GH_TOKEN`, `HF_TOKEN`, or AWS credentials are forwarded into the container by default. Unset anything you do not want reaching the containerised agent before launching Parallel Code. + - **Environment.** Most of your shell environment is forwarded into the container with `-e`. A blocklist strips host-specific variables — `PATH`, `HOME`, all temp-dir vars (`TMPDIR`/`TEMPDIR`/`TMP`/`TEMP`), display/desktop session vars (`DISPLAY`, `WAYLAND_DISPLAY`, `DBUS_*`, `XAUTHORITY`), linker overrides (`LD_PRELOAD`, `LD_LIBRARY_PATH`, `DYLD_*`), session vars (`LOGNAME`, `MAIL`), agent socket / k8s vars (`SSH_AUTH_SOCK`, `GPG_AGENT_INFO`, `KUBECONFIG`), and the `XDG_*`/`ELECTRON_*`/`SUDO_*`/`CLAUDE_CODE_*` families (the authoritative list is `DOCKER_ENV_BLOCK_LIST` in `electron/ipc/pty.ts`). **API keys and tokens in your shell are not on that blocklist** — for example `ANTHROPIC_API_KEY`, `OPENAI_API_KEY`, `GITHUB_TOKEN`/`GH_TOKEN`, `HF_TOKEN`, or AWS credentials are forwarded into the container by default. Unset anything you do not want reaching the containerised agent before launching Forge. - **Worktree mount.** The container bind-mounts the worktree (and its parent for coordinator tasks) plus the main repo's `.git` directory. - **Read-only credential mounts.** To allow the agent to authenticate to git/GitHub/npm/etc., the following host files are bind-mounted **read-only** into the container's home if they exist: `~/.ssh`, `~/.gitconfig`, `~/.config/gh`, `~/.npmrc`, `~/.netrc`, and the file pointed to by `$GOOGLE_APPLICATION_CREDENTIALS`. Read-only stops modification, not reading — the agent can still read and use any credential mounted this way. - - **Writable shared agent auth (opt-in).** If you enable "Share agent auth across Linux containers" in Settings, Parallel Code creates and bind-mounts **writable** host directories under `~/.parallel-code/agent-auth//` into the container's per-agent config locations (e.g. `~/.claude`, `~/.codex`, `~/.gemini`, `~/.config/opencode`, `~/.config/github-copilot`, and `~/.claude.json`), so the containerised agent can write fresh credentials on first login and reuse them on later runs. Those host directories then accumulate agent credentials in your home directory across sessions. For Claude specifically, Parallel Code also seeds `hasTrustDialogAccepted: true` and `hasCompletedProjectOnboarding: true` for the worktree path inside the mounted `.claude.json`, so the agent does not prompt for trust on first run in a freshly mounted container. + - **Writable shared agent auth (opt-in).** If you enable "Share agent auth across Linux containers" in Settings, Forge creates and bind-mounts **writable** host directories under `~/.forge/agent-auth//` into the container's per-agent config locations (e.g. `~/.claude`, `~/.codex`, `~/.gemini`, `~/.config/opencode`, `~/.config/github-copilot`, and `~/.claude.json`), so the containerised agent can write fresh credentials on first login and reuse them on later runs. Those host directories then accumulate agent credentials in your home directory across sessions. For Claude specifically, Forge also seeds `hasTrustDialogAccepted: true` and `hasCompletedProjectOnboarding: true` for the worktree path inside the mounted `.claude.json`, so the agent does not prompt for trust on first run in a freshly mounted container. -Across all of the above, no traffic is routed through servers operated by the Parallel Code project. Most of it does reach third parties — GitHub, Docker registries, MiniMax, Anthropic, OpenAI, your tailnet, or whatever your chosen AI CLI talks to — whose own privacy policies govern what they receive. +Across all of the above, no traffic is routed through servers operated by the Forge project. Most of it does reach third parties — GitHub, Docker registries, MiniMax, Anthropic, OpenAI, your tailnet, or whatever your chosen AI CLI talks to — whose own privacy policies govern what they receive. ## OS-level integrations A few features hand short pieces of data to your operating system itself: -- **Clipboard pasting and drag-drop** — when you paste into or drop content onto an agent prompt, Parallel Code reads the OS clipboard or the drop payload. If it contains a file reference (`public.file-url`, `text/uri-list`, or GNOME's `x-special/gnome-copied-files`), the file path is passed to the agent. If it contains an image, the image bytes are written to your OS temp directory so the agent can reference the image as a file path: clipboard pastes overwrite a single file at `$TMPDIR/parallel-code-clipboard.png`, and drops are written to `$TMPDIR/parallel-code-drop--[-]` — the original drop filename, if any, is sanitised and appended. These temp files are not deleted by Parallel Code; they remain in your temp directory until your OS cleans it (typically on reboot or via periodic cleanup). Plain text is inserted into the prompt. As with any prompt, once you submit it (or the app detects it as prompt input from the terminal), it can be stored locally as task metadata (the task's `lastPrompt`) in `state.json`. -- **Clipboard writes** — when you click "Copy" in certain UI controls (for example the Connect Phone dialog), Parallel Code writes to the OS clipboard. The Remote Access URL it copies contains the **session bearer token** as a query parameter, so anything that reads or syncs your clipboard (clipboard managers, screen-sharing tools, cross-device clipboard sync) will see that token until you copy something else or restart Remote Access. Other clipboard writes (terminal selection, theme prompts, task steps) carry only the content you asked to copy. -- **Native notifications** — when a task completes, needs attention, or when GitHub PR checks succeed or fail, Parallel Code uses the OS notification API. The notification's title and body (typically the task name and a short status; for failed PR checks, the failed check names) are visible to the operating system and any surfaces that mirror notifications (Notification Center, etc.). +- **Clipboard pasting and drag-drop** — when you paste into or drop content onto an agent prompt, Forge reads the OS clipboard or the drop payload. If it contains a file reference (`public.file-url`, `text/uri-list`, or GNOME's `x-special/gnome-copied-files`), the file path is passed to the agent. If it contains an image, the image bytes are written to your OS temp directory so the agent can reference the image as a file path: clipboard pastes overwrite a single file at `$TMPDIR/forge-clipboard.png`, and drops are written to `$TMPDIR/forge-drop--[-]` — the original drop filename, if any, is sanitised and appended. These temp files are not deleted by Forge; they remain in your temp directory until your OS cleans it (typically on reboot or via periodic cleanup). Plain text is inserted into the prompt. As with any prompt, once you submit it (or the app detects it as prompt input from the terminal), it can be stored locally as task metadata (the task's `lastPrompt`) in `state.json`. +- **Clipboard writes** — when you click "Copy" in certain UI controls (for example the Connect Phone dialog), Forge writes to the OS clipboard. The Remote Access URL it copies contains the **session bearer token** as a query parameter, so anything that reads or syncs your clipboard (clipboard managers, screen-sharing tools, cross-device clipboard sync) will see that token until you copy something else or restart Remote Access. Other clipboard writes (terminal selection, theme prompts, task steps) carry only the content you asked to copy. +- **Native notifications** — when a task completes, needs attention, or when GitHub PR checks succeed or fail, Forge uses the OS notification API. The notification's title and body (typically the task name and a short status; for failed PR checks, the failed check names) are visible to the operating system and any surfaces that mirror notifications (Notification Center, etc.). - **Microphone entitlement (macOS)** — the packaged macOS app declares `NSMicrophoneUsageDescription` and the corresponding hardened-runtime entitlement, so the OS will permit microphone access if a feature requests it. The current build contains no active microphone capture code; granting the permission has no effect until a microphone-using feature ships. Note that the renderer permission handler in `electron/main.ts` auto-approves audio media requests — so if microphone-using code is added later, the only consent gate will be the OS-level microphone prompt. -- **Rendered markdown** — plan content shown in the task notes panel and the plan viewer dialog, plus any `.md` files you open from the UI, are rendered to HTML by the app for display. The renderer sanitises the output but allows standard image (``) and link (``) tags. If markdown rendered in the UI contains an external `` reference — for example because an agent wrote one into a plan or markdown file — your renderer will fetch that resource directly from the third-party host as soon as the content is shown, leaking your IP and the resource URL to that host. Parallel Code does not insert such references itself. +- **Rendered markdown** — plan content shown in the task notes panel and the plan viewer dialog, plus any `.md` files you open from the UI, are rendered to HTML by the app for display. The renderer sanitises the output but allows standard image (``) and link (``) tags. If markdown rendered in the UI contains an external `` reference — for example because an agent wrote one into a plan or markdown file — your renderer will fetch that resource directly from the third-party host as soon as the content is shown, leaking your IP and the resource URL to that host. Forge does not insert such references itself. ## Local storage locations -Configuration and state are kept in standard per-OS application directories — on macOS under `~/Library/Application Support/Parallel Code`, on Linux under `~/.config/Parallel Code`. Files in those directories include: +Configuration and state are kept in standard per-OS application directories — on macOS under `~/Library/Application Support/Forge`, on Linux under `~/.config/Forge`. Files in those directories include: - `state.json` and a rolling `state.json.bak` (overwritten on the next save) so a corrupted write does not lose your tasks. - `keybindings.json` and `keybindings.json.bak` for custom keybindings. - `themes/` — custom theme CSS files. - `arena-presets.json` and `arena-history.json` for the Arena feature. - `.updaterId` — the per-install UUID sent in the `x-user-staging-id` header on update checks (see Application updates). -- Chromium's standard cache and storage directories (`Cache/`, `Code Cache/`, `GPUCache/`, `Local Storage/`, `IndexedDB/`, `Network/`, and similar) are also created inside the userData directory at first launch. Parallel Code's renderer does not use `localStorage`, `IndexedDB`, cookies, or service workers, so these directories hold only Chromium's own asset caches — not application state or user content. +- Chromium's standard cache and storage directories (`Cache/`, `Code Cache/`, `GPUCache/`, `Local Storage/`, `IndexedDB/`, `Network/`, and similar) are also created inside the userData directory at first launch. Forge's renderer does not use `localStorage`, `IndexedDB`, cookies, or service workers, so these directories hold only Chromium's own asset caches — not application state or user content. -Inside the git repositories you point the app at, Parallel Code may also write: +Inside the git repositories you point the app at, Forge may also write: -- `.claude/` — for Claude Code, Parallel Code seeds empty `settings.json` and `settings.local.json` placeholders on every spawn (so the bwrap sandbox can bind-mount them) and holds `steps.json` for agent step tracking. When sub-tasks run, `.claude/settings.local.json` also carries the coordinator preamble described under _Third-party AI coding CLIs_ above, plus similar agent-scoped scratch state. -- `.parallel-code/` — when you use Docker mode or coordinator sub-tasks, this is created in the worktree for coordination state, project Docker configuration (e.g. an optional `.parallel-code/Dockerfile`), and per-sub-task MCP token configs. -- `.git/info/exclude` — Parallel Code appends `.mcp.json`, `.parallel-code/`, `.claude/steps.json`, and a small set of bwrap bind-mount artifact placeholders (`/.bashrc`, `/.gitconfig`, `/.zshrc`, and similar) so these app-managed paths do not surface in `git status`. +- `.claude/` — for Claude Code, Forge seeds empty `settings.json` and `settings.local.json` placeholders on every spawn (so the bwrap sandbox can bind-mount them) and holds `steps.json` for agent step tracking. When sub-tasks run, `.claude/settings.local.json` also carries the coordinator preamble described under _Third-party AI coding CLIs_ above, plus similar agent-scoped scratch state. +- `.forge/` — when you use Docker mode or coordinator sub-tasks, this is created in the worktree for coordination state, project Docker configuration (e.g. an optional `.forge/Dockerfile`), and per-sub-task MCP token configs. +- `.git/info/exclude` — Forge appends `.mcp.json`, `.forge/`, `.claude/steps.json`, and a small set of bwrap bind-mount artifact placeholders (`/.bashrc`, `/.gitconfig`, `/.zshrc`, and similar) so these app-managed paths do not surface in `git status`. -Other places Parallel Code can write state: +Other places Forge can write state: -- `~/.parallel-code/agent-auth//` — only if you enable "Share agent auth across Linux containers" (see Docker task isolation); accumulates agent credentials across sessions. -- OS temp directory (`$TMPDIR`) — `parallel-code-clipboard.png` and the `parallel-code-drop-*` files from clipboard/drop image handling (not deleted by Parallel Code), plus the host-mode coordinator and sub-task MCP configs and `.parallel-code-atomic-.tmp` files described under Sub-task coordinator above. +- `~/.forge/agent-auth//` — only if you enable "Share agent auth across Linux containers" (see Docker task isolation); accumulates agent credentials across sessions. +- OS temp directory (`$TMPDIR`) — `forge-clipboard.png` and the `forge-drop-*` files from clipboard/drop image handling (not deleted by Forge), plus the host-mode coordinator and sub-task MCP configs and `.forge-atomic-.tmp` files described under Sub-task coordinator above. Persistence the app itself does not control but that mirrors content the app produced: - The mobile client's bearer token persists in browser `localStorage` on the paired device until you clear browser data on that device or restart Remote Access (which invalidates the previous token). - Native notifications (task completion, PR check results) typically remain in the operating system's notification centre until you clear it. -Stop any running agents and coordinator sessions before deleting app-generated state so the app is not mid-write when files disappear. Also review `.parallel-code/` before deleting it, because it may contain project-owned configuration such as `.parallel-code/Dockerfile`, not only disposable coordination files. +Stop any running agents and coordinator sessions before deleting app-generated state so the app is not mid-write when files disappear. Also review `.forge/` before deleting it, because it may contain project-owned configuration such as `.forge/Dockerfile`, not only disposable coordination files. ## Your data and rights @@ -116,7 +116,7 @@ Because the project itself collects no personal data on infrastructure it contro ## Children's privacy -Parallel Code is a developer tool aimed at software developers, not children, and is not directed at users under 13. The project itself operates no servers and does not collect personal data on infrastructure it controls. The network requests Parallel Code does make (see _Network activity initiated by Parallel Code itself_ above) carry standard HTTP metadata such as your IP address to the destinations listed there; data those third parties receive is governed by their own privacy policies, not this one. +Forge is a developer tool aimed at software developers, not children, and is not directed at users under 13. The project itself operates no servers and does not collect personal data on infrastructure it controls. The network requests Forge does make (see _Network activity initiated by Forge itself_ above) carry standard HTTP metadata such as your IP address to the destinations listed there; data those third parties receive is governed by their own privacy policies, not this one. ## Changes to this policy @@ -124,6 +124,6 @@ If this policy changes, the update will appear in this file in the project repos ## Contact -Parallel Code is maintained by Johannes Millan, the same author as [Super Productivity](https://super-productivity.com) — the email below is that author's general contact address and reaches the same person. Questions or concerns can be raised as an issue on the [GitHub repository](https://github.com/johannesjo/parallel-code/issues) or by email at [contact@super-productivity.com](mailto:contact@super-productivity.com). GitHub issues are public — please do not include personal data, secrets (API keys, tokens), or anything you would not want indexed by search engines. If a question requires sharing personal data, use email rather than posting it publicly. +Forge is maintained by thunderock. It is an open-source fork of [Parallel Code](https://github.com/johannesjo/parallel-code) by Johannes Millan (MIT). Questions or concerns can be raised as an issue on the [GitHub repository](https://github.com/thunderock/forge/issues) or by email at [findashutoshtiwari@gmail.com](mailto:findashutoshtiwari@gmail.com). GitHub issues are public — please do not include personal data, secrets (API keys, tokens), or anything you would not want indexed by search engines. If a question requires sharing personal data, use email rather than posting it publicly. This policy is published alongside the project's open-source license: the project follows the behavior described above and will update this document when that behavior changes. The software itself is provided without warranty under the terms of the project's license. diff --git a/README.md b/README.md index be4579225..00bfc2011 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,5 @@

- Parallel Code + Forge

@@ -22,17 +22,11 @@ SolidJS TypeScript macOS | Linux - License + License

- - Watch intro on YouTube - -

- -

- Parallel Code demo + Forge demo

## Screenshots @@ -43,7 +37,7 @@ | **Diff review with inline comments** | **AI Arena — race agents head-to-head** | | ![Diff review](screens/diff-dialog-code-comments.png) | ![AI Arena](screens/ai-arena-mode.png) | -## Why Parallel Code? +## Why Forge? - **Use the AI coding tools you already trust** — [Claude Code](https://docs.anthropic.com/en/docs/claude-code), [Codex CLI](https://github.com/openai/codex), [Gemini CLI](https://github.com/google-gemini/gemini-cli), and [Copilot CLI](https://docs.github.com/en/copilot/concepts/agents/about-copilot-cli) — all from one interface. - **Free and open source** — no extra subscription required. MIT licensed. @@ -67,7 +61,7 @@ ## How it works -When you create a task, Parallel Code: +When you create a task, Forge: 1. Creates a new git branch from your main branch 2. Sets up a [git worktree](https://git-scm.com/docs/git-worktree) so the agent works in a separate directory @@ -87,8 +81,8 @@ When you're happy with the result, merge the branch back to main from the sideba - **PR CI status watcher** — desktop notification when GitHub checks settle - Shell terminals per task, scoped to the worktree - **Direct mode** for working on the main branch without isolation, plus support for **folders without a git repo** -- **Existing worktree import** — bring already-created worktrees into Parallel Code -- **Sandboxing with project-specific Dockerfiles** — drop a `.parallel-code/Dockerfile` into the project and tasks run inside it +- **Existing worktree import** — bring already-created worktrees into Forge +- **Sandboxing with project-specific Dockerfiles** — drop a `.forge/Dockerfile` into the project and tasks run inside it - **Coverage radar** — per-file test-coverage badges in the Changed Files panel - **Configurable keyboard shortcuts** with per-agent presets - 10 themes — Islands Dark, Minimal, Graphite, Midnight, Classic, Indigo, Ember, Glacier, Zenburnesque, Workbench @@ -111,13 +105,13 @@ When you're happy with the result, merge the branch back to main from the sideba ## Getting Started -1. **Download** the latest release for your platform from the [releases page](https://github.com/johannesjo/parallel-code/releases/latest): +1. **Download** the latest release for your platform from the [releases page](https://github.com/thunderock/forge/releases/latest): - **macOS** — `.dmg` (universal) - **Linux** — `.AppImage` or `.deb` 2. **Install at least one AI coding CLI:** [Claude Code](https://docs.anthropic.com/en/docs/claude-code), [Codex CLI](https://github.com/openai/codex), [Gemini CLI](https://github.com/google-gemini/gemini-cli), [Antigravity CLI](https://antigravity.google/), or [Copilot CLI](https://docs.github.com/en/copilot/concepts/agents/about-copilot-cli) -3. **Open Parallel Code**, point it at a git repo, and start dispatching tasks. +3. **Open Forge**, point it at a git repo, and start dispatching tasks.
Antigravity CLI: run natively, not in Docker isolation @@ -132,8 +126,8 @@ The bundled image still ships the `agy` binary, and `~/.gemini/antigravity-cli` Build from source ```sh -git clone https://github.com/johannesjo/parallel-code.git -cd parallel-code +git clone https://github.com/thunderock/forge.git +cd forge npm install npm run dev ``` @@ -176,8 +170,10 @@ Requires [Node.js](https://nodejs.org/) v18+. --- -If Parallel Code saves you time, consider giving it a [star on GitHub](https://github.com/johannesjo/parallel-code). It helps others find the project. +If Forge saves you time, consider giving it a [star on GitHub](https://github.com/thunderock/forge). It helps others find the project. ## License MIT + +Forge is an open-source fork of [Parallel Code](https://github.com/johannesjo/parallel-code) by Johannes Millan, also MIT-licensed. diff --git a/build/128x128.png b/build/128x128.png index 4b1d35747..20299f6c0 100644 Binary files a/build/128x128.png and b/build/128x128.png differ diff --git a/build/128x128@2x.png b/build/128x128@2x.png index 0b02e9d40..1939fc50f 100644 Binary files a/build/128x128@2x.png and b/build/128x128@2x.png differ diff --git a/build/icon-rounded.svg b/build/icon-rounded.svg index 89aa7c5b1..228992061 100644 --- a/build/icon-rounded.svg +++ b/build/icon-rounded.svg @@ -1,12 +1,7 @@ - - - - - - - - + + + diff --git a/build/icon-squared.svg b/build/icon-squared.svg index 67d22c8f9..b56b643ab 100644 --- a/build/icon-squared.svg +++ b/build/icon-squared.svg @@ -1,14 +1,4 @@ - - - - - - - - - - - - - + + + diff --git a/build/icon.icns b/build/icon.icns index 847611d19..a7d59839a 100644 Binary files a/build/icon.icns and b/build/icon.icns differ diff --git a/build/icon.ico b/build/icon.ico index 5a7dd1754..250ae6056 100644 Binary files a/build/icon.ico and b/build/icon.ico differ diff --git a/build/icon.png b/build/icon.png index f98272d77..0c246a152 100644 Binary files a/build/icon.png and b/build/icon.png differ diff --git a/build/icon.svg b/build/icon.svg index 19247ba4d..6320dfef3 100644 --- a/build/icon.svg +++ b/build/icon.svg @@ -1,7 +1,5 @@ - - - - - - + + + + diff --git a/build/logo-preview.html b/build/logo-preview.html index 4c6e4e277..5d02736d7 100644 --- a/build/logo-preview.html +++ b/build/logo-preview.html @@ -33,9 +33,9 @@ font-family: 'JetBrains Mono', monospace; font-weight: 600; font-size: 34px; - color: #2ec8ff; + color: #ff6a2c; " - >Parallel CodeForge diff --git a/build/logo-text-squared.svg b/build/logo-text-squared.svg index 5a84c20ce..6862662dc 100644 --- a/build/logo-text-squared.svg +++ b/build/logo-text-squared.svg @@ -1,27 +1,6 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + Forge diff --git a/build/logo-text.svg b/build/logo-text.svg index ae50b074f..8b5a17f03 100644 --- a/build/logo-text.svg +++ b/build/logo-text.svg @@ -1,41 +1,8 @@ - + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + Forge diff --git a/docker/Dockerfile b/docker/Dockerfile index f775de193..fe9d90a77 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -1,24 +1,100 @@ -# 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) +# Forge — Docker agent image +# Pre-installs common dev tools + language toolchains so agents don't waste time on setup. +# Multi-stage: the `rust`, `go`, and `tools` builder stages build in parallel under BuildKit; +# the final stage (the shipped agent image) copies their artifacts in. +# Build: DOCKER_BUILDKIT=1 docker build -t thunderockforge/forge-agent:latest docker/ -FROM ubuntu:22.04 +ARG UBUNTU=ubuntu:22.04 +# ===== builder: tools (lazygit, git-delta, uv + Python 3.11, ruff/black/isort/mypy, AWS CLI v2) ===== +FROM ${UBUNTU} AS tools +ENV DEBIAN_FRONTEND=noninteractive \ + UV_INSTALL_DIR=/opt/extra/bin \ + UV_PYTHON_INSTALL_DIR=/opt/extra/uv/python \ + UV_TOOL_DIR=/opt/extra/uv/tools \ + UV_TOOL_BIN_DIR=/opt/extra/bin \ + PATH=/opt/extra/bin:$PATH +RUN apt-get update && apt-get install -y --no-install-recommends \ + ca-certificates curl unzip \ + && rm -rf /var/lib/apt/lists/* +RUN mkdir -p /opt/extra/bin +# lazygit + git-delta — pinned versions, downloaded directly from release assets. +# Avoids the unauthenticated api.github.com/releases/latest endpoint whose +# 60-req/hr/IP rate limit intermittently 403s CI runners. Bump versions here. +ARG LAZYGIT_VERSION=0.62.2 +ARG DELTA_VERSION=0.19.2 +RUN set -eux; \ + arch="$(dpkg --print-architecture)"; \ + case "$arch" in amd64) lg=x86_64; dl=x86_64-unknown-linux-gnu ;; \ + arm64) lg=arm64; dl=aarch64-unknown-linux-gnu ;; \ + *) echo "unsupported arch $arch"; exit 1 ;; esac; \ + curl --retry 3 --retry-delay 2 --retry-all-errors -fsSL \ + "https://github.com/jesseduffield/lazygit/releases/download/v${LAZYGIT_VERSION}/lazygit_${LAZYGIT_VERSION}_Linux_${lg}.tar.gz" -o /tmp/lazygit.tgz; \ + tar -C /opt/extra/bin -xzf /tmp/lazygit.tgz lazygit; \ + curl --retry 3 --retry-delay 2 --retry-all-errors -fsSL \ + "https://github.com/dandavison/delta/releases/download/${DELTA_VERSION}/delta-${DELTA_VERSION}-${dl}.tar.gz" -o /tmp/delta.tgz; \ + tar -C /tmp -xzf /tmp/delta.tgz; mv "/tmp/delta-${DELTA_VERSION}-${dl}/delta" /opt/extra/bin/delta; \ + rm -rf /tmp/lazygit.tgz /tmp/delta.tgz "/tmp/delta-${DELTA_VERSION}-${dl}" +# uv + Python 3.11 + linters +RUN set -eux; \ + curl -LsSf https://astral.sh/uv/install.sh | sh; \ + command -v uv >/dev/null || cp "$HOME/.local/bin/uv" /opt/extra/bin/; \ + uv python install 3.11; \ + ln -sf "$(uv python find 3.11)" /opt/extra/bin/python3.11; \ + uv tool install ruff; uv tool install black; uv tool install isort; uv tool install mypy +# AWS CLI v2 +RUN set -eux; \ + arch="$(uname -m)"; \ + curl -fsSL "https://awscli.amazonaws.com/awscli-exe-linux-${arch}.zip" -o /tmp/awscliv2.zip; \ + cd /tmp; unzip -q awscliv2.zip; ./aws/install --install-dir /opt/extra/aws-cli --bin-dir /opt/extra/bin; \ + cd /; rm -rf /tmp/aws /tmp/awscliv2.zip +RUN chmod -R a+rX /opt/extra + +# ===== builder: rust (long pole) ===== +FROM ${UBUNTU} AS rust +ENV DEBIAN_FRONTEND=noninteractive \ + RUSTUP_HOME=/usr/local/rustup CARGO_HOME=/usr/local/cargo PATH=/usr/local/cargo/bin:$PATH +RUN apt-get update && apt-get install -y --no-install-recommends \ + ca-certificates curl build-essential pkg-config libssl-dev \ + && rm -rf /var/lib/apt/lists/* +RUN set -eux; \ + curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs \ + | sh -s -- -y --default-toolchain 1.93.0 --profile default --no-modify-path; \ + rustup component add clippy rust-analyzer rust-src; \ + cargo install cargo-watch cargo-audit; \ + cargo install --locked cargo-nextest; \ + chmod -R a+rX "$CARGO_HOME" "$RUSTUP_HOME" + +# ===== builder: go ===== +FROM ${UBUNTU} AS go +ENV DEBIAN_FRONTEND=noninteractive +RUN apt-get update && apt-get install -y --no-install-recommends ca-certificates curl \ + && rm -rf /var/lib/apt/lists/* +RUN set -eux; \ + arch="$(dpkg --print-architecture)"; \ + case "$arch" in amd64) ga=amd64 ;; arm64) ga=arm64 ;; *) echo "unsupported $arch"; exit 1 ;; esac; \ + GO_VERSION=1.24.4; \ + curl -fsSL "https://go.dev/dl/go${GO_VERSION}.linux-${ga}.tar.gz" -o /tmp/go.tgz; \ + tar -C /usr/local -xzf /tmp/go.tgz; rm /tmp/go.tgz + +# ===== final: the shipped agent image (run with --user 1000:1000) ===== +FROM ${UBUNTU} ENV DEBIAN_FRONTEND=noninteractive \ LANG=C.UTF-8 \ LC_ALL=C.UTF-8 -# Core build tools + languages +# Core tools + languages + dev extras 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 \ + build-essential pkg-config libssl-dev \ # Python python3 python3-pip python3-venv \ # Shells & utilities - bash zsh jq ripgrep fd-find fzf tree unzip less \ + bash zsh jq ripgrep fd-find fzf tree unzip less bat \ + # Dev extras + htop direnv cmake clang postgresql-client silversearcher-ag \ # Process inspection procps \ # Networking @@ -27,6 +103,10 @@ RUN apt-get update && apt-get install -y --no-install-recommends \ nano \ && rm -rf /var/lib/apt/lists/* +# Make fd/bat available under their common names (Ubuntu ships fdfind/batcat) +RUN ln -sf "$(command -v fdfind)" /usr/local/bin/fd 2>/dev/null || true; \ + ln -sf "$(command -v batcat)" /usr/local/bin/bat 2>/dev/null || true + # 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 \ @@ -40,23 +120,14 @@ RUN mkdir -p /etc/apt/keyrings \ # 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 \ + 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 -# Antigravity CLI (agy) — distributed as a Go binary via the official installer -# (not on npm). The installer's `--dir` flag drops the binary straight into a -# system path on PATH, so the non-root `agent` user that runs containers resolves -# it. A failed fetch makes the build step fail, surfacing the break at image build -# time rather than at task launch. +# Antigravity CLI (agy) — Go binary via the official installer; --dir drops it on PATH RUN curl -fsSL https://antigravity.google/cli/install.sh | bash -s -- --dir /usr/local/bin \ && agy --version @@ -64,12 +135,41 @@ RUN curl -fsSL https://antigravity.google/cli/install.sh | bash -s -- --dir /usr RUN git config --system init.defaultBranch main \ && git config --system advice.detachedHead false +# Language toolchains + extra CLIs from the parallel builder stages +COPY --from=tools /opt/extra /opt/extra +COPY --from=rust /usr/local/cargo /usr/local/cargo +COPY --from=rust /usr/local/rustup /usr/local/rustup +COPY --from=go /usr/local/go /usr/local/go +ENV RUSTUP_HOME=/usr/local/rustup CARGO_HOME=/usr/local/cargo \ + UV_INSTALL_DIR=/opt/extra/bin \ + UV_PYTHON_INSTALL_DIR=/opt/extra/uv/python \ + UV_TOOL_DIR=/opt/extra/uv/tools \ + UV_TOOL_BIN_DIR=/opt/extra/bin \ + PATH=/usr/local/go/bin:/usr/local/cargo/bin:/opt/extra/bin:$PATH + # 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 +# Curated dotfiles (no secrets, no baked git identity) + vim-gitgutter +COPY --chown=1000:1000 dotfiles/bashrc /home/agent/.bashrc +COPY --chown=1000:1000 dotfiles/gitconfig /home/agent/.gitconfig +COPY --chown=1000:1000 dotfiles/vimrc /home/agent/.vimrc +RUN set -eux; \ + git clone --depth=1 https://github.com/airblade/vim-gitgutter \ + /home/agent/.vim/pack/plugins/start/vim-gitgutter; \ + chown -R 1000:1000 /home/agent/.vim + +# GSD (get-shit-done) — Claude Code workflow layer, baked into the agent skeleton. +# Forge seeds /home/agent/.claude + .gsd into each task's per-agent $HOME at +# container start (see electron/ipc/pty.ts). Config is secret-free. +COPY --chown=1000:1000 dotfiles/gsd-defaults.json /home/agent/.gsd/defaults.json +RUN set -eux; \ + HOME=/home/agent npx -y get-shit-done-cc@latest --global --yes; \ + rm -rf /home/agent/.npm; \ + chown -R 1000:1000 /home/agent/.claude /home/agent/.gsd; \ + chmod -R a+rX /home/agent/.claude /home/agent/.gsd + ENV SHELL=/bin/bash WORKDIR /app - CMD ["bash"] diff --git a/docker/dotfiles/bashrc b/docker/dotfiles/bashrc new file mode 100644 index 000000000..a0ff41309 --- /dev/null +++ b/docker/dotfiles/bashrc @@ -0,0 +1,32 @@ +# ~/.bashrc — personal agent sandbox. Curated from ashutosh_setup. +# Secrets and Adobe-specific logic intentionally omitted. + +bind '"\e[A": previous-history' 2>/dev/null +bind '"\e[B": next-history' 2>/dev/null +bind '"\eOA": previous-history' 2>/dev/null +bind '"\eOB": next-history' 2>/dev/null + +export HISTFILE=$HOME/.bash_history +export HISTSIZE=10000000 +export HISTFILESIZE=10000000 +export HISTCONTROL=ignoreboth:erasedups +export HISTTIMEFORMAT='%F %T ' +shopt -s histappend cmdhist checkwinsize globstar +PROMPT_COMMAND='history -a; history -n' + +alias grep='grep --color=auto' +alias ls='ls --color=auto' +alias ll='ls -lh --color=auto' +alias la='ls -lAh --color=auto' +alias lg='lazygit' +alias fvim='vim "$(fzf)"' +alias fcat="fzf --preview 'bat --color=always --style=numbers {}'" + +for _f in \ + /usr/share/doc/fzf/examples/key-bindings.bash \ + /usr/share/fzf/key-bindings.bash \ + /usr/share/doc/fzf/examples/completion.bash \ + /usr/share/fzf/completion.bash; do + [ -f "$_f" ] && source "$_f" +done +unset _f diff --git a/docker/dotfiles/gitconfig b/docker/dotfiles/gitconfig new file mode 100644 index 000000000..6d0327630 --- /dev/null +++ b/docker/dotfiles/gitconfig @@ -0,0 +1,35 @@ +[alias] + co = checkout + a = add + b = branch + c = commit + d = diff + f = fetch + g = grep + l = log + m = merge + p = pull + r = remote + s = status + w = whatchanged + lg = log --graph + lo = log --oneline + lp = log --patch + lfp = log --first-parent + lt = log --topo-order + ll = log --graph --topo-order --abbrev-commit --date=short --decorate --all --boundary --pretty=format:'%Cgreen%ad %Cred%h%Creset -%C(yellow)%d%Creset %s %Cblue[%cn]%Creset %Cblue%G?%Creset' + unstage = reset HEAD -- + slast = rev-parse @~ + head = log --name-status HEAD^..HEAD +[core] + editor = vim + pager = delta +[interactive] + diffFilter = delta --color-only +[delta] + navigate = true + dark = true +[merge] + conflictStyle = zdiff3 +[pull] + rebase = true diff --git a/docker/dotfiles/gsd-defaults.json b/docker/dotfiles/gsd-defaults.json new file mode 100644 index 000000000..5950beccc --- /dev/null +++ b/docker/dotfiles/gsd-defaults.json @@ -0,0 +1,68 @@ +{ + "resolve_model_ids": "anthropic", + "model_profile": "quality", + "model_overrides": { + "gsd-planner": "claude-opus-4-8", + "gsd-roadmapper": "claude-opus-4-8", + "gsd-pattern-mapper": "claude-opus-4-8", + "gsd-executor": "claude-opus-4-8", + "gsd-debugger": "claude-opus-4-8", + "gsd-doc-writer": "claude-opus-4-8", + "gsd-verifier": "claude-opus-4-8", + "gsd-plan-checker": "claude-opus-4-8", + "gsd-integration-checker": "claude-opus-4-8", + "gsd-nyquist-auditor": "claude-opus-4-8", + "gsd-ui-checker": "claude-opus-4-8", + "gsd-ui-auditor": "claude-opus-4-8", + "gsd-doc-verifier": "claude-opus-4-8", + "gsd-phase-researcher": "claude-opus-4-8", + "gsd-project-researcher": "claude-opus-4-8", + "gsd-ui-researcher": "claude-opus-4-8", + "gsd-codebase-mapper": "claude-opus-4-8", + "gsd-research-synthesizer": "claude-opus-4-8" + }, + "mode": "yolo", + "granularity": "standard", + "commit_docs": false, + "parallelization": true, + "branching_strategy": "phase", + "quick_branch_template": null, + "workflow": { + "research": true, + "plan_check": true, + "verifier": true, + "auto_advance": true, + "nyquist_validation": true, + "pattern_mapper": true, + "ui_phase": true, + "ui_safety_gate": true, + "ai_integration_phase": true, + "tdd_mode": true, + "code_review": true, + "code_review_depth": "deep", + "use_worktrees": true, + "ui_review": true, + "skip_discuss": false, + "research_before_questions": true + }, + "hooks": { + "workflow_guard": false, + "context_warnings": true + }, + "code_quality": { + "fallow": { + "enabled": true, + "scope": "phase", + "profile": "quality", + "mcp": false + } + }, + "intel": { + "enabled": true + }, + "graphify": { + "enabled": true, + "build_timeout": 300, + "auto_update": true + } +} diff --git a/docker/dotfiles/vimrc b/docker/dotfiles/vimrc new file mode 100644 index 000000000..a568d52ae --- /dev/null +++ b/docker/dotfiles/vimrc @@ -0,0 +1,25 @@ +" --- vim-gitgutter: red/green diff hints when editing tracked files --- +" Plugin is installed via vim 8 native packages at: +" ~/.vim/pack/plugins/start/vim-gitgutter +" (cloned by the Dockerfile). No plugin manager needed. + +set updatetime=100 " faster gutter refresh +let g:gitgutter_highlight_lines = 1 " highlight whole changed line, not just sign column + +" jump between hunks +nmap ]h (GitGutterNextHunk) +nmap [h (GitGutterPrevHunk) + +" preview / stage / undo a hunk under cursor +nmap hp (GitGutterPreviewHunk) +nmap hs (GitGutterStageHunk) +nmap hu (GitGutterUndoHunk) + +" basic editor sanity (safe defaults; remove or override if you have preferences) +syntax on +set number +set ruler +set hlsearch +set incsearch +set ignorecase +set smartcase diff --git a/electron/ipc/docker-config.test.ts b/electron/ipc/docker-config.test.ts index 8095f4857..2b65b210a 100644 --- a/electron/ipc/docker-config.test.ts +++ b/electron/ipc/docker-config.test.ts @@ -61,14 +61,14 @@ describe('selectMcpJsonDir — .mcp.json placement', () => { // ── copied mcp-server.cjs path ───────────────────────────────────────────────── describe('getDockerMcpServerDestPath — copied mcp-server.cjs location', () => { - it('places mcp-server.cjs in worktree .parallel-code dir', () => { + it('places mcp-server.cjs in worktree .forge dir', () => { const dest = getDockerMcpServerDestPath('/worktrees/coord', '/project'); - expect(dest).toBe('/worktrees/coord/.parallel-code/mcp-server.cjs'); + expect(dest).toBe('/worktrees/coord/.forge/mcp-server.cjs'); }); it('falls back to projectRoot when worktreePath is undefined', () => { const dest = getDockerMcpServerDestPath(undefined, '/project'); - expect(dest).toBe('/project/.parallel-code/mcp-server.cjs'); + expect(dest).toBe('/project/.forge/mcp-server.cjs'); }); it('dest is under the mounted worktree, not the unmounted projectRoot', () => { @@ -77,7 +77,7 @@ describe('getDockerMcpServerDestPath — copied mcp-server.cjs location', () => const dest = getDockerMcpServerDestPath(worktreePath, projectRoot); // The container mounts worktreePath (not projectRoot), so the script must live there expect(dest.startsWith(worktreePath)).toBe(true); - expect(dest.startsWith(projectRoot + '/.parallel-code')).toBe(false); + expect(dest.startsWith(projectRoot + '/.forge')).toBe(false); }); it('filename is always mcp-server.cjs', () => { @@ -90,7 +90,7 @@ describe('getDockerMcpServerDestPath — copied mcp-server.cjs location', () => describe('buildCoordinatorMCPConfig — config content', () => { const baseOpts = { - mcpServerPath: '/worktrees/coord/.parallel-code/mcp-server.cjs', + mcpServerPath: '/worktrees/coord/.forge/mcp-server.cjs', serverUrl: 'http://host.docker.internal:3001', token: 'test-token-abc', coordinatorTaskId: 'coord-task-1', @@ -98,19 +98,19 @@ describe('buildCoordinatorMCPConfig — config content', () => { it('has type:stdio and command:node', () => { const cfg = buildCoordinatorMCPConfig(baseOpts); - const server = cfg.mcpServers['parallel-code']; + const server = cfg.mcpServers['forge']; expect(server.type).toBe('stdio'); expect(server.command).toBe('node'); }); it('args[0] is the mcp-server.cjs path (the copied worktree path, not host path)', () => { const cfg = buildCoordinatorMCPConfig(baseOpts); - expect(cfg.mcpServers['parallel-code'].args[0]).toBe(baseOpts.mcpServerPath); + expect(cfg.mcpServers['forge'].args[0]).toBe(baseOpts.mcpServerPath); }); it('args contain --url pointing to host.docker.internal', () => { const cfg = buildCoordinatorMCPConfig(baseOpts); - const args = cfg.mcpServers['parallel-code'].args; + const args = cfg.mcpServers['forge'].args; const urlIdx = args.indexOf('--url'); expect(urlIdx).toBeGreaterThan(0); expect(args[urlIdx + 1]).toBe('http://host.docker.internal:3001'); @@ -118,14 +118,14 @@ describe('buildCoordinatorMCPConfig — config content', () => { it('token is passed via env var, not args', () => { const cfg = buildCoordinatorMCPConfig(baseOpts); - const args = cfg.mcpServers['parallel-code'].args; + const args = cfg.mcpServers['forge'].args; expect(args).not.toContain('--token'); - expect(cfg.mcpServers['parallel-code'].env['PARALLEL_CODE_MCP_TOKEN']).toBe(baseOpts.token); + expect(cfg.mcpServers['forge'].env['FORGE_MCP_TOKEN']).toBe(baseOpts.token); }); it('args contain --coordinator-id', () => { const cfg = buildCoordinatorMCPConfig(baseOpts); - const args = cfg.mcpServers['parallel-code'].args; + const args = cfg.mcpServers['forge'].args; const coordIdx = args.indexOf('--coordinator-id'); expect(coordIdx).toBeGreaterThan(0); expect(args[coordIdx + 1]).toBe(baseOpts.coordinatorTaskId); @@ -133,7 +133,7 @@ describe('buildCoordinatorMCPConfig — config content', () => { it('omits --skip-permissions by default', () => { const cfg = buildCoordinatorMCPConfig(baseOpts); - expect(cfg.mcpServers['parallel-code'].args).not.toContain('--skip-permissions'); + expect(cfg.mcpServers['forge'].args).not.toContain('--skip-permissions'); }); it('adds --skip-permissions when both flags are true', () => { @@ -142,7 +142,7 @@ describe('buildCoordinatorMCPConfig — config content', () => { skipPermissions: true, propagateSkipPermissions: true, }); - expect(cfg.mcpServers['parallel-code'].args).toContain('--skip-permissions'); + expect(cfg.mcpServers['forge'].args).toContain('--skip-permissions'); }); it('does NOT add --skip-permissions when propagateSkipPermissions is false', () => { @@ -151,7 +151,7 @@ describe('buildCoordinatorMCPConfig — config content', () => { skipPermissions: true, propagateSkipPermissions: false, }); - expect(cfg.mcpServers['parallel-code'].args).not.toContain('--skip-permissions'); + expect(cfg.mcpServers['forge'].args).not.toContain('--skip-permissions'); }); it('does NOT add --skip-permissions when skipPermissions is false', () => { @@ -160,13 +160,13 @@ describe('buildCoordinatorMCPConfig — config content', () => { skipPermissions: false, propagateSkipPermissions: true, }); - expect(cfg.mcpServers['parallel-code'].args).not.toContain('--skip-permissions'); + expect(cfg.mcpServers['forge'].args).not.toContain('--skip-permissions'); }); - it('JSON-serialised output is valid JSON with the parallel-code key', () => { + it('JSON-serialised output is valid JSON with the forge key', () => { const cfg = buildCoordinatorMCPConfig(baseOpts); const json = JSON.stringify(cfg, null, 2); const parsed = JSON.parse(json) as typeof cfg; - expect(parsed.mcpServers['parallel-code']).toBeDefined(); + expect(parsed.mcpServers['forge']).toBeDefined(); }); }); diff --git a/electron/ipc/docker-pull.test.ts b/electron/ipc/docker-pull.test.ts new file mode 100644 index 000000000..6c49c322a --- /dev/null +++ b/electron/ipc/docker-pull.test.ts @@ -0,0 +1,319 @@ +/** + * Unit tests for the Docker image pre-pull resilience orchestrator. + * Pure logic — Docker is fully faked, no network/subprocess/timers. + */ + +import crypto from 'crypto'; +import { describe, expect, it, vi } from 'vitest'; +import { + acquireAgentImage, + downloadAndLoadReleaseImage, + ensureDockerImageAvailable, + hostImageArch, + parseSha256, + releaseImageAssetName, + releaseImageUrls, +} from './docker-pull.js'; + +const IMAGE = 'thunderockforge/forge-agent:latest'; + +/** Build a deps object with sensible fakes; override per test. */ +function makeDeps(over: { present?: boolean[]; pullCodes?: number[]; signal?: AbortSignal }) { + const present = [...(over.present ?? [])]; + const pullCodes = [...(over.pullCodes ?? [])]; + const status: string[] = []; + const pull = vi.fn(async () => (pullCodes.length ? (pullCodes.shift() as number) : -1)); + const delay = vi.fn(async () => {}); + const imagePresent = vi.fn(async () => (present.length ? (present.shift() as boolean) : false)); + return { + deps: { + imagePresent, + pull, + delay, + onStatus: (l: string) => status.push(l), + signal: over.signal ?? new AbortController().signal, + }, + status, + pull, + delay, + imagePresent, + }; +} + +describe('ensureDockerImageAvailable', () => { + it('skips the pull entirely when the image is already cached locally', async () => { + const { deps, pull } = makeDeps({ present: [true] }); + const res = await ensureDockerImageAvailable(IMAGE, deps); + expect(res).toEqual({ ok: true, usedLocal: true }); + expect(pull).not.toHaveBeenCalled(); + }); + + it('pulls once and succeeds when the image is missing', async () => { + const { deps, pull } = makeDeps({ present: [false], pullCodes: [0] }); + const res = await ensureDockerImageAvailable(IMAGE, deps); + expect(res).toEqual({ ok: true, usedLocal: false }); + expect(pull).toHaveBeenCalledTimes(1); + }); + + it('retries with backoff and succeeds on a later attempt', async () => { + const { deps, pull, delay, status } = makeDeps({ + present: [false], + pullCodes: [1, 0], // fail, then succeed + }); + const res = await ensureDockerImageAvailable(IMAGE, deps, { maxAttempts: 3 }); + expect(res).toEqual({ ok: true, usedLocal: false }); + expect(pull).toHaveBeenCalledTimes(2); + expect(delay).toHaveBeenCalledTimes(1); + expect(status.some((s) => /retry/i.test(s))).toBe(true); + }); + + it('gives up after maxAttempts when pulls keep failing and nothing is cached', async () => { + const { deps, pull } = makeDeps({ + present: [false, false], // initial check + final fallback check + pullCodes: [1, 1, 1], + }); + const res = await ensureDockerImageAvailable(IMAGE, deps, { maxAttempts: 3 }); + expect(res).toEqual({ ok: false, reason: 'pull-failed' }); + expect(pull).toHaveBeenCalledTimes(3); + }); + + it('falls back to a locally cached copy when pulls fail but the image is present', async () => { + const { deps } = makeDeps({ + present: [false, true], // missing up front, but present on the final fallback check + pullCodes: [1, 1, 1], + }); + const res = await ensureDockerImageAvailable(IMAGE, deps, { maxAttempts: 3 }); + expect(res).toEqual({ ok: true, usedLocal: true }); + }); + + it('returns cancelled without pulling when aborted before start', async () => { + const ac = new AbortController(); + ac.abort(); + const { deps, pull } = makeDeps({ present: [false], signal: ac.signal }); + const res = await ensureDockerImageAvailable(IMAGE, deps); + expect(res).toEqual({ ok: false, reason: 'cancelled' }); + expect(pull).not.toHaveBeenCalled(); + }); + + it('returns cancelled when aborted during a pull', async () => { + const ac = new AbortController(); + const { deps } = makeDeps({ present: [false], pullCodes: [-1], signal: ac.signal }); + // Abort as soon as the pull is attempted. + deps.pull = vi.fn(async () => { + ac.abort(); + return -1; + }); + const res = await ensureDockerImageAvailable(IMAGE, deps, { maxAttempts: 3 }); + expect(res).toEqual({ ok: false, reason: 'cancelled' }); + }); +}); + +/** Deps for acquireAgentImage; `present` feeds sequential imagePresent() answers. */ +function makeAcquireDeps(over: { + present?: boolean[]; + pullCodes?: number[]; + downloadRelease?: (signal: AbortSignal) => Promise; + localBuild?: (signal: AbortSignal) => Promise; + signal?: AbortSignal; +}) { + const present = [...(over.present ?? [])]; + const pullCodes = [...(over.pullCodes ?? [])]; + const status: string[] = []; + const deps = { + imagePresent: vi.fn(async () => (present.length ? (present.shift() as boolean) : false)), + pull: vi.fn(async () => (pullCodes.length ? (pullCodes.shift() as number) : -1)), + delay: vi.fn(async () => {}), + onStatus: (l: string) => status.push(l), + signal: over.signal ?? new AbortController().signal, + downloadRelease: over.downloadRelease, + localBuild: over.localBuild, + }; + return { deps, status }; +} + +describe('acquireAgentImage', () => { + it('returns the pull result and never touches fallbacks when the pull succeeds', async () => { + const downloadRelease = vi.fn(async () => true); + const localBuild = vi.fn(async () => true); + const { deps } = makeAcquireDeps({ + present: [false], + pullCodes: [0], + downloadRelease, + localBuild, + }); + const res = await acquireAgentImage(IMAGE, deps); + expect(res).toEqual({ ok: true, usedLocal: false }); + expect(downloadRelease).not.toHaveBeenCalled(); + expect(localBuild).not.toHaveBeenCalled(); + }); + + it('downloads from the release when the pull fails, and reports a non-local success', async () => { + const downloadRelease = vi.fn(async () => true); + const localBuild = vi.fn(async () => true); + const { deps } = makeAcquireDeps({ + present: [false, false, true], // initial miss, pull-fallback miss, present after download + pullCodes: [1], + downloadRelease, + localBuild, + }); + const res = await acquireAgentImage(IMAGE, deps, { maxAttempts: 1 }); + expect(res).toEqual({ ok: true, usedLocal: false }); + expect(downloadRelease).toHaveBeenCalledTimes(1); + expect(localBuild).not.toHaveBeenCalled(); + }); + + it('falls back to a local build when both the pull and the release download fail', async () => { + const downloadRelease = vi.fn(async () => false); + const localBuild = vi.fn(async () => true); + const { deps } = makeAcquireDeps({ + present: [false, false, true], // initial miss, pull-fallback miss, present after build + pullCodes: [1], + downloadRelease, + localBuild, + }); + const res = await acquireAgentImage(IMAGE, deps, { maxAttempts: 1 }); + expect(res).toEqual({ ok: true, usedLocal: true }); + expect(downloadRelease).toHaveBeenCalledTimes(1); + expect(localBuild).toHaveBeenCalledTimes(1); + }); + + it('returns pull-failed when every rung fails', async () => { + const { deps } = makeAcquireDeps({ + present: [false, false], + pullCodes: [1], + downloadRelease: vi.fn(async () => false), + localBuild: vi.fn(async () => false), + }); + const res = await acquireAgentImage(IMAGE, deps, { maxAttempts: 1 }); + expect(res).toEqual({ ok: false, reason: 'pull-failed' }); + }); + + it('degrades to a plain pull (pull-failed) when no fallbacks are supplied', async () => { + const { deps } = makeAcquireDeps({ present: [false, false], pullCodes: [1] }); + const res = await acquireAgentImage(IMAGE, deps, { maxAttempts: 1 }); + expect(res).toEqual({ ok: false, reason: 'pull-failed' }); + }); + + it('returns cancelled without running fallbacks when aborted up front', async () => { + const ac = new AbortController(); + ac.abort(); + const downloadRelease = vi.fn(async () => true); + const { deps } = makeAcquireDeps({ present: [false], signal: ac.signal, downloadRelease }); + const res = await acquireAgentImage(IMAGE, deps); + expect(res).toEqual({ ok: false, reason: 'cancelled' }); + expect(downloadRelease).not.toHaveBeenCalled(); + }); +}); + +describe('release image helpers', () => { + it('maps Node process.arch to a release arch', () => { + expect(hostImageArch('arm64')).toBe('arm64'); + expect(hostImageArch('x64')).toBe('amd64'); + expect(hostImageArch('ia32')).toBeNull(); + }); + + it('names assets per arch', () => { + expect(releaseImageAssetName('amd64')).toBe('forge-agent-amd64.tar.gz'); + expect(releaseImageAssetName('arm64')).toBe('forge-agent-arm64.tar.gz'); + }); + + it('yields the pinned URL first, then latest', () => { + const urls = releaseImageUrls({ + owner: 'thunderock', + repo: 'forge', + version: '1.2.3', + arch: 'arm64', + }); + expect(urls).toHaveLength(2); + expect(urls[0].tarball).toContain('/releases/download/v1.2.3/forge-agent-arm64.tar.gz'); + expect(urls[0].checksum).toBe(`${urls[0].tarball}.sha256`); + expect(urls[1].tarball).toContain('/releases/latest/download/forge-agent-arm64.tar.gz'); + }); + + it('yields only the latest URL when no version is known', () => { + const urls = releaseImageUrls({ owner: 'o', repo: 'r', version: '', arch: 'amd64' }); + expect(urls).toHaveLength(1); + expect(urls[0].tarball).toContain('/releases/latest/download/'); + }); + + it('parses a sha256sum line', () => { + const hex = 'a'.repeat(64); + expect(parseSha256(`${hex} forge-agent-amd64.tar.gz`)).toBe(hex); + expect(parseSha256('not-a-hash')).toBeNull(); + }); +}); + +describe('downloadAndLoadReleaseImage', () => { + const payload = new TextEncoder().encode('fake-image-tarball-bytes'); + const digest = crypto.createHash('sha256').update(Buffer.from(payload)).digest('hex'); + + function bodyOf(bytes: Uint8Array) { + return (async function* () { + yield bytes; + })(); + } + function resp(opts: { + status?: number; + bytes?: Uint8Array; + text?: string; + len?: number; + }): Response { + const status = opts.status ?? 200; + return { + ok: status >= 200 && status < 300, + status, + headers: { + get: (h: string) => + h.toLowerCase() === 'content-length' && opts.len ? String(opts.len) : null, + }, + text: async () => opts.text ?? '', + body: opts.bytes ? bodyOf(opts.bytes) : null, + } as unknown as Response; + } + const target = { owner: 'thunderock', repo: 'forge', version: '1.2.3', arch: 'amd64' as const }; + + it('downloads, verifies the checksum, and docker-loads the image', async () => { + const fetchImpl = vi.fn(async (url: string) => + url.endsWith('.sha256') + ? resp({ text: `${digest} forge-agent-amd64.tar.gz` }) + : resp({ bytes: payload, len: payload.length }), + ); + const dockerLoadFile = vi.fn(async () => 0); + const ok = await downloadAndLoadReleaseImage(target, () => {}, new AbortController().signal, { + fetchImpl: fetchImpl as unknown as typeof fetch, + dockerLoadFile, + }); + expect(ok).toBe(true); + expect(dockerLoadFile).toHaveBeenCalledTimes(1); + }); + + it('skips a 404 pinned release and succeeds from the latest release', async () => { + const fetchImpl = vi.fn(async (url: string) => { + if (url.includes('/download/v1.2.3/')) return resp({ status: 404 }); + if (url.endsWith('.sha256')) return resp({ status: 404 }); // latest has no checksum + return resp({ bytes: payload, len: payload.length }); + }); + const dockerLoadFile = vi.fn(async () => 0); + const ok = await downloadAndLoadReleaseImage(target, () => {}, new AbortController().signal, { + fetchImpl: fetchImpl as unknown as typeof fetch, + dockerLoadFile, + }); + expect(ok).toBe(true); + expect(dockerLoadFile).toHaveBeenCalledTimes(1); + }); + + it('refuses to load on a checksum mismatch', async () => { + const fetchImpl = vi.fn(async (url: string) => + url.endsWith('.sha256') + ? resp({ text: `${'0'.repeat(64)} forge-agent-amd64.tar.gz` }) + : resp({ bytes: payload, len: payload.length }), + ); + const dockerLoadFile = vi.fn(async () => 0); + const ok = await downloadAndLoadReleaseImage(target, () => {}, new AbortController().signal, { + fetchImpl: fetchImpl as unknown as typeof fetch, + dockerLoadFile, + }); + expect(ok).toBe(false); + expect(dockerLoadFile).not.toHaveBeenCalled(); + }); +}); diff --git a/electron/ipc/docker-pull.ts b/electron/ipc/docker-pull.ts new file mode 100644 index 000000000..bc64635bf --- /dev/null +++ b/electron/ipc/docker-pull.ts @@ -0,0 +1,394 @@ +import { execFile, execFileSync, spawn as cpSpawn } from 'child_process'; +import crypto from 'crypto'; +import fs from 'fs'; +import os from 'os'; +import path from 'path'; +import { once } from 'events'; + +/** Project images are built locally (forge-project:), never pulled from a registry. */ +export const PROJECT_IMAGE_PREFIX = 'forge-project:'; + +interface EnsureImageDeps { + /** Resolve true if an image with this tag is already present locally. */ + imagePresent: (image: string) => Promise; + /** Pull the image; resolve with the process exit code (0 = success). */ + pull: (image: string, signal: AbortSignal) => Promise; + /** Abortable sleep. */ + delay: (ms: number, signal: AbortSignal) => Promise; + /** Emit a human-friendly status line to the terminal. */ + onStatus: (line: string) => void; + signal: AbortSignal; +} + +interface EnsureImageOptions { + maxAttempts?: number; + /** Backoff before retry N (index 0 = wait before 2nd attempt). Last value reused. */ + backoffMs?: number[]; +} + +export type EnsureImageResult = + | { ok: true; usedLocal: boolean } + | { ok: false; reason: 'cancelled' | 'pull-failed' }; + +/** + * Ensure a registry image is available locally before `docker run`. + * + * Fast-paths when the image is already cached (no network). Otherwise pulls with + * bounded retries + backoff so a transient Docker Hub blip doesn't hard-fail the + * task, and falls back to any locally cached copy before giving up. + */ +export async function ensureDockerImageAvailable( + image: string, + deps: EnsureImageDeps, + opts: EnsureImageOptions = {}, +): Promise { + const maxAttempts = opts.maxAttempts ?? 3; + const backoff = opts.backoffMs ?? [2000, 4000]; + + if (deps.signal.aborted) return { ok: false, reason: 'cancelled' }; + + // Already cached — `docker run` will use it, no network round-trip needed. + if (await deps.imagePresent(image)) return { ok: true, usedLocal: true }; + + for (let attempt = 1; attempt <= maxAttempts; attempt++) { + if (deps.signal.aborted) return { ok: false, reason: 'cancelled' }; + deps.onStatus( + attempt === 1 + ? `Pulling ${image} … (first run can take a few minutes)` + : `Retrying pull (attempt ${attempt}/${maxAttempts}) …`, + ); + + const code = await deps.pull(image, deps.signal).catch(() => -1); + if (deps.signal.aborted) return { ok: false, reason: 'cancelled' }; + if (code === 0) return { ok: true, usedLocal: false }; + + if (attempt < maxAttempts) { + const wait = backoff[Math.min(attempt - 1, backoff.length - 1)]; + deps.onStatus(`Pull failed — retrying in ${Math.round(wait / 1000)}s …`); + await deps.delay(wait, deps.signal); + } + } + + // Retries exhausted — use any locally cached copy rather than fail outright + // (e.g. a concurrent pull landed it, or an older image is good enough). + if (await deps.imagePresent(image)) return { ok: true, usedLocal: true }; + + return { ok: false, reason: 'pull-failed' }; +} + +/** + * Synchronous existence check, used on the spawn fast-path so a cached image + * still launches without deferring to an async tick. Bounded timeout; treats + * any failure (incl. a hung daemon) as "not present" so we fall back to a pull. + */ +export function dockerImagePresentSync(image: string): boolean { + try { + const out = execFileSync( + 'docker', + ['image', 'ls', '--filter', `reference=${image}`, '--format', '{{.ID}}'], + { encoding: 'utf8', timeout: 4000, stdio: ['ignore', 'pipe', 'ignore'] }, + ); + return !!out.trim(); + } catch { + return false; + } +} + +/** True if an image with this tag exists locally (existence only — no staleness check). */ +export function dockerImagePresentByTag(image: string): Promise { + return new Promise((resolve) => { + // `docker image ls --filter reference=` works around the containerd store + // breaking tag-based `docker image inspect`. + execFile( + 'docker', + ['image', 'ls', '--filter', `reference=${image}`, '--format', '{{.ID}}'], + { encoding: 'utf8', timeout: 5000 }, + (err, stdout) => resolve(!err && !!String(stdout).trim()), + ); + }); +} + +/** Stream `docker pull ` output to `onData`; resolve with the exit code (-1 on spawn error/abort). */ +export function pullDockerImage( + image: string, + onData: (text: string) => void, + signal: AbortSignal, +): Promise { + return new Promise((resolve) => { + const child = cpSpawn('docker', ['pull', image], { signal }); + child.stdout?.on('data', (d: Buffer) => onData(d.toString('utf8'))); + child.stderr?.on('data', (d: Buffer) => onData(d.toString('utf8'))); + child.on('error', () => resolve(-1)); // includes AbortError when signal fires + child.on('close', (code) => resolve(code ?? -1)); + }); +} + +/** Promise that resolves after `ms`, or immediately if the signal aborts. */ +export function delay(ms: number, signal: AbortSignal): Promise { + return new Promise((resolve) => { + if (signal.aborted) return resolve(); + const onAbort = () => { + clearTimeout(timer); + resolve(); + }; + const timer = setTimeout(() => { + signal.removeEventListener('abort', onAbort); + resolve(); + }, ms); + signal.addEventListener('abort', onAbort, { once: true }); + }); +} + +// ─── Durable fallback: fetch the agent image from a GitHub release ──────────── +// When the Docker daemon can't reach a registry, `docker pull` fails for ANY +// registry — a common failure when the daemon runs in a VM (Colima/Lima) whose +// virtual network the host's VPN doesn't route. The host process CAN reach GitHub, +// so we download the image tarball host-side and `docker load` it into the daemon +// over the local socket, which needs no VM egress at all. + +export type ReleaseImageArch = 'amd64' | 'arm64'; + +/** Map a Node `process.arch` to the release-asset arch, or null if unsupported. */ +export function hostImageArch(arch: string = process.arch): ReleaseImageArch | null { + if (arch === 'arm64') return 'arm64'; + if (arch === 'x64') return 'amd64'; + return null; +} + +/** Release-asset file name for an arch (matches the CI upload in release.yml). */ +export function releaseImageAssetName(arch: ReleaseImageArch): string { + return `forge-agent-${arch}.tar.gz`; +} + +export interface ReleaseImageTarget { + owner: string; + repo: string; + /** App version without a leading 'v' (e.g. "1.11.0"); empty skips the pinned URL. */ + version: string; + arch: ReleaseImageArch; +} + +/** Candidate asset URLs: the version-pinned release first, then the repo's latest. */ +export function releaseImageUrls(t: ReleaseImageTarget): { tarball: string; checksum: string }[] { + const base = `https://github.com/${t.owner}/${t.repo}/releases`; + const name = releaseImageAssetName(t.arch); + const urls: { tarball: string; checksum: string }[] = []; + if (t.version) { + urls.push({ + tarball: `${base}/download/v${t.version}/${name}`, + checksum: `${base}/download/v${t.version}/${name}.sha256`, + }); + } + urls.push({ + tarball: `${base}/latest/download/${name}`, + checksum: `${base}/latest/download/${name}.sha256`, + }); + return urls; +} + +/** Parse a `sha256sum`-style " filename" line; return the lowercase digest or null. */ +export function parseSha256(text: string): string | null { + const m = text.trim().match(/^([a-f0-9]{64})\b/i); + return m ? m[1].toLowerCase() : null; +} + +function humanBytes(n: number): string { + if (!Number.isFinite(n) || n <= 0) return '0 B'; + const units = ['B', 'KB', 'MB', 'GB']; + const i = Math.min(units.length - 1, Math.floor(Math.log(n) / Math.log(1024))); + return `${(n / 1024 ** i).toFixed(i === 0 ? 0 : 1)} ${units[i]}`; +} + +type ByteStream = AsyncIterable | ReadableStream; + +async function* streamChunks(body: ByteStream): AsyncGenerator { + if (Symbol.asyncIterator in body) { + yield* body as AsyncIterable; + return; + } + const reader = (body as ReadableStream).getReader(); + try { + for (;;) { + const { done, value } = await reader.read(); + if (done) break; + if (value) yield value; + } + } finally { + reader.releaseLock(); + } +} + +async function fetchChecksum( + doFetch: typeof fetch, + url: string, + signal: AbortSignal, +): Promise { + try { + const res = await doFetch(url, { signal, redirect: 'follow' }); + if (!res.ok) return null; + return parseSha256(await res.text()); + } catch { + return null; + } +} + +/** Stream `docker load -i `; resolve with the exit code (-1 on spawn error/abort). */ +function dockerLoadFromFile( + file: string, + onData: (text: string) => void, + signal: AbortSignal, +): Promise { + return new Promise((resolve) => { + const child = cpSpawn('docker', ['load', '-i', file], { signal }); + child.stdout?.on('data', (d: Buffer) => onData(d.toString('utf8'))); + child.stderr?.on('data', (d: Buffer) => onData(d.toString('utf8'))); + child.on('error', () => resolve(-1)); + child.on('close', (code) => resolve(code ?? -1)); + }); +} + +export interface DownloadLoadDeps { + fetchImpl?: typeof fetch; + dockerLoadFile?: ( + file: string, + onData: (text: string) => void, + signal: AbortSignal, + ) => Promise; +} + +/** + * Download the agent-image tarball host-side and `docker load` it into the daemon. + * Verifies the published SHA-256 (when present) before trusting the load. Tries the + * version-pinned release first, then the repo's latest release. Resolves true if the + * image was loaded successfully. + */ +export async function downloadAndLoadReleaseImage( + target: ReleaseImageTarget, + onStatus: (line: string) => void, + signal: AbortSignal, + deps: DownloadLoadDeps = {}, +): Promise { + const doFetch = deps.fetchImpl ?? fetch; + const loadFile = deps.dockerLoadFile ?? dockerLoadFromFile; + const asset = releaseImageAssetName(target.arch); + + for (const { tarball, checksum } of releaseImageUrls(target)) { + if (signal.aborted) return false; + const tmp = path.join( + os.tmpdir(), + `forge-agent-${target.arch}-${crypto.randomBytes(6).toString('hex')}.tar.gz`, + ); + try { + let res: Response; + try { + res = await doFetch(tarball, { signal, redirect: 'follow' }); + } catch (err) { + if (signal.aborted) return false; + onStatus(`Download error: ${String(err)}`); + continue; + } + if (res.status === 404) continue; // pinned release/asset missing — try the next candidate + if (!res.ok || !res.body) { + onStatus(`GitHub returned ${res.status} for ${asset}`); + continue; + } + + const total = Number(res.headers.get('content-length')) || 0; + onStatus( + `Downloading ${asset}${total ? ` (${humanBytes(total)})` : ''} from GitHub release …`, + ); + + const hash = crypto.createHash('sha256'); + const out = fs.createWriteStream(tmp); + let downloaded = 0; + let lastPct = -1; + try { + for await (const chunk of streamChunks(res.body as ByteStream)) { + if (signal.aborted) throw new Error('aborted'); + const buf = Buffer.from(chunk); + hash.update(buf); + downloaded += buf.length; + if (!out.write(buf)) await once(out, 'drain'); + if (total) { + const pct = Math.floor((downloaded / total) * 100); + if (pct >= lastPct + 10) { + lastPct = pct; + onStatus(` ${pct}% (${humanBytes(downloaded)} / ${humanBytes(total)})`); + } + } + } + await new Promise((resolve, reject) => + out.end((err?: Error | null) => (err ? reject(err) : resolve())), + ); + } catch (err) { + out.destroy(); + if (signal.aborted) return false; + onStatus(`Download interrupted: ${String(err)}`); + continue; + } + + // Verify integrity before trusting the tarball (skip only if unpublished). + const expected = await fetchChecksum(doFetch, checksum, signal); + if (signal.aborted) return false; + if (expected && hash.digest('hex') !== expected) { + onStatus(`Checksum mismatch for ${asset} — refusing to load.`); + continue; + } + + onStatus(`Loading ${asset} into Docker (docker load) …`); + const code = await loadFile(tmp, onStatus, signal); + if (signal.aborted) return false; + if (code === 0) { + onStatus('Loaded agent image from GitHub release.'); + return true; + } + onStatus(`docker load failed (exit ${code}).`); + } finally { + fs.promises.unlink(tmp).catch(() => {}); + } + } + return false; +} + +export interface AcquireImageDeps extends EnsureImageDeps { + /** Download the image from the GitHub release and `docker load` it. True if loaded. */ + downloadRelease?: (signal: AbortSignal) => Promise; + /** Build the bundled Dockerfile locally. True if built. */ + localBuild?: (signal: AbortSignal) => Promise; +} + +/** + * Acquire an image with a layered fallback chain: + * cached → `docker pull` (retries) → GitHub-release download + `docker load` → local build. + * + * The pull runs in the daemon (fails when the daemon's VM has no egress); the release + * download runs host-side and `docker load`s over the socket, so it survives an islanded + * VM. Local build is a last resort (it needs VM egress too). Only the default agent image + * supplies `downloadRelease`/`localBuild`; other images degrade to a plain pull. + */ +export async function acquireAgentImage( + image: string, + deps: AcquireImageDeps, + opts: EnsureImageOptions = {}, +): Promise { + const pullRes = await ensureDockerImageAvailable(image, deps, opts); + if (pullRes.ok) return pullRes; + if (deps.signal.aborted || pullRes.reason === 'cancelled') + return { ok: false, reason: 'cancelled' }; + + if (deps.downloadRelease) { + deps.onStatus('Registry unreachable — fetching the agent image from GitHub instead …'); + const loaded = await deps.downloadRelease(deps.signal).catch(() => false); + if (deps.signal.aborted) return { ok: false, reason: 'cancelled' }; + if (loaded && (await deps.imagePresent(image))) return { ok: true, usedLocal: false }; + } + + if (deps.localBuild) { + deps.onStatus('Building the agent image locally from the bundled Dockerfile …'); + const built = await deps.localBuild(deps.signal).catch(() => false); + if (deps.signal.aborted) return { ok: false, reason: 'cancelled' }; + if (built && (await deps.imagePresent(image))) return { ok: true, usedLocal: true }; + } + + return { ok: false, reason: 'pull-failed' }; +} diff --git a/electron/ipc/git-symlink-excludes.test.ts b/electron/ipc/git-symlink-excludes.test.ts index 630e5a571..8380ec886 100644 --- a/electron/ipc/git-symlink-excludes.test.ts +++ b/electron/ipc/git-symlink-excludes.test.ts @@ -58,7 +58,7 @@ describe('ensureSymlinkExcludes', () => { expect(mockAppendFileSync).toHaveBeenCalledOnce(); const [, appended] = mockAppendFileSync.mock.calls[0] as [string, string]; - expect(appended).toContain('# parallel-code: worktree symlinks'); + expect(appended).toContain('# forge: worktree symlinks'); expect(appended).toContain('/node_modules'); }); @@ -108,12 +108,12 @@ describe('ensureSymlinkExcludes', () => { it('appends without repeating the header when it is already present', () => { mockExecFileSync.mockReturnValueOnce('.git\n'); - mockReadFileSync.mockReturnValueOnce('# parallel-code: worktree symlinks\n/existing\n'); + mockReadFileSync.mockReturnValueOnce('# forge: worktree symlinks\n/existing\n'); ensureSymlinkExcludes('/worktree', ['.env']); const [, appended] = mockAppendFileSync.mock.calls[0] as [string, string]; - expect(appended).not.toContain('# parallel-code: worktree symlinks'); + expect(appended).not.toContain('# forge: worktree symlinks'); expect(appended).toContain('/.env'); }); diff --git a/electron/ipc/git.ts b/electron/ipc/git.ts index 9c71a1bee..add70298a 100644 --- a/electron/ipc/git.ts +++ b/electron/ipc/git.ts @@ -162,7 +162,7 @@ const SANDBOX_EXCLUDE_PATTERNS = [ '/.zprofile', '/.zshrc', ]; -const SANDBOX_EXCLUDE_HEADER = '# parallel-code: sandbox bind-mount artifacts'; +const SANDBOX_EXCLUDE_HEADER = '# forge: sandbox bind-mount artifacts'; const seededSandboxExcludes = new Set(); /** @@ -170,7 +170,7 @@ const seededSandboxExcludes = new Set(); * symlinked name is appended individually on subsequent calls so new names * added in later worktrees are also covered. */ -const SYMLINK_EXCLUDE_HEADER = '# parallel-code: worktree symlinks'; +const SYMLINK_EXCLUDE_HEADER = '# forge: worktree symlinks'; // --- Internal helpers --- diff --git a/electron/ipc/pty.test.ts b/electron/ipc/pty.test.ts index 5655aa4fb..86a6a7519 100644 --- a/electron/ipc/pty.test.ts +++ b/electron/ipc/pty.test.ts @@ -10,6 +10,12 @@ const { mockExecFileSync, mockExecFile, mockChildProcessSpawn, mockPtySpawn, moc if (command === 'which' && args?.[0] === 'nonexistent-binary-xyz') { throw new Error('not found'); } + // Docker image-presence fast-path: report the image as cached locally so + // spawn stays synchronous (no pull) by default. Tests exercising the pull + // path tag their image with "needs-pull" to force a cache miss. + if (command === 'docker' && args?.[0] === 'image' && args?.[1] === 'ls') { + return args?.[3]?.includes('needs-pull') ? '' : 'abc123def456\n'; + } return ''; }); @@ -127,7 +133,7 @@ function buildSpawnArgs( cols: 120, rows: 40, dockerMode: true, - dockerImage: 'parallel-code-agent:test', + dockerImage: 'forge-agent:test', shareDockerAgentAuth: false, onOutput: { __CHANNEL_ID__: 'channel-1' }, ...overrides, @@ -250,6 +256,14 @@ describe('spawnAgent docker mode', () => { expect(getFlagValues(args, '-e')).toContain(`HOME=${DOCKER_CONTAINER_HOME}/agent-${agentId}`); }); + it('seeds baked gsd config into the per-agent HOME at container start', () => { + spawnAgent(createMockWindow(), buildSpawnArgs({ agentId: nextAgentId() })); + const bootstrap = getLastSpawnCall().args.find((a) => a.includes('exec "$@"')); + expect(bootstrap).toBeDefined(); + expect(bootstrap).toContain('cp -an /home/agent/.claude/.'); + expect(bootstrap).toContain('cp -an /home/agent/.gsd/.'); + }); + it('does not forward host or renderer HOME as a generic docker env flag', () => { const hostHome = '/Users/host-home'; const rendererHome = '/Users/renderer-home'; @@ -296,7 +310,7 @@ describe('spawnAgent docker mode', () => { expect(getFlagValues(ctx.args, '-e')).toContain(`HOME=`); expect(logged).not.toContain('secret-api-key'); expect(logged).not.toContain(`HOME=${DOCKER_CONTAINER_HOME}`); - expect(logged).toContain('parallel-code-agent:test'); + expect(logged).toContain('forge-agent:test'); }); it('redacts inline docker env values in spawn debug logs', () => { @@ -367,7 +381,7 @@ describe('spawnAgent docker mode', () => { const containerHome = `${DOCKER_CONTAINER_HOME}/agent-${agentId}`; const volumeFlags = getFlagValues(getLastSpawnCall().args, '-v'); - const expectedHostDir = `${home}/.parallel-code/agent-auth/${command}/${relDir}`; + const expectedHostDir = `${home}/.forge/agent-auth/${command}/${relDir}`; expect(volumeFlags).toContain(`${expectedHostDir}:${containerHome}/${relDir}`); }, ); @@ -381,7 +395,7 @@ describe('spawnAgent docker mode', () => { buildSpawnArgs({ command: 'claude', shareDockerAgentAuth: true }), ); - const hostDir = `${home}/.parallel-code/agent-auth/claude/.claude`; + const hostDir = `${home}/.forge/agent-auth/claude/.claude`; expect(fs.existsSync(hostDir)).toBe(true); }); @@ -397,7 +411,7 @@ describe('spawnAgent docker mode', () => { const containerHome = `${DOCKER_CONTAINER_HOME}/agent-${agentId}`; const volumeFlags = getFlagValues(getLastSpawnCall().args, '-v'); - const expectedHostFile = `${home}/.parallel-code/agent-auth/claude/.claude.json`; + const expectedHostFile = `${home}/.forge/agent-auth/claude/.claude.json`; expect(volumeFlags).toContain(`${expectedHostFile}:${containerHome}/.claude.json`); expect(JSON.parse(fs.readFileSync(expectedHostFile, 'utf8'))).toMatchObject({ projects: { @@ -422,7 +436,7 @@ describe('spawnAgent docker mode', () => { }), ); - const hostFile = `${home}/.parallel-code/agent-auth/claude/.claude.json`; + const hostFile = `${home}/.forge/agent-auth/claude/.claude.json`; const config = JSON.parse(fs.readFileSync(hostFile, 'utf8')) as { projects?: Record< string, @@ -438,7 +452,7 @@ describe('spawnAgent docker mode', () => { it('preserves existing Claude project config when pre-seeding folder trust', () => { const home = makeTempHome([]); vi.stubEnv('HOME', home); - const hostFile = `${home}/.parallel-code/agent-auth/claude/.claude.json`; + const hostFile = `${home}/.forge/agent-auth/claude/.claude.json`; fs.mkdirSync(path.dirname(hostFile), { recursive: true }); fs.writeFileSync( hostFile, @@ -491,7 +505,7 @@ describe('spawnAgent docker mode', () => { ); const volumeFlags = getFlagValues(getLastSpawnCall().args, '-v'); - expect(volumeFlags.some((v) => v.includes('.parallel-code/agent-auth'))).toBe(false); + expect(volumeFlags.some((v) => v.includes('.forge/agent-auth'))).toBe(false); }); it('does not mount agent auth directory for an unknown agent command', () => { @@ -504,13 +518,13 @@ describe('spawnAgent docker mode', () => { ); const volumeFlags = getFlagValues(getLastSpawnCall().args, '-v'); - expect(volumeFlags.some((v) => v.includes('.parallel-code/agent-auth'))).toBe(false); + expect(volumeFlags.some((v) => v.includes('.forge/agent-auth'))).toBe(false); }); it('does not crash spawn when .claude.json contains malformed JSON', () => { const home = makeTempHome([]); vi.stubEnv('HOME', home); - const hostFile = `${home}/.parallel-code/agent-auth/claude/.claude.json`; + const hostFile = `${home}/.forge/agent-auth/claude/.claude.json`; fs.mkdirSync(path.dirname(hostFile), { recursive: true }); fs.writeFileSync(hostFile, '{invalid json'); @@ -526,7 +540,7 @@ describe('spawnAgent docker mode', () => { it('preserves existing project config for other paths after trust seeding', () => { const home = makeTempHome([]); vi.stubEnv('HOME', home); - const hostFile = `${home}/.parallel-code/agent-auth/claude/.claude.json`; + const hostFile = `${home}/.forge/agent-auth/claude/.claude.json`; fs.mkdirSync(path.dirname(hostFile), { recursive: true }); fs.writeFileSync( hostFile, @@ -581,7 +595,7 @@ describe('spawnAgent docker mode', () => { }), ); - const hostFile = `${home}/.parallel-code/agent-auth/claude/.claude.json`; + const hostFile = `${home}/.forge/agent-auth/claude/.claude.json`; const config = JSON.parse(fs.readFileSync(hostFile, 'utf8')) as { projects?: Record< string, @@ -611,7 +625,7 @@ describe('spawnAgent docker mode', () => { }), ); - const hostFile = `${home}/.parallel-code/agent-auth/claude/.claude.json`; + const hostFile = `${home}/.forge/agent-auth/claude/.claude.json`; expect(fs.existsSync(hostFile)).toBe(false); }); @@ -630,7 +644,7 @@ describe('spawnAgent docker mode', () => { ); // Verify trust is written to host file after first spawn - const claudeJsonPath = `${home}/.parallel-code/agent-auth/claude/.claude.json`; + const claudeJsonPath = `${home}/.forge/agent-auth/claude/.claude.json`; const afterFirst = JSON.parse(fs.readFileSync(claudeJsonPath, 'utf-8')) as { projects: Record; }; @@ -815,18 +829,18 @@ describe('validateCommand', () => { }); describe('resolveProjectDockerfile', () => { - it('returns absolute path when .parallel-code/Dockerfile exists in project root', () => { + it('returns absolute path when .forge/Dockerfile exists in project root', () => { const projectRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'pty-resolve-')); tempPaths.push(projectRoot); - const dockerDir = path.join(projectRoot, '.parallel-code'); + const dockerDir = path.join(projectRoot, '.forge'); fs.mkdirSync(dockerDir, { recursive: true }); fs.writeFileSync(path.join(dockerDir, 'Dockerfile'), 'FROM node:20\n'); const result = resolveProjectDockerfile(projectRoot); - expect(result).toBe(path.join(projectRoot, '.parallel-code', 'Dockerfile')); + expect(result).toBe(path.join(projectRoot, '.forge', 'Dockerfile')); }); - it('returns null when .parallel-code/Dockerfile does not exist', () => { + it('returns null when .forge/Dockerfile does not exist', () => { const projectRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'pty-resolve-')); tempPaths.push(projectRoot); @@ -839,10 +853,10 @@ describe('resolveProjectDockerfile', () => { expect(result).toBeNull(); }); - it('returns null when .parallel-code/Dockerfile is a directory', () => { + it('returns null when .forge/Dockerfile is a directory', () => { const projectRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'pty-resolve-')); tempPaths.push(projectRoot); - fs.mkdirSync(path.join(projectRoot, '.parallel-code', 'Dockerfile'), { recursive: true }); + fs.mkdirSync(path.join(projectRoot, '.forge', 'Dockerfile'), { recursive: true }); const result = resolveProjectDockerfile(projectRoot); expect(result).toBeNull(); @@ -850,19 +864,19 @@ describe('resolveProjectDockerfile', () => { }); describe('projectImageTag', () => { - it('returns a tag in the format parallel-code-project:<12-char-hash>', () => { + it('returns a tag in the format forge-project:<12-char-hash>', () => { const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'pty-tag-')); tempPaths.push(tmpDir); const dockerfilePath = path.join(tmpDir, 'Dockerfile'); fs.writeFileSync(dockerfilePath, 'FROM node:20\nRUN echo hello\n'); const tag = projectImageTag(dockerfilePath); - expect(tag).toMatch(/^parallel-code-project:[a-f0-9]{12}$/); + expect(tag).toMatch(/^forge-project:[a-f0-9]{12}$/); }); - it('returns parallel-code-project:unknown for non-existent Dockerfile path', () => { + it('returns forge-project:unknown for non-existent Dockerfile path', () => { const tag = projectImageTag('/nonexistent/Dockerfile'); - expect(tag).toBe('parallel-code-project:unknown'); + expect(tag).toBe('forge-project:unknown'); }); }); @@ -896,7 +910,7 @@ describe('dockerImageExists', () => { ); await expect( - dockerImageExists('parallel-code-project:test', { + dockerImageExists('forge-project:test', { dockerfilePath: '/nonexistent/Dockerfile', }), ).resolves.toBe(false); @@ -907,14 +921,14 @@ describe('buildDockerImage', () => { it('uses the provided build context for a project dockerfile', () => { const projectRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'pty-build-context-')); tempPaths.push(projectRoot); - const dockerDir = path.join(projectRoot, '.parallel-code'); + const dockerDir = path.join(projectRoot, '.forge'); fs.mkdirSync(dockerDir, { recursive: true }); const dockerfilePath = path.join(dockerDir, 'Dockerfile'); fs.writeFileSync(dockerfilePath, 'FROM node:20\n'); buildDockerImage(createMockWindow(), 'channel:build-test', { dockerfilePath, - imageTag: 'parallel-code-project:test', + imageTag: 'forge-project:test', buildContext: projectRoot, } as unknown as Parameters[2]); @@ -928,7 +942,7 @@ describe('buildDockerImage', () => { describe('killAgent — Docker container lifecycle', () => { it('calls docker stop with the predictable container name when agent is killed', () => { const agentId = nextAgentId(); - const containerName = `parallel-code-${agentId.slice(0, 12)}`; + const containerName = `forge-${agentId.slice(0, 12)}`; spawnAgent(createMockWindow(), buildSpawnArgs({ agentId })); killAgent(agentId); @@ -940,14 +954,14 @@ describe('killAgent — Docker container lifecycle', () => { expect(stopCall?.[1]).toContain(containerName); }); - it('container name is always parallel-code-', () => { + it('container name is always forge-', () => { const agentId = 'agent-abcdef-ghij-klmn'; - const expected = `parallel-code-${agentId.slice(0, 12)}`; - expect(expected).toBe('parallel-code-agent-abcdef'); + const expected = `forge-${agentId.slice(0, 12)}`; + expect(expected).toBe('forge-agent-abcdef'); // The container name must be deterministic and predictable for cleanup // (no random suffix) so we can always `docker stop` it by name. - expect(expected.startsWith('parallel-code-')).toBe(true); - expect(expected.length).toBe(14 + 12); // 'parallel-code-' + 12 chars + expect(expected.startsWith('forge-')).toBe(true); + expect(expected.length).toBe(6 + 12); // 'forge-' + 12 chars }); it('does not call docker stop for a non-Docker agent', () => { @@ -1024,7 +1038,7 @@ describe('seedClaudeProjectTrust — concurrent spawns', () => { }), ); - const hostFile = `${home}/.parallel-code/agent-auth/claude/.claude.json`; + const hostFile = `${home}/.forge/agent-auth/claude/.claude.json`; const config = JSON.parse(fs.readFileSync(hostFile, 'utf8')) as { projects: Record; }; @@ -1161,7 +1175,7 @@ describe('spawnAgent docker mode — path edge cases', () => { }), ); - const claudeJson = `${home}/.parallel-code/agent-auth/claude/.claude.json`; + const claudeJson = `${home}/.forge/agent-auth/claude/.claude.json`; // .claude.json must not be created for non-Claude agents expect(fs.existsSync(claudeJson)).toBe(false); }); @@ -1183,7 +1197,7 @@ describe('seedClaudeProjectTrust — file permissions', () => { }), ); - const hostFile = `${home}/.parallel-code/agent-auth/claude/.claude.json`; + const hostFile = `${home}/.forge/agent-auth/claude/.claude.json`; expect(fs.existsSync(hostFile)).toBe(true); const stat = fs.statSync(hostFile); // mode & 0o777 strips file-type bits; 0o600 = owner r/w, no group/other access @@ -1199,7 +1213,7 @@ describe('buildDockerCredentialMounts — read-only auth dir', () => { vi.stubEnv('HOME', home); // Create the parent as a file to block mkdirSync - const authBase = path.join(home, '.parallel-code'); + const authBase = path.join(home, '.forge'); fs.mkdirSync(authBase, { recursive: true }); // Create 'agent-auth' as a file so mkdirSync for 'claude' inside it will fail fs.writeFileSync(path.join(authBase, 'agent-auth'), 'not-a-dir'); @@ -1219,3 +1233,92 @@ describe('buildDockerCredentialMounts — read-only auth dir', () => { expect(warnMessages.some((m) => /\[docker-auth\].*Could not/.test(m))).toBe(true); }); }); + +describe('spawnAgent docker mode — image pull resilience', () => { + const flush = () => new Promise((resolve) => setImmediate(resolve)); + + // A docker pull child whose stdout/stderr/close handlers we can drive. + function fakePullChild(closeHandlers: ((code: number) => void)[]) { + return { + stdout: { on: vi.fn() }, + stderr: { on: vi.fn() }, + on: vi.fn((event: string, cb: (code: number) => void) => { + if (event === 'close') closeHandlers.push(cb); + }), + }; + } + + it('pulls a missing image first, then launches the container', async () => { + const image = 'registry.test/needs-pull-ok:latest'; + // Async presence check (and fallback) report the image absent. + mockExecFile.mockImplementation((_cmd: string, _args: string[], opts: unknown, cb: unknown) => { + const done = (typeof opts === 'function' ? opts : cb) as (e: unknown, out: string) => void; + done?.(null, ''); + }); + const closeHandlers: ((code: number) => void)[] = []; + mockChildProcessSpawn.mockImplementation(() => fakePullChild(closeHandlers)); + + spawnAgent(createMockWindow(), buildSpawnArgs({ dockerImage: image, agentId: nextAgentId() })); + + await flush(); + // A pull was started and the container has NOT launched yet. + expect(mockChildProcessSpawn).toHaveBeenCalledWith( + 'docker', + ['pull', image], + expect.anything(), + ); + expect(mockPtySpawn).not.toHaveBeenCalled(); + + closeHandlers[0]?.(0); // pull succeeds + await flush(); + expect(mockPtySpawn).toHaveBeenCalled(); // container launched after the pull + }); + + it('reports a friendly error (not a raw daemon dump) when the pull keeps failing', async () => { + vi.useFakeTimers(); + try { + const image = 'registry.test/needs-pull-fail:latest'; + mockExecFile.mockImplementation( + (_cmd: string, _args: string[], opts: unknown, cb: unknown) => { + const done = (typeof opts === 'function' ? opts : cb) as ( + e: unknown, + out: string, + ) => void; + done?.(null, ''); // never present + }, + ); + const closeHandlers: ((code: number) => void)[] = []; + mockChildProcessSpawn.mockImplementation(() => fakePullChild(closeHandlers)); + + const win = createMockWindow(); + spawnAgent( + win, + buildSpawnArgs({ + dockerImage: image, + agentId: nextAgentId(), + onOutput: { __CHANNEL_ID__: 'ch-pull-fail' }, + }), + ); + + // Drive three failing pull attempts through their backoff windows. + for (let i = 0; i < 3; i += 1) { + await vi.advanceTimersByTimeAsync(0); + expect(closeHandlers.length).toBe(i + 1); + closeHandlers[i](1); + await vi.advanceTimersByTimeAsync(5000); + } + await vi.advanceTimersByTimeAsync(0); + + const calls = vi.mocked(win.webContents.send).mock.calls as Array< + [string, { type?: string; data?: { exit_code?: number } }] + >; + // Never launched a container, and surfaced a clean Exit instead of hanging. + expect(mockPtySpawn).not.toHaveBeenCalled(); + const exit = calls.find(([, msg]) => msg?.type === 'Exit'); + expect(exit).toBeTruthy(); + expect(exit?.[1].data?.exit_code).toBe(1); + } finally { + vi.useRealTimers(); + } + }); +}); diff --git a/electron/ipc/pty.ts b/electron/ipc/pty.ts index 5cf494408..8bcf720e0 100644 --- a/electron/ipc/pty.ts +++ b/electron/ipc/pty.ts @@ -8,6 +8,16 @@ import type { BrowserWindow } from 'electron'; import { RingBuffer } from '../remote/ring-buffer.js'; import { resolveUserShell } from '../user-shell.js'; import { ensureClaudeSandboxFiles, ensureSandboxExcludes } from './git.js'; +import { + acquireAgentImage, + dockerImagePresentByTag, + dockerImagePresentSync, + pullDockerImage, + delay as abortableDelay, + downloadAndLoadReleaseImage, + hostImageArch, + PROJECT_IMAGE_PREFIX, +} from './docker-pull.js'; import { debug as logDebug } from '../log.js'; const __filename = fileURLToPath(import.meta.url); @@ -28,6 +38,12 @@ interface PtySession { const sessions = new Map(); +/** Agents whose Docker image pull is in flight (no live PTY session yet). */ +const pendingPulls = new Map(); + +/** Images confirmed present locally this session — skip the per-spawn presence check. */ +const knownPresentImages = new Set(); + function sendToChannel(win: BrowserWindow, channelId: string, msg: unknown): void { if (!win.isDestroyed()) { win.webContents.send(`channel:${channelId}`, msg); @@ -199,6 +215,18 @@ export function spawnAgent( const command = args.command || resolveUserShell(); const cwd = args.cwd || process.env.HOME || '/'; + // A renderer reload while a Docker image pull is in flight has no live + // session to reattach to. Abort the stale pull (it resumes from cache next + // time) so its captured old channel goes silent, then fall through to a + // fresh spawn on the new channel. + if (args.attachExisting) { + const inflightPull = pendingPulls.get(args.agentId); + if (inflightPull) { + inflightPull.abort(); + pendingPulls.delete(args.agentId); + } + } + // Renderer reloads should reattach to still-running PTYs before validating // the launch command. The process already exists; a missing binary after // reload should not strand the live session on the old renderer channel. @@ -259,7 +287,7 @@ export function spawnAgent( 'NODE_OPTIONS', 'ELECTRON_RUN_AS_NODE', // Prevent renderer from injecting or overriding MCP auth tokens. - 'PARALLEL_CODE_MCP_TOKEN', + 'FORGE_MCP_TOKEN', ]); const safeEnvOverrides: Record = {}; for (const [k, v] of Object.entries(args.env ?? {})) { @@ -290,7 +318,7 @@ export function spawnAgent( // 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; + const containerName = args.dockerMode ? `forge-${args.agentId.slice(0, 12)}` : null; if (args.dockerMode) { const name = containerName as string; @@ -305,7 +333,7 @@ export function spawnAgent( name, // Label so we can identify all containers owned by this app '--label', - 'parallel-code=true', + 'forge=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', @@ -343,11 +371,11 @@ export function spawnAgent( `${DOCKER_CONTAINER_HOME}/agent-${args.agentId}`, ), image, - // Pre-create the per-agent HOME directory then exec the real command. - // $HOME is already set by the -e flag above; using it here avoids repeating the path. + // Seed the per-agent HOME from the baked skeleton (gsd config), then exec. + // cp -an is no-clobber so a shared-auth .claude bind mount keeps its credentials. 'sh', '-c', - 'mkdir -p "$HOME" && exec "$@"', + 'mkdir -p "$HOME/.claude" "$HOME/.gsd"; cp -an /home/agent/.claude/. "$HOME/.claude/" 2>/dev/null || true; cp -an /home/agent/.gsd/. "$HOME/.gsd/" 2>/dev/null || true; exec "$@"', '--', command, ...args.args, @@ -357,147 +385,237 @@ export function spawnAgent( spawnArgs = args.args; } - logDebug('pty', `spawn command ${args.agentId}`, { - taskId: args.taskId, - command: spawnCommand, - args: redactedSpawnArgs(spawnCommand, spawnArgs), - cwd, - dockerMode: args.dockerMode === true, - }); - - const proc = pty.spawn(spawnCommand, spawnArgs, { - name: 'xterm-256color', - cols: args.cols, - rows: args.rows, - cwd: args.dockerMode ? undefined : cwd, - env: args.dockerMode ? filteredEnv : spawnEnv, - }); + const launch = () => { + logDebug('pty', `spawn command ${args.agentId}`, { + taskId: args.taskId, + command: spawnCommand, + args: redactedSpawnArgs(spawnCommand, spawnArgs), + cwd, + dockerMode: args.dockerMode === true, + }); - const session: PtySession = { - proc, - channelId, - taskId: args.taskId, - agentId: args.agentId, - isShell: args.isShell ?? false, - flushTimer: null, - subscribers: new Set(), - scrollback: new RingBuffer(), - containerName, - }; - sessions.set(args.agentId, session); + const proc = pty.spawn(spawnCommand, spawnArgs, { + name: 'xterm-256color', + cols: args.cols, + rows: args.rows, + cwd: args.dockerMode ? undefined : cwd, + env: args.dockerMode ? filteredEnv : spawnEnv, + }); - // Batching strategy matching the Rust implementation - let batchChunks: Buffer[] = []; - let batchSize = 0; - let tailChunks: Buffer[] = []; - let tailSize = 0; + const session: PtySession = { + proc, + channelId, + taskId: args.taskId, + agentId: args.agentId, + isShell: args.isShell ?? false, + flushTimer: null, + subscribers: new Set(), + scrollback: new RingBuffer(), + containerName, + }; + sessions.set(args.agentId, session); - const send = (msg: unknown) => { - sendToChannel(win, session.channelId, msg); - }; + // Batching strategy matching the Rust implementation + let batchChunks: Buffer[] = []; + let batchSize = 0; + let tailChunks: Buffer[] = []; + let tailSize = 0; - // 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 send = (msg: unknown) => { + sendToChannel(win, session.channelId, msg); + }; - const flush = () => { - 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); + // 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') }); } - batchChunks = []; - batchSize = 0; - if (session.flushTimer) { - clearTimeout(session.flushTimer); - session.flushTimer = null; - } - }; - proc.onData((data: string) => { - const chunk = Buffer.from(data, 'utf8'); - - // Maintain tail buffer for exit diagnostics - 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; - } + const flush = () => { + 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); + } + batchChunks = []; + batchSize = 0; + if (session.flushTimer) { + clearTimeout(session.flushTimer); + session.flushTimer = null; + } + }; - batchChunks.push(chunk); - batchSize += chunk.length; + proc.onData((data: string) => { + const chunk = Buffer.from(data, 'utf8'); + + // Maintain tail buffer for exit diagnostics + 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; + } - // Flush large batches immediately - if (batchSize >= BATCH_MAX) { - flush(); - return; - } + batchChunks.push(chunk); + batchSize += chunk.length; - // Small read = likely interactive prompt, flush immediately - if (chunk.length < 1024) { - flush(); - return; - } + // Flush large batches immediately + if (batchSize >= BATCH_MAX) { + flush(); + return; + } - // Otherwise schedule flush on timer - if (!session.flushTimer) { - session.flushTimer = setTimeout(flush, BATCH_INTERVAL); - } - }); + // Small read = likely interactive prompt, flush immediately + if (chunk.length < 1024) { + flush(); + return; + } - proc.onExit(({ exitCode, signal }) => { - // If this session was replaced by a new spawn with the same agentId, - // skip cleanup — the new session owns the map entry now. - if (sessions.get(args.agentId) !== session) return; + // Otherwise schedule flush on timer + if (!session.flushTimer) { + session.flushTimer = setTimeout(flush, BATCH_INTERVAL); + } + }); - if (containerName) { - console.warn( - `[docker] container ${containerName} exited — code=${exitCode} signal=${signal ?? 'none'}`, - ); - } + proc.onExit(({ exitCode, signal }) => { + // If this session was replaced by a new spawn with the same agentId, + // skip cleanup — the new session owns the map entry now. + if (sessions.get(args.agentId) !== session) return; - // 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') - .map((l) => l.replace(/\r$/, '')) - .filter((l) => l.length > 0) - .slice(-MAX_LINES); - - send({ - type: 'Exit', - data: { - exit_code: exitCode, - signal: signal !== undefined ? String(signal) : null, - last_output: lines, - }, + 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') + .map((l) => l.replace(/\r$/, '')) + .filter((l) => l.length > 0) + .slice(-MAX_LINES); + + send({ + type: 'Exit', + data: { + exit_code: exitCode, + signal: signal !== undefined ? String(signal) : null, + last_output: lines, + }, + }); + + emitPtyEvent('exit', args.agentId, { exitCode, signal }); + sessions.delete(args.agentId); }); - emitPtyEvent('exit', args.agentId, { exitCode, signal }); - sessions.delete(args.agentId); - }); + emitPtyEvent('spawn', args.agentId); + }; - emitPtyEvent('spawn', args.agentId); + const resolvedImage = args.dockerImage || DOCKER_DEFAULT_IMAGE; + + // Non-Docker tasks (and locally-built project images) spawn immediately. + // Registry images get a resilient pre-pull so a transient Docker Hub blip + // doesn't hard-fail the task with a cryptic `docker run` daemon error. + if (args.dockerMode && !resolvedImage.startsWith(PROJECT_IMAGE_PREFIX)) { + // Fast path: a cached image spawns synchronously (no async tick) — this is + // the common case and keeps task launch snappy. + if (knownPresentImages.has(resolvedImage) || dockerImagePresentSync(resolvedImage)) { + knownPresentImages.add(resolvedImage); + launch(); + return; + } + const ac = new AbortController(); + pendingPulls.set(args.agentId, ac); + const cleanup = () => { + if (pendingPulls.get(args.agentId) === ac) pendingPulls.delete(args.agentId); + }; + const sendData = (text: string) => + sendToChannel(win, channelId, { + type: 'Data', + data: Buffer.from(text, 'utf8').toString('base64'), + }); + void (async () => { + try { + const isDefaultImage = resolvedImage === DOCKER_DEFAULT_IMAGE; + const res = await acquireAgentImage(resolvedImage, { + imagePresent: dockerImagePresentByTag, + pull: (img, signal) => pullDockerImage(img, sendData, signal), + delay: abortableDelay, + onStatus: (line) => sendData(`\x1b[2m[docker] ${line}\x1b[0m\r\n`), + signal: ac.signal, + // Default image only: when a registry is unreachable (e.g. the daemon's VM + // has no egress), fetch the image host-side from the GitHub release and + // `docker load` it; local build is the final rung. + downloadRelease: isDefaultImage + ? (signal) => fetchDefaultImageFromRelease(sendData, signal) + : undefined, + localBuild: isDefaultImage + ? (signal) => runDefaultImageBuild(sendData, signal) + : undefined, + }); + // Killed or superseded by a reattach — another path owns the lifecycle. + if (ac.signal.aborted || (!res.ok && res.reason === 'cancelled')) { + cleanup(); + return; + } + if (!res.ok) { + cleanup(); + const tried = isDefaultImage + ? 'registry pull, GitHub release download, and local build' + : 'registry pull'; + sendData( + `\r\n\x1b[31m[docker] Could not obtain ${resolvedImage}.\x1b[0m\r\n` + + `\x1b[2m[docker] Tried: ${tried}.\r\n` + + `[docker] If Docker runs in a VM (Colima/Lima/Docker Desktop), a VPN can leave the VM\r\n` + + `[docker] unable to reach the internet even when the host can. Try restarting the Docker\r\n` + + `[docker] VM (e.g. \`colima restart\`) or reconnecting the network, then retry.\x1b[0m\r\n`, + ); + sendToChannel(win, channelId, { + type: 'Exit', + data: { + exit_code: 1, + signal: null, + last_output: [`Failed to pull Docker image ${resolvedImage}`], + }, + }); + emitPtyEvent('exit', args.agentId, { exitCode: 1, signal: undefined }); + return; + } + knownPresentImages.add(resolvedImage); + launch(); + cleanup(); + } catch (err) { + cleanup(); + sendData(`\r\n\x1b[31m[docker] Error preparing image: ${String(err)}\x1b[0m\r\n`); + sendToChannel(win, channelId, { + type: 'Exit', + data: { exit_code: 1, signal: null, last_output: [] }, + }); + emitPtyEvent('exit', args.agentId, { exitCode: 1, signal: undefined }); + } + })(); + return; + } + + launch(); } export function writeToAgent(agentId: string, data: string): void { @@ -542,6 +660,14 @@ export function killAgent(agentId: string): void { stopDockerContainer(session.containerName); } session.proc.kill(); + return; + } + // No live session — a Docker image pull may still be in flight; abort it so + // it doesn't launch a container for a task the user already killed. + const pendingPull = pendingPulls.get(agentId); + if (pendingPull) { + pendingPull.abort(); + pendingPulls.delete(agentId); } } @@ -550,6 +676,8 @@ export function countRunningAgents(): number { } export function killAllAgents(): void { + for (const ac of pendingPulls.values()) ac.abort(); + pendingPulls.clear(); for (const [, session] of sessions) { if (session.flushTimer) clearTimeout(session.flushTimer); session.subscribers.clear(); @@ -806,7 +934,7 @@ function buildDockerCredentialMounts( if (shareAgentAuth) { const baseCommand = path.basename(agentCommand); for (const relDir of AGENT_CONFIG_DIRS[baseCommand] ?? []) { - const hostDir = path.join(home, '.parallel-code', 'agent-auth', baseCommand, relDir); + const hostDir = path.join(home, '.forge', 'agent-auth', baseCommand, relDir); try { fs.mkdirSync(hostDir, { recursive: true, mode: 0o700 }); mounts.push('-v', `${hostDir}:${containerHome}/${relDir}`); @@ -815,7 +943,7 @@ function buildDockerCredentialMounts( } } for (const relFile of AGENT_CONFIG_FILES[baseCommand] ?? []) { - const hostFile = path.join(home, '.parallel-code', 'agent-auth', baseCommand, relFile); + const hostFile = path.join(home, '.forge', 'agent-auth', baseCommand, relFile); try { const hostDir = path.dirname(hostFile); fs.mkdirSync(hostDir, { recursive: true, mode: 0o700 }); @@ -856,10 +984,10 @@ export async function isDockerAvailable(): Promise { } /** The default image name for Docker-isolated tasks. */ -export const DOCKER_DEFAULT_IMAGE = 'parallel-code-agent:latest'; +export const DOCKER_DEFAULT_IMAGE = 'thunderockforge/forge-agent:latest'; /** Label key used to stamp the Dockerfile content hash on built images. */ -const DOCKERFILE_HASH_LABEL = 'parallel-code-dockerfile-hash'; +const DOCKERFILE_HASH_LABEL = 'forge-dockerfile-hash'; /** * Resolve the path to the bundled Dockerfile. @@ -893,11 +1021,11 @@ function getDockerfileHash(): string | null { } /** - * Check if a project has a local Dockerfile at .parallel-code/Dockerfile. + * Check if a project has a local Dockerfile at .forge/Dockerfile. * Returns the absolute path if found, null otherwise. */ export function resolveProjectDockerfile(projectRoot: string): string | null { - const p = path.join(projectRoot, '.parallel-code', 'Dockerfile'); + const p = path.join(projectRoot, '.forge', 'Dockerfile'); try { return fs.statSync(p).isFile() ? p : null; } catch { @@ -907,11 +1035,11 @@ export function resolveProjectDockerfile(projectRoot: string): string | null { /** * Derive a deterministic image tag for a project Dockerfile. - * Tag format: parallel-code-project: + * Tag format: forge-project: */ export function projectImageTag(dockerfilePath: string): string { const hash = hashDockerfile(dockerfilePath); - return `parallel-code-project:${(hash ?? 'unknown').slice(0, 12)}`; + return `forge-project:${(hash ?? 'unknown').slice(0, 12)}`; } /** @@ -1049,3 +1177,66 @@ export function buildDockerImage( return buildPromise; } + +/** + * Fetch the default agent image host-side from the GitHub release and `docker load` + * it into the daemon. Used when a registry pull fails because the daemon (often a + * Colima/Lima VM) has no internet egress but the host does. Streams to the terminal. + */ +async function fetchDefaultImageFromRelease( + sendData: (text: string) => void, + signal: AbortSignal, +): Promise { + const arch = hostImageArch(); + if (!arch) { + sendData( + `\x1b[2m[docker] No prebuilt agent image for this architecture (${process.arch}).\x1b[0m\r\n`, + ); + return false; + } + let version = ''; + try { + const { app } = await import('electron'); + version = app.getVersion(); + } catch { + // Dev/test without an Electron runtime — fall back to the repo's latest release. + } + return downloadAndLoadReleaseImage( + { owner: 'thunderock', repo: 'forge', version, arch }, + (line) => sendData(`\x1b[2m[docker] ${line}\x1b[0m\r\n`), + signal, + ); +} + +/** Build the bundled Dockerfile into the default image, streaming to the terminal. */ +function runDefaultImageBuild( + sendData: (text: string) => void, + signal: AbortSignal, +): Promise { + return new Promise((resolve) => { + const dockerfile = resolveDockerfilePath(); + if (!dockerfile) { + sendData(`\x1b[2m[docker] Bundled Dockerfile not found; cannot build locally.\x1b[0m\r\n`); + return resolve(false); + } + const hash = hashDockerfile(dockerfile) ?? 'unknown'; + const proc = cpSpawn( + 'docker', + [ + 'build', + '-t', + DOCKER_DEFAULT_IMAGE, + '--label', + `${DOCKERFILE_HASH_LABEL}=${hash}`, + '-f', + dockerfile, + path.dirname(dockerfile), + ], + { signal }, + ); + proc.stdout?.on('data', (d: Buffer) => sendData(d.toString('utf8'))); + proc.stderr?.on('data', (d: Buffer) => sendData(d.toString('utf8'))); + proc.on('error', () => resolve(false)); + proc.on('close', (code) => resolve(code === 0)); + }); +} diff --git a/electron/ipc/register-mcp.test.ts b/electron/ipc/register-mcp.test.ts index 0f5d952ab..31f34be04 100644 --- a/electron/ipc/register-mcp.test.ts +++ b/electron/ipc/register-mcp.test.ts @@ -28,7 +28,7 @@ const TEST_COORDINATOR_ID = '12345678-1234-4234-8234-123456789abc'; const tempDirs: string[] = []; function mkTemp(): string { - const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'parallel-code-mcp-test-')); + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'forge-mcp-test-')); tempDirs.push(dir); return dir; } @@ -83,7 +83,7 @@ describe('Layer 3 — MCP startup pipeline (no Electron, real FS)', () => { expect(fs.existsSync(destPath)).toBe(true); const written = JSON.parse(fs.readFileSync(mcpJsonPath, 'utf-8')) as typeof mcpConfig; - const server = written.mcpServers['parallel-code']; + const server = written.mcpServers['forge']; // Server path in config points to the COPIED file inside the worktree, not the original expect(server.args[0]).toBe(destPath); @@ -96,10 +96,10 @@ describe('Layer 3 — MCP startup pipeline (no Electron, real FS)', () => { // Token is passed via env var, not args expect(server.args).not.toContain('--token'); - expect(server.env['PARALLEL_CODE_MCP_TOKEN']).toBe(token); + expect(server.env['FORGE_MCP_TOKEN']).toBe(token); }); - it('mcp-server.cjs is copied to .parallel-code inside the worktree', () => { + it('mcp-server.cjs is copied to .forge inside the worktree', () => { const worktreePath = mkTemp(); const projectRoot = mkTemp(); const fakeSrc = path.join(mkTemp(), 'mcp-server.cjs'); @@ -109,9 +109,9 @@ describe('Layer 3 — MCP startup pipeline (no Electron, real FS)', () => { fs.mkdirSync(path.dirname(destPath), { recursive: true }); fs.copyFileSync(fakeSrc, destPath); - // Must be inside worktree, under .parallel-code/ + // Must be inside worktree, under .forge/ expect(destPath.startsWith(worktreePath)).toBe(true); - expect(destPath).toContain('/.parallel-code/mcp-server.cjs'); + expect(destPath).toContain('/.forge/mcp-server.cjs'); expect(fs.existsSync(destPath)).toBe(true); }); @@ -169,7 +169,7 @@ describe('Layer 3 — MCP startup pipeline (no Electron, real FS)', () => { const written = JSON.parse(fs.readFileSync(mcpJsonPath, 'utf-8')) as typeof cfg; // URL is 127.0.0.1 (non-Docker) - const args = written.mcpServers['parallel-code'].args; + const args = written.mcpServers['forge'].args; const urlIdx = args.indexOf('--url'); expect(args[urlIdx + 1]).toBe('http://127.0.0.1:3001'); // Server path is the host path (no copy) @@ -182,7 +182,7 @@ describe('Layer 3 — MCP startup pipeline (no Electron, real FS)', () => { // ───────────────────────────────────────────────────────────────────────────── describe('Layer 3b — .mcp.json merge and cleanup', () => { - it('merges parallel-code into a pre-existing .mcp.json preserving other servers', () => { + it('merges forge into a pre-existing .mcp.json preserving other servers', () => { const worktreePath = mkTemp(); const projectRoot = mkTemp(); const mcpJsonDir = selectMcpJsonDir(worktreePath, projectRoot); @@ -207,10 +207,10 @@ describe('Layer 3b — .mcp.json merge and cleanup', () => { const written = JSON.parse(fs.readFileSync(mcpJsonPath, 'utf-8')) as typeof parsed; expect(written.mcpServers?.['my-server']).toEqual({ command: 'my-tool', args: [] }); - expect(written.mcpServers?.['parallel-code']).toBeDefined(); + expect(written.mcpServers?.['forge']).toBeDefined(); }); - it('deregister removes parallel-code key and preserves other servers', () => { + it('deregister removes forge key and preserves other servers', () => { const worktreePath = mkTemp(); const projectRoot = mkTemp(); const mcpJsonDir = selectMcpJsonDir(worktreePath, projectRoot); @@ -220,7 +220,7 @@ describe('Layer 3b — .mcp.json merge and cleanup', () => { const twoServers = { mcpServers: { 'my-server': { command: 'my-tool', args: [] }, - 'parallel-code': { command: 'node', args: ['server.cjs'] }, + forge: { command: 'node', args: ['server.cjs'] }, }, }; fs.writeFileSync(mcpJsonPath, JSON.stringify(twoServers, null, 2)); @@ -228,7 +228,7 @@ describe('Layer 3b — .mcp.json merge and cleanup', () => { // Simulate deregisterCoordinator cleanup logic const raw = fs.readFileSync(mcpJsonPath, 'utf-8'); const content = JSON.parse(raw) as { mcpServers?: Record }; - if (content.mcpServers) delete content.mcpServers['parallel-code']; + if (content.mcpServers) delete content.mcpServers['forge']; const hasServers = Object.keys(content.mcpServers ?? {}).length > 0; const hasOtherKeys = Object.keys(content).filter((k) => k !== 'mcpServers').length > 0; if (!hasServers && !hasOtherKeys) { @@ -241,21 +241,21 @@ describe('Layer 3b — .mcp.json merge and cleanup', () => { expect(fs.existsSync(mcpJsonPath)).toBe(true); const written = JSON.parse(fs.readFileSync(mcpJsonPath, 'utf-8')) as typeof content; expect(written.mcpServers?.['my-server']).toEqual({ command: 'my-tool', args: [] }); - expect(written.mcpServers?.['parallel-code']).toBeUndefined(); + expect(written.mcpServers?.['forge']).toBeUndefined(); }); - it('deregister deletes the file when parallel-code was the only entry', () => { + it('deregister deletes the file when forge was the only entry', () => { const worktreePath = mkTemp(); const projectRoot = mkTemp(); const mcpJsonDir = selectMcpJsonDir(worktreePath, projectRoot); const mcpJsonPath = path.join(mcpJsonDir, '.mcp.json'); - const onlyUs = { mcpServers: { 'parallel-code': { command: 'node', args: [] } } }; + const onlyUs = { mcpServers: { forge: { command: 'node', args: [] } } }; fs.writeFileSync(mcpJsonPath, JSON.stringify(onlyUs, null, 2)); const raw = fs.readFileSync(mcpJsonPath, 'utf-8'); const content = JSON.parse(raw) as { mcpServers?: Record }; - if (content.mcpServers) delete content.mcpServers['parallel-code']; + if (content.mcpServers) delete content.mcpServers['forge']; const hasServers = Object.keys(content.mcpServers ?? {}).length > 0; const hasOtherKeys = Object.keys(content).filter((k) => k !== 'mcpServers').length > 0; if (!hasServers && !hasOtherKeys) { @@ -487,7 +487,7 @@ describe('Layer 5 — Failure modes', () => { token: 'tok', coordinatorTaskId: TEST_COORDINATOR_ID, }); - const args = staleCfg.mcpServers['parallel-code'].args; + const args = staleCfg.mcpServers['forge'].args; const urlIdx = args.indexOf('--url'); const url = args[urlIdx + 1]; diff --git a/electron/ipc/register.ts b/electron/ipc/register.ts index f1e4ee697..582e4014b 100644 --- a/electron/ipc/register.ts +++ b/electron/ipc/register.ts @@ -113,7 +113,7 @@ export function getDockerMcpServerDestPath( worktreePath: string | undefined, projectRoot: string, ): string { - return path.join(worktreePath ?? projectRoot, '.parallel-code', 'mcp-server.cjs'); + return path.join(worktreePath ?? projectRoot, '.forge', 'mcp-server.cjs'); } export interface CoordinatorMCPConfigOpts { @@ -128,7 +128,7 @@ export interface CoordinatorMCPConfigOpts { /** Builds the coordinator MCP server config used for launch args and `.mcp.json`. */ export function buildCoordinatorMCPConfig(opts: CoordinatorMCPConfigOpts): { mcpServers: { - 'parallel-code': { + forge: { type: 'stdio'; command: 'node'; args: string[]; @@ -138,7 +138,7 @@ export function buildCoordinatorMCPConfig(opts: CoordinatorMCPConfigOpts): { } { return { mcpServers: { - 'parallel-code': { + forge: { type: 'stdio', command: 'node', args: [ @@ -149,7 +149,7 @@ export function buildCoordinatorMCPConfig(opts: CoordinatorMCPConfigOpts): { opts.coordinatorTaskId, ...(opts.skipPermissions && opts.propagateSkipPermissions ? ['--skip-permissions'] : []), ], - env: { PARALLEL_CODE_MCP_TOKEN: opts.token }, + env: { FORGE_MCP_TOKEN: opts.token }, }, }, }; @@ -327,8 +327,8 @@ function sanitizeDroppedName(name: string): string { .trim() .slice(0, 200); const stamp = `${Date.now()}-${crypto.randomBytes(3).toString('hex')}`; - if (base) return `parallel-code-drop-${stamp}-${base}`; - return `parallel-code-drop-${stamp}.png`; + if (base) return `forge-drop-${stamp}-${base}`; + return `forge-drop-${stamp}.png`; } /** @@ -951,7 +951,7 @@ export function registerAllHandlers(win: BrowserWindow): void { }); // --- Clipboard --- - const clipboardImagePath = path.join(os.tmpdir(), 'parallel-code-clipboard.png'); + const clipboardImagePath = path.join(os.tmpdir(), 'forge-clipboard.png'); // Resolve the most useful representation of the current clipboard contents // for pasting into a terminal. Order of preference: @@ -1344,7 +1344,7 @@ export function registerAllHandlers(win: BrowserWindow): void { // Clean up the host-temp MCP config file written by StartMCPServer (non-Docker only). const tempConfigPath = path.join( app.getPath('temp'), - `parallel-code-mcp-${args.coordinatorTaskId}.json`, + `forge-mcp-${args.coordinatorTaskId}.json`, ); try { fs.unlinkSync(tempConfigPath); @@ -1637,7 +1637,7 @@ export function registerAllHandlers(win: BrowserWindow): void { // merge logic ever grows fallible, Docker residue is never left behind. const mcpConfig = { mcpServers: { - 'parallel-code': { + forge: { type: 'stdio' as const, command: 'node', args: [ @@ -1650,7 +1650,7 @@ export function registerAllHandlers(win: BrowserWindow): void { ? ['--skip-permissions'] : []), ], - env: { PARALLEL_CODE_MCP_TOKEN: remoteServer.token }, + env: { FORGE_MCP_TOKEN: remoteServer.token }, }, }, }; @@ -1659,11 +1659,11 @@ export function registerAllHandlers(win: BrowserWindow): void { // Merge mcpConfig into the pre-validated existingMcpContent (parsed above, // before any coordinator state was mutated). - // Capture the previous parallel-code entry so deregistration can restore it instead of + // Capture the previous forge entry so deregistration can restore it instead of // unconditionally deleting it (which would remove a user-owned entry). const existingServers = (existingMcpContent.mcpServers as Record | undefined) ?? {}; - const previousMcpParallelCode: unknown = existingServers['parallel-code']; + const previousMcpForge: unknown = existingServers['forge']; let mergedMcpJson: string | undefined; if (mcpJsonDir && worktreeMcpPath) { existingMcpContent.mcpServers = { ...existingServers, ...mcpConfig.mcpServers }; @@ -1678,13 +1678,13 @@ export function registerAllHandlers(win: BrowserWindow): void { coordinator.setDockerContainerName(args.coordinatorTaskId, args.dockerContainerName ?? ''); coordinator.setDockerImage(args.coordinatorTaskId, args.dockerImage ?? null); console.warn('[MCP] Docker mode: copied MCP server to', dockerMcpServerPath); - // Keep .parallel-code/ out of git status in the sub-task worktree. + // Keep .forge/ out of git status in the sub-task worktree. // Use .git/info/exclude (local-only, never committed) to avoid dirtying // a tracked .gitignore file on every Docker coordinator startup. appendGitExclude( args.worktreePath ?? args.projectRoot, - '.parallel-code/', - '\n# Parallel Code Docker MCP dir\n.parallel-code/\n', + '.forge/', + '\n# Forge Docker MCP dir\n.forge/\n', ); } else { coordinator.setDockerContainerName(args.coordinatorTaskId, null); @@ -1707,32 +1707,29 @@ export function registerAllHandlers(win: BrowserWindow): void { // No host-temp configPath needed. let configPath: string | undefined; if (!args.dockerContainerName) { - configPath = path.join( - app.getPath('temp'), - `parallel-code-mcp-${args.coordinatorTaskId}.json`, - ); + configPath = path.join(app.getPath('temp'), `forge-mcp-${args.coordinatorTaskId}.json`); atomicWriteFileSync(configPath, configJson, { mode: 0o600 }); } // Write .mcp.json for auto-discovery. Read before writing — merge only the - // parallel-code key so we don't destroy user-defined entries. Track whether + // forge key so we don't destroy user-defined entries. Track whether // we created the file so deregisterCoordinator can clean up correctly. if (mcpJsonDir && worktreeMcpPath && mergedMcpJson !== undefined) { atomicWriteFileSync(worktreeMcpPath, mergedMcpJson, { mode: 0o600 }); - const writtenMcpParallelCode: unknown = mcpConfig.mcpServers['parallel-code']; + const writtenMcpForge: unknown = mcpConfig.mcpServers['forge']; coordinator.setMcpJsonInfo( args.coordinatorTaskId, worktreeMcpPath, !mcpFileExistedBefore, - previousMcpParallelCode, - writtenMcpParallelCode, + previousMcpForge, + writtenMcpForge, ); // Append to .git/info/exclude (local-only gitignore, not committed) appendGitExclude( mcpJsonDir, '.mcp.json', - '\n# Parallel Code MCP config (contains ephemeral token)\n.mcp.json\n', + '\n# Forge MCP config (contains ephemeral token)\n.mcp.json\n', ); console.warn('[MCP] .mcp.json written to:', worktreeMcpPath); diff --git a/electron/ipc/updater.ts b/electron/ipc/updater.ts index 9ba8a4bc7..4b6b0841a 100644 --- a/electron/ipc/updater.ts +++ b/electron/ipc/updater.ts @@ -66,7 +66,6 @@ const status: UpdateStatus = { let mainWindow: BrowserWindow | null = null; let wired = false; -let startupCheckTimer: ReturnType | null = null; function broadcast(): void { if (mainWindow && !mainWindow.isDestroyed()) { @@ -125,7 +124,8 @@ function wireUpdaterEvents(): void { } /** - * Wire the updater to a window and run one silent check shortly after launch. + * Wire the updater to a window. Forge disables the automatic post-launch + * check; updates are user-initiated via `checkForUpdates`. * Safe to call when auto-update is unsupported — it becomes a no-op. */ export function initAutoUpdater(win: BrowserWindow): void { @@ -137,16 +137,9 @@ export function initAutoUpdater(win: BrowserWindow): void { return; } wireUpdaterEvents(); - // Delay the first check so it does not compete with app startup work. - startupCheckTimer = setTimeout(() => { - startupCheckTimer = null; - void checkForUpdates(); - }, 10_000); + // Forge: startup auto-check disabled (local/personal build). Updates are + // user-initiated via the "Check for updates" action. win.once('closed', () => { - if (startupCheckTimer) { - clearTimeout(startupCheckTimer); - startupCheckTimer = null; - } // Detach event handlers so a re-created window re-wires from a clean // slate. `removeAllListeners` is safe because this module is the sole // consumer of the `autoUpdater` singleton. diff --git a/electron/mcp/agent-args.test.ts b/electron/mcp/agent-args.test.ts index c7fa90b6c..b14d6c660 100644 --- a/electron/mcp/agent-args.test.ts +++ b/electron/mcp/agent-args.test.ts @@ -10,11 +10,11 @@ import { const config = { mcpServers: { - 'parallel-code': { + forge: { command: 'node', args: ['/tmp/mcp-server.cjs', '--url', 'http://127.0.0.1:1234', '--coordinator-id', 'task-1'], env: { - PARALLEL_CODE_MCP_TOKEN: 'token-1', + FORGE_MCP_TOKEN: 'token-1', }, }, }, @@ -37,7 +37,7 @@ describe('MCP agent launch args', () => { it('quotes Codex inline config env keys so non-bare TOML keys remain valid', () => { const override = buildCodexMcpConfigOverride({ mcpServers: { - 'parallel-code': { + forge: { command: 'node', args: [], env: { diff --git a/electron/mcp/agent-args.ts b/electron/mcp/agent-args.ts index 5add7eab3..4255efe93 100644 --- a/electron/mcp/agent-args.ts +++ b/electron/mcp/agent-args.ts @@ -1,12 +1,12 @@ -interface ParallelCodeMcpServerConfig { +interface ForgeMcpServerConfig { command: string; args: string[]; env: Record; } -export interface ParallelCodeMcpConfig { +export interface ForgeMcpConfig { mcpServers: { - 'parallel-code': ParallelCodeMcpServerConfig; + forge: ForgeMcpServerConfig; }; } @@ -36,15 +36,15 @@ function tomlStringMap(values: Record): string { .join(', ')} }`; } -export function buildCodexMcpConfigOverride(config: ParallelCodeMcpConfig): string { - const server = config.mcpServers['parallel-code']; - return `mcp_servers.parallel-code={ command = ${tomlString(server.command)}, args = ${tomlStringArray(server.args)}, env = ${tomlStringMap(server.env)} }`; +export function buildCodexMcpConfigOverride(config: ForgeMcpConfig): string { + const server = config.mcpServers['forge']; + return `mcp_servers.forge={ command = ${tomlString(server.command)}, args = ${tomlStringArray(server.args)}, env = ${tomlStringMap(server.env)} }`; } export function buildMcpLaunchArgs( command: string, configPath: string | undefined, - config: ParallelCodeMcpConfig, + config: ForgeMcpConfig, ): string[] { if (isCodexCommand(command)) { return ['--config', buildCodexMcpConfigOverride(config)]; diff --git a/electron/mcp/agent-frame-fixtures.ts b/electron/mcp/agent-frame-fixtures.ts index ad78a1a4c..44bab90dd 100644 --- a/electron/mcp/agent-frame-fixtures.ts +++ b/electron/mcp/agent-frame-fixtures.ts @@ -14,7 +14,7 @@ export const READY_AGENT_FRAME_FIXTURES: AgentFrameFixture[] = [ '│ >', '❯', '', - 'opus · /Users/brooksc/git/parallel-code/.worktrees/task-023-10-loading-states · ctx:24k/200k', + 'opus · /Users/brooksc/git/forge/.worktrees/task-023-10-loading-states · ctx:24k/200k', ].join('\n'), }, { @@ -22,7 +22,7 @@ export const READY_AGENT_FRAME_FIXTURES: AgentFrameFixture[] = [ frame: [ '│ >', '-- INSERT --', - 'opus · /Users/brooksc/git/parallel-code/.worktrees/task-029-linting · ctx:0/200k', + 'opus · /Users/brooksc/git/forge/.worktrees/task-029-linting · ctx:0/200k', ].join('\n'), }, { @@ -33,7 +33,7 @@ export const READY_AGENT_FRAME_FIXTURES: AgentFrameFixture[] = [ '❯ ', '────────────────────────────────────────────────────────────────', '--INSERT--⏵⏵ bypass permissions on (shift+tab to cycle)', - 'Sonnet 4 | ~/git/parallel-code/.worktrees/task/example', + 'Sonnet 4 | ~/git/forge/.worktrees/task/example', ].join('\r'), }, { @@ -41,13 +41,13 @@ export const READY_AGENT_FRAME_FIXTURES: AgentFrameFixture[] = [ frame: [ '›', '', - 'gpt-5.5 default · /Users/brooksc/git/parallel-code/.worktrees/task-028-unit-tests', + 'gpt-5.5 default · /Users/brooksc/git/forge/.worktrees/task-028-unit-tests', ].join('\n'), }, { name: 'Gemini typed-message prompt', frame: [ - 'workspace /Users/brooksc/git/parallel-code/.worktrees/task-gemini branch sandbox', + 'workspace /Users/brooksc/git/forge/.worktrees/task-gemini branch sandbox', '> Type your message or @path/to/file', ].join('\n'), }, @@ -59,18 +59,16 @@ export const NOT_READY_AGENT_FRAME_FIXTURES: NotReadyAgentFrameFixture[] = [ reason: 'busy', frame: [ '› Implement the requested fix', - 'gpt-5.5 default · /Users/brooksc/git/parallel-code/.worktrees/task-028-unit-tests', + 'gpt-5.5 default · /Users/brooksc/git/forge/.worktrees/task-028-unit-tests', 'Working (18m 51s • esc to interrupt) • 1 background terminal running • /stop to close', ].join('\n'), }, { name: 'Codex MCP startup screen', reason: 'startup_or_dialog', - frame: [ - 'Starting MCP servers (0/2): codex_apps, parallel-code', - 'Booting MCP server parallel-code', - '›', - ].join('\n'), + frame: ['Starting MCP servers (0/2): codex_apps, forge', 'Booting MCP server forge', '›'].join( + '\n', + ), }, { name: 'Agent trust dialog', diff --git a/electron/mcp/atomic.ts b/electron/mcp/atomic.ts index 14760ec73..ee40d693a 100644 --- a/electron/mcp/atomic.ts +++ b/electron/mcp/atomic.ts @@ -77,7 +77,7 @@ export function atomicWriteFileSync( options?: { mode?: number }, ): void { const mode = resolveMode(filePath, options?.mode); - const tmp = join(dirname(filePath), `.parallel-code-atomic-${randomUUID()}.tmp`); + const tmp = join(dirname(filePath), `.forge-atomic-${randomUUID()}.tmp`); let fd = -1; try { fd = openSync(tmp, 'w', mode); // pre-set mode; still subject to umask @@ -114,7 +114,7 @@ export async function atomicWriteFile( options?: { mode?: number }, ): Promise { const mode = await resolveModeAsync(filePath, options?.mode); - const tmp = join(dirname(filePath), `.parallel-code-atomic-${randomUUID()}.tmp`); + const tmp = join(dirname(filePath), `.forge-atomic-${randomUUID()}.tmp`); let fh: Awaited> | undefined; try { fh = await open(tmp, 'w', mode); // pre-set mode; still subject to umask diff --git a/electron/mcp/config.test.ts b/electron/mcp/config.test.ts index 1edb5bda3..9715f9b4a 100644 --- a/electron/mcp/config.test.ts +++ b/electron/mcp/config.test.ts @@ -13,44 +13,42 @@ describe('getMCPRemoteServerUrl', () => { }); it('uses host.docker.internal on macOS Docker Desktop', () => { - expect(getMCPRemoteServerUrl(7777, 'parallel-code-container', 'darwin')).toBe( + expect(getMCPRemoteServerUrl(7777, 'forge-container', 'darwin')).toBe( 'http://host.docker.internal:7777', ); }); it('uses localhost on Linux (--network host shares host loopback)', () => { - expect(getMCPRemoteServerUrl(7777, 'parallel-code-container', 'linux')).toBe( - 'http://127.0.0.1:7777', - ); + expect(getMCPRemoteServerUrl(7777, 'forge-container', 'linux')).toBe('http://127.0.0.1:7777'); }); }); describe('getSubTaskMcpConfigPath', () => { - it('in Docker mode, places config in coordinator .parallel-code dir (the explicit volume)', () => { - const serverPath = '/worktree/.parallel-code/mcp-server.cjs'; + it('in Docker mode, places config in coordinator .forge dir (the explicit volume)', () => { + const serverPath = '/worktree/.forge/mcp-server.cjs'; expect(getSubTaskMcpConfigPath('my-container', serverPath, 'task-abc')).toBe( - '/worktree/.parallel-code/subtask-task-abc.json', + '/worktree/.forge/subtask-task-abc.json', ); }); it('in Docker mode, never places config in the sub-task worktree (not a volume mount)', () => { - const serverPath = '/coordinator-worktree/.parallel-code/mcp-server.cjs'; + const serverPath = '/coordinator-worktree/.forge/mcp-server.cjs'; const result = getSubTaskMcpConfigPath('my-container', serverPath, 'task-abc'); expect(result).not.toContain('sub-task-worktree'); - expect(result).toContain('.parallel-code'); + expect(result).toContain('.forge'); }); it('in host mode, places config in the OS temp directory', () => { - const serverPath = '/usr/lib/parallel-code/mcp-server.cjs'; + const serverPath = '/usr/lib/forge/mcp-server.cjs'; expect(getSubTaskMcpConfigPath(null, serverPath, 'task-xyz', '/tmp')).toBe( - '/tmp/parallel-code-subtask-task-xyz.json', + '/tmp/forge-subtask-task-xyz.json', ); }); it('in host mode with no container, uses OS tmpdir default', () => { - const serverPath = '/usr/lib/parallel-code/mcp-server.cjs'; + const serverPath = '/usr/lib/forge/mcp-server.cjs'; const result = getSubTaskMcpConfigPath(undefined, serverPath, 'task-123'); - expect(result).toBe(join(tmpdir(), 'parallel-code-subtask-task-123.json')); + expect(result).toBe(join(tmpdir(), 'forge-subtask-task-123.json')); }); }); diff --git a/electron/mcp/config.ts b/electron/mcp/config.ts index bcb0b4ba8..cc9987228 100644 --- a/electron/mcp/config.ts +++ b/electron/mcp/config.ts @@ -18,7 +18,7 @@ export function getMCPRemoteServerUrl( * * In Docker mode the sub-task worktree is NOT an explicit volume mount, so * auto-discovery inside the container is unreliable. Instead, write the config - * to the coordinator's .parallel-code/ dir (same dir as mcp-server.cjs) which + * to the coordinator's .forge/ dir (same dir as mcp-server.cjs) which * IS the explicit volume, and always pass it via --mcp-config. * * In host mode, use the OS temp directory (existing behaviour). @@ -31,7 +31,7 @@ export function getSubTaskMcpConfigPath( ): string { return dockerContainerName ? join(dirname(serverPath), `subtask-${taskId}.json`) - : join(tempDir, `parallel-code-subtask-${taskId}.json`); + : join(tempDir, `forge-subtask-${taskId}.json`); } /** diff --git a/electron/mcp/coordinator-real-agents.integration.test.ts b/electron/mcp/coordinator-real-agents.integration.test.ts index 03e8ed816..2adc76c03 100644 --- a/electron/mcp/coordinator-real-agents.integration.test.ts +++ b/electron/mcp/coordinator-real-agents.integration.test.ts @@ -44,11 +44,11 @@ function runGit(cwd: string, args: string[]): void { } function createRepo(): string { - const repo = mkdtempSync(join(tmpdir(), 'parallel-code-real-agent-repo-')); + const repo = mkdtempSync(join(tmpdir(), 'forge-real-agent-repo-')); runGit(repo, ['init']); runGit(repo, ['checkout', '-b', 'main']); - runGit(repo, ['config', 'user.email', 'parallel-code-test@example.com']); - runGit(repo, ['config', 'user.name', 'Parallel Code Test']); + runGit(repo, ['config', 'user.email', 'forge-test@example.com']); + runGit(repo, ['config', 'user.name', 'Forge Test']); writeFileSync(join(repo, 'README.md'), '# real agent smoke\n'); runGit(repo, ['add', 'README.md']); runGit(repo, ['commit', '-m', 'initial']); @@ -161,13 +161,13 @@ describeRealAgents('Coordinator real agent startup smoke', () => { const coordinator = new Coordinator(); const rendererEvents: RendererEvent[] = []; let taskId: string | undefined; - const token = `PARALLEL_CODE_REAL_AGENT_SMOKE_${name.toUpperCase()}`; + const token = `FORGE_REAL_AGENT_SMOKE_${name.toUpperCase()}`; // Isolate spawned CLIs from host dotfiles/credentials unless explicitly testing // authenticated local agent profiles. const tempHome = RUN_REAL_AGENT_HOST_HOME ? undefined - : mkdtempSync(join(tmpdir(), 'parallel-code-test-home-')); + : mkdtempSync(join(tmpdir(), 'forge-test-home-')); const origHome = process.env.HOME; if (tempHome) { process.env.HOME = tempHome; @@ -185,7 +185,7 @@ describeRealAgents('Coordinator real agent startup smoke', () => { const task = await coordinator.createTask({ name: `${name} real startup smoke`, prompt: [ - 'This is a Parallel Code startup delivery smoke test.', + 'This is a Forge startup delivery smoke test.', 'Do not edit files, run commands, commit, or call tools.', `Reply with exactly this token and no extra text: ${token}`, ].join(' '), diff --git a/electron/mcp/coordinator-real-pty.integration.test.ts b/electron/mcp/coordinator-real-pty.integration.test.ts index c8a175ae8..c3eac4761 100644 --- a/electron/mcp/coordinator-real-pty.integration.test.ts +++ b/electron/mcp/coordinator-real-pty.integration.test.ts @@ -35,11 +35,11 @@ function runGit(cwd: string, args: string[]): void { } function createRepo(): string { - const repo = mkdtempSync(join(tmpdir(), 'parallel-code-real-pty-repo-')); + const repo = mkdtempSync(join(tmpdir(), 'forge-real-pty-repo-')); runGit(repo, ['init']); runGit(repo, ['checkout', '-b', 'main']); - runGit(repo, ['config', 'user.email', 'parallel-code-test@example.com']); - runGit(repo, ['config', 'user.name', 'Parallel Code Test']); + runGit(repo, ['config', 'user.email', 'forge-test@example.com']); + runGit(repo, ['config', 'user.name', 'Forge Test']); writeFileSync(join(repo, 'README.md'), '# test repo\n'); runGit(repo, ['add', 'README.md']); runGit(repo, ['commit', '-m', 'initial']); diff --git a/electron/mcp/coordinator.test.ts b/electron/mcp/coordinator.test.ts index 3b7c34312..ae3898619 100644 --- a/electron/mcp/coordinator.test.ts +++ b/electron/mcp/coordinator.test.ts @@ -788,7 +788,7 @@ describe('Coordinator registerCoordinator — idempotency', () => { coordinatorTaskId: 'coord-1', }); const output = mockSubscribeToAgent.mock.calls[0]?.[1] as (encoded: string) => void; - output(encode('Starting MCP servers (0/2): codex_apps, parallel-code\n›')); + output(encode('Starting MCP servers (0/2): codex_apps, forge\n›')); await vi.advanceTimersByTimeAsync(1_500); @@ -3196,7 +3196,7 @@ describe('Coordinator MCP_TaskCreated spawn settings', () => { }; expect(payload.mcpLaunchArgs).toEqual([ '--config', - expect.stringContaining('mcp_servers.parallel-code'), + expect.stringContaining('mcp_servers.forge'), ]); }); @@ -3265,15 +3265,15 @@ describe('Coordinator sub-task MCP config isolation', () => { await coordinator.createTask({ name: 'task-b', prompt: 'do b', coordinatorTaskId: 'coord-1' }); const configWrites = mockAtomicWriteFile.mock.calls.filter((c) => - (c[0] as string).includes('parallel-code-subtask-'), + (c[0] as string).includes('forge-subtask-'), ); expect(configWrites).toHaveLength(2); const taskIds = configWrites.map((c) => { const cfg = JSON.parse(c[1] as string) as { - mcpServers: { 'parallel-code': { args: string[] } }; + mcpServers: { forge: { args: string[] } }; }; - const args = cfg.mcpServers['parallel-code'].args; + const args = cfg.mcpServers['forge'].args; const idx = args.indexOf('--task-id'); return idx >= 0 ? args[idx + 1] : null; }); @@ -3303,7 +3303,7 @@ describe('Coordinator sub-task MCP config isolation', () => { await coordinator.createTask({ name: 'task-b', prompt: 'do b', coordinatorTaskId: 'coord-1' }); const configPaths = mockAtomicWriteFile.mock.calls - .filter((c) => (c[0] as string).includes('parallel-code-subtask-')) + .filter((c) => (c[0] as string).includes('forge-subtask-')) .map((c) => c[0] as string); expect(configPaths[0]).not.toBe(configPaths[1]); @@ -3343,17 +3343,15 @@ describe('Coordinator MCP config restart rewrite', () => { expect(task?.mcpConfigPath).toBeDefined(); const initialWrite = mockAtomicWriteFile.mock.calls.find((c) => - (c[0] as string).includes('parallel-code-subtask-'), + (c[0] as string).includes('forge-subtask-'), ); expect(initialWrite).toBeDefined(); if (!initialWrite) throw new Error('expected initial config write'); const initialConfig = JSON.parse(initialWrite[1] as string) as { - mcpServers: { 'parallel-code': { args: string[]; env: Record } }; + mcpServers: { forge: { args: string[]; env: Record } }; }; - expect(initialConfig.mcpServers['parallel-code'].env['PARALLEL_CODE_MCP_TOKEN']).toBe( - 'old-token', - ); - expect(initialConfig.mcpServers['parallel-code'].args).toContain('http://localhost:3001'); + expect(initialConfig.mcpServers['forge'].env['FORGE_MCP_TOKEN']).toBe('old-token'); + expect(initialConfig.mcpServers['forge'].args).toContain('http://localhost:3001'); // Simulate coordinator restart with new port/token mockAtomicWriteFileSync.mockClear(); @@ -3366,7 +3364,7 @@ describe('Coordinator MCP config restart rewrite', () => { ); const rewriteCall = mockAtomicWriteFileSync.mock.calls.find((c) => - (c[0] as string).includes('parallel-code-subtask-'), + (c[0] as string).includes('forge-subtask-'), ); expect(rewriteCall).toBeDefined(); if (!rewriteCall) throw new Error('expected rewrite call'); @@ -3375,16 +3373,14 @@ describe('Coordinator MCP config restart rewrite', () => { // Rewritten config is valid JSON with updated URL and token, preserving the task id const newConfig = JSON.parse(rewriteCall[1] as string) as { - mcpServers: { 'parallel-code': { args: string[]; env: Record } }; + mcpServers: { forge: { args: string[]; env: Record } }; }; - const newArgs = newConfig.mcpServers['parallel-code'].args; - expect(newConfig.mcpServers['parallel-code'].env['PARALLEL_CODE_MCP_TOKEN']).toBe('new-token'); + const newArgs = newConfig.mcpServers['forge'].args; + expect(newConfig.mcpServers['forge'].env['FORGE_MCP_TOKEN']).toBe('new-token'); expect(newArgs).toContain('http://localhost:3002'); expect(newArgs).toContain('task-1'); // Old values must be gone - expect(newConfig.mcpServers['parallel-code'].env['PARALLEL_CODE_MCP_TOKEN']).not.toBe( - 'old-token', - ); + expect(newConfig.mcpServers['forge'].env['FORGE_MCP_TOKEN']).not.toBe('old-token'); expect(newArgs).not.toContain('http://localhost:3001'); }); @@ -3406,7 +3402,7 @@ describe('Coordinator MCP config restart rewrite', () => { ); const configWrites = mockAtomicWriteFileSync.mock.calls.filter((c) => - (c[0] as string).includes('parallel-code-subtask-'), + (c[0] as string).includes('forge-subtask-'), ); expect(configWrites).toHaveLength(0); }); @@ -3436,16 +3432,16 @@ describe('Coordinator MCP config restart rewrite', () => { ); const rewrites = mockAtomicWriteFileSync.mock.calls.filter((c) => - (c[0] as string).includes('parallel-code-subtask-'), + (c[0] as string).includes('forge-subtask-'), ); expect(rewrites).toHaveLength(2); for (const rewrite of rewrites) { const cfg = JSON.parse(rewrite[1] as string) as { - mcpServers: { 'parallel-code': { args: string[]; env: Record } }; + mcpServers: { forge: { args: string[]; env: Record } }; }; - expect(cfg.mcpServers['parallel-code'].env['PARALLEL_CODE_MCP_TOKEN']).toBe('new-token'); - expect(cfg.mcpServers['parallel-code'].args).toContain('http://localhost:3002'); + expect(cfg.mcpServers['forge'].env['FORGE_MCP_TOKEN']).toBe('new-token'); + expect(cfg.mcpServers['forge'].args).toContain('http://localhost:3002'); } }); @@ -3464,7 +3460,7 @@ describe('Coordinator MCP config restart rewrite', () => { ); const configWrites = mockAtomicWriteFileSync.mock.calls.filter((c) => - (c[0] as string).includes('parallel-code-subtask-'), + (c[0] as string).includes('forge-subtask-'), ); expect(configWrites).toHaveLength(0); }); @@ -3500,15 +3496,15 @@ describe('Coordinator two-class token — subtask configs use subtaskToken', () await coordinator.createTask({ name: 'test', prompt: 'do', coordinatorTaskId: 'coord-1' }); const configWrite = mockAtomicWriteFile.mock.calls.find((c) => - (c[0] as string).includes('parallel-code-subtask-'), + (c[0] as string).includes('forge-subtask-'), ); expect(configWrite).toBeDefined(); if (!configWrite) throw new Error('expected config write'); const config = JSON.parse(configWrite[1] as string) as { - mcpServers: { 'parallel-code': { env: Record } }; + mcpServers: { forge: { env: Record } }; }; - const writtenToken = config.mcpServers['parallel-code'].env['PARALLEL_CODE_MCP_TOKEN']; + const writtenToken = config.mcpServers['forge'].env['FORGE_MCP_TOKEN']; expect(writtenToken).toBe('subtask-secret'); expect(writtenToken).not.toBe('coordinator-secret'); }); @@ -3533,15 +3529,15 @@ describe('Coordinator two-class token — subtask configs use subtaskToken', () ); const rewrite = mockAtomicWriteFileSync.mock.calls.find((c) => - (c[0] as string).includes('parallel-code-subtask-'), + (c[0] as string).includes('forge-subtask-'), ); expect(rewrite).toBeDefined(); if (!rewrite) throw new Error('expected rewrite'); const config = JSON.parse(rewrite[1] as string) as { - mcpServers: { 'parallel-code': { env: Record } }; + mcpServers: { forge: { env: Record } }; }; - const writtenToken = config.mcpServers['parallel-code'].env['PARALLEL_CODE_MCP_TOKEN']; + const writtenToken = config.mcpServers['forge'].env['FORGE_MCP_TOKEN']; expect(writtenToken).toBe('new-subtask'); expect(writtenToken).not.toBe('new-coordinator'); }); @@ -4330,7 +4326,7 @@ describe('Coordinator cleanupTask — Docker sub-task container stop', () => { coordinator.setDefaultProject('proj-1', '/tmp/project'); coordinator.registerCoordinator('coord-1', 'proj-1'); coordinator.setDockerContainerName('coord-1', 'my-coord-container'); - coordinator.setDockerImage('coord-1', 'parallel-code-agent:latest'); + coordinator.setDockerImage('coord-1', 'forge-agent:latest'); }); it('closeTask calls killAgent (which stops the sub-task Docker container via pty.ts)', async () => { @@ -4480,11 +4476,11 @@ describe('Multiple Docker coordinators — isolation', () => { }); it('each coordinator has an isolated docker container name (no singleton leak)', () => { - coordA.setDockerContainerName('coord-a', 'parallel-code-container-a'); - coordB.setDockerContainerName('coord-b', 'parallel-code-container-b'); + coordA.setDockerContainerName('coord-a', 'forge-container-a'); + coordB.setDockerContainerName('coord-b', 'forge-container-b'); // The container names must differ — no shared singleton - expect('parallel-code-container-a').not.toBe('parallel-code-container-b'); + expect('forge-container-a').not.toBe('forge-container-b'); }); it('sub-task MCP config for coord-a uses coord-a coordinator id, not coord-b', async () => { @@ -4498,17 +4494,15 @@ describe('Multiple Docker coordinators — isolation', () => { await coordA.createTask({ name: 'task-a', prompt: 'do a', coordinatorTaskId: 'coord-a' }); const configWrites = mockAtomicWriteFile.mock.calls.filter((c) => - (c[0] as string).includes('parallel-code-subtask-'), + (c[0] as string).includes('forge-subtask-'), ); expect(configWrites).toHaveLength(1); const cfg = JSON.parse(configWrites[0][1] as string) as { - mcpServers: { 'parallel-code': { args: string[]; env: Record } }; + mcpServers: { forge: { args: string[]; env: Record } }; }; - expect(cfg.mcpServers['parallel-code'].env['PARALLEL_CODE_MCP_TOKEN']).toBe('subtask-tok-a'); - expect(cfg.mcpServers['parallel-code'].env['PARALLEL_CODE_MCP_TOKEN']).not.toBe( - 'subtask-tok-b', - ); + expect(cfg.mcpServers['forge'].env['FORGE_MCP_TOKEN']).toBe('subtask-tok-a'); + expect(cfg.mcpServers['forge'].env['FORGE_MCP_TOKEN']).not.toBe('subtask-tok-b'); }); }); @@ -4530,7 +4524,7 @@ describe('Coordinator Docker sub-task — per-container spawn', () => { coordinator.setDefaultProject('proj-1', '/tmp/project'); coordinator.registerCoordinator('coord-1', 'proj-1'); coordinator.setDockerContainerName('coord-1', 'my-coord-container'); - coordinator.setDockerImage('coord-1', 'parallel-code-agent:latest'); + coordinator.setDockerImage('coord-1', 'forge-agent:latest'); }); it('sub-task spawned with dockerMode: true (docker run), not docker exec', async () => { @@ -4728,7 +4722,7 @@ describe('Coordinator close with active sub-tasks', () => { const task = coordinator.getTask('task-1'); const configPath = task?.mcpConfigPath; expect(configPath).toBeDefined(); - expect(configPath).toMatch(/parallel-code-subtask-task-1\.json$/); + expect(configPath).toMatch(/forge-subtask-task-1\.json$/); mockUnlinkSync.mockClear(); await coordinator.closeTask('task-1'); @@ -4747,11 +4741,11 @@ describe('Coordinator close with active sub-tasks', () => { mockUnlinkSync.mockClear(); await coordinator.closeTask('task-1'); - // unlinkSync should NOT have been called with a parallel-code path - const parallelCodeCalls = mockUnlinkSync.mock.calls.filter( - (c) => typeof c[0] === 'string' && (c[0] as string).includes('parallel-code-subtask'), + // unlinkSync should NOT have been called with a forge path + const forgeCalls = mockUnlinkSync.mock.calls.filter( + (c) => typeof c[0] === 'string' && (c[0] as string).includes('forge-subtask'), ); - expect(parallelCodeCalls).toHaveLength(0); + expect(forgeCalls).toHaveLength(0); }); it('task is removed from map even when docker exec sub-tasks are active', async () => { @@ -4938,10 +4932,10 @@ describe('Coordinator removeCoordinatedTask', () => { mockUnlinkSync.mockClear(); coordinator.removeCoordinatedTask('task-1'); - const parallelCodeCalls = mockUnlinkSync.mock.calls.filter( - (c) => typeof c[0] === 'string' && (c[0] as string).includes('parallel-code-subtask'), + const forgeCalls = mockUnlinkSync.mock.calls.filter( + (c) => typeof c[0] === 'string' && (c[0] as string).includes('forge-subtask'), ); - expect(parallelCodeCalls).toHaveLength(0); + expect(forgeCalls).toHaveLength(0); }); it('does NOT notify renderer (UI already removed the task)', async () => { @@ -5002,7 +4996,7 @@ describe('Coordinator restart round-trip integration', () => { ); const taskId = 'hydrated-restart-1'; - const configPath = join(os.tmpdir(), `parallel-code-subtask-${taskId}.json`); + const configPath = join(os.tmpdir(), `forge-subtask-${taskId}.json`); mockAtomicWriteFileSync.mockClear(); coordinator.hydrateTask({ @@ -5021,9 +5015,9 @@ describe('Coordinator restart round-trip integration', () => { expect(rewrite).toBeDefined(); if (!rewrite) throw new Error('expected config rewrite'); const config = JSON.parse(rewrite[1] as string) as { - mcpServers: { 'parallel-code': { env: Record } }; + mcpServers: { forge: { env: Record } }; }; - const writtenToken = config.mcpServers['parallel-code'].env['PARALLEL_CODE_MCP_TOKEN']; + const writtenToken = config.mcpServers['forge'].env['FORGE_MCP_TOKEN']; expect(writtenToken).toBe('new-subtask-secret'); expect(writtenToken).not.toBe('new-coordinator-secret'); }); @@ -5038,7 +5032,7 @@ describe('Coordinator restart round-trip integration', () => { ); const taskId = 'hydrated-restart-codex'; - const configPath = join(os.tmpdir(), `parallel-code-subtask-${taskId}.json`); + const configPath = join(os.tmpdir(), `forge-subtask-${taskId}.json`); const result = coordinator.hydrateTask({ id: taskId, @@ -5100,7 +5094,7 @@ describe('Coordinator restart round-trip integration', () => { ); const taskId = 'hydrated-restart-existing'; - const configPath = join(os.tmpdir(), `parallel-code-subtask-${taskId}.json`); + const configPath = join(os.tmpdir(), `forge-subtask-${taskId}.json`); const args = { id: taskId, name: 'hydrated-task', @@ -5134,7 +5128,7 @@ describe('Coordinator restart round-trip integration', () => { const taskId = 'hydrated-restart-2'; const agentId = 'agent-restart-2'; - const configPath = join(os.tmpdir(), `parallel-code-subtask-${taskId}.json`); + const configPath = join(os.tmpdir(), `forge-subtask-${taskId}.json`); coordinator.hydrateTask({ id: taskId, @@ -5184,7 +5178,7 @@ describe('Coordinator hydrateTask — mcpConfigPath directory scoping', () => { 'http://localhost:3001', 'tok', 'subtok', - '/srv/app/.parallel-code/mcp-server.js', + '/srv/app/.forge/mcp-server.js', ); }); @@ -5219,7 +5213,7 @@ describe('Coordinator hydrateTask — mcpConfigPath directory scoping', () => { worktreePath: '/tmp/wrong-dir', agentId: 'agent-wrong-dir', coordinatorTaskId: 'coord-1', - mcpConfigPath: `/tmp/evil/parallel-code-subtask-${taskId}.json`, + mcpConfigPath: `/tmp/evil/forge-subtask-${taskId}.json`, }); expect(coordinator.getTask(taskId)?.mcpConfigPath).toBeUndefined(); @@ -5227,7 +5221,7 @@ describe('Coordinator hydrateTask — mcpConfigPath directory scoping', () => { it('correct host tmpdir path is accepted and config write occurs', () => { const taskId = 'task-valid-host'; - const validPath = join(os.tmpdir(), `parallel-code-subtask-${taskId}.json`); + const validPath = join(os.tmpdir(), `forge-subtask-${taskId}.json`); mockAtomicWriteFileSync.mockClear(); coordinator.hydrateTask({ @@ -5249,7 +5243,7 @@ describe('Coordinator hydrateTask — mcpConfigPath directory scoping', () => { it('Docker mode: dirname(serverPath)/subtask-{id}.json is accepted and config write occurs', () => { const taskId = 'task-valid-docker'; - const serverPath = '/srv/app/.parallel-code/mcp-server.js'; + const serverPath = '/srv/app/.forge/mcp-server.js'; const dockerPath = join(dirname(serverPath), `subtask-${taskId}.json`); mockAtomicWriteFileSync.mockClear(); @@ -5361,8 +5355,8 @@ describe('Coordinator Docker mode — per-container sub-tasks', () => { coordinator.registerCoordinator('coord-docker', 'proj-1', { worktreePath: '/tmp/project/.worktrees/task/coord', }); - coordinator.setDockerContainerName('coord-docker', 'parallel-code-abcdef123456'); - coordinator.setDockerImage('coord-docker', 'parallel-code-agent:latest'); + coordinator.setDockerContainerName('coord-docker', 'forge-abcdef123456'); + coordinator.setDockerImage('coord-docker', 'forge-agent:latest'); coordinator.setCoordinatorSpawnDefaults('coord-docker', 'claude', []); }); @@ -5382,7 +5376,7 @@ describe('Coordinator Docker mode — per-container sub-tasks', () => { // Args do not contain 'exec' expect(spawnCall.args).not.toContain('exec'); // Args do not contain the coordinator container name - expect(spawnCall.args).not.toContain('parallel-code-abcdef123456'); + expect(spawnCall.args).not.toContain('forge-abcdef123456'); }); it('createTask passes the coordinator Docker image to spawnAgent', async () => { @@ -5392,10 +5386,10 @@ describe('Coordinator Docker mode — per-container sub-tasks', () => { }); const spawnCall = mockSpawnAgent.mock.calls[0][1]; - expect(spawnCall.dockerImage).toBe('parallel-code-agent:latest'); + expect(spawnCall.dockerImage).toBe('forge-agent:latest'); }); - it('createTask sets dockerMountWorktreeParent: true so coordinator .parallel-code/ is accessible', async () => { + it('createTask sets dockerMountWorktreeParent: true so coordinator .forge/ is accessible', async () => { await coordinator.createTask({ name: 'docker-sub-task', coordinatorTaskId: 'coord-docker', @@ -5854,13 +5848,13 @@ describe('Coordinator deregisterCoordinator — .mcp.json cleanup', () => { coordinator.setMcpJsonInfo('coord-1', '/tmp/.mcp.json', false); }); - it('removes only the parallel-code key and preserves other servers', () => { + it('removes only the forge key and preserves other servers', () => { mockExistsSync.mockReturnValue(true); mockReadFileSync.mockReturnValue( JSON.stringify({ mcpServers: { 'my-server': { command: 'my-tool', args: [] }, - 'parallel-code': { command: 'node', args: ['server.js'] }, + forge: { command: 'node', args: ['server.js'] }, }, }), ); @@ -5875,15 +5869,15 @@ describe('Coordinator deregisterCoordinator — .mcp.json cleanup', () => { mcpServers: Record; }; expect(written.mcpServers['my-server']).toBeDefined(); - expect(written.mcpServers['parallel-code']).toBeUndefined(); + expect(written.mcpServers['forge']).toBeUndefined(); expect(mockUnlinkSync).not.toHaveBeenCalledWith('/tmp/.mcp.json'); }); - it('deletes the file when parallel-code was the only entry', () => { + it('deletes the file when forge was the only entry', () => { mockExistsSync.mockReturnValue(true); mockReadFileSync.mockReturnValue( JSON.stringify({ - mcpServers: { 'parallel-code': { command: 'node', args: ['server.js'] } }, + mcpServers: { forge: { command: 'node', args: ['server.js'] } }, }), ); @@ -5909,7 +5903,7 @@ describe('Coordinator deregisterCoordinator — .mcp.json cleanup', () => { mockExistsSync.mockReturnValue(true); mockReadFileSync.mockReturnValue( JSON.stringify({ - mcpServers: { 'parallel-code': { command: 'node', args: [] } }, + mcpServers: { forge: { command: 'node', args: [] } }, someOtherKey: 'kept', }), ); diff --git a/electron/mcp/coordinator.ts b/electron/mcp/coordinator.ts index 5b4132d28..7ff93fe9c 100644 --- a/electron/mcp/coordinator.ts +++ b/electron/mcp/coordinator.ts @@ -138,7 +138,7 @@ function execStdout(result: Awaited>): string { } /** - * The per-sub-task MCP config: a stdio launch of the parallel-code MCP server + * The per-sub-task MCP config: a stdio launch of the forge MCP server * scoped to one task. Identical across createTask, coordinator re-registration, * and hydration rewrite — only the resolved doneToken differs, so callers own * token generation and pass the final value in. @@ -152,13 +152,13 @@ function buildSubtaskMcpConfig(args: { }) { return { mcpServers: { - 'parallel-code': { + forge: { type: 'stdio' as const, command: 'node', args: [args.serverPath, '--url', args.serverUrl, '--task-id', args.taskId], env: { - PARALLEL_CODE_MCP_TOKEN: args.subtaskToken, - PARALLEL_CODE_MCP_DONE_TOKEN: args.doneToken, + FORGE_MCP_TOKEN: args.subtaskToken, + FORGE_MCP_DONE_TOKEN: args.doneToken, }, }, }, @@ -963,7 +963,7 @@ export class Coordinator { } task.preambleFileExistedBefore = preambleFileOriginalContent !== null; // Write a per-sub-task MCP config so the agent can call signal_done. - // In Docker mode, write to the coordinator's .parallel-code/ dir (which IS the explicitly + // In Docker mode, write to the coordinator's .forge/ dir (which IS the explicitly // mounted volume) rather than the sub-task worktree (which may not be in the container). // Always pass explicit MCP launch args so agents don't rely on auto-discovery. const mcpServerInfoForTask = coordinatorState.mcpServerInfo; @@ -1016,7 +1016,7 @@ export class Coordinator { dockerMode: true, dockerImage: coordinatorState.dockerImage ?? undefined, // Mount parent dir so the sub-task can reach the coordinator's - // .parallel-code/ dir (which holds the per-sub-task MCP config). + // .forge/ dir (which holds the per-sub-task MCP config). // resolveWorktreeGitDirMount adds the main .git dir mount. dockerMountWorktreeParent: true, } @@ -1504,7 +1504,7 @@ export class Coordinator { } await execAsync('git', ['add', '-A', '--', ...dirtyPaths], { cwd: task.worktreePath }); try { - await execAsync('git', ['commit', '-m', 'Remove Parallel Code sub-task preamble'], { + await execAsync('git', ['commit', '-m', 'Remove Forge sub-task preamble'], { cwd: task.worktreePath, }); } catch { @@ -1984,10 +1984,10 @@ export class Coordinator { // Validate the persisted mcpConfigPath is exactly one of the two paths that // getSubTaskMcpConfigPath generates — basename-only is too permissive and would // allow a crafted state file to direct the token write to an arbitrary location. - // Host mode: os.tmpdir()/parallel-code-subtask-{id}.json + // Host mode: os.tmpdir()/forge-subtask-{id}.json // Docker mode: dirname(serverPath)/subtask-{id}.json (looked up from live coordinator state) const serverInfo = this.coordinators.get(opts.coordinatorTaskId)?.mcpServerInfo; - const expectedHostPath = join(os.tmpdir(), `parallel-code-subtask-${opts.id}.json`); + const expectedHostPath = join(os.tmpdir(), `forge-subtask-${opts.id}.json`); const expectedDockerPath = serverInfo ? join(dirname(serverInfo.serverPath), `subtask-${opts.id}.json`) : null; @@ -2148,15 +2148,15 @@ export class Coordinator { coordinatorTaskId: string, mcpJsonPath: string, createdMcpJson: boolean, - previousMcpParallelCode?: unknown, - writtenMcpParallelCode?: unknown, + previousMcpForge?: unknown, + writtenMcpForge?: unknown, ): void { const state = this.coordinators.get(coordinatorTaskId); if (state) { state.mcpJsonPath = mcpJsonPath; state.createdMcpJson = createdMcpJson; - state.previousMcpParallelCode = previousMcpParallelCode; - state.writtenMcpParallelCode = writtenMcpParallelCode; + state.previousMcpForge = previousMcpForge; + state.writtenMcpForge = writtenMcpForge; } } @@ -2176,9 +2176,9 @@ export class Coordinator { }); } - // Clean up coordinator .mcp.json — restore or remove only the parallel-code key. + // Clean up coordinator .mcp.json — restore or remove only the forge key. // Always read current contents (user may have added keys while running). - // If there was a pre-existing parallel-code entry, restore it; otherwise delete the key. + // If there was a pre-existing forge entry, restore it; otherwise delete the key. if (coordinator.mcpJsonPath) { try { const raw = existsSync(coordinator.mcpJsonPath) @@ -2187,17 +2187,17 @@ export class Coordinator { if (raw !== null) { const content = JSON.parse(raw) as { mcpServers?: Record }; if (content.mcpServers) { - const current = content.mcpServers['parallel-code']; - const weWrote = coordinator.writtenMcpParallelCode; + const current = content.mcpServers['forge']; + const weWrote = coordinator.writtenMcpForge; // Only restore/delete if the current value still matches what we wrote, // or if we don't have a record of what we wrote (legacy path — always restore). const safeToRestore = weWrote === undefined || JSON.stringify(current) === JSON.stringify(weWrote); if (safeToRestore) { - if (coordinator.previousMcpParallelCode !== undefined) { - content.mcpServers['parallel-code'] = coordinator.previousMcpParallelCode; + if (coordinator.previousMcpForge !== undefined) { + content.mcpServers['forge'] = coordinator.previousMcpForge; } else { - delete content.mcpServers['parallel-code']; + delete content.mcpServers['forge']; } } } diff --git a/electron/mcp/docker.integration.test.ts b/electron/mcp/docker.integration.test.ts index 5d8ce0202..097b9d0e7 100644 --- a/electron/mcp/docker.integration.test.ts +++ b/electron/mcp/docker.integration.test.ts @@ -69,9 +69,9 @@ describeDocker('Docker MCP integration', () => { if (worktreePath) rmSync(worktreePath, { recursive: true, force: true }); }); - it('runs the bundled MCP server inside the Parallel Code Docker image and reaches the host remote API', async () => { - worktreePath = mkdtempSync(join(tmpdir(), 'parallel-code-docker-mcp-')); - const mcpDir = join(worktreePath, '.parallel-code'); + it('runs the bundled MCP server inside the Forge Docker image and reaches the host remote API', async () => { + worktreePath = mkdtempSync(join(tmpdir(), 'forge-docker-mcp-')); + const mcpDir = join(worktreePath, '.forge'); mkdirSync(mcpDir, { recursive: true }); const bundledMcpServerPath = join(process.cwd(), 'dist-electron', 'mcp-server.cjs'); @@ -115,14 +115,14 @@ describeDocker('Docker MCP integration', () => { getCoordinator: () => coordinator, }); - const serverUrl = getMCPRemoteServerUrl(remoteServer.port, 'parallel-code-test-container'); + const serverUrl = getMCPRemoteServerUrl(remoteServer.port, 'forge-test-container'); const mcpConfig = { mcpServers: { - 'parallel-code': { + forge: { type: 'stdio', command: 'node', args: [dockerMcpServerPath, '--url', serverUrl, '--coordinator-id', 'coord-1'], - env: { PARALLEL_CODE_MCP_TOKEN: remoteServer.token }, + env: { FORGE_MCP_TOKEN: remoteServer.token }, }, }, }; @@ -141,7 +141,7 @@ describeDocker('Docker MCP integration', () => { '-w', worktreePath, '-e', - `PARALLEL_CODE_MCP_TOKEN=${remoteServer.token}`, + `FORGE_MCP_TOKEN=${remoteServer.token}`, DOCKER_DEFAULT_IMAGE, 'node', dockerMcpServerPath, @@ -151,7 +151,7 @@ describeDocker('Docker MCP integration', () => { 'coord-1', ], }); - const client = new Client({ name: 'parallel-code-docker-mcp-test', version: '1.0.0' }); + const client = new Client({ name: 'forge-docker-mcp-test', version: '1.0.0' }); try { await client.connect(transport); @@ -174,9 +174,9 @@ describeDocker('Docker MCP integration', () => { // Regression test for the bug where sub-task MCP config was written to the sub-task // worktree (not a Docker volume mount) and relied on auto-discovery instead of --mcp-config. // This test simulates a sub-task running via `docker exec --mcp-config `. - it('sub-task reaches MCP server via explicit --mcp-config in coordinator .parallel-code dir', async () => { - const coordWorktree = mkdtempSync(join(tmpdir(), 'parallel-code-coord-')); - const mcpDir = join(coordWorktree, '.parallel-code'); + it('sub-task reaches MCP server via explicit --mcp-config in coordinator .forge dir', async () => { + const coordWorktree = mkdtempSync(join(tmpdir(), 'forge-coord-')); + const mcpDir = join(coordWorktree, '.forge'); mkdirSync(mcpDir, { recursive: true }); const bundledMcpServerPath = join(process.cwd(), 'dist-electron', 'mcp-server.cjs'); @@ -212,28 +212,28 @@ describeDocker('Docker MCP integration', () => { }); try { - const serverUrl = getMCPRemoteServerUrl(port2, 'parallel-code-test-container'); + const serverUrl = getMCPRemoteServerUrl(port2, 'forge-test-container'); const taskId = 'sub-task-docker-test'; - // getSubTaskMcpConfigPath must use the coordinator's .parallel-code dir, not a sub-task worktree + // getSubTaskMcpConfigPath must use the coordinator's .forge dir, not a sub-task worktree const configPath = getSubTaskMcpConfigPath( - 'parallel-code-test-container', + 'forge-test-container', dockerMcpServerPath, taskId, ); expect(dirname(configPath)).toBe(mcpDir); // the coordinator's volume dir - // Assert the config path is inside the coordinator's .parallel-code dir (which IS a Docker volume) + // Assert the config path is inside the coordinator's .forge dir (which IS a Docker volume) // NOT in any sub-task worktree (which is NOT a Docker volume) - expect(dirname(configPath).endsWith('/.parallel-code')).toBe(true); + expect(dirname(configPath).endsWith('/.forge')).toBe(true); expect(configPath.startsWith(coordWorktree)).toBe(true); const subMcpConfig = { mcpServers: { - 'parallel-code': { + forge: { type: 'stdio', command: 'node', args: [dockerMcpServerPath, '--url', serverUrl, '--task-id', taskId], - env: { PARALLEL_CODE_MCP_TOKEN: subServer.token }, + env: { FORGE_MCP_TOKEN: subServer.token }, }, }, }; @@ -252,7 +252,7 @@ describeDocker('Docker MCP integration', () => { '-w', coordWorktree, '-e', - `PARALLEL_CODE_MCP_TOKEN=${subServer.token}`, + `FORGE_MCP_TOKEN=${subServer.token}`, DOCKER_DEFAULT_IMAGE, 'node', dockerMcpServerPath, @@ -262,7 +262,7 @@ describeDocker('Docker MCP integration', () => { taskId, ], }); - const subClient = new Client({ name: 'parallel-code-subtask-test', version: '1.0.0' }); + const subClient = new Client({ name: 'forge-subtask-test', version: '1.0.0' }); try { await subClient.connect(transport); @@ -295,7 +295,7 @@ describeDocker('Layer 2 — Docker smoke tests', () => { beforeAll(async () => { requireDockerImage(DOCKER_DEFAULT_IMAGE); - coordWorktree = mkdtempSync(join(tmpdir(), 'parallel-code-smoke-')); + coordWorktree = mkdtempSync(join(tmpdir(), 'forge-smoke-')); remotePort = await findFreePort(); const mockCoordinator = { listTasks: () => [] } as unknown as Coordinator; @@ -437,7 +437,7 @@ describeDocker('Layer 4 — Production-path coordinator Docker scenario', () => }); it('uses production config pipeline to generate .mcp.json, then create_task reaches coordinator', async () => { - scenarioWorktree = mkdtempSync(join(tmpdir(), 'parallel-code-prod-path-')); + scenarioWorktree = mkdtempSync(join(tmpdir(), 'forge-prod-path-')); // --- Start remote server with a real coordinator stub --- const port = await findFreePort(); @@ -490,7 +490,7 @@ describeDocker('Layer 4 — Production-path coordinator Docker scenario', () => writeFileSync(mcpJsonPath, JSON.stringify(mcpConfig, null, 2), { mode: 0o600 }); // --- Assert the generated config uses the correct URL --- - const args = mcpConfig.mcpServers['parallel-code'].args; + const args = mcpConfig.mcpServers['forge'].args; const urlIdx = args.indexOf('--url'); if (platform() === 'darwin') { expect(args[urlIdx + 1]).toContain('host.docker.internal'); @@ -515,7 +515,7 @@ describeDocker('Layer 4 — Production-path coordinator Docker scenario', () => '-w', scenarioWorktree, '-e', - `PARALLEL_CODE_MCP_TOKEN=${scenarioServer.token}`, + `FORGE_MCP_TOKEN=${scenarioServer.token}`, DOCKER_DEFAULT_IMAGE, 'node', destPath, @@ -525,7 +525,7 @@ describeDocker('Layer 4 — Production-path coordinator Docker scenario', () => 'coord-prod', ], }); - const client = new Client({ name: 'parallel-code-prod-path-test', version: '1.0.0' }); + const client = new Client({ name: 'forge-prod-path-test', version: '1.0.0' }); try { await client.connect(transport); @@ -552,7 +552,7 @@ describeDocker('Layer 4 — Production-path coordinator Docker scenario', () => // // No Docker required. Verifies that StartMCPServer args accept `dockerImage` // and that `getSubTaskMcpConfigPath` still returns a path in the coordinator's -// .parallel-code/ dir (the only dir that is a mounted volume in both coordinator +// .forge/ dir (the only dir that is a mounted volume in both coordinator // and per-sub-task containers). describe('Docker per-container sub-tasks — StartMCPServer arg validation', () => { @@ -564,8 +564,8 @@ describe('Docker per-container sub-tasks — StartMCPServer arg validation', () coordinatorTaskId: VALID_UUID, projectId: 'proj-1', projectRoot: '/tmp/project', - dockerContainerName: 'parallel-code-abc123', - dockerImage: 'parallel-code-agent:latest', + dockerContainerName: 'forge-abc123', + dockerImage: 'forge-agent:latest', }), ).not.toThrow(); }); @@ -581,12 +581,12 @@ describe('Docker per-container sub-tasks — StartMCPServer arg validation', () ).toThrow('dockerImage must not be blank'); }); - it('sub-task MCP config path is in coordinator .parallel-code/ dir (a Docker volume), not the sub-task worktree', () => { + it('sub-task MCP config path is in coordinator .forge/ dir (a Docker volume), not the sub-task worktree', () => { const coordWorktree = '/tmp/project/.worktrees/task/coord-abc'; - const mcpServerPath = `${coordWorktree}/.parallel-code/mcp-server.cjs`; - const configPath = getSubTaskMcpConfigPath('parallel-code-coord-abc', mcpServerPath, 'sub-1'); - // Must be inside the coordinator's .parallel-code/ dir - expect(configPath.startsWith(`${coordWorktree}/.parallel-code/`)).toBe(true); + const mcpServerPath = `${coordWorktree}/.forge/mcp-server.cjs`; + const configPath = getSubTaskMcpConfigPath('forge-coord-abc', mcpServerPath, 'sub-1'); + // Must be inside the coordinator's .forge/ dir + expect(configPath.startsWith(`${coordWorktree}/.forge/`)).toBe(true); // Must NOT be in a sub-task worktree (which is not a Docker volume) expect(configPath).not.toContain('.worktrees/task/sub-'); }); @@ -616,7 +616,7 @@ describe('Docker coordinator bootstrap — path edge cases', () => { it('preserves spaces in worktree path for mcp-server.cjs dest', () => { const worktreePath = '/Users/alice bob/my repos/.worktrees/task/coord-abc'; const dest = getDockerMcpServerDestPath(worktreePath, '/irrelevant'); - expect(dest).toBe(`${worktreePath}/.parallel-code/mcp-server.cjs`); + expect(dest).toBe(`${worktreePath}/.forge/mcp-server.cjs`); }); it('preserves spaces in worktree path for .mcp.json dir selection', () => { @@ -630,15 +630,13 @@ describe('Docker coordinator bootstrap — path edge cases', () => { // Separately, the worktreePath IS the Claude trust key — verify it is not mangled. const worktreePath = '/Users/alice bob/my repos/.worktrees/task/coord abc 123'; const cfg = buildCoordinatorMCPConfig({ - mcpServerPath: `${worktreePath}/.parallel-code/mcp-server.cjs`, + mcpServerPath: `${worktreePath}/.forge/mcp-server.cjs`, serverUrl: 'http://host.docker.internal:3001', token: 'tok', coordinatorTaskId: 'coord-1', }); // The MCP server path arg must contain the verbatim worktree path - expect(cfg.mcpServers['parallel-code'].args[0]).toBe( - `${worktreePath}/.parallel-code/mcp-server.cjs`, - ); + expect(cfg.mcpServers['forge'].args[0]).toBe(`${worktreePath}/.forge/mcp-server.cjs`); }); }); @@ -647,15 +645,15 @@ describe('Docker coordinator bootstrap — path edge cases', () => { describe('Docker coordinator bootstrap — port/token rotation in .mcp.json', () => { it('buildCoordinatorMCPConfig reflects new port when called with updated serverUrl', () => { const opts = { - mcpServerPath: '/worktrees/coord/.parallel-code/mcp-server.cjs', + mcpServerPath: '/worktrees/coord/.forge/mcp-server.cjs', serverUrl: 'http://host.docker.internal:3001', token: 'old-token', coordinatorTaskId: 'coord-1', }; const cfgBefore = buildCoordinatorMCPConfig(opts); - const argsBefore = cfgBefore.mcpServers['parallel-code'].args; + const argsBefore = cfgBefore.mcpServers['forge'].args; expect(argsBefore).toContain('http://host.docker.internal:3001'); - expect(cfgBefore.mcpServers['parallel-code'].env['PARALLEL_CODE_MCP_TOKEN']).toBe('old-token'); + expect(cfgBefore.mcpServers['forge'].env['FORGE_MCP_TOKEN']).toBe('old-token'); // Simulate restart: new port and token const cfgAfter = buildCoordinatorMCPConfig({ @@ -663,13 +661,11 @@ describe('Docker coordinator bootstrap — port/token rotation in .mcp.json', () serverUrl: 'http://host.docker.internal:3099', token: 'new-token', }); - const argsAfter = cfgAfter.mcpServers['parallel-code'].args; + const argsAfter = cfgAfter.mcpServers['forge'].args; expect(argsAfter).toContain('http://host.docker.internal:3099'); - expect(cfgAfter.mcpServers['parallel-code'].env['PARALLEL_CODE_MCP_TOKEN']).toBe('new-token'); + expect(cfgAfter.mcpServers['forge'].env['FORGE_MCP_TOKEN']).toBe('new-token'); expect(argsAfter).not.toContain('http://host.docker.internal:3001'); - expect(cfgAfter.mcpServers['parallel-code'].env['PARALLEL_CODE_MCP_TOKEN']).not.toBe( - 'old-token', - ); + expect(cfgAfter.mcpServers['forge'].env['FORGE_MCP_TOKEN']).not.toBe('old-token'); }); }); @@ -680,14 +676,14 @@ describe('Docker coordinator bootstrap — non-Claude agent MCP connectivity', ( // MCP config is written by the coordinator bootstrap, not by the inner agent. // A Codex or OpenCode agent in Docker still gets MCP injected via .mcp.json. const cfg = buildCoordinatorMCPConfig({ - mcpServerPath: '/worktrees/coord/.parallel-code/mcp-server.cjs', + mcpServerPath: '/worktrees/coord/.forge/mcp-server.cjs', serverUrl: 'http://host.docker.internal:3001', token: 'tok', coordinatorTaskId: 'coord-1', }); // Config structure is the same regardless of inner agent command - expect(cfg.mcpServers['parallel-code'].type).toBe('stdio'); - expect(cfg.mcpServers['parallel-code'].command).toBe('node'); + expect(cfg.mcpServers['forge'].type).toBe('stdio'); + expect(cfg.mcpServers['forge'].command).toBe('node'); }); }); @@ -739,7 +735,7 @@ describeDocker('Layer 9 — Project Dockerfile image MCP smoke test', () => { requireDockerImage(projectImage); const worktree = mkdtempSync(join(tmpdir(), 'docker-project-img-')); - const mcpDir = join(worktree, '.parallel-code'); + const mcpDir = join(worktree, '.forge'); mkdirSync(mcpDir, { recursive: true }); const mcpServerSrc = join(dirname(new URL(import.meta.url).pathname), '..', 'mcp-server.cjs'); @@ -769,7 +765,7 @@ describeDocker('Layer 9 — Project Dockerfile image MCP smoke test', () => { '-w', worktree, '-e', - `PARALLEL_CODE_MCP_TOKEN=${server.token}`, + `FORGE_MCP_TOKEN=${server.token}`, projectImage, 'node', destPath, @@ -799,7 +795,7 @@ describe('Docker coordinator bootstrap — unicode paths', () => { it('unicode characters in worktree path are preserved verbatim in config', () => { const worktreePath = '/Users/张三/projects/我的代码/.worktrees/task/coord-abc'; const dest = getDockerMcpServerDestPath(worktreePath, '/irrelevant'); - expect(dest).toBe(`${worktreePath}/.parallel-code/mcp-server.cjs`); + expect(dest).toBe(`${worktreePath}/.forge/mcp-server.cjs`); expect(selectMcpJsonDir(worktreePath, '/irrelevant')).toBe(worktreePath); }); @@ -810,21 +806,21 @@ describe('Docker coordinator bootstrap — unicode paths', () => { token: 'tok', coordinatorTaskId: 'coord-任务-1', }); - const coordIdx = cfg.mcpServers['parallel-code'].args.indexOf('--coordinator-id'); - expect(cfg.mcpServers['parallel-code'].args[coordIdx + 1]).toBe('coord-任务-1'); + const coordIdx = cfg.mcpServers['forge'].args.indexOf('--coordinator-id'); + expect(cfg.mcpServers['forge'].args[coordIdx + 1]).toBe('coord-任务-1'); }); it('unicode in worktree path generates valid JSON', () => { const worktreePath = '/home/ünïcödé/project/.worktrees/tâsk/coord'; const cfg = buildCoordinatorMCPConfig({ - mcpServerPath: `${worktreePath}/.parallel-code/mcp-server.cjs`, + mcpServerPath: `${worktreePath}/.forge/mcp-server.cjs`, serverUrl: 'http://host.docker.internal:3001', token: 'tök', coordinatorTaskId: 'coord-ünïcödé', }); const json = JSON.stringify(cfg, null, 2); const parsed = JSON.parse(json) as typeof cfg; - expect(parsed.mcpServers['parallel-code'].args[0]).toContain('ünïcödé'); + expect(parsed.mcpServers['forge'].args[0]).toContain('ünïcödé'); }); }); @@ -835,7 +831,7 @@ describeDocker('Layer 2 — MCP tool schema drift between host and Docker', () = requireDockerImage(DOCKER_DEFAULT_IMAGE); const worktree = mkdtempSync(join(tmpdir(), 'docker-schema-drift-')); - const mcpDir = join(worktree, '.parallel-code'); + const mcpDir = join(worktree, '.forge'); mkdirSync(mcpDir, { recursive: true }); const bundledMcpServerPath = join(process.cwd(), 'dist-electron', 'mcp-server.cjs'); @@ -859,7 +855,7 @@ describeDocker('Layer 2 — MCP tool schema drift between host and Docker', () = const hostTransport = new HostTransport({ command: 'node', args: [destPath, '--url', `http://127.0.0.1:${port}`], - env: { ...process.env, PARALLEL_CODE_MCP_TOKEN: server.token }, + env: { ...process.env, FORGE_MCP_TOKEN: server.token }, }); const hostClient = new HostClient({ name: 'host-schema-test', version: '1.0.0' }); @@ -882,7 +878,7 @@ describeDocker('Layer 2 — MCP tool schema drift between host and Docker', () = '-w', worktree, '-e', - `PARALLEL_CODE_MCP_TOKEN=${server.token}`, + `FORGE_MCP_TOKEN=${server.token}`, DOCKER_DEFAULT_IMAGE, 'node', destPath, @@ -929,7 +925,7 @@ describeDocker('Layer 2 — Large MCP response over Docker stdio', () => { requireDockerImage(DOCKER_DEFAULT_IMAGE); const worktree = mkdtempSync(join(tmpdir(), 'docker-large-resp-')); - const mcpDir = join(worktree, '.parallel-code'); + const mcpDir = join(worktree, '.forge'); mkdirSync(mcpDir, { recursive: true }); const bundledMcpServerPath = join(process.cwd(), 'dist-electron', 'mcp-server.cjs'); @@ -972,7 +968,7 @@ describeDocker('Layer 2 — Large MCP response over Docker stdio', () => { '-w', worktree, '-e', - `PARALLEL_CODE_MCP_TOKEN=${server.token}`, + `FORGE_MCP_TOKEN=${server.token}`, DOCKER_DEFAULT_IMAGE, 'node', destPath, diff --git a/electron/mcp/mcp-tool-list.ts b/electron/mcp/mcp-tool-list.ts index d820f4b91..2550a97dc 100644 --- a/electron/mcp/mcp-tool-list.ts +++ b/electron/mcp/mcp-tool-list.ts @@ -10,7 +10,7 @@ export const SUBTASK_TOOLS: ToolDef[] = [ { name: 'land_self', description: - 'Land your own completed sub-task through the Parallel Code backend. Call this only after committing your work and running verification successfully. A successful call is terminal; do not call signal_done afterward.', + 'Land your own completed sub-task through the Forge backend. Call this only after committing your work and running verification successfully. A successful call is terminal; do not call signal_done afterward.', inputSchema: { type: 'object', properties: { diff --git a/electron/mcp/preamble.ts b/electron/mcp/preamble.ts index f5a270f79..6243c8303 100644 --- a/electron/mcp/preamble.ts +++ b/electron/mcp/preamble.ts @@ -124,8 +124,8 @@ export async function buildNormalizedPreambleFileDiff( if (normalizedContent === baseContent) return ''; const id = randomUUID(); - const tmpBase = join(os.tmpdir(), `parallel-code-base-${id}`); - const tmpNorm = join(os.tmpdir(), `parallel-code-norm-${id}`); + const tmpBase = join(os.tmpdir(), `forge-base-${id}`); + const tmpNorm = join(os.tmpdir(), `forge-norm-${id}`); try { writeFileSync(tmpBase, baseContent); writeFileSync(tmpNorm, normalizedContent); diff --git a/electron/mcp/prompt-detect.test.ts b/electron/mcp/prompt-detect.test.ts index 159ee21df..0db9a1eb8 100644 --- a/electron/mcp/prompt-detect.test.ts +++ b/electron/mcp/prompt-detect.test.ts @@ -145,9 +145,7 @@ describe('chunkContainsAgentPrompt', () => { ], [ 'Gemini prompt after MCP startup completion scrollback', - ['Starting MCP servers (0/1): parallel-code', 'Starting MCP servers complete', '>'].join( - '\n', - ), + ['Starting MCP servers (0/1): forge', 'Starting MCP servers complete', '>'].join('\n'), ], ])('classifies ready agent fixture: %s', (_name, fixture) => { expect(getAgentPromptReadiness(fixture)).toMatchObject({ ready: true, reason: 'ready' }); @@ -166,7 +164,7 @@ describe('chunkContainsAgentPrompt', () => { ], [ 'Codex startup screen', - 'Starting MCP servers (0/2): codex_apps, parallel-code\n›', + 'Starting MCP servers (0/2): codex_apps, forge\n›', 'startup_or_dialog', ], [ @@ -260,9 +258,9 @@ describe('chunkContainsAgentPrompt', () => { }); it('does not treat Codex startup screens as ready', () => { - expect( - chunkContainsAgentPrompt('Starting MCP servers (0/2): codex_apps, parallel-code\n›'), - ).toBe(false); + expect(chunkContainsAgentPrompt('Starting MCP servers (0/2): codex_apps, forge\n›')).toBe( + false, + ); expect(chunkContainsAgentPrompt('model: loading /model to change\n›')).toBe(false); }); diff --git a/electron/mcp/server.ts b/electron/mcp/server.ts index 29f7ac350..2c32239c6 100644 --- a/electron/mcp/server.ts +++ b/electron/mcp/server.ts @@ -265,13 +265,13 @@ function parseArgs(argv: string[]): { url: string; taskId: string; coordinatorId async function main(): Promise { const { url, taskId, coordinatorId } = parseArgs(process.argv.slice(2)); - const token = process.env.PARALLEL_CODE_MCP_TOKEN ?? ''; - const doneToken = process.env.PARALLEL_CODE_MCP_DONE_TOKEN || undefined; + const token = process.env.FORGE_MCP_TOKEN ?? ''; + const doneToken = process.env.FORGE_MCP_DONE_TOKEN || undefined; if (!url || !token) { console.error( 'Usage: node server.js --url [--task-id ] [--coordinator-id ]\n' + - 'Token must be set via PARALLEL_CODE_MCP_TOKEN environment variable.', + 'Token must be set via FORGE_MCP_TOKEN environment variable.', ); process.exit(1); } @@ -289,10 +289,7 @@ async function main(): Promise { } const client = new MCPClient(url, token, coordinatorId || undefined, doneToken); - const server = new Server( - { name: 'parallel-code', version: '1.0.0' }, - { capabilities: { tools: {} } }, - ); + const server = new Server({ name: 'forge', version: '1.0.0' }, { capabilities: { tools: {} } }); server.setRequestHandler(ListToolsRequestSchema, async () => { return { tools: selectTools(taskId, coordinatorId) }; diff --git a/electron/mcp/sub-task-preamble.ts b/electron/mcp/sub-task-preamble.ts index dd70a19ed..b7b91141c 100644 --- a/electron/mcp/sub-task-preamble.ts +++ b/electron/mcp/sub-task-preamble.ts @@ -1,6 +1,6 @@ -export const SUB_TASK_PREAMBLE = `[SUB-TASK MODE] You are a coordinated sub-task inside Parallel Code. A coordinator agent dispatched you to complete specific work. +export const SUB_TASK_PREAMBLE = `[SUB-TASK MODE] You are a coordinated sub-task inside Forge. A coordinator agent dispatched you to complete specific work. -You have two sub-task MCP tools available via the parallel-code server: +You have two sub-task MCP tools available via the forge server: - land_self — Happy-path finish line. Call this after committing your work and passing verification. The backend will merge your branch into the coordinator branch and clean up your task. - signal_done — Legacy/manual-review finish line. Use this only if the coordinator explicitly asks to review and land your branch manually. diff --git a/electron/mcp/types.ts b/electron/mcp/types.ts index 1b0044b4e..308ebd4cf 100644 --- a/electron/mcp/types.ts +++ b/electron/mcp/types.ts @@ -88,12 +88,12 @@ export interface CoordinatorState { propagateSkipPermissions: boolean; /** Path to the .mcp.json file written for this coordinator. */ mcpJsonPath: string; - /** True if Parallel Code created .mcp.json from scratch; false if it was pre-existing. */ + /** True if Forge created .mcp.json from scratch; false if it was pre-existing. */ createdMcpJson: boolean; - /** Previous value of mcpServers["parallel-code"] before this coordinator wrote its entry, if any. */ - previousMcpParallelCode?: unknown; - /** The value this coordinator wrote into mcpServers["parallel-code"]; used to detect concurrent edits on deregister. */ - writtenMcpParallelCode?: unknown; + /** Previous value of mcpServers["forge"] before this coordinator wrote its entry, if any. */ + previousMcpForge?: unknown; + /** The value this coordinator wrote into mcpServers["forge"]; used to detect concurrent edits on deregister. */ + writtenMcpForge?: unknown; } export interface SubtaskVerificationCheck { diff --git a/electron/vite.config.electron.ts b/electron/vite.config.electron.ts index c5d29107c..1167413e7 100644 --- a/electron/vite.config.electron.ts +++ b/electron/vite.config.electron.ts @@ -15,7 +15,7 @@ export default defineConfig({ watch: { // Creating git worktrees inside this repo would otherwise look like a giant // source-tree change to Vite in dev mode, causing the renderer to reload - // right when Parallel Code creates a task for itself. The function ignores + // right when Forge creates a task for itself. The function ignores // anything resolving outside the project root (e.g. host parent dirs). ignored: [ '**/.worktrees/**', diff --git a/index.html b/index.html index acc5439bc..208a5e39d 100644 --- a/index.html +++ b/index.html @@ -5,7 +5,7 @@ - Parallel Code + Forge