diff --git a/.ade/cto/identity.yaml b/.ade/cto/identity.yaml index a6ca64553..265223cd2 100644 --- a/.ade/cto/identity.yaml +++ b/.ade/cto/identity.yaml @@ -11,12 +11,6 @@ memoryPolicy: compactionThreshold: 0.7 preCompactionFlush: true temporalDecayHalfLifeDays: 30 -openclawContextPolicy: - shareMode: filtered - blockedCategories: - - secret - - token - - system_prompt onboardingState: completedSteps: - identity diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d660073ed..5c4aa1f51 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -27,8 +27,7 @@ jobs: apps/desktop/node_modules apps/ade-cli/node_modules apps/web/node_modules - apps/ade-code/node_modules - key: nm-${{ hashFiles('apps/desktop/package-lock.json','apps/ade-cli/package-lock.json','apps/web/package-lock.json','apps/ade-code/package-lock.json') }} + key: nm-${{ hashFiles('apps/desktop/package-lock.json','apps/ade-cli/package-lock.json','apps/web/package-lock.json') }} - name: Install all dependencies (parallel) if: steps.cache.outputs.cache-hit != 'true' @@ -36,7 +35,6 @@ jobs: cd apps/desktop && npm ci & cd apps/ade-cli && npm ci & cd apps/web && npm ci & - cd apps/ade-code && npm ci & wait # ── Secret scanning (no deps needed) ─────────────────────────────────── @@ -65,8 +63,7 @@ jobs: apps/desktop/node_modules apps/ade-cli/node_modules apps/web/node_modules - apps/ade-code/node_modules - key: nm-${{ hashFiles('apps/desktop/package-lock.json','apps/ade-cli/package-lock.json','apps/web/package-lock.json','apps/ade-code/package-lock.json') }} + key: nm-${{ hashFiles('apps/desktop/package-lock.json','apps/ade-cli/package-lock.json','apps/web/package-lock.json') }} - run: cd apps/desktop && npm run typecheck typecheck-ade-cli: @@ -83,8 +80,7 @@ jobs: apps/desktop/node_modules apps/ade-cli/node_modules apps/web/node_modules - apps/ade-code/node_modules - key: nm-${{ hashFiles('apps/desktop/package-lock.json','apps/ade-cli/package-lock.json','apps/web/package-lock.json','apps/ade-code/package-lock.json') }} + key: nm-${{ hashFiles('apps/desktop/package-lock.json','apps/ade-cli/package-lock.json','apps/web/package-lock.json') }} - run: cd apps/ade-cli && npm run typecheck typecheck-web: @@ -101,28 +97,9 @@ jobs: apps/desktop/node_modules apps/ade-cli/node_modules apps/web/node_modules - apps/ade-code/node_modules - key: nm-${{ hashFiles('apps/desktop/package-lock.json','apps/ade-cli/package-lock.json','apps/web/package-lock.json','apps/ade-code/package-lock.json') }} + key: nm-${{ hashFiles('apps/desktop/package-lock.json','apps/ade-cli/package-lock.json','apps/web/package-lock.json') }} - run: cd apps/web && npm run typecheck - typecheck-ade-code: - needs: install - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - uses: actions/setup-node@v4 - with: - node-version: 22 - - uses: actions/cache/restore@v4 - with: - path: | - apps/desktop/node_modules - apps/ade-cli/node_modules - apps/web/node_modules - apps/ade-code/node_modules - key: nm-${{ hashFiles('apps/desktop/package-lock.json','apps/ade-cli/package-lock.json','apps/web/package-lock.json','apps/ade-code/package-lock.json') }} - - run: cd apps/ade-code && npm run typecheck - lint-desktop: needs: install runs-on: ubuntu-latest @@ -137,8 +114,7 @@ jobs: apps/desktop/node_modules apps/ade-cli/node_modules apps/web/node_modules - apps/ade-code/node_modules - key: nm-${{ hashFiles('apps/desktop/package-lock.json','apps/ade-cli/package-lock.json','apps/web/package-lock.json','apps/ade-code/package-lock.json') }} + key: nm-${{ hashFiles('apps/desktop/package-lock.json','apps/ade-cli/package-lock.json','apps/web/package-lock.json') }} - run: cd apps/desktop && npm run lint test-desktop: @@ -159,8 +135,7 @@ jobs: apps/desktop/node_modules apps/ade-cli/node_modules apps/web/node_modules - apps/ade-code/node_modules - key: nm-${{ hashFiles('apps/desktop/package-lock.json','apps/ade-cli/package-lock.json','apps/web/package-lock.json','apps/ade-code/package-lock.json') }} + key: nm-${{ hashFiles('apps/desktop/package-lock.json','apps/ade-cli/package-lock.json','apps/web/package-lock.json') }} - run: cd apps/desktop && npx vitest run --shard=${{ matrix.shard }}/8 test-ade-cli: @@ -177,11 +152,10 @@ jobs: apps/desktop/node_modules apps/ade-cli/node_modules apps/web/node_modules - apps/ade-code/node_modules - key: nm-${{ hashFiles('apps/desktop/package-lock.json','apps/ade-cli/package-lock.json','apps/web/package-lock.json','apps/ade-code/package-lock.json') }} + key: nm-${{ hashFiles('apps/desktop/package-lock.json','apps/ade-cli/package-lock.json','apps/web/package-lock.json') }} - run: cd apps/ade-cli && npm test - test-ade-code: + build: needs: install runs-on: ubuntu-latest steps: @@ -195,30 +169,52 @@ jobs: apps/desktop/node_modules apps/ade-cli/node_modules apps/web/node_modules - apps/ade-code/node_modules - key: nm-${{ hashFiles('apps/desktop/package-lock.json','apps/ade-cli/package-lock.json','apps/web/package-lock.json','apps/ade-code/package-lock.json') }} - - run: cd apps/ade-code && npm test + key: nm-${{ hashFiles('apps/desktop/package-lock.json','apps/ade-cli/package-lock.json','apps/web/package-lock.json') }} + - run: cd apps/desktop && npm run build + - run: cd apps/ade-cli && npm run build + - run: cd apps/web && npm run build - build: - needs: install - runs-on: ubuntu-latest + build-runtime-binaries: + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + include: + - target: darwin-arm64 + os: macos-15 + - target: darwin-x64 + os: macos-13 + - target: linux-x64 + os: ubuntu-latest + - target: linux-arm64 + os: ubuntu-24.04-arm steps: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 with: node-version: 22 - - uses: actions/cache/restore@v4 + cache: npm + cache-dependency-path: apps/ade-cli/package-lock.json + + - name: Install ADE CLI dependencies + run: cd apps/ade-cli && npm ci + + - name: Build ADE runtime binary + run: cd apps/ade-cli && npm run build:static -- --target ${{ matrix.target }} + + - name: Smoke test ADE runtime binary + run: | + apps/ade-cli/dist-static/ade-${{ matrix.target }} --version + tar -tzf apps/ade-cli/dist-static/ade-${{ matrix.target }}.native.tar.gz | grep -q '^\./node_modules/' + + - name: Upload ADE runtime binary + uses: actions/upload-artifact@v4 with: + name: ade-runtime-${{ matrix.target }} path: | - apps/desktop/node_modules - apps/ade-cli/node_modules - apps/web/node_modules - apps/ade-code/node_modules - key: nm-${{ hashFiles('apps/desktop/package-lock.json','apps/ade-cli/package-lock.json','apps/web/package-lock.json','apps/ade-code/package-lock.json') }} - - run: cd apps/desktop && npm run build - - run: cd apps/ade-cli && npm run build - - run: cd apps/web && npm run build - - run: cd apps/ade-code && npm run build + apps/ade-cli/dist-static/ade-${{ matrix.target }} + apps/ade-cli/dist-static/ade-${{ matrix.target }}.native.tar.gz + if-no-files-found: error validate-docs: needs: install @@ -234,8 +230,7 @@ jobs: apps/desktop/node_modules apps/ade-cli/node_modules apps/web/node_modules - apps/ade-code/node_modules - key: nm-${{ hashFiles('apps/desktop/package-lock.json','apps/ade-cli/package-lock.json','apps/web/package-lock.json','apps/ade-code/package-lock.json') }} + key: nm-${{ hashFiles('apps/desktop/package-lock.json','apps/ade-cli/package-lock.json','apps/web/package-lock.json') }} - run: node scripts/validate-docs.mjs # ── Windows build smoke (self-contained — no shared cache) ──────────── @@ -244,6 +239,7 @@ jobs: # time. Self-contained because windows-latest node_modules contain # platform-specific native binaries that can't share a Linux cache. build-win: + needs: build-runtime-binaries runs-on: windows-latest steps: - uses: actions/checkout@v4 @@ -261,6 +257,18 @@ jobs: - name: Install ADE CLI dependencies run: cd apps/ade-cli && npm ci + - name: Download ADE runtime binaries + uses: actions/download-artifact@v4 + with: + pattern: ade-runtime-* + path: apps/desktop/resources/runtime + merge-multiple: true + + - name: Materialize ADE runtime resources + env: + ADE_RUNTIME_ARTIFACTS_DIR: ${{ github.workspace }}/apps/desktop/resources/runtime + run: cd apps/desktop && npm run materialize:runtime-resources + - name: Reset release output shell: pwsh run: | @@ -282,12 +290,11 @@ jobs: - typecheck-desktop - typecheck-ade-cli - typecheck-web - - typecheck-ade-code - lint-desktop - test-desktop - test-ade-cli - - test-ade-code - build + - build-runtime-binaries - validate-docs - build-win runs-on: ubuntu-latest diff --git a/.github/workflows/release-core.yml b/.github/workflows/release-core.yml index a963a2375..4c8f03787 100644 --- a/.github/workflows/release-core.yml +++ b/.github/workflows/release-core.yml @@ -35,7 +35,9 @@ jobs: git merge-base --is-ancestor HEAD refs/remotes/origin/main build-mac-release: - needs: verify + needs: + - verify + - build-runtime-binaries runs-on: macos-15 concurrency: group: release-${{ inputs.release_tag }}-mac @@ -60,6 +62,18 @@ jobs: - name: Install ADE CLI dependencies run: cd apps/ade-cli && npm ci + - name: Download ADE runtime binaries + uses: actions/download-artifact@v4 + with: + pattern: ade-runtime-* + path: apps/desktop/resources/runtime + merge-multiple: true + + - name: Materialize ADE runtime resources + env: + ADE_RUNTIME_ARTIFACTS_DIR: ${{ github.workspace }}/apps/desktop/resources/runtime + run: cd apps/desktop && npm run materialize:runtime-resources + - name: Stamp release version env: ADE_RELEASE_TAG: ${{ inputs.release_tag }} @@ -121,7 +135,9 @@ jobs: if-no-files-found: error build-win-release: - needs: verify + needs: + - verify + - build-runtime-binaries runs-on: windows-latest concurrency: group: release-${{ inputs.release_tag }}-win @@ -146,6 +162,18 @@ jobs: - name: Install ADE CLI dependencies run: cd apps/ade-cli && npm ci + - name: Download ADE runtime binaries + uses: actions/download-artifact@v4 + with: + pattern: ade-runtime-* + path: apps/desktop/resources/runtime + merge-multiple: true + + - name: Materialize ADE runtime resources + env: + ADE_RUNTIME_ARTIFACTS_DIR: ${{ github.workspace }}\apps\desktop\resources\runtime + run: cd apps/desktop && npm run materialize:runtime-resources + - name: Stamp release version env: ADE_RELEASE_TAG: ${{ inputs.release_tag }} @@ -175,13 +203,135 @@ jobs: apps/desktop/release/latest.yml if-no-files-found: error + build-runtime-binaries: + needs: verify + strategy: + fail-fast: false + matrix: + include: + - target: darwin-arm64 + os: macos-15 + - target: darwin-x64 + os: macos-13 + - target: linux-x64 + os: ubuntu-latest + - target: linux-arm64 + os: ubuntu-24.04-arm + runs-on: ${{ matrix.os }} + concurrency: + group: release-${{ inputs.release_tag }}-runtime-${{ matrix.target }} + cancel-in-progress: true + steps: + - uses: actions/checkout@v4 + with: + ref: ${{ inputs.target_ref }} + fetch-depth: 0 + + - uses: actions/setup-node@v4 + with: + node-version: 22 + cache: npm + cache-dependency-path: | + apps/desktop/package-lock.json + apps/ade-cli/package-lock.json + + - name: Install desktop dependencies + run: cd apps/desktop && npm ci + + - name: Install ADE CLI dependencies + run: cd apps/ade-cli && npm ci + + - name: Stamp runtime release version + env: + ADE_RELEASE_TAG: ${{ inputs.release_tag }} + run: cd apps/desktop && npm run version:release + + - name: Build ADE runtime binary + run: cd apps/ade-cli && npm run build:static -- --target ${{ matrix.target }} + + - name: Materialize runtime notarization API key + if: ${{ startsWith(matrix.target, 'darwin-') }} + env: + APPLE_API_KEY_P8: ${{ secrets.APPLE_API_KEY_P8 }} + APPLE_API_KEY_ID: ${{ secrets.APPLE_API_KEY_ID }} + run: | + if [ -z "$APPLE_API_KEY_P8" ] || [ -z "$APPLE_API_KEY_ID" ]; then + echo "::error::Missing APPLE_API_KEY_P8 or APPLE_API_KEY_ID GitHub secret." + exit 1 + fi + + KEY_PATH="$RUNNER_TEMP/AuthKey_${APPLE_API_KEY_ID}.p8" + printf '%s' "$APPLE_API_KEY_P8" > "$KEY_PATH" + chmod 600 "$KEY_PATH" + echo "APPLE_API_KEY=$KEY_PATH" >> "$GITHUB_ENV" + + - name: Import runtime Developer ID certificate + if: ${{ startsWith(matrix.target, 'darwin-') }} + env: + CSC_LINK: ${{ secrets.CSC_LINK }} + CSC_KEY_PASSWORD: ${{ secrets.CSC_KEY_PASSWORD }} + run: | + if [ -z "$CSC_LINK" ] || [ -z "$CSC_KEY_PASSWORD" ]; then + echo "::error::Missing CSC_LINK or CSC_KEY_PASSWORD GitHub secret." + exit 1 + fi + + CERT_PATH="$RUNNER_TEMP/runtime-signing.p12" + if [ -f "$CSC_LINK" ]; then + cp "$CSC_LINK" "$CERT_PATH" + elif [[ "$CSC_LINK" == file://* ]]; then + cp "${CSC_LINK#file://}" "$CERT_PATH" + elif [[ "$CSC_LINK" == http://* || "$CSC_LINK" == https://* ]]; then + curl -fsSL "$CSC_LINK" -o "$CERT_PATH" + else + printf '%s' "$CSC_LINK" | base64 --decode > "$CERT_PATH" + fi + + KEYCHAIN="$RUNNER_TEMP/runtime-signing.keychain-db" + KEYCHAIN_PASSWORD="$(openssl rand -hex 24)" + security create-keychain -p "$KEYCHAIN_PASSWORD" "$KEYCHAIN" + security set-keychain-settings -lut 21600 "$KEYCHAIN" + security unlock-keychain -p "$KEYCHAIN_PASSWORD" "$KEYCHAIN" + EXISTING_KEYCHAINS="$(security list-keychains -d user | tr -d '\"' | xargs)" + security list-keychains -d user -s "$KEYCHAIN" $EXISTING_KEYCHAINS + security default-keychain -s "$KEYCHAIN" + security import "$CERT_PATH" -k "$KEYCHAIN" -P "$CSC_KEY_PASSWORD" -T /usr/bin/codesign -T /usr/bin/security + security set-key-partition-list -S apple-tool:,apple:,codesign: -s -k "$KEYCHAIN_PASSWORD" "$KEYCHAIN" + + - name: Sign and notarize ADE runtime binary + if: ${{ startsWith(matrix.target, 'darwin-') }} + env: + APPLE_API_KEY_ID: ${{ secrets.APPLE_API_KEY_ID }} + APPLE_API_ISSUER: ${{ secrets.APPLE_API_ISSUER }} + run: cd apps/ade-cli && npm run notarize:static -- --binary=dist-static/ade-${{ matrix.target }} + + - name: Smoke test ADE runtime binary + run: | + apps/ade-cli/dist-static/ade-${{ matrix.target }} --version + tar -tzf apps/ade-cli/dist-static/ade-${{ matrix.target }}.native.tar.gz | grep -q '^\./node_modules/' + + - name: Upload ADE runtime binary + uses: actions/upload-artifact@v4 + with: + name: ade-runtime-${{ matrix.target }} + path: | + apps/ade-cli/dist-static/ade-${{ matrix.target }} + apps/ade-cli/dist-static/ade-${{ matrix.target }}.native.tar.gz + if-no-files-found: error + publish-release: if: ${{ inputs.publish }} needs: + - build-runtime-binaries - build-mac-release - build-win-release runs-on: ubuntu-latest steps: + - uses: actions/checkout@v4 + with: + ref: ${{ inputs.target_ref }} + fetch-depth: 1 + - name: Download macOS release artifacts uses: actions/download-artifact@v4 with: @@ -194,6 +344,18 @@ jobs: name: ade-win-release-${{ inputs.release_tag }} path: release-assets/win + - name: Download ADE runtime binaries + uses: actions/download-artifact@v4 + with: + pattern: ade-runtime-* + path: release-assets/runtime + merge-multiple: true + + - name: Add standalone runtime installer + run: | + cp apps/ade-cli/scripts/install-runtime.sh release-assets/runtime/install.sh + chmod 755 release-assets/runtime/install.sh + - name: Create or update draft GitHub release env: GH_TOKEN: ${{ github.token }} @@ -210,6 +372,8 @@ jobs: release-assets/win/*.exe release-assets/win/*.exe.blockmap release-assets/win/latest.yml + release-assets/runtime/install.sh + release-assets/runtime/ade-* ) if [ "${#files[@]}" -eq 0 ]; then diff --git a/.gitignore b/.gitignore index 033d0764a..855ee2909 100644 --- a/.gitignore +++ b/.gitignore @@ -65,3 +65,5 @@ ios-signing/ /.codex-derived-data package-lock.json !/apps/ade-code/package-lock.json +/apps/desktop/release-alpha +apps/desktop/resources/runtime/ade-* diff --git a/README.md b/README.md index b7acf51ca..807d64bb1 100644 --- a/README.md +++ b/README.md @@ -112,7 +112,12 @@ Requirements: macOS 13+, git on `PATH`, Node 22+ for headless CLI workflows. ## CLI ```bash +ade desktop +ade runtime status --text +ade runtime start +ade runtime stop ade doctor --json +ade code ade lanes create --name fix-checkout-flow ade prs checks 168 --text ade tests run --suite unit --wait @@ -123,12 +128,12 @@ ade actions list --text # discover every service action ## Architecture -Local-first, on purpose. Runtime state lives under `.ade/` inside each project — SQLite db, worktree checkouts, proof artifacts, encrypted secrets. +Local-first, on purpose. The center of ADE is the **runtime daemon** — a single per-machine `ade` service that owns projects, lanes, chats, processes, sync, and proof artifacts. Desktop, the terminal client, the iOS app, and SSH-attached desktop windows all attach to it as clients. Runtime state lives under `.ade/` inside each project (SQLite db, worktree checkouts, proof artifacts, encrypted secrets) and the machine-wide socket lives under `~/.ade/sock/ade.sock`. ```text -apps/desktop Electron host — SQLite, git, processes, AI runtimes, sync host -apps/ade-cli Node CLI over the desktop socket (or headless) -apps/ios SwiftUI companion that syncs with a desktop host +apps/ade-cli ADE runtime daemon (`ade serve`) + `ade` CLI + `ade code` terminal client +apps/desktop Electron client — multi-window, attaches to a local or SSH-bound runtime +apps/ios SwiftUI controller that attaches to a runtime over WebSocket apps/web Public website and download surface docs/ Product and engineering docs ``` @@ -137,11 +142,67 @@ Deep reference: [ARCHITECTURE.md](docs/ARCHITECTURE.md). ## Develop +First-time setup: + +```bash +npm run setup +``` + +Daily desktop dev: + +```bash +npm run dev +``` + +That aliases to `npm run dev:desktop`: it rebuilds `apps/ade-cli`, launches the Electron desktop app, and points it at the dev runtime socket `/tmp/ade-runtime-dev.sock`. If no dev runtime is listening, desktop is allowed to create it. This is the normal desktop-dev flow. + +Dev command matrix: + ```bash -cd apps/desktop && npm install && npm run dev # live Electron app -cd apps/ade-cli && npm install && npm run build # build the CLI +npm run dev:desktop # desktop only; dev socket; desktop may auto-create runtime +npm run dev:desktop:attach # desktop only; fail if dev runtime is not already running +npm run dev:desktop:clean # desktop only; clear Vite cache before launch +npm run dev:code # terminal TUI only; starts dev runtime if missing +npm run dev:code:attach # terminal TUI only; fail if dev runtime is not already running +npm run dev:runtime # runtime only in the foreground +npm run dev:all # start shared dev runtime, then run desktop/code attach commands in separate terminals +npm run dev:stop # stop the dev runtime +npm stop dev # same as dev:stop +``` + +The dev commands intentionally use a temp socket so they do not collide with the installed ADE app: + +```text +/tmp/ade-runtime-dev.sock ``` +Override it when needed: + +```bash +npm run dev:desktop -- --socket /tmp/my-ade-dev.sock +npm run dev:code -- --socket /tmp/my-ade-dev.sock +ADE_DEV_RUNTIME_SOCKET_PATH=/tmp/my-ade-dev.sock npm run dev:runtime +``` + +To test auto-runtime creation, use the `:auto`/default commands after stopping the dev runtime: + +```bash +npm run dev:stop +npm run dev:desktop # tests desktop creating the dev runtime +npm run dev:stop +npm run dev:code # tests TUI wrapper creating the dev runtime +``` + +Local packaged builds: + +```bash +npm run package:alpha # current checkout -> ADE Alpha.app, ade-alpha, ~/.ade-alpha +npm run package:beta # origin/main -> ADE Beta.app, ade-beta, ~/.ade-beta +``` + +These are unsigned local macOS app builds under `apps/desktop/release-alpha` and `apps/desktop/release-beta`. They do not replace the production `ADE.app`, production `ade`, or `~/.ade` runtime/state. +Local channel packages include the host runtime binary for this Mac. Release builds still require the full cross-platform runtime artifact set used by remote runtime bootstrap. + Validate with `npm --prefix apps/desktop run typecheck` and `run test`. The desktop test suite is large — run the smallest relevant subset first. ## Links diff --git a/apps/ade-cli/README.md b/apps/ade-cli/README.md index ece7db0fb..fbbf14f40 100644 --- a/apps/ade-cli/README.md +++ b/apps/ade-cli/README.md @@ -1,58 +1,229 @@ # ADE CLI -`apps/ade-cli` owns the `ade` command-line entry point for agents and local automation. +`apps/ade-cli` owns the `ade` command, the per-machine ADE runtime daemon, and the terminal `ade code` client. The runtime daemon is the source of truth for lanes, chats, PR state, process state, sync, and proof artifacts on a machine. Desktop ADE, `ade code`, the iOS app, and SSH-attached desktops all attach to it. -The CLI is the primary agent interface. It prefers the live ADE desktop socket at `.ade/ade.sock` so commands operate against the same lanes, chats, PR state, process runtime, and proof artifacts as the UI. If the desktop app is not running, it falls back to a short-lived headless runtime for actions that can safely run without Electron. +## Modes -## Scripts +The `ade` binary has three operating modes: + +- **Socket** — the runtime daemon (`ade serve`) listens on `~/.ade/sock/ade.sock` (POSIX) or `\\.\pipe\ade-runtime` (Windows). All other CLI commands and clients open that socket and speak ADE JSON-RPC. +- **Headless** (`--headless` or `ade code --embedded`) — the CLI builds an in-process `AdeRuntime` for one project and answers the same JSON-RPC surface directly. Used for one-shot commands and as a fallback when no socket is available. +- **`ade rpc --stdio`** — attaches to the local runtime daemon and bridges its JSON-RPC over stdio. This is the transport the desktop's remote runtime feature spawns over SSH. + +Default routing for typed commands: prefer the socket if reachable; auto-spawn `ade serve` in the background if the socket does not exist; fall back to headless for commands that don't need shared live state. Add `--socket` to require the daemon, or `--headless` to force in-process execution. + +## Machine layout + +`resolveMachineAdeLayout()` (in `src/services/projects/machineLayout.ts`) is the single source for per-machine paths. Override the root with `ADE_HOME`. + +| Path | Purpose | +| --- | --- | +| `~/.ade/` | Per-machine ADE state root. | +| `~/.ade/sock/ade.sock` | Runtime daemon socket (POSIX). | +| `\\.\pipe\ade-runtime` | Runtime daemon named pipe (Windows). | +| `~/.ade/projects.json` | Project registry. | +| `~/.ade/secrets/` | Encrypted credential store (`credentials.json.enc` + `.machine-key`). | +| `~/.ade/bin/ade` | Bundled static runtime binary (release installs / remote uploads). | +| `~/.ade/runtime//` | Native node modules for that runtime binary. | +| `~/.ade/runtime/launchd.{out,err}.log` | Daemon stdout/stderr when running as a login service on macOS. | + +Per-project state stays under `/.ade/` and is governed by `projectConfigService` (see `docs/features/onboarding-and-settings/configuration-schema.md`). + +Channel builds use parallel state roots and binary names so Stable, Beta, and Alpha can coexist: + +```text +ADE.app -> ade -> ~/.ade +ADE Beta.app -> ade-beta -> ~/.ade-beta +ADE Alpha.app -> ade-alpha -> ~/.ade-alpha +``` + +## Install paths + +Three ways to put `ade` on a machine: + +1. **Standalone runtime install** — single static binary plus its native dependency archive, fetched from a GitHub release. Suitable for headless macOS/Linux servers. + + ```bash + curl -fsSL https://github.com/arul28/ADE/releases/latest/download/install.sh | sh + ``` + + Environment overrides accepted by `install.sh`: + + - `ADE_VERSION=vX.Y.Z` — install a specific release tag (default `latest`). + - `ADE_INSTALL_DIR=/usr/local/bin` — destination directory for the binary. + - `ADE_RELEASE_REPO=owner/repo` — fetch from a fork. + - `ADE_HOME=/custom/.ade` — change the per-machine state root. + + The script downloads `ade-` to `$ADE_INSTALL_DIR/ade`, extracts `ade-.native.tar.gz` to `~/.ade/runtime//`, runs `ade --version` to verify, and best-effort registers the per-user login service on macOS / systemd. + +2. **Desktop bundle** — every packaged ADE.app ships the CLI. macOS path: + + ```bash + /Applications/ADE.app/Contents/Resources/ade-cli/bin/ade + ``` + + Add it to `PATH` once with the channel-specific helper: + + ```bash + /Applications/ADE.app/Contents/Resources/ade-cli/install-path.sh + ``` + + The `install-path.sh` wrapper exposes `ade` (or `ade-beta` / `ade-alpha` from the matching `.app`). The wrapper runs the CLI under the packaged Electron runtime, so users do not need a separate Node install. The desktop General settings tab also exposes Install / Repair via `AdeCliSection` (`window.ade.adeCli.installForUser()`). + +3. **Source build** — for repository development: + + ```bash + cd apps/ade-cli + npm run build + npm link # or: npm pack && npm install -g ./ade-cli-*.tgz + ``` + + Requires Node.js 22 or newer (the headless runtime depends on `node:sqlite`). + +## Service manager + +The runtime daemon runs as a per-user login service. The implementations live in `src/serviceManager/`. + +| Platform | Backend | Service path | +| --- | --- | --- | +| macOS | launchd `LaunchAgent` | `~/Library/LaunchAgents/com.ade.runtime.plist` | +| Linux | `systemctl --user` | `~/.config/systemd/user/ade-runtime.service` | +| Windows | `schtasks.exe ONLOGON` | scheduled task `ADE Runtime` | + +The default service label is `com.ade.runtime`; channel builds override it via `ADE_PACKAGE_CHANNEL=alpha|beta` (`com.ade.runtime.alpha`, `com.ade.runtime.beta`). `ADE_RUNTIME_SERVICE_NAME` overrides the label outright. macOS also writes `~/.ade/runtime/launchd.{out,err}.log`. + +Manage the service from the CLI: ```bash -npm run cli:dev -- help -npm run cli:dev -- doctor --project-root /absolute/path/to/repo -npm run dev -- --project-root /absolute/path/to/repo -npm run build -npm run typecheck -npm run test +ade serve --install-service # write the plist/unit/task and start it +ade serve --uninstall-service # stop and remove it +ade serve --service-status # JSON: { ok, installed, running, path, message } + +# Aliases on the runtime command (same backend): +ade runtime install-service +ade runtime uninstall-service +ade runtime service-status --text ``` -## Install and PATH +`resolveAdeServeCommand()` builds the service command from the current `ade` binary path so the installed service launches the same ADE channel that ran the install. + +## Foreground daemon -For local development, build the package and link its `ade` binary: +`ade serve` runs the runtime in the foreground. Use it for development or when the system service is disabled. ```bash -cd apps/ade-cli -npm run build -npm link -ade doctor --project-root /absolute/path/to/repo +ade serve +ade serve --socket ~/.ade/sock/ade.sock +ade serve --port 8787 # also accept JSON-RPC on 127.0.0.1:8787 +ade serve --no-sync # disable phone-sync host for this run ``` -The package is also packable as a normal Node CLI. It requires Node.js 22 or newer because ADE uses `node:sqlite` in the headless runtime. +## Runtime control + +`ade runtime` is the typed wrapper for daemon lifecycle commands: ```bash -cd apps/ade-cli -npm pack -npm install -g ./ade-cli-*.tgz +ade runtime status --text # is the daemon up, which socket +ade runtime start # spawn it in the background if missing +ade runtime stop # graceful shutdown via JSON-RPC +ade runtime install-service # delegates to ade serve --install-service ``` -The desktop macOS build also bundles the CLI at: +`ade runtime start` is idempotent: it spawns `ade serve` detached and returns once the runtime answers `ade/initialize`. `ade runtime stop` calls the daemon's `shutdown` method. + +## Project registry + +The runtime daemon owns a per-machine project registry at `~/.ade/projects.json` (`ProjectRegistry` in `src/services/projects/projectRegistry.ts`). A project record carries a stable `projectId` (`project_`), root path, display name, `addedAt`, `lastOpenedAt`, and the resolved git origin URL. + +Manage the registry through typed CLI commands: ```bash -/Applications/ADE.app/Contents/Resources/ade-cli/bin/ade +ade projects list --text +ade projects add /path/to/project +ade projects remove project_abc123… +ade projects touch project_abc123… +ade init # adds the cwd as a project +ade init /path/to/project # adds an explicit path +``` + +…or call the same JSON-RPC methods directly: + +```text +projects.list { } -> ProjectRecord[] +projects.add { rootPath } -> ProjectRecord +projects.remove { projectId } -> { removed } +projects.touch { projectId } -> ProjectRecord +``` + +Adding a project creates `/.ade/` if needed but does not run any heavy onboarding. The first project-scoped JSON-RPC call lazily builds an `AdeRuntime` for that root via `ProjectScopeRegistry`. + +## RPC surface + +The runtime exposes two layers of JSON-RPC methods (`src/multiProjectRpcServer.ts`): + +**Runtime-scoped** — no `projectId` required: + +```text +ade/initialize ade/initialized ping shutdown exit +runtime/info machineInfo.get +projects.list projects.add projects.remove projects.touch +runtimeEvents.subscribe runtimeEvents.unsubscribe +sync.getStatus sync.refreshDiscovery +sync.listDevices sync.updateLocalDevice +sync.connectToBrain sync.disconnectFromBrain +sync.forgetDevice +sync.getTransferReadiness sync.transferBrainToLocal +sync.getPin sync.setPin sync.clearPin +sync.setActiveLanePresence ``` -To make the desktop-bundled command available as `ade`, add a symlink from a directory on `PATH`: +**Project-scoped** — every other request must carry `params.projectId`. `ade/actions/call` (and the legacy ADE action / tool catalog underneath it) is dispatched into the per-project `ProjectScope` returned by `ProjectScopeRegistry.get(projectId)`. + +`ade/initialize` advertises `runtimeInfo.multiProject: true` and `capabilities.projects: true`. Clients use that to switch between sending `projectId` per request (multi-project runtime) and the legacy per-process binding (embedded runtime). Sync is hosted by the daemon for the most-recently-opened registered project; `ProjectScopeRegistry.ensureSyncHost` re-elects a host when projects are added or removed. + +## Credentials + +`src/services/credentials/credentialStore.ts` owns the machine-scoped credential store under `~/.ade/secrets/`: + +- `KeytarCredentialStore` (default when `keytar` is loadable) keys against the OS keychain under service `com.ade.runtime.credentials.v1`. +- `EncryptedFileCredentialStore` falls back to AES-256-GCM at `~/.ade/secrets/credentials.json.enc`, with the AES key in `~/.ade/secrets/.machine-key` (mode 600). +- `ElectronSafeStorageCredentialStore` is used when the desktop process talks to the same files but wants to encrypt with `safeStorage` instead. + +Disable keytar with `ADE_CREDENTIAL_STORE_DISABLE_KEYTAR=1` to force the encrypted-file store. + +## `ade code` + +`ade code` launches the terminal-native ADE Work chat (Ink + React, in `src/tuiClient/`). Default behavior: ```bash -/Applications/ADE.app/Contents/Resources/ade-cli/install-path.sh +ade code # attach to the machine daemon, auto-spawn it if missing +ade code --embedded # force the in-process embedded runtime +ade code --print-state # smoke-test the connection and exit +ade --socket /path/to/ade.sock code # attach to a specific socket +ade --project-root /repo code # bind to a specific project root ``` -That wrapper runs the CLI with the packaged ADE Electron runtime, so users do not need a separate Node install for the desktop-bundled path. +See `docs/features/ade-code/README.md` for the full attach/embedded handshake, slash command catalog, and right-pane drawers. + +## `ade rpc --stdio` + +`ade rpc --stdio` attaches to the local runtime daemon (auto-spawning it if needed) and bridges its JSON-RPC over stdio. The remote-runtime path on the desktop runs `ade rpc --stdio` over an SSH `exec` channel; see `docs/features/remote-runtime/internal-architecture.md` for the protocol shape and bootstrap sequence. + +## `ade desktop` -## CLI surface +`ade desktop` opens the installed ADE app from the terminal. On macOS it runs `open -a "ADE"` (or `ADE Beta` / `ADE Alpha` based on `ADE_PACKAGE_CHANNEL` / `ADE_DESKTOP_APP_NAME`). The desktop attaches to the same machine runtime; if the daemon is not running, the desktop spawns and waits for it via `LocalRuntimeConnectionPool`. + +## CLI surface (selected) ```bash +ade desktop +ade runtime status --text +ade runtime start +ade runtime stop ade auth status -ade doctor +ade doctor --json +ade projects list --text +ade init ade lanes list --text ade lanes create "fix-checkout-flow" --parent main ade lanes create "lin-123" --linear-issue-json '{"id":"...","identifier":"LIN-123","title":"...","projectId":"...","projectSlug":"...","teamId":"...","teamKey":"...","stateId":"...","stateName":"Todo","stateType":"unstarted","priority":2,"priorityLabel":"high","labels":[],"assigneeId":null,"assigneeName":null,"createdAt":"...","updatedAt":"..."}' @@ -61,7 +232,6 @@ ade --role cto linear search-issues --query "auth" --state-type started,unstarte ade git commit --lane lane-id ade git push --lane lane-id ade git branches --lane lane-id --text -ade git user-identity --lane lane-id --text ade diff patch --lane lane-id --path src/file.ts --text ade prs create --lane lane-id --base main --title "Fix checkout flow" ade prs create --lane lane-id --base main --close-linear-issue-on-merge @@ -76,10 +246,9 @@ ade shell start-cli codex --lane lane-id --permission-mode edit --message "fix f ade shell start-cli --provider claude --lane lane-id --permission-mode default ade chat create --lane lane-id --model gpt-5.5 ade code -ade --socket /path/to/ade.sock code +ade code --embedded ade tests run --lane lane-id --suite unit --wait ade proof list --arg ownerKind=chat --arg ownerId=session-id -ade help ios-sim preview-render ade ios-sim devices --text ade --socket ios-sim apps --text ade --socket ios-sim launch --target target-id --text @@ -88,84 +257,105 @@ ade --socket app-control launch --command "npm run dev" --text ade --socket browser open http://localhost:5173 --new-tab --text ade --socket macos-vm status --lane lane-id --text ade --socket macos-vm start --lane lane-id --create --no-display --text -ade --socket macos-vm screenshot --lane lane-id --text -ade --socket macos-vm click --lane lane-id --x 120 --y 420 --text ade --socket update status --text -ade --socket update check --text -ade --socket update install --text ade actions list ade actions run git.stageFile --arg laneId=lane-id --arg path=src/index.ts ade cursor cloud agents list --text ade cursor cloud agents create --repo https://github.com/owner/repo --prompt "fix flaky test" --auto-pr -ade cursor cloud me ``` Use typed commands first. They validate common arguments and provide stable JSON fields or readable text summaries. Use `ade help ` for exact flags, `ade actions list --text` to discover the full service-backed action catalog, and `ade actions run ` only when there is no typed command for the workflow yet. -**`ade code`** starts the terminal Work chat client (`apps/ade-code`). Build it with `npm run build` inside that directory, install the `ade-code` package, or point **`ADE_CODE_EXECUTABLE`** at `dist/cli.js`. Unlike other commands that auto-pick the desktop socket from the project layout during `executePlan`, **`ade code` only forwards `--socket` when you pass global `--socket` to `ade`** (for example `ade --socket /path/to/ade.sock code`). Without that, the TUI runs in **embedded** headless mode instead of opening a socket implicitly. +Output modes are explicit: `--text` for human-readable summaries, `--json` (default for piped output) for stable JSON, and `--pretty` for pretty-printed JSON. -The `prs path-to-merge` and `prs pipeline save` commands persist a partial `PipelineSettings` patch via `issue_inventory.savePipelineSettings` before launching the resolver. The Path to Merge orchestrator reads these from saved settings, so the same flags work either way: +`--socket` requires the daemon and fails fast when it is missing. Without `--socket`, the CLI auto-attaches when reachable and falls back to headless for commands that can run that way. -| Flag | PipelineSettings field | Values | -| --- | --- | --- | -| `--max-rounds ` (alias `--rounds`) | `maxRounds` | positive integer | -| `--auto-merge` / `--no-auto-merge` | `autoMerge` | boolean | -| `--merge-method ` | `mergeMethod` | `repo_default` \| `merge` \| `squash` \| `rebase` | -| `--conflict-strategy ` | `conflictStrategy` | `pause` \| `rebase` \| `merge` \| `auto` | -| `--force-finalize ` | `forceFinalizeMode` | `off` \| `conditional` \| `unconditional` | -| `--force-finalize-require-no-ci` / `--force-finalize-allow-ci` | `forceFinalizeRequireNoCiFailures` | boolean | -| `--early-merge-on-green` / `--no-early-merge-on-green` | `earlyMergeOnGreen` | boolean | +## `ade auth` and `ade doctor` -To set fields without a dedicated flag (for example `autoAgentSettings`), call the action directly: +ADE CLI auth is local project access, not a separate cloud login. `ade auth status` verifies that the current terminal can initialize an ADE runtime for the project. Provider credentials, GitHub tokens, Linear tokens, and computer-use policy are read from ADE project settings and the existing secure stores. + +`ade doctor` reports local-only readiness metadata by default: + +- CLI version, Node/runtime version, project root, workspace root, `.ade` initialization, and config file presence. +- Machine socket path, whether the socket exists, and whether this invocation is using `runtime-socket`, `desktop-socket`, or `headless` mode. +- RPC tool count, ADE action count, and action counts by domain. +- Git repository readiness and GitHub readiness signals from local remotes, `gh` availability, and token environment presence. +- Linear readiness from local encrypted token presence or headless environment variables. +- Provider/model readiness from local ADE config, API-key provider references, and provider CLI availability. +- Computer-use readiness from local platform capabilities. +- Packaged/PATH status for the `ade` binary and concrete next actions. + +Default doctor / auth checks do not call provider, GitHub, or Linear networks. They report presence and local readiness only, without printing secret values. + +Agents starting an unfamiliar ADE session should begin with: ```bash -ade actions run issue_inventory.savePipelineSettings --args-list-json \ - '["pr-1",{"autoAgentSettings":{"provider":"claude","model":"sonnet","reasoningEffort":"high","permissionMode":"guarded_edit","confidenceThreshold":0.7}}]' +ade doctor --json +ade actions list --text ``` -Output modes are explicit: +…then prefer typed commands such as `ade lanes list --text`, `ade files read --text`, `ade prs checks --text`, or `ade tests runs --json`. Use `ade actions run …` as the broad escape hatch. + +## Repo development + +The installed `ade` command is the production CLI. Repository development uses root npm scripts so the command always runs the CLI and desktop code from this checkout, not whichever `ade` happens to be first on `PATH`. ```bash -ade lanes list --text -ade git status --lane lane-id --json -ade actions run git.stageFile --arg laneId=lane-id --arg path=src/index.ts --json +npm run setup +npm run dev:desktop +npm run dev:code +npm run dev:runtime +npm run dev:stop ``` -Commands that need UI-owned state, long-running Work chat state, live Run tab process state, or desktop proof state should use the live ADE socket: +The dev scripts are the same runtime daemon, just running from source against a temporary socket so a packaged ADE on the same machine is not affected: -```bash -ade doctor --project-root /absolute/path/to/repo --socket --json -ade lanes list --project-root /absolute/path/to/repo --socket --text +```text +/tmp/ade-runtime-dev.sock ``` -Without `--socket`, the CLI auto-connects to the desktop socket when it is available and falls back to headless mode when it is not. +Full matrix: -## Auth and readiness +```bash +npm run dev:desktop # desktop only; dev socket; desktop may auto-create runtime +npm run dev:desktop:attach # desktop only; fail unless dev runtime is already running +npm run dev:desktop:clean # desktop only; clear Vite cache before launch +npm run dev:code # terminal TUI only; starts dev runtime if missing +npm run dev:code:attach # terminal TUI only; fail unless dev runtime is already running +npm run dev:runtime # runtime only in the foreground +npm run dev:all # start shared dev runtime, then use attach commands in separate terminals +npm run dev:stop # stop the dev runtime +npm stop dev # same as dev:stop +``` -ADE CLI auth is local project access, not a separate cloud login. `ade auth status` verifies that the current terminal can initialize an ADE runtime for the project. Provider credentials, GitHub tokens, and computer-use policy are read from ADE project settings and the existing secure stores. +Local packaged builds are separate from dev-mode scripts: -`ade doctor` reports local-only readiness metadata by default: +```bash +npm run package:alpha # current checkout -> ADE Alpha.app, ade-alpha, ~/.ade-alpha +npm run package:beta # origin/main -> ADE Beta.app, ade-beta, ~/.ade-beta +``` -- CLI version, Node/runtime version, project root, workspace root, `.ade` initialization, and config file presence. -- Desktop socket path, whether the socket exists, and whether this invocation is actually using `desktop-socket` or `headless` mode. -- RPC tool count, ADE service action count, and action counts by domain. -- Git repository readiness and GitHub readiness signals from local remotes, `gh` availability, and token environment presence. -- Linear readiness from local encrypted token presence or headless environment variables. -- Provider/model readiness from local ADE config, API-key provider references, and provider CLI availability. -- Computer-use readiness from local platform capabilities. -- Packaged/PATH status for the `ade` binary and concrete next actions. +Use these when you want a production-shaped local app without going through the GitHub release workflow. Use the dev scripts when you want Vite/Electron live reload and the temp dev socket. Local channel packages include the host runtime binary for the build machine. GitHub release builds use and validate the full cross-platform runtime artifact set. -Default doctor/auth checks do not call provider, GitHub, or Linear networks. They report presence and local readiness only, without printing secret values. +The `prs path-to-merge` and `prs pipeline save` commands persist a partial `PipelineSettings` patch via `issue_inventory.savePipelineSettings` before launching the resolver. The Path to Merge orchestrator reads these from saved settings, so the same flags work either way: + +| Flag | PipelineSettings field | Values | +| --- | --- | --- | +| `--max-rounds ` (alias `--rounds`) | `maxRounds` | positive integer | +| `--auto-merge` / `--no-auto-merge` | `autoMerge` | boolean | +| `--merge-method ` | `mergeMethod` | `repo_default` \| `merge` \| `squash` \| `rebase` | +| `--conflict-strategy ` | `conflictStrategy` | `pause` \| `rebase` \| `merge` \| `auto` | +| `--force-finalize ` | `forceFinalizeMode` | `off` \| `conditional` \| `unconditional` | +| `--force-finalize-require-no-ci` / `--force-finalize-allow-ci` | `forceFinalizeRequireNoCiFailures` | boolean | +| `--early-merge-on-green` / `--no-early-merge-on-green` | `earlyMergeOnGreen` | boolean | -Agents should start unfamiliar ADE sessions with: +To set fields without a dedicated flag (for example `autoAgentSettings`), call the action directly: ```bash -ade doctor --json -ade actions list --text +ade actions run issue_inventory.savePipelineSettings --args-list-json \ + '["pr-1",{"autoAgentSettings":{"provider":"claude","model":"sonnet","reasoningEffort":"high","permissionMode":"guarded_edit","confidenceThreshold":0.7}}]' ``` -Then prefer typed commands such as `ade lanes list --text`, `ade files read --text`, `ade prs checks --text`, or `ade tests runs --json`. Use `ade actions run ...` as the broad escape hatch for internal ADE actions that do not yet have a typed command. - ## Automations Automation rules are managed with `ade automations `. Run `ade help automations` for the full flag reference. The lane-mode flags layer on top of `--from-file` / `--stdin` / `--text` for `create` and `update`: diff --git a/apps/ade-cli/package-lock.json b/apps/ade-cli/package-lock.json index f08dd37f6..0e75d5414 100644 --- a/apps/ade-cli/package-lock.json +++ b/apps/ade-cli/package-lock.json @@ -9,9 +9,15 @@ "version": "0.0.0", "dependencies": { "@cursor/sdk": "^1.0.9", + "@linear/sdk": "^84.0.0", + "bonjour-service": "^1.3.0", + "ink": "^5.2.1", + "ink-text-input": "^6.0.0", "node-cron": "^3.0.3", "node-pty": "^1.1.0", + "react": "^18.3.1", "sql.js": "^1.13.0", + "ws": "^8.20.0", "yaml": "^2.8.2" }, "bin": { @@ -19,6 +25,10 @@ }, "devDependencies": { "@types/node": "^20.11.30", + "@types/react": "^18.3.28", + "@types/ws": "^8.18.1", + "ink-testing-library": "^4.0.0", + "postject": "^1.0.0-alpha.6", "tsup": "^8.3.5", "tsx": "^4.20.6", "typescript": "^5.7.3", @@ -28,6 +38,43 @@ "node": ">=22.0.0" } }, + "node_modules/@alcalzone/ansi-tokenize": { + "version": "0.1.3", + "resolved": "https://registry.npmjs.org/@alcalzone/ansi-tokenize/-/ansi-tokenize-0.1.3.tgz", + "integrity": "sha512-3yWxPTq3UQ/FY9p1ErPxIyfT64elWaMvM9lIHnaqpyft63tkxodF5aUElYHrdisWve5cETkh1+KBw1yJuW0aRw==", + "license": "MIT", + "dependencies": { + "ansi-styles": "^6.2.1", + "is-fullwidth-code-point": "^4.0.0" + }, + "engines": { + "node": ">=14.13.1" + } + }, + "node_modules/@alcalzone/ansi-tokenize/node_modules/ansi-styles": { + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", + "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/@alcalzone/ansi-tokenize/node_modules/is-fullwidth-code-point": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-4.0.0.tgz", + "integrity": "sha512-O4L094N2/dZ7xqVdrXhh9r1KODPJpFms8B5sGdJLPy664AgvXsreZUyCQQNItZRDlYug4xStLjNp/sz3HvBowQ==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/@bufbuild/protobuf": { "version": "1.10.0", "resolved": "https://registry.npmjs.org/@bufbuild/protobuf/-/protobuf-1.10.0.tgz", @@ -580,6 +627,15 @@ "license": "MIT", "optional": true }, + "node_modules/@graphql-typed-document-node/core": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/@graphql-typed-document-node/core/-/core-3.2.0.tgz", + "integrity": "sha512-mB9oAsNCm9aM3/SOv4YtBMqZbYj10R7dkq8byBqxGY/ncFwhf2oQzMV+LCRlWoDSEBJ3COiR1yeDvMtsoOsuFQ==", + "license": "MIT", + "peerDependencies": { + "graphql": "^0.8.0 || ^0.9.0 || ^0.10.0 || ^0.11.0 || ^0.12.0 || ^0.13.0 || ^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0" + } + }, "node_modules/@jest/schemas": { "version": "29.6.3", "resolved": "https://registry.npmjs.org/@jest/schemas/-/schemas-29.6.3.tgz", @@ -627,6 +683,24 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, + "node_modules/@leichtgewicht/ip-codec": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@leichtgewicht/ip-codec/-/ip-codec-2.0.5.tgz", + "integrity": "sha512-Vo+PSpZG2/fmgmiNzYK9qWRh8h/CHrwD0mo1h1DzL4yzHNSfWYujGTYsWGreD000gcgmZ7K4Ys6Tx9TxtsKdDw==", + "license": "MIT" + }, + "node_modules/@linear/sdk": { + "version": "84.0.0", + "resolved": "https://registry.npmjs.org/@linear/sdk/-/sdk-84.0.0.tgz", + "integrity": "sha512-jPtGlY06zG86ba6cL78d4JB9m61YMHo3L0luMrtVFgeOounI/GK3S3c6Ncjw7cxWzcsepoNZD10mQYoT81uKKA==", + "license": "MIT", + "dependencies": { + "@graphql-typed-document-node/core": "^3.2.0" + }, + "engines": { + "node": ">=18.x" + } + }, "node_modules/@npmcli/fs": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/@npmcli/fs/-/fs-1.1.1.tgz", @@ -1039,6 +1113,34 @@ "undici-types": "~6.21.0" } }, + "node_modules/@types/prop-types": { + "version": "15.7.15", + "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz", + "integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/@types/react": { + "version": "18.3.28", + "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.28.tgz", + "integrity": "sha512-z9VXpC7MWrhfWipitjNdgCauoMLRdIILQsAEV+ZesIzBq/oUlxk0m3ApZuMFCXdnS4U7KrI+l3WRUEGQ8K1QKw==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "@types/prop-types": "*", + "csstype": "^3.2.2" + } + }, + "node_modules/@types/ws": { + "version": "8.18.1", + "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz", + "integrity": "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@vitest/expect": { "version": "0.34.6", "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-0.34.6.tgz", @@ -1190,6 +1292,21 @@ "node": ">=8" } }, + "node_modules/ansi-escapes": { + "version": "7.3.0", + "resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-7.3.0.tgz", + "integrity": "sha512-BvU8nYgGQBxcmMuEeUEmNTvrMVjJNSH7RgW24vXexN4Ven6qCvy4TntnvlnwnMLTVlcRQQdbRY8NKnaIoeWDNg==", + "license": "MIT", + "dependencies": { + "environment": "^1.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/ansi-regex": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", @@ -1249,6 +1366,18 @@ "node": "*" } }, + "node_modules/auto-bind": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/auto-bind/-/auto-bind-5.0.1.tgz", + "integrity": "sha512-ooviqdwwgfIfNmDwo94wlshcdzfO64XV0Cg6oDsDYBJfITDz1EngD2z7DkbvCWn+XIMsIqW27sEVF6qcpJrRcg==", + "license": "MIT", + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/balanced-match": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", @@ -1296,6 +1425,16 @@ "readable-stream": "^3.4.0" } }, + "node_modules/bonjour-service": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/bonjour-service/-/bonjour-service-1.3.0.tgz", + "integrity": "sha512-3YuAUiSkWykd+2Azjgyxei8OWf8thdn8AITIog2M4UICzoqfjlqr64WIjEXZllf/W6vK1goqleSR6brGomxQqA==", + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "multicast-dns": "^7.2.5" + } + }, "node_modules/brace-expansion": { "version": "1.1.14", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.14.tgz", @@ -1403,6 +1542,18 @@ "node": ">=4" } }, + "node_modules/chalk": { + "version": "5.6.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.6.2.tgz", + "integrity": "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==", + "license": "MIT", + "engines": { + "node": "^12.17.0 || ^14.13 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, "node_modules/check-error": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/check-error/-/check-error-1.0.3.tgz", @@ -1449,6 +1600,151 @@ "node": ">=6" } }, + "node_modules/cli-boxes": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/cli-boxes/-/cli-boxes-3.0.0.tgz", + "integrity": "sha512-/lzGpEWL/8PfI0BmBOPRwp0c/wFNX1RdUML3jK/RcSBA9T8mZDdQpqYBKtCFTOfQbwPqWEOpjqW+Fnayc0969g==", + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/cli-cursor": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/cli-cursor/-/cli-cursor-4.0.0.tgz", + "integrity": "sha512-VGtlMu3x/4DOtIUwEkRezxUZ2lBacNJCHash0N0WeZDBS+7Ux1dm3XWAgWYxLJFMMdOeXMHXorshEFhbMSGelg==", + "license": "MIT", + "dependencies": { + "restore-cursor": "^4.0.0" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/cli-truncate": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/cli-truncate/-/cli-truncate-4.0.0.tgz", + "integrity": "sha512-nPdaFdQ0h/GEigbPClz11D0v/ZJEwxmeVZGeMo3Z5StPtUTkA9o1lD6QwoirYiSDzbcwn2XcjwmCp68W1IS4TA==", + "license": "MIT", + "dependencies": { + "slice-ansi": "^5.0.0", + "string-width": "^7.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/cli-truncate/node_modules/ansi-regex": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", + "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/cli-truncate/node_modules/ansi-styles": { + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", + "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/cli-truncate/node_modules/emoji-regex": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.6.0.tgz", + "integrity": "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==", + "license": "MIT" + }, + "node_modules/cli-truncate/node_modules/is-fullwidth-code-point": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-4.0.0.tgz", + "integrity": "sha512-O4L094N2/dZ7xqVdrXhh9r1KODPJpFms8B5sGdJLPy664AgvXsreZUyCQQNItZRDlYug4xStLjNp/sz3HvBowQ==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/cli-truncate/node_modules/slice-ansi": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-5.0.0.tgz", + "integrity": "sha512-FC+lgizVPfie0kkhqUScwRu1O/lF6NOgJmlCgK+/LYxDCTk8sGelYaHDhFcDN+Sn3Cv+3VSa4Byeo+IMCzpMgQ==", + "license": "MIT", + "dependencies": { + "ansi-styles": "^6.0.0", + "is-fullwidth-code-point": "^4.0.0" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/slice-ansi?sponsor=1" + } + }, + "node_modules/cli-truncate/node_modules/string-width": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz", + "integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==", + "license": "MIT", + "dependencies": { + "emoji-regex": "^10.3.0", + "get-east-asian-width": "^1.0.0", + "strip-ansi": "^7.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/cli-truncate/node_modules/strip-ansi": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz", + "integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^6.2.2" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, + "node_modules/code-excerpt": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/code-excerpt/-/code-excerpt-4.0.0.tgz", + "integrity": "sha512-xxodCmBen3iy2i0WtAK8FlFNrRzjUqjRsMfho58xT/wvZU1YTM3fCnRjcy1gJPMepaRlgm/0e6w8SpWHpn3/cA==", + "license": "MIT", + "dependencies": { + "convert-to-spaces": "^2.0.1" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + } + }, "node_modules/color-support": { "version": "1.1.3", "resolved": "https://registry.npmjs.org/color-support/-/color-support-1.1.3.tgz", @@ -1497,6 +1793,22 @@ "license": "ISC", "optional": true }, + "node_modules/convert-to-spaces": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/convert-to-spaces/-/convert-to-spaces-2.0.1.tgz", + "integrity": "sha512-rcQ1bsQO9799wq24uE5AM2tAILy4gXGIK/njFWcVQkGNZ96edlpY+A7bjwvzjYvLDyzmG1MmMLZhpcsb+klNMQ==", + "license": "MIT", + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + } + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "devOptional": true, + "license": "MIT" + }, "node_modules/debug": { "version": "4.4.3", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", @@ -1575,6 +1887,18 @@ "node": "^14.15.0 || ^16.10.0 || >=18.0.0" } }, + "node_modules/dns-packet": { + "version": "5.6.1", + "resolved": "https://registry.npmjs.org/dns-packet/-/dns-packet-5.6.1.tgz", + "integrity": "sha512-l4gcSouhcgIKRvyy99RNVOgxXiicE+2jZoNmaNmZ6JXiGajBOJAesk1OBlJuM5k2c+eudGdLxDqXuPCKIj6kpw==", + "license": "MIT", + "dependencies": { + "@leichtgewicht/ip-codec": "^2.0.1" + }, + "engines": { + "node": ">=6" + } + }, "node_modules/emoji-regex": { "version": "8.0.0", "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", @@ -1611,6 +1935,18 @@ "node": ">=6" } }, + "node_modules/environment": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/environment/-/environment-1.1.0.tgz", + "integrity": "sha512-xUtoPkMggbz0MPyPiIWr1Kp4aeWJjDZ6SMvURhimjdZgsRuDplF5/s9hcgGhyXMhs+6vpnuoiZ2kFiu3FMnS8Q==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/err-code": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/err-code/-/err-code-2.0.3.tgz", @@ -1618,6 +1954,16 @@ "license": "MIT", "optional": true }, + "node_modules/es-toolkit": { + "version": "1.46.1", + "resolved": "https://registry.npmjs.org/es-toolkit/-/es-toolkit-1.46.1.tgz", + "integrity": "sha512-5eNtXOs3tbfxXOj04tjjseeWkRWaoCjdEI+96DgwzZoe6c9juL49pXlzAFTI72aWC9Y8p7168g6XIKjh7k6pyQ==", + "license": "MIT", + "workspaces": [ + "docs", + "benchmarks" + ] + }, "node_modules/esbuild": { "version": "0.27.3", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.3.tgz", @@ -1659,6 +2005,15 @@ "@esbuild/win32-x64": "0.27.3" } }, + "node_modules/escape-string-regexp": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-2.0.0.tgz", + "integrity": "sha512-UpzcLCXolUWcNu5HtVMHYdXJjArjsF9C0aNnquZYY4uW/Vu0miy5YoWvbV345HauVvcAUnpRuhMMcqTcGOY2+w==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/expand-template": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/expand-template/-/expand-template-2.0.3.tgz", @@ -1668,6 +2023,12 @@ "node": ">=6" } }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "license": "MIT" + }, "node_modules/fdir": { "version": "6.5.0", "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", @@ -1762,6 +2123,18 @@ "node": "^12.13.0 || ^14.15.0 || >=16.0.0" } }, + "node_modules/get-east-asian-width": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/get-east-asian-width/-/get-east-asian-width-1.6.0.tgz", + "integrity": "sha512-QRbvDIbx6YklUe6RxeTeleMR0yv3cYH6PsPZHcnVn7xv7zO1BHN8r0XETu8n6Ye3Q+ahtSarc3WgtNWmehIBfA==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/get-func-name": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/get-func-name/-/get-func-name-2.0.2.tgz", @@ -1818,6 +2191,16 @@ "license": "ISC", "optional": true }, + "node_modules/graphql": { + "version": "16.14.0", + "resolved": "https://registry.npmjs.org/graphql/-/graphql-16.14.0.tgz", + "integrity": "sha512-BBvQ/406p+4CZbTpCbVPSxfzrZrbnuWSP1ELYgyS6B+hNeKzgrdB4JczCa5VZUBQrDa9hUngm0KnexY6pJRN5Q==", + "license": "MIT", + "peer": true, + "engines": { + "node": "^12.22.0 || ^14.16.0 || ^16.0.0 || >=17.0.0" + } + }, "node_modules/has-unicode": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/has-unicode/-/has-unicode-2.0.1.tgz", @@ -1931,29 +2314,186 @@ "license": "ISC", "optional": true }, - "node_modules/inflight": { - "version": "1.0.6", - "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", - "integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==", - "deprecated": "This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful.", - "license": "ISC", - "optional": true, + "node_modules/inflight": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", + "integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==", + "deprecated": "This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful.", + "license": "ISC", + "optional": true, + "dependencies": { + "once": "^1.3.0", + "wrappy": "1" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "license": "ISC" + }, + "node_modules/ini": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz", + "integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==", + "license": "ISC" + }, + "node_modules/ink": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/ink/-/ink-5.2.1.tgz", + "integrity": "sha512-BqcUyWrG9zq5HIwW6JcfFHsIYebJkWWb4fczNah1goUO0vv5vneIlfwuS85twyJ5hYR/y18FlAYUxrO9ChIWVg==", + "license": "MIT", + "dependencies": { + "@alcalzone/ansi-tokenize": "^0.1.3", + "ansi-escapes": "^7.0.0", + "ansi-styles": "^6.2.1", + "auto-bind": "^5.0.1", + "chalk": "^5.3.0", + "cli-boxes": "^3.0.0", + "cli-cursor": "^4.0.0", + "cli-truncate": "^4.0.0", + "code-excerpt": "^4.0.0", + "es-toolkit": "^1.22.0", + "indent-string": "^5.0.0", + "is-in-ci": "^1.0.0", + "patch-console": "^2.0.0", + "react-reconciler": "^0.29.0", + "scheduler": "^0.23.0", + "signal-exit": "^3.0.7", + "slice-ansi": "^7.1.0", + "stack-utils": "^2.0.6", + "string-width": "^7.2.0", + "type-fest": "^4.27.0", + "widest-line": "^5.0.0", + "wrap-ansi": "^9.0.0", + "ws": "^8.18.0", + "yoga-layout": "~3.2.1" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@types/react": ">=18.0.0", + "react": ">=18.0.0", + "react-devtools-core": "^4.19.1" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "react-devtools-core": { + "optional": true + } + } + }, + "node_modules/ink-testing-library": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/ink-testing-library/-/ink-testing-library-4.0.0.tgz", + "integrity": "sha512-yF92kj3pmBvk7oKbSq5vEALO//o7Z9Ck/OaLNlkzXNeYdwfpxMQkSowGTFUCS5MSu9bWfSZMewGpp7bFc66D7Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@types/react": ">=18.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/ink-text-input": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/ink-text-input/-/ink-text-input-6.0.0.tgz", + "integrity": "sha512-Fw64n7Yha5deb1rHY137zHTAbSTNelUKuB5Kkk2HACXEtwIHBCf9OH2tP/LQ9fRYTl1F0dZgbW0zPnZk6FA9Lw==", + "license": "MIT", + "dependencies": { + "chalk": "^5.3.0", + "type-fest": "^4.18.2" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "ink": ">=5", + "react": ">=18" + } + }, + "node_modules/ink/node_modules/ansi-regex": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", + "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/ink/node_modules/ansi-styles": { + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", + "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/ink/node_modules/emoji-regex": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.6.0.tgz", + "integrity": "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==", + "license": "MIT" + }, + "node_modules/ink/node_modules/indent-string": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/indent-string/-/indent-string-5.0.0.tgz", + "integrity": "sha512-m6FAo/spmsW2Ab2fU35JTYwtOKa2yAwXSwgjSv1TJzh4Mh7mC3lzAOVLBprb72XsTrgkEIsl7YrFNAiDiRhIGg==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/ink/node_modules/string-width": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz", + "integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==", + "license": "MIT", "dependencies": { - "once": "^1.3.0", - "wrappy": "1" + "emoji-regex": "^10.3.0", + "get-east-asian-width": "^1.0.0", + "strip-ansi": "^7.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/inherits": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", - "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", - "license": "ISC" - }, - "node_modules/ini": { - "version": "1.3.8", - "resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz", - "integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==", - "license": "ISC" + "node_modules/ink/node_modules/strip-ansi": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz", + "integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^6.2.2" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } }, "node_modules/ip-address": { "version": "10.1.1", @@ -1975,6 +2515,21 @@ "node": ">=8" } }, + "node_modules/is-in-ci": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-in-ci/-/is-in-ci-1.0.0.tgz", + "integrity": "sha512-eUuAjybVTHMYWm/U+vBO1sY/JOCgoPCXRxzdju0K+K0BiGW0SChEL1MLC0PoCIR1OlPo5YAp8HuQoUlsWEICwg==", + "license": "MIT", + "bin": { + "is-in-ci": "cli.js" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/is-lambda": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/is-lambda/-/is-lambda-1.0.1.tgz", @@ -1998,6 +2553,12 @@ "node": ">=10" } }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "license": "MIT" + }, "node_modules/lilconfig": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.3.tgz", @@ -2037,6 +2598,18 @@ "url": "https://github.com/sponsors/antfu" } }, + "node_modules/loose-envify": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", + "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", + "license": "MIT", + "dependencies": { + "js-tokens": "^3.0.0 || ^4.0.0" + }, + "bin": { + "loose-envify": "cli.js" + } + }, "node_modules/loupe": { "version": "2.3.7", "resolved": "https://registry.npmjs.org/loupe/-/loupe-2.3.7.tgz", @@ -2096,6 +2669,15 @@ "node": ">= 10" } }, + "node_modules/mimic-fn": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-2.1.0.tgz", + "integrity": "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/mimic-response": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-3.1.0.tgz", @@ -2261,6 +2843,19 @@ "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", "devOptional": true }, + "node_modules/multicast-dns": { + "version": "7.2.5", + "resolved": "https://registry.npmjs.org/multicast-dns/-/multicast-dns-7.2.5.tgz", + "integrity": "sha512-2eznPJP8z2BFLX50tf0LuODrpINqP1RVIm/CObbTcBRITQgmC/TjcREF1NeTBzIcR5XO/ukWo+YHOjBbFwIupg==", + "license": "MIT", + "dependencies": { + "dns-packet": "^5.2.2", + "thunky": "^1.0.2" + }, + "bin": { + "multicast-dns": "cli.js" + } + }, "node_modules/mz": { "version": "2.7.0", "resolved": "https://registry.npmjs.org/mz/-/mz-2.7.0.tgz", @@ -2419,6 +3014,21 @@ "wrappy": "1" } }, + "node_modules/onetime": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/onetime/-/onetime-5.1.2.tgz", + "integrity": "sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==", + "license": "MIT", + "dependencies": { + "mimic-fn": "^2.1.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/p-limit": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-4.0.0.tgz", @@ -2450,6 +3060,15 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/patch-console": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/patch-console/-/patch-console-2.0.0.tgz", + "integrity": "sha512-0YNdUceMdaQwoKce1gatDScmMo5pu/tfABfnzEqeG0gtTmd7mh/WcwgUjtAeOU7N8nFFlbQBnFK2gXW5fGvmMA==", + "license": "MIT", + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + } + }, "node_modules/path-is-absolute": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", @@ -2583,6 +3202,32 @@ } } }, + "node_modules/postject": { + "version": "1.0.0-alpha.6", + "resolved": "https://registry.npmjs.org/postject/-/postject-1.0.0-alpha.6.tgz", + "integrity": "sha512-b9Eb8h2eVqNE8edvKdwqkrY6O7kAwmI8kcnBv1NScolYJbo59XUF0noFq+lxbC1yN20bmC0WBEbDC5H/7ASb0A==", + "dev": true, + "license": "MIT", + "dependencies": { + "commander": "^9.4.0" + }, + "bin": { + "postject": "dist/cli.js" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/postject/node_modules/commander": { + "version": "9.5.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-9.5.0.tgz", + "integrity": "sha512-KRs7WVDKg86PWiuAqhDrAQnTXZKraVcCc6vFdL14qrZ/DcWwuRo7VoiYXalXO7S5GKpqYiVEwCbgFDfxNHKJBQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.20.0 || >=14" + } + }, "node_modules/prebuild-install": { "version": "7.1.3", "resolved": "https://registry.npmjs.org/prebuild-install/-/prebuild-install-7.1.3.tgz", @@ -2670,12 +3315,40 @@ "rc": "cli.js" } }, + "node_modules/react": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", + "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/react-is": { "version": "18.3.1", "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz", "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==", "dev": true }, + "node_modules/react-reconciler": { + "version": "0.29.2", + "resolved": "https://registry.npmjs.org/react-reconciler/-/react-reconciler-0.29.2.tgz", + "integrity": "sha512-zZQqIiYgDCTP/f1N/mAR10nJGrPD2ZR+jDSEsKWJHYC7Cm2wodlwbR3upZRdC3cjIjSlTLNVyO7Iu0Yy7t2AYg==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0", + "scheduler": "^0.23.2" + }, + "engines": { + "node": ">=0.10.0" + }, + "peerDependencies": { + "react": "^18.3.1" + } + }, "node_modules/readable-stream": { "version": "3.6.2", "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", @@ -2721,6 +3394,22 @@ "url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1" } }, + "node_modules/restore-cursor": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/restore-cursor/-/restore-cursor-4.0.0.tgz", + "integrity": "sha512-I9fPXU9geO9bHOt9pHHOhOkYerIMsmVaWB0rA2AI9ERh/+x/i7MV5HKBNrg+ljO5eoPVgCcnFuRjJ9uH6I/3eg==", + "license": "MIT", + "dependencies": { + "onetime": "^5.1.0", + "signal-exit": "^3.0.2" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/retry": { "version": "0.12.0", "resolved": "https://registry.npmjs.org/retry/-/retry-0.12.0.tgz", @@ -2819,6 +3508,15 @@ "license": "MIT", "optional": true }, + "node_modules/scheduler": { + "version": "0.23.2", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz", + "integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0" + } + }, "node_modules/semver": { "version": "7.7.4", "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", @@ -2848,8 +3546,7 @@ "version": "3.0.7", "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", - "license": "ISC", - "optional": true + "license": "ISC" }, "node_modules/simple-concat": { "version": "1.0.1", @@ -2896,6 +3593,49 @@ "simple-concat": "^1.0.0" } }, + "node_modules/slice-ansi": { + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-7.1.2.tgz", + "integrity": "sha512-iOBWFgUX7caIZiuutICxVgX1SdxwAVFFKwt1EvMYYec/NWO5meOJ6K5uQxhrYBdQJne4KxiqZc+KptFOWFSI9w==", + "license": "MIT", + "dependencies": { + "ansi-styles": "^6.2.1", + "is-fullwidth-code-point": "^5.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/chalk/slice-ansi?sponsor=1" + } + }, + "node_modules/slice-ansi/node_modules/ansi-styles": { + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", + "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/slice-ansi/node_modules/is-fullwidth-code-point": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-5.1.0.tgz", + "integrity": "sha512-5XHYaSyiqADb4RnZ1Bdad6cPp8Toise4TzEjcOYDHZkTCbKgiUl7WTUCpNWHuxmDt91wnsZBc9xinNzopv3JMQ==", + "license": "MIT", + "dependencies": { + "get-east-asian-width": "^1.3.1" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/smart-buffer": { "version": "4.2.0", "resolved": "https://registry.npmjs.org/smart-buffer/-/smart-buffer-4.2.0.tgz", @@ -2997,6 +3737,18 @@ "node": ">= 8" } }, + "node_modules/stack-utils": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/stack-utils/-/stack-utils-2.0.6.tgz", + "integrity": "sha512-XlkWvfIm6RmsWtNJx+uqtKLS8eqFbxUg0ZzLXqY0caEy9l7hruX8IpiDnjsLavoBgqCCR71TqWO8MaXYheJ3RQ==", + "license": "MIT", + "dependencies": { + "escape-string-regexp": "^2.0.0" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/stackback": { "version": "0.0.2", "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", @@ -3171,6 +3923,12 @@ "node": ">=0.8" } }, + "node_modules/thunky": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/thunky/-/thunky-1.1.0.tgz", + "integrity": "sha512-eHY7nBftgThBqOyHGVN+l8gF0BucP09fMo0oO/Lb0w1OF80dJv+lDVpXG60WMQvkcxAkNybKsrEIE3ZtKGmPrA==", + "license": "MIT" + }, "node_modules/tinybench": { "version": "2.9.0", "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", @@ -3324,6 +4082,18 @@ "node": ">=4" } }, + "node_modules/type-fest": { + "version": "4.41.0", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-4.41.0.tgz", + "integrity": "sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA==", + "license": "(MIT OR CC0-1.0)", + "engines": { + "node": ">=16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/typescript": { "version": "5.9.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", @@ -3981,37 +4751,181 @@ "dependencies": { "isexe": "^2.0.0" }, - "bin": { - "node-which": "bin/node-which" + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/wide-align": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/wide-align/-/wide-align-1.1.5.tgz", + "integrity": "sha512-eDMORYaPNZ4sQIuuYPDHdQvf4gyCF9rEEV/yPxGfwPkRodwEgiMUUXTx/dex+Me0wxx53S+NgUHaP7y3MGlDmg==", + "license": "ISC", + "optional": true, + "dependencies": { + "string-width": "^1.0.2 || 2 || 3 || 4" + } + }, + "node_modules/widest-line": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/widest-line/-/widest-line-5.0.0.tgz", + "integrity": "sha512-c9bZp7b5YtRj2wOe6dlj32MK+Bx/M/d+9VB2SHM1OtsUHR0aV0tdP6DWh/iMt0kWi1t5g1Iudu6hQRNd1A4PVA==", + "license": "MIT", + "dependencies": { + "string-width": "^7.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/widest-line/node_modules/ansi-regex": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", + "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/widest-line/node_modules/emoji-regex": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.6.0.tgz", + "integrity": "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==", + "license": "MIT" + }, + "node_modules/widest-line/node_modules/string-width": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz", + "integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==", + "license": "MIT", + "dependencies": { + "emoji-regex": "^10.3.0", + "get-east-asian-width": "^1.0.0", + "strip-ansi": "^7.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/widest-line/node_modules/strip-ansi": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz", + "integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^6.2.2" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, + "node_modules/wrap-ansi": { + "version": "9.0.2", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-9.0.2.tgz", + "integrity": "sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww==", + "license": "MIT", + "dependencies": { + "ansi-styles": "^6.2.1", + "string-width": "^7.0.0", + "strip-ansi": "^7.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/wrap-ansi/node_modules/ansi-regex": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", + "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", + "license": "MIT", + "engines": { + "node": ">=12" }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/wrap-ansi/node_modules/ansi-styles": { + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", + "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", + "license": "MIT", "engines": { - "node": ">= 8" + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, - "node_modules/why-is-node-running": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", - "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", - "dev": true, + "node_modules/wrap-ansi/node_modules/emoji-regex": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.6.0.tgz", + "integrity": "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==", + "license": "MIT" + }, + "node_modules/wrap-ansi/node_modules/string-width": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz", + "integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==", + "license": "MIT", "dependencies": { - "siginfo": "^2.0.0", - "stackback": "0.0.2" - }, - "bin": { - "why-is-node-running": "cli.js" + "emoji-regex": "^10.3.0", + "get-east-asian-width": "^1.0.0", + "strip-ansi": "^7.1.0" }, "engines": { - "node": ">=8" + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/wide-align": { - "version": "1.1.5", - "resolved": "https://registry.npmjs.org/wide-align/-/wide-align-1.1.5.tgz", - "integrity": "sha512-eDMORYaPNZ4sQIuuYPDHdQvf4gyCF9rEEV/yPxGfwPkRodwEgiMUUXTx/dex+Me0wxx53S+NgUHaP7y3MGlDmg==", - "license": "ISC", - "optional": true, + "node_modules/wrap-ansi/node_modules/strip-ansi": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz", + "integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==", + "license": "MIT", "dependencies": { - "string-width": "^1.0.2 || 2 || 3 || 4" + "ansi-regex": "^6.2.2" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" } }, "node_modules/wrappy": { @@ -4020,6 +4934,27 @@ "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", "license": "ISC" }, + "node_modules/ws": { + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.20.0.tgz", + "integrity": "sha512-sAt8BhgNbzCtgGbt2OxmpuryO63ZoDk/sqaB/znQm94T4fCEsy/yV+7CdC1kJhOU9lboAEU7R3kquuycDoibVA==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, "node_modules/yallist": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", @@ -4052,6 +4987,12 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/yoga-layout": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/yoga-layout/-/yoga-layout-3.2.1.tgz", + "integrity": "sha512-0LPOt3AxKqMdFBZA3HBAt/t/8vIKq7VaQYbuA8WxCgung+p9TVyKRYdpvCb80HcdTN2NkbIKbhNwKUfm3tQywQ==", + "license": "MIT" + }, "node_modules/zod": { "version": "3.25.76", "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", @@ -4063,6 +5004,27 @@ } }, "dependencies": { + "@alcalzone/ansi-tokenize": { + "version": "0.1.3", + "resolved": "https://registry.npmjs.org/@alcalzone/ansi-tokenize/-/ansi-tokenize-0.1.3.tgz", + "integrity": "sha512-3yWxPTq3UQ/FY9p1ErPxIyfT64elWaMvM9lIHnaqpyft63tkxodF5aUElYHrdisWve5cETkh1+KBw1yJuW0aRw==", + "requires": { + "ansi-styles": "^6.2.1", + "is-fullwidth-code-point": "^4.0.0" + }, + "dependencies": { + "ansi-styles": { + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", + "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==" + }, + "is-fullwidth-code-point": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-4.0.0.tgz", + "integrity": "sha512-O4L094N2/dZ7xqVdrXhh9r1KODPJpFms8B5sGdJLPy664AgvXsreZUyCQQNItZRDlYug4xStLjNp/sz3HvBowQ==" + } + } + }, "@bufbuild/protobuf": { "version": "1.10.0", "resolved": "https://registry.npmjs.org/@bufbuild/protobuf/-/protobuf-1.10.0.tgz", @@ -4323,6 +5285,12 @@ "integrity": "sha512-k2Ty1JcVojjJFwrg/ThKi2ujJ7XNLYaFGNB/bWT9wGR+oSMJHMa5w+CUq6p/pVrKeNNgA7pCqEcjSnHVoqJQFw==", "optional": true }, + "@graphql-typed-document-node/core": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/@graphql-typed-document-node/core/-/core-3.2.0.tgz", + "integrity": "sha512-mB9oAsNCm9aM3/SOv4YtBMqZbYj10R7dkq8byBqxGY/ncFwhf2oQzMV+LCRlWoDSEBJ3COiR1yeDvMtsoOsuFQ==", + "requires": {} + }, "@jest/schemas": { "version": "29.6.3", "resolved": "https://registry.npmjs.org/@jest/schemas/-/schemas-29.6.3.tgz", @@ -4364,6 +5332,19 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, + "@leichtgewicht/ip-codec": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@leichtgewicht/ip-codec/-/ip-codec-2.0.5.tgz", + "integrity": "sha512-Vo+PSpZG2/fmgmiNzYK9qWRh8h/CHrwD0mo1h1DzL4yzHNSfWYujGTYsWGreD000gcgmZ7K4Ys6Tx9TxtsKdDw==" + }, + "@linear/sdk": { + "version": "84.0.0", + "resolved": "https://registry.npmjs.org/@linear/sdk/-/sdk-84.0.0.tgz", + "integrity": "sha512-jPtGlY06zG86ba6cL78d4JB9m61YMHo3L0luMrtVFgeOounI/GK3S3c6Ncjw7cxWzcsepoNZD10mQYoT81uKKA==", + "requires": { + "@graphql-typed-document-node/core": "^3.2.0" + } + }, "@npmcli/fs": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/@npmcli/fs/-/fs-1.1.1.tgz", @@ -4612,6 +5593,31 @@ "undici-types": "~6.21.0" } }, + "@types/prop-types": { + "version": "15.7.15", + "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz", + "integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==", + "devOptional": true + }, + "@types/react": { + "version": "18.3.28", + "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.28.tgz", + "integrity": "sha512-z9VXpC7MWrhfWipitjNdgCauoMLRdIILQsAEV+ZesIzBq/oUlxk0m3ApZuMFCXdnS4U7KrI+l3WRUEGQ8K1QKw==", + "devOptional": true, + "requires": { + "@types/prop-types": "*", + "csstype": "^3.2.2" + } + }, + "@types/ws": { + "version": "8.18.1", + "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz", + "integrity": "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==", + "dev": true, + "requires": { + "@types/node": "*" + } + }, "@vitest/expect": { "version": "0.34.6", "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-0.34.6.tgz", @@ -4730,6 +5736,14 @@ "indent-string": "^4.0.0" } }, + "ansi-escapes": { + "version": "7.3.0", + "resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-7.3.0.tgz", + "integrity": "sha512-BvU8nYgGQBxcmMuEeUEmNTvrMVjJNSH7RgW24vXexN4Ven6qCvy4TntnvlnwnMLTVlcRQQdbRY8NKnaIoeWDNg==", + "requires": { + "environment": "^1.0.0" + } + }, "ansi-regex": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", @@ -4770,6 +5784,11 @@ "integrity": "sha512-jgsaNduz+ndvGyFt3uSuWqvy4lCnIJiovtouQN5JZHOKCS2QuhEdbcQHFhVksz2N2U9hXJo8odG7ETyWlEeuDw==", "dev": true }, + "auto-bind": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/auto-bind/-/auto-bind-5.0.1.tgz", + "integrity": "sha512-ooviqdwwgfIfNmDwo94wlshcdzfO64XV0Cg6oDsDYBJfITDz1EngD2z7DkbvCWn+XIMsIqW27sEVF6qcpJrRcg==" + }, "balanced-match": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", @@ -4799,6 +5818,15 @@ "readable-stream": "^3.4.0" } }, + "bonjour-service": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/bonjour-service/-/bonjour-service-1.3.0.tgz", + "integrity": "sha512-3YuAUiSkWykd+2Azjgyxei8OWf8thdn8AITIog2M4UICzoqfjlqr64WIjEXZllf/W6vK1goqleSR6brGomxQqA==", + "requires": { + "fast-deep-equal": "^3.1.3", + "multicast-dns": "^7.2.5" + } + }, "brace-expansion": { "version": "1.1.14", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.14.tgz", @@ -4874,6 +5902,11 @@ "type-detect": "^4.1.0" } }, + "chalk": { + "version": "5.6.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.6.2.tgz", + "integrity": "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==" + }, "check-error": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/check-error/-/check-error-1.0.3.tgz", @@ -4903,6 +5936,85 @@ "integrity": "sha512-4diC9HaTE+KRAMWhDhrGOECgWZxoevMc5TlkObMqNSsVU62PYzXZ/SMTjzyGAFF1YusgxGcSWTEXBhp0CPwQ1A==", "optional": true }, + "cli-boxes": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/cli-boxes/-/cli-boxes-3.0.0.tgz", + "integrity": "sha512-/lzGpEWL/8PfI0BmBOPRwp0c/wFNX1RdUML3jK/RcSBA9T8mZDdQpqYBKtCFTOfQbwPqWEOpjqW+Fnayc0969g==" + }, + "cli-cursor": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/cli-cursor/-/cli-cursor-4.0.0.tgz", + "integrity": "sha512-VGtlMu3x/4DOtIUwEkRezxUZ2lBacNJCHash0N0WeZDBS+7Ux1dm3XWAgWYxLJFMMdOeXMHXorshEFhbMSGelg==", + "requires": { + "restore-cursor": "^4.0.0" + } + }, + "cli-truncate": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/cli-truncate/-/cli-truncate-4.0.0.tgz", + "integrity": "sha512-nPdaFdQ0h/GEigbPClz11D0v/ZJEwxmeVZGeMo3Z5StPtUTkA9o1lD6QwoirYiSDzbcwn2XcjwmCp68W1IS4TA==", + "requires": { + "slice-ansi": "^5.0.0", + "string-width": "^7.0.0" + }, + "dependencies": { + "ansi-regex": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", + "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==" + }, + "ansi-styles": { + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", + "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==" + }, + "emoji-regex": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.6.0.tgz", + "integrity": "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==" + }, + "is-fullwidth-code-point": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-4.0.0.tgz", + "integrity": "sha512-O4L094N2/dZ7xqVdrXhh9r1KODPJpFms8B5sGdJLPy664AgvXsreZUyCQQNItZRDlYug4xStLjNp/sz3HvBowQ==" + }, + "slice-ansi": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-5.0.0.tgz", + "integrity": "sha512-FC+lgizVPfie0kkhqUScwRu1O/lF6NOgJmlCgK+/LYxDCTk8sGelYaHDhFcDN+Sn3Cv+3VSa4Byeo+IMCzpMgQ==", + "requires": { + "ansi-styles": "^6.0.0", + "is-fullwidth-code-point": "^4.0.0" + } + }, + "string-width": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz", + "integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==", + "requires": { + "emoji-regex": "^10.3.0", + "get-east-asian-width": "^1.0.0", + "strip-ansi": "^7.1.0" + } + }, + "strip-ansi": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz", + "integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==", + "requires": { + "ansi-regex": "^6.2.2" + } + } + } + }, + "code-excerpt": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/code-excerpt/-/code-excerpt-4.0.0.tgz", + "integrity": "sha512-xxodCmBen3iy2i0WtAK8FlFNrRzjUqjRsMfho58xT/wvZU1YTM3fCnRjcy1gJPMepaRlgm/0e6w8SpWHpn3/cA==", + "requires": { + "convert-to-spaces": "^2.0.1" + } + }, "color-support": { "version": "1.1.3", "resolved": "https://registry.npmjs.org/color-support/-/color-support-1.1.3.tgz", @@ -4939,6 +6051,17 @@ "integrity": "sha512-ty/fTekppD2fIwRvnZAVdeOiGd1c7YXEixbgJTNzqcxJWKQnjJ/V1bNEEE6hygpM3WjwHFUVK6HTjWSzV4a8sQ==", "optional": true }, + "convert-to-spaces": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/convert-to-spaces/-/convert-to-spaces-2.0.1.tgz", + "integrity": "sha512-rcQ1bsQO9799wq24uE5AM2tAILy4gXGIK/njFWcVQkGNZ96edlpY+A7bjwvzjYvLDyzmG1MmMLZhpcsb+klNMQ==" + }, + "csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "devOptional": true + }, "debug": { "version": "4.4.3", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", @@ -4987,6 +6110,14 @@ "integrity": "sha512-EjePK1srD3P08o2j4f0ExnylqRs5B9tJjcp9t1krH2qRi8CCdsYfwe9JgSLurFBWwq4uOlipzfk5fHNvwFKr8Q==", "dev": true }, + "dns-packet": { + "version": "5.6.1", + "resolved": "https://registry.npmjs.org/dns-packet/-/dns-packet-5.6.1.tgz", + "integrity": "sha512-l4gcSouhcgIKRvyy99RNVOgxXiicE+2jZoNmaNmZ6JXiGajBOJAesk1OBlJuM5k2c+eudGdLxDqXuPCKIj6kpw==", + "requires": { + "@leichtgewicht/ip-codec": "^2.0.1" + } + }, "emoji-regex": { "version": "8.0.0", "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", @@ -5016,12 +6147,22 @@ "integrity": "sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==", "optional": true }, + "environment": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/environment/-/environment-1.1.0.tgz", + "integrity": "sha512-xUtoPkMggbz0MPyPiIWr1Kp4aeWJjDZ6SMvURhimjdZgsRuDplF5/s9hcgGhyXMhs+6vpnuoiZ2kFiu3FMnS8Q==" + }, "err-code": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/err-code/-/err-code-2.0.3.tgz", "integrity": "sha512-2bmlRpNKBxT/CRmPOlyISQpNj+qSeYvcym/uT0Jx2bMOlKLtSy1ZmLuVxSEKKyor/N5yhvp/ZiG1oE3DEYMSFA==", "optional": true }, + "es-toolkit": { + "version": "1.46.1", + "resolved": "https://registry.npmjs.org/es-toolkit/-/es-toolkit-1.46.1.tgz", + "integrity": "sha512-5eNtXOs3tbfxXOj04tjjseeWkRWaoCjdEI+96DgwzZoe6c9juL49pXlzAFTI72aWC9Y8p7168g6XIKjh7k6pyQ==" + }, "esbuild": { "version": "0.27.3", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.3.tgz", @@ -5056,11 +6197,21 @@ "@esbuild/win32-x64": "0.27.3" } }, + "escape-string-regexp": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-2.0.0.tgz", + "integrity": "sha512-UpzcLCXolUWcNu5HtVMHYdXJjArjsF9C0aNnquZYY4uW/Vu0miy5YoWvbV345HauVvcAUnpRuhMMcqTcGOY2+w==" + }, "expand-template": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/expand-template/-/expand-template-2.0.3.tgz", "integrity": "sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==" }, + "fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==" + }, "fdir": { "version": "6.5.0", "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", @@ -5126,6 +6277,11 @@ "wide-align": "^1.1.5" } }, + "get-east-asian-width": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/get-east-asian-width/-/get-east-asian-width-1.6.0.tgz", + "integrity": "sha512-QRbvDIbx6YklUe6RxeTeleMR0yv3cYH6PsPZHcnVn7xv7zO1BHN8r0XETu8n6Ye3Q+ahtSarc3WgtNWmehIBfA==" + }, "get-func-name": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/get-func-name/-/get-func-name-2.0.2.tgz", @@ -5166,6 +6322,12 @@ "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", "optional": true }, + "graphql": { + "version": "16.14.0", + "resolved": "https://registry.npmjs.org/graphql/-/graphql-16.14.0.tgz", + "integrity": "sha512-BBvQ/406p+4CZbTpCbVPSxfzrZrbnuWSP1ELYgyS6B+hNeKzgrdB4JczCa5VZUBQrDa9hUngm0KnexY6pJRN5Q==", + "peer": true + }, "has-unicode": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/has-unicode/-/has-unicode-2.0.1.tgz", @@ -5260,6 +6422,93 @@ "resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz", "integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==" }, + "ink": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/ink/-/ink-5.2.1.tgz", + "integrity": "sha512-BqcUyWrG9zq5HIwW6JcfFHsIYebJkWWb4fczNah1goUO0vv5vneIlfwuS85twyJ5hYR/y18FlAYUxrO9ChIWVg==", + "requires": { + "@alcalzone/ansi-tokenize": "^0.1.3", + "ansi-escapes": "^7.0.0", + "ansi-styles": "^6.2.1", + "auto-bind": "^5.0.1", + "chalk": "^5.3.0", + "cli-boxes": "^3.0.0", + "cli-cursor": "^4.0.0", + "cli-truncate": "^4.0.0", + "code-excerpt": "^4.0.0", + "es-toolkit": "^1.22.0", + "indent-string": "^5.0.0", + "is-in-ci": "^1.0.0", + "patch-console": "^2.0.0", + "react-reconciler": "^0.29.0", + "scheduler": "^0.23.0", + "signal-exit": "^3.0.7", + "slice-ansi": "^7.1.0", + "stack-utils": "^2.0.6", + "string-width": "^7.2.0", + "type-fest": "^4.27.0", + "widest-line": "^5.0.0", + "wrap-ansi": "^9.0.0", + "ws": "^8.18.0", + "yoga-layout": "~3.2.1" + }, + "dependencies": { + "ansi-regex": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", + "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==" + }, + "ansi-styles": { + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", + "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==" + }, + "emoji-regex": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.6.0.tgz", + "integrity": "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==" + }, + "indent-string": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/indent-string/-/indent-string-5.0.0.tgz", + "integrity": "sha512-m6FAo/spmsW2Ab2fU35JTYwtOKa2yAwXSwgjSv1TJzh4Mh7mC3lzAOVLBprb72XsTrgkEIsl7YrFNAiDiRhIGg==" + }, + "string-width": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz", + "integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==", + "requires": { + "emoji-regex": "^10.3.0", + "get-east-asian-width": "^1.0.0", + "strip-ansi": "^7.1.0" + } + }, + "strip-ansi": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz", + "integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==", + "requires": { + "ansi-regex": "^6.2.2" + } + } + } + }, + "ink-testing-library": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/ink-testing-library/-/ink-testing-library-4.0.0.tgz", + "integrity": "sha512-yF92kj3pmBvk7oKbSq5vEALO//o7Z9Ck/OaLNlkzXNeYdwfpxMQkSowGTFUCS5MSu9bWfSZMewGpp7bFc66D7Q==", + "dev": true, + "requires": {} + }, + "ink-text-input": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/ink-text-input/-/ink-text-input-6.0.0.tgz", + "integrity": "sha512-Fw64n7Yha5deb1rHY137zHTAbSTNelUKuB5Kkk2HACXEtwIHBCf9OH2tP/LQ9fRYTl1F0dZgbW0zPnZk6FA9Lw==", + "requires": { + "chalk": "^5.3.0", + "type-fest": "^4.18.2" + } + }, "ip-address": { "version": "10.1.1", "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.1.1.tgz", @@ -5272,6 +6521,11 @@ "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", "optional": true }, + "is-in-ci": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-in-ci/-/is-in-ci-1.0.0.tgz", + "integrity": "sha512-eUuAjybVTHMYWm/U+vBO1sY/JOCgoPCXRxzdju0K+K0BiGW0SChEL1MLC0PoCIR1OlPo5YAp8HuQoUlsWEICwg==" + }, "is-lambda": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/is-lambda/-/is-lambda-1.0.1.tgz", @@ -5290,6 +6544,11 @@ "integrity": "sha512-34wB/Y7MW7bzjKRjUKTa46I2Z7eV62Rkhva+KkopW7Qvv/OSWBqvkSY7vusOPrNuZcUG3tApvdVgNB8POj3SPw==", "dev": true }, + "js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==" + }, "lilconfig": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.3.tgz", @@ -5314,6 +6573,14 @@ "integrity": "sha512-SFppqq5p42fe2qcZQqqEOiVRXl+WCP1MdT6k7BDEW1j++sp5fIY+/fdRQitvKgB5BrBcmrs5m/L0v2FrU5MY1g==", "dev": true }, + "loose-envify": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", + "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", + "requires": { + "js-tokens": "^3.0.0 || ^4.0.0" + } + }, "loupe": { "version": "2.3.7", "resolved": "https://registry.npmjs.org/loupe/-/loupe-2.3.7.tgz", @@ -5365,6 +6632,11 @@ "ssri": "^8.0.0" } }, + "mimic-fn": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-2.1.0.tgz", + "integrity": "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==" + }, "mimic-response": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-3.1.0.tgz", @@ -5477,6 +6749,15 @@ "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", "devOptional": true }, + "multicast-dns": { + "version": "7.2.5", + "resolved": "https://registry.npmjs.org/multicast-dns/-/multicast-dns-7.2.5.tgz", + "integrity": "sha512-2eznPJP8z2BFLX50tf0LuODrpINqP1RVIm/CObbTcBRITQgmC/TjcREF1NeTBzIcR5XO/ukWo+YHOjBbFwIupg==", + "requires": { + "dns-packet": "^5.2.2", + "thunky": "^1.0.2" + } + }, "mz": { "version": "2.7.0", "resolved": "https://registry.npmjs.org/mz/-/mz-2.7.0.tgz", @@ -5587,6 +6868,14 @@ "wrappy": "1" } }, + "onetime": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/onetime/-/onetime-5.1.2.tgz", + "integrity": "sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==", + "requires": { + "mimic-fn": "^2.1.0" + } + }, "p-limit": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-4.0.0.tgz", @@ -5605,6 +6894,11 @@ "aggregate-error": "^3.0.0" } }, + "patch-console": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/patch-console/-/patch-console-2.0.0.tgz", + "integrity": "sha512-0YNdUceMdaQwoKce1gatDScmMo5pu/tfABfnzEqeG0gtTmd7mh/WcwgUjtAeOU7N8nFFlbQBnFK2gXW5fGvmMA==" + }, "path-is-absolute": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", @@ -5672,6 +6966,23 @@ "lilconfig": "^3.1.1" } }, + "postject": { + "version": "1.0.0-alpha.6", + "resolved": "https://registry.npmjs.org/postject/-/postject-1.0.0-alpha.6.tgz", + "integrity": "sha512-b9Eb8h2eVqNE8edvKdwqkrY6O7kAwmI8kcnBv1NScolYJbo59XUF0noFq+lxbC1yN20bmC0WBEbDC5H/7ASb0A==", + "dev": true, + "requires": { + "commander": "^9.4.0" + }, + "dependencies": { + "commander": { + "version": "9.5.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-9.5.0.tgz", + "integrity": "sha512-KRs7WVDKg86PWiuAqhDrAQnTXZKraVcCc6vFdL14qrZ/DcWwuRo7VoiYXalXO7S5GKpqYiVEwCbgFDfxNHKJBQ==", + "dev": true + } + } + }, "prebuild-install": { "version": "7.1.3", "resolved": "https://registry.npmjs.org/prebuild-install/-/prebuild-install-7.1.3.tgz", @@ -5738,12 +7049,29 @@ "strip-json-comments": "~2.0.1" } }, + "react": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", + "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", + "requires": { + "loose-envify": "^1.1.0" + } + }, "react-is": { "version": "18.3.1", "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz", "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==", "dev": true }, + "react-reconciler": { + "version": "0.29.2", + "resolved": "https://registry.npmjs.org/react-reconciler/-/react-reconciler-0.29.2.tgz", + "integrity": "sha512-zZQqIiYgDCTP/f1N/mAR10nJGrPD2ZR+jDSEsKWJHYC7Cm2wodlwbR3upZRdC3cjIjSlTLNVyO7Iu0Yy7t2AYg==", + "requires": { + "loose-envify": "^1.1.0", + "scheduler": "^0.23.2" + } + }, "readable-stream": { "version": "3.6.2", "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", @@ -5772,6 +7100,15 @@ "integrity": "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==", "dev": true }, + "restore-cursor": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/restore-cursor/-/restore-cursor-4.0.0.tgz", + "integrity": "sha512-I9fPXU9geO9bHOt9pHHOhOkYerIMsmVaWB0rA2AI9ERh/+x/i7MV5HKBNrg+ljO5eoPVgCcnFuRjJ9uH6I/3eg==", + "requires": { + "onetime": "^5.1.0", + "signal-exit": "^3.0.2" + } + }, "retry": { "version": "0.12.0", "resolved": "https://registry.npmjs.org/retry/-/retry-0.12.0.tgz", @@ -5833,6 +7170,14 @@ "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", "optional": true }, + "scheduler": { + "version": "0.23.2", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz", + "integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==", + "requires": { + "loose-envify": "^1.1.0" + } + }, "semver": { "version": "7.7.4", "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", @@ -5853,8 +7198,7 @@ "signal-exit": { "version": "3.0.7", "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", - "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", - "optional": true + "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==" }, "simple-concat": { "version": "1.0.1", @@ -5871,6 +7215,30 @@ "simple-concat": "^1.0.0" } }, + "slice-ansi": { + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-7.1.2.tgz", + "integrity": "sha512-iOBWFgUX7caIZiuutICxVgX1SdxwAVFFKwt1EvMYYec/NWO5meOJ6K5uQxhrYBdQJne4KxiqZc+KptFOWFSI9w==", + "requires": { + "ansi-styles": "^6.2.1", + "is-fullwidth-code-point": "^5.0.0" + }, + "dependencies": { + "ansi-styles": { + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", + "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==" + }, + "is-fullwidth-code-point": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-5.1.0.tgz", + "integrity": "sha512-5XHYaSyiqADb4RnZ1Bdad6cPp8Toise4TzEjcOYDHZkTCbKgiUl7WTUCpNWHuxmDt91wnsZBc9xinNzopv3JMQ==", + "requires": { + "get-east-asian-width": "^1.3.1" + } + } + } + }, "smart-buffer": { "version": "4.2.0", "resolved": "https://registry.npmjs.org/smart-buffer/-/smart-buffer-4.2.0.tgz", @@ -5936,6 +7304,14 @@ "minipass": "^3.1.1" } }, + "stack-utils": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/stack-utils/-/stack-utils-2.0.6.tgz", + "integrity": "sha512-XlkWvfIm6RmsWtNJx+uqtKLS8eqFbxUg0ZzLXqY0caEy9l7hruX8IpiDnjsLavoBgqCCR71TqWO8MaXYheJ3RQ==", + "requires": { + "escape-string-regexp": "^2.0.0" + } + }, "stackback": { "version": "0.0.2", "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", @@ -6073,6 +7449,11 @@ "thenify": ">= 3.1.0 < 4" } }, + "thunky": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/thunky/-/thunky-1.1.0.tgz", + "integrity": "sha512-eHY7nBftgThBqOyHGVN+l8gF0BucP09fMo0oO/Lb0w1OF80dJv+lDVpXG60WMQvkcxAkNybKsrEIE3ZtKGmPrA==" + }, "tinybench": { "version": "2.9.0", "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", @@ -6169,6 +7550,11 @@ "integrity": "sha512-Acylog8/luQ8L7il+geoSxhEkazvkslg7PSNKOX59mbB9cOveP5aq9h74Y7YU8yDpJwetzQQrfIwtf4Wp4LKcw==", "dev": true }, + "type-fest": { + "version": "4.41.0", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-4.41.0.tgz", + "integrity": "sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA==" + }, "typescript": { "version": "5.9.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", @@ -6519,11 +7905,100 @@ "string-width": "^1.0.2 || 2 || 3 || 4" } }, + "widest-line": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/widest-line/-/widest-line-5.0.0.tgz", + "integrity": "sha512-c9bZp7b5YtRj2wOe6dlj32MK+Bx/M/d+9VB2SHM1OtsUHR0aV0tdP6DWh/iMt0kWi1t5g1Iudu6hQRNd1A4PVA==", + "requires": { + "string-width": "^7.0.0" + }, + "dependencies": { + "ansi-regex": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", + "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==" + }, + "emoji-regex": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.6.0.tgz", + "integrity": "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==" + }, + "string-width": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz", + "integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==", + "requires": { + "emoji-regex": "^10.3.0", + "get-east-asian-width": "^1.0.0", + "strip-ansi": "^7.1.0" + } + }, + "strip-ansi": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz", + "integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==", + "requires": { + "ansi-regex": "^6.2.2" + } + } + } + }, + "wrap-ansi": { + "version": "9.0.2", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-9.0.2.tgz", + "integrity": "sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww==", + "requires": { + "ansi-styles": "^6.2.1", + "string-width": "^7.0.0", + "strip-ansi": "^7.1.0" + }, + "dependencies": { + "ansi-regex": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", + "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==" + }, + "ansi-styles": { + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", + "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==" + }, + "emoji-regex": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.6.0.tgz", + "integrity": "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==" + }, + "string-width": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz", + "integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==", + "requires": { + "emoji-regex": "^10.3.0", + "get-east-asian-width": "^1.0.0", + "strip-ansi": "^7.1.0" + } + }, + "strip-ansi": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz", + "integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==", + "requires": { + "ansi-regex": "^6.2.2" + } + } + } + }, "wrappy": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==" }, + "ws": { + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.20.0.tgz", + "integrity": "sha512-sAt8BhgNbzCtgGbt2OxmpuryO63ZoDk/sqaB/znQm94T4fCEsy/yV+7CdC1kJhOU9lboAEU7R3kquuycDoibVA==", + "requires": {} + }, "yallist": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", @@ -6540,6 +8015,11 @@ "integrity": "sha512-4LCcse/U2MHZ63HAJVE+v71o7yOdIe4cZ70Wpf8D/IyjDKYQLV5GD46B+hSTjJsvV5PztjvHoU580EftxjDZFQ==", "dev": true }, + "yoga-layout": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/yoga-layout/-/yoga-layout-3.2.1.tgz", + "integrity": "sha512-0LPOt3AxKqMdFBZA3HBAt/t/8vIKq7VaQYbuA8WxCgung+p9TVyKRYdpvCb80HcdTN2NkbIKbhNwKUfm3tQywQ==" + }, "zod": { "version": "3.25.76", "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", diff --git a/apps/ade-cli/package.json b/apps/ade-cli/package.json index 5077eb3f1..20124366c 100644 --- a/apps/ade-cli/package.json +++ b/apps/ade-cli/package.json @@ -17,18 +17,31 @@ "cli:dev": "npm run build --silent && node dist/cli.cjs", "dev": "tsx src/cli.ts", "build": "tsup && node ./scripts/verify-built-cli.mjs", + "build:static": "node ./scripts/build-static.mjs", + "notarize:static": "node ./scripts/notarize-static-runtime.mjs", + "package:native-deps": "node ./scripts/package-native-deps.mjs", "typecheck": "tsc -p tsconfig.json --noEmit", "test": "vitest run" }, "dependencies": { "@cursor/sdk": "^1.0.9", + "@linear/sdk": "^84.0.0", + "bonjour-service": "^1.3.0", + "ink": "^5.2.1", + "ink-text-input": "^6.0.0", "node-cron": "^3.0.3", "node-pty": "^1.1.0", + "react": "^18.3.1", "sql.js": "^1.13.0", + "ws": "^8.20.0", "yaml": "^2.8.2" }, "devDependencies": { "@types/node": "^20.11.30", + "@types/react": "^18.3.28", + "@types/ws": "^8.18.1", + "ink-testing-library": "^4.0.0", + "postject": "^1.0.0-alpha.6", "tsup": "^8.3.5", "tsx": "^4.20.6", "typescript": "^5.7.3", diff --git a/apps/ade-cli/scripts/build-static.mjs b/apps/ade-cli/scripts/build-static.mjs new file mode 100644 index 000000000..a2b3e8264 --- /dev/null +++ b/apps/ade-cli/scripts/build-static.mjs @@ -0,0 +1,287 @@ +import { execFile } from "node:child_process"; +import fs from "node:fs/promises"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { promisify } from "node:util"; + +const execFileAsync = promisify(execFile); +const packageRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); +const defaultOutDir = path.join(packageRoot, "dist-static"); +const fuse = "NODE_SEA_FUSE_fce680ab2cc467b6e072b8b5df1996b2"; + +function parseArgs(argv) { + const args = { + target: currentTarget(), + outDir: defaultOutDir, + skipBuild: false, + skipNativeDeps: false, + }; + for (let i = 0; i < argv.length; i += 1) { + const token = argv[i]; + if (token === "--target") { + args.target = argv[++i] ?? ""; + } else if (token === "--out-dir") { + args.outDir = path.resolve(argv[++i] ?? ""); + } else if (token === "--skip-build") { + args.skipBuild = true; + } else if (token === "--skip-native-deps") { + args.skipNativeDeps = true; + } else if (token === "--help" || token === "-h") { + printHelp(); + process.exit(0); + } else { + throw new Error(`Unknown argument: ${token}`); + } + } + validateTarget(args.target); + return args; +} + +function printHelp() { + process.stdout.write([ + "Usage: node scripts/build-static.mjs [--target darwin-arm64] [--out-dir dist-static]", + "", + "Builds an ADE runtime executable with Node SEA. Cross-target builds require", + "ADE_STATIC_NODE_BINARY to point at a matching Node executable.", + "", + ].join("\n")); +} + +function currentTarget() { + const platform = process.platform === "darwin" ? "darwin" : process.platform === "linux" ? "linux" : process.platform; + const arch = process.arch === "x64" ? "x64" : process.arch === "arm64" ? "arm64" : process.arch; + return `${platform}-${arch}`; +} + +function validateTarget(target) { + if (!/^(darwin|linux)-(arm64|x64)$/.test(target)) { + throw new Error(`Unsupported runtime target '${target}'. Expected darwin-arm64, darwin-x64, linux-arm64, or linux-x64.`); + } +} + +async function assertHostOrExplicitBinary(target) { + if (target === currentTarget() || process.env.ADE_STATIC_NODE_BINARY) return; + throw new Error(`Cannot build ${target} from ${currentTarget()} without ADE_STATIC_NODE_BINARY.`); +} + +async function run(command, args, options = {}) { + let stdout = ""; + let stderr = ""; + try { + const result = await execFileAsync(command, args, { + cwd: packageRoot, + env: process.env, + maxBuffer: 50 * 1024 * 1024, + ...options, + }); + stdout = result.stdout; + stderr = result.stderr; + } catch (error) { + stdout = typeof error?.stdout === "string" ? error.stdout : ""; + stderr = typeof error?.stderr === "string" ? error.stderr : ""; + if (stdout) process.stdout.write(stdout); + if (stderr) process.stderr.write(stderr); + throw error; + } + if (stdout) process.stdout.write(stdout); + if (stderr) process.stderr.write(stderr); +} + +async function assertSeaCapableNodeBinary(binaryPath) { + const contents = await fs.readFile(binaryPath); + if (contents.includes(Buffer.from(fuse))) return; + throw new Error([ + `Node binary '${binaryPath}' is not SEA-capable; it does not contain ${fuse}.`, + "Use an official Node.js release binary for this target, or set ADE_STATIC_NODE_BINARY to one before running build:static.", + ].join(" ")); +} + +async function removeSignatureIfNeeded(binaryPath) { + if (process.platform !== "darwin") return; + try { + await run("codesign", ["--remove-signature", binaryPath]); + } catch { + // Some Node builds are unsigned. postject can proceed in that case. + } +} + +async function adHocSignIfNeeded(binaryPath) { + if (process.platform !== "darwin") return; + await run("codesign", ["--sign", "-", binaryPath]); +} + +async function writeSeaEntry(workDir) { + const cliPath = path.join(packageRoot, "dist", "cli.cjs"); + const seaEntryPath = path.join(workDir, "cli-sea.cjs"); + const cliSource = await fs.readFile(cliPath, "utf8"); + const banner = `\ +var __adeSeaOriginalRequire = require; +var __adeSeaModule = __adeSeaOriginalRequire("module"); +var __adeSeaPath = __adeSeaOriginalRequire("path"); +var __adeSeaOs = __adeSeaOriginalRequire("os"); +var __adeSeaFs = __adeSeaOriginalRequire("fs"); +function __adeSeaTargetLabel() { + var platform = process.platform === "darwin" ? "darwin" : process.platform === "linux" ? "linux" : process.platform; + var arch = process.arch === "x64" ? "x64" : process.arch === "arm64" ? "arm64" : process.arch; + return platform + "-" + arch; +} +function __adeSeaRuntimeRootFromNodeModules(value) { + if (!value) return null; + return __adeSeaPath.basename(value) === "node_modules" ? __adeSeaPath.dirname(value) : value; +} +function __adeSeaDirectoryExists(value) { + try { + return __adeSeaFs.statSync(value).isDirectory(); + } catch { + return false; + } +} +function __adeSeaCandidateRuntimeRoots() { + var target = __adeSeaTargetLabel(); + var roots = []; + var explicitRoot = process.env.ADE_RUNTIME_ROOT; + var explicitNodeModules = process.env.ADE_RUNTIME_NODE_MODULES; + if (explicitRoot) roots.push(explicitRoot); + if (explicitNodeModules) roots.push(__adeSeaRuntimeRootFromNodeModules(explicitNodeModules)); + if (process.env.NODE_PATH) { + process.env.NODE_PATH.split(__adeSeaPath.delimiter).forEach(function (entry) { + roots.push(__adeSeaRuntimeRootFromNodeModules(entry)); + }); + } + roots.push(__adeSeaPath.join(__adeSeaPath.dirname(process.execPath), "ade-" + target + ".native")); + roots.push(__adeSeaPath.dirname(process.execPath)); + roots.push(__adeSeaPath.join(__adeSeaPath.dirname(process.execPath), "..", "runtime", target)); + roots.push(__adeSeaPath.join(__adeSeaOs.homedir(), ".ade", "runtime", target)); + return roots.filter(function (entry, index) { + return Boolean(entry) && roots.indexOf(entry) === index; + }); +} +function __adeSeaResolveRuntimeRoot() { + var roots = __adeSeaCandidateRuntimeRoots(); + for (var index = 0; index < roots.length; index += 1) { + var root = roots[index]; + if (__adeSeaDirectoryExists(__adeSeaPath.join(root, "node_modules"))) return root; + } + return null; +} +var __adeSeaRuntimeRoot = __adeSeaResolveRuntimeRoot(); +if (__adeSeaRuntimeRoot) { + var __adeSeaRuntimeNodeModules = __adeSeaPath.join(__adeSeaRuntimeRoot, "node_modules"); + var __adeSeaNodePath = process.env.NODE_PATH || ""; + var __adeSeaNodePathParts = __adeSeaNodePath.split(__adeSeaPath.delimiter).filter(Boolean); + if (!__adeSeaNodePathParts.includes(__adeSeaRuntimeNodeModules)) { + process.env.NODE_PATH = [__adeSeaRuntimeNodeModules].concat(__adeSeaNodePathParts).join(__adeSeaPath.delimiter); + if (typeof __adeSeaModule._initPaths === "function") __adeSeaModule._initPaths(); + } +} +var __adeSeaFilesystemRequire = __adeSeaModule.createRequire( + __adeSeaRuntimeRoot ? __adeSeaPath.join(__adeSeaRuntimeRoot, ".ade-runtime.cjs") : process.execPath +); +function __adeSeaRequire(id) { + try { + return __adeSeaOriginalRequire(id); + } catch (error) { + if (error && (error.code === "ERR_UNKNOWN_BUILTIN_MODULE" || error.code === "MODULE_NOT_FOUND")) { + return __adeSeaFilesystemRequire(id); + } + throw error; + } +} +Object.assign(__adeSeaRequire, __adeSeaOriginalRequire); +__adeSeaRequire.resolve = function __adeSeaRequireResolve(id, options) { + try { + return __adeSeaOriginalRequire.resolve(id, options); + } catch (error) { + if (error && (error.code === "ERR_UNKNOWN_BUILTIN_MODULE" || error.code === "MODULE_NOT_FOUND")) { + return __adeSeaFilesystemRequire.resolve(id, options); + } + throw error; + } +}; +require = __adeSeaRequire; +var __adeSeaArgv1 = process.argv[1] || ""; +if (!/(^|[/\\\\])cli\\.(?:ts|js|cjs)$/.test(__adeSeaArgv1)) { + if (__adeSeaArgv1 === process.execPath || /(^|[/\\\\])ade(?:[-.]|$)/.test(__adeSeaArgv1)) { + process.argv[1] = "cli.cjs"; + } else { + process.argv.splice(1, 0, "cli.cjs"); + } +} +`; + const source = cliSource.startsWith("#!") + ? cliSource.replace(/^#!.*\n/u, "") + : cliSource; + await fs.writeFile(seaEntryPath, `${banner}\n${source}`, "utf8"); + return seaEntryPath; +} + +async function main() { + const args = parseArgs(process.argv.slice(2)); + await assertHostOrExplicitBinary(args.target); + await fs.mkdir(args.outDir, { recursive: true }); + + if (!args.skipBuild) { + await run(process.platform === "win32" ? "npm.cmd" : "npm", ["run", "build"]); + } + + const workDir = path.join(args.outDir, ".sea", args.target); + await fs.rm(workDir, { recursive: true, force: true }); + await fs.mkdir(workDir, { recursive: true }); + const seaEntryPath = await writeSeaEntry(workDir); + + const seaConfigPath = path.join(workDir, "sea-config.json"); + const blobPath = path.join(workDir, "ade.blob"); + const seaConfig = { + main: seaEntryPath, + output: blobPath, + disableExperimentalSEAWarning: true, + useCodeCache: false, + useSnapshot: false, + }; + await fs.writeFile(seaConfigPath, `${JSON.stringify(seaConfig, null, 2)}\n`, "utf8"); + await run(process.execPath, ["--experimental-sea-config", seaConfigPath]); + + const sourceNodeBinary = process.env.ADE_STATIC_NODE_BINARY || process.execPath; + await assertSeaCapableNodeBinary(sourceNodeBinary); + const binaryName = `ade-${args.target}${process.platform === "win32" ? ".exe" : ""}`; + const binaryPath = path.join(args.outDir, binaryName); + await fs.copyFile(sourceNodeBinary, binaryPath); + await fs.chmod(binaryPath, 0o755); + await removeSignatureIfNeeded(binaryPath); + + const postjectArgs = [ + binaryPath, + "NODE_SEA_BLOB", + blobPath, + "--sentinel-fuse", + fuse, + ]; + if (args.target.startsWith("darwin-")) { + postjectArgs.push("--macho-segment-name", "NODE_SEA"); + } + await run(path.join(packageRoot, "node_modules", ".bin", process.platform === "win32" ? "postject.cmd" : "postject"), postjectArgs); + await adHocSignIfNeeded(binaryPath); + + let nativeArchivePath = null; + if (!args.skipNativeDeps) { + await run(process.execPath, [ + path.join(packageRoot, "scripts", "package-native-deps.mjs"), + "--target", + args.target, + "--out-dir", + args.outDir, + ]); + nativeArchivePath = path.join(args.outDir, `ade-${args.target}.native.tar.gz`); + } + + process.stdout.write(`${JSON.stringify({ + target: args.target, + binaryPath, + nativeArchivePath, + }, null, 2)}\n`); +} + +main().catch((error) => { + process.stderr.write(`[build-static] ${error instanceof Error ? error.message : String(error)}\n`); + process.exit(1); +}); diff --git a/apps/ade-cli/scripts/install-runtime.sh b/apps/ade-cli/scripts/install-runtime.sh new file mode 100644 index 000000000..309c52382 --- /dev/null +++ b/apps/ade-cli/scripts/install-runtime.sh @@ -0,0 +1,108 @@ +#!/usr/bin/env sh +set -eu + +repo="${ADE_RELEASE_REPO:-arul28/ADE}" +version="${ADE_VERSION:-latest}" +install_dir="${ADE_INSTALL_DIR:-}" +ade_home="${ADE_HOME:-$HOME/.ade}" + +die() { + printf '%s\n' "ade install: $*" >&2 + exit 1 +} + +need() { + command -v "$1" >/dev/null 2>&1 || die "missing required command: $1" +} + +detect_target() { + os="$(uname -s | tr '[:upper:]' '[:lower:]')" + arch="$(uname -m | tr '[:upper:]' '[:lower:]')" + + case "$os" in + darwin) platform="darwin" ;; + linux) platform="linux" ;; + *) die "unsupported OS: $os" ;; + esac + + case "$arch" in + arm64|aarch64) cpu="arm64" ;; + x86_64|amd64) cpu="x64" ;; + *) die "unsupported architecture: $arch" ;; + esac + + printf '%s-%s\n' "$platform" "$cpu" +} + +download() { + url="$1" + out="$2" + if command -v curl >/dev/null 2>&1; then + curl -fsSL "$url" -o "$out" + elif command -v wget >/dev/null 2>&1; then + wget -q "$url" -O "$out" + else + die "missing curl or wget" + fi +} + +asset_url() { + name="$1" + if [ "$version" = "latest" ]; then + printf 'https://github.com/%s/releases/latest/download/%s\n' "$repo" "$name" + else + printf 'https://github.com/%s/releases/download/%s/%s\n' "$repo" "$version" "$name" + fi +} + +choose_install_dir() { + if [ -n "$install_dir" ]; then + printf '%s\n' "$install_dir" + return + fi + + if [ -w /usr/local/bin ]; then + printf '%s\n' "/usr/local/bin" + return + fi + + printf '%s\n' "$HOME/.local/bin" +} + +need uname +need tar +need chmod +target="$(detect_target)" +binary_name="ade-$target" +archive_name="$binary_name.native.tar.gz" +dest_dir="$(choose_install_dir)" +runtime_dir="$ade_home/runtime/$target" +tmp_dir="$(mktemp -d "${TMPDIR:-/tmp}/ade-install.XXXXXX")" +trap 'rm -rf "$tmp_dir"' EXIT HUP INT TERM + +mkdir -p "$dest_dir" "$runtime_dir" "$ade_home/bin" + +download "$(asset_url "$binary_name")" "$tmp_dir/ade" +download "$(asset_url "$archive_name")" "$tmp_dir/native.tar.gz" + +chmod 755 "$tmp_dir/ade" +cp "$tmp_dir/ade" "$dest_dir/ade" +chmod 755 "$dest_dir/ade" + +rm -rf "$runtime_dir/node_modules" +tar -xzf "$tmp_dir/native.tar.gz" -C "$runtime_dir" +export NODE_PATH="$runtime_dir/node_modules${NODE_PATH:+:$NODE_PATH}" + +"$dest_dir/ade" --version >/dev/null || die "installed ade binary failed to run" + +if command -v systemctl >/dev/null 2>&1 && systemctl --user show-environment >/dev/null 2>&1; then + "$dest_dir/ade" serve --install-service >/dev/null 2>&1 || true +elif [ "$(uname -s)" = "Darwin" ]; then + "$dest_dir/ade" serve --install-service >/dev/null 2>&1 || true +fi + +printf 'ADE runtime installed: %s\n' "$dest_dir/ade" +case ":$PATH:" in + *":$dest_dir:"*) ;; + *) printf 'Add %s to PATH to run ade from new shells.\n' "$dest_dir" ;; +esac diff --git a/apps/ade-cli/scripts/notarize-static-runtime.mjs b/apps/ade-cli/scripts/notarize-static-runtime.mjs new file mode 100644 index 000000000..d90cebdbb --- /dev/null +++ b/apps/ade-cli/scripts/notarize-static-runtime.mjs @@ -0,0 +1,133 @@ +import { execFile } from "node:child_process"; +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { promisify } from "node:util"; + +const execFileAsync = promisify(execFile); + +function readFlag(name) { + const prefix = `${name}=`; + for (const arg of process.argv.slice(2)) { + if (arg.startsWith(prefix)) return arg.slice(prefix.length).trim(); + } + return null; +} + +function hasEnv(name) { + return Boolean(process.env[name] && String(process.env[name]).trim().length > 0); +} + +async function assertExists(filePath, label) { + try { + await fs.access(filePath); + } catch { + throw new Error(`Missing ${label}: ${filePath}`); + } +} + +async function run(command, args, options = {}) { + const result = await execFileAsync(command, args, { + maxBuffer: 10 * 1024 * 1024, + ...options, + }); + if (result.stdout) process.stdout.write(result.stdout); + if (result.stderr) process.stderr.write(result.stderr); + return result; +} + +async function findDeveloperIdIdentity() { + const { stdout } = await run("security", ["find-identity", "-v", "-p", "codesigning"]); + const explicit = process.env.ADE_RUNTIME_CODESIGN_IDENTITY || process.env.CSC_NAME; + if (explicit?.trim()) return explicit.trim(); + + for (const line of stdout.split(/\r?\n/)) { + const match = /"([^"]*Developer ID Application[^"]*)"/.exec(line); + if (match?.[1]) return match[1]; + } + throw new Error("Unable to find a Developer ID Application signing identity."); +} + +function buildNotarytoolArgs(zipPath) { + if (hasEnv("APPLE_API_KEY") && hasEnv("APPLE_API_KEY_ID") && hasEnv("APPLE_API_ISSUER")) { + return [ + "notarytool", + "submit", + zipPath, + "--key", + process.env.APPLE_API_KEY, + "--key-id", + process.env.APPLE_API_KEY_ID, + "--issuer", + process.env.APPLE_API_ISSUER, + "--wait", + ]; + } + + if (hasEnv("APPLE_ID") && hasEnv("APPLE_APP_SPECIFIC_PASSWORD") && hasEnv("APPLE_TEAM_ID")) { + return [ + "notarytool", + "submit", + zipPath, + "--apple-id", + process.env.APPLE_ID, + "--password", + process.env.APPLE_APP_SPECIFIC_PASSWORD, + "--team-id", + process.env.APPLE_TEAM_ID, + "--wait", + ]; + } + + if (hasEnv("APPLE_KEYCHAIN_PROFILE")) { + const args = ["notarytool", "submit", zipPath, "--keychain-profile", process.env.APPLE_KEYCHAIN_PROFILE, "--wait"]; + if (hasEnv("APPLE_KEYCHAIN")) args.push("--keychain", process.env.APPLE_KEYCHAIN); + return args; + } + + throw new Error( + "Missing notarization credentials. Provide APPLE_API_KEY + APPLE_API_KEY_ID + APPLE_API_ISSUER, " + + "or APPLE_ID + APPLE_APP_SPECIFIC_PASSWORD + APPLE_TEAM_ID, or APPLE_KEYCHAIN_PROFILE.", + ); +} + +const binary = readFlag("--binary"); +if (!binary) { + throw new Error("Usage: node scripts/notarize-static-runtime.mjs --binary=/path/to/ade-darwin-arm64"); +} + +const binaryPath = path.resolve(binary); +await assertExists(binaryPath, "ADE runtime binary"); + +if (process.platform !== "darwin") { + throw new Error("Static runtime notarization must run on macOS."); +} + +const identity = await findDeveloperIdIdentity(); +console.log(`[runtime:notarize] Signing ${binaryPath} with ${identity}`); +await run("codesign", [ + "--force", + "--options", + "runtime", + "--timestamp", + "--sign", + identity, + binaryPath, +]); +await run("codesign", ["--verify", "--strict", "--verbose=4", binaryPath]); + +const workDir = await fs.mkdtemp(path.join(os.tmpdir(), "ade-runtime-notary-")); +const zipPath = path.join(workDir, `${path.basename(binaryPath)}.zip`); +try { + console.log(`[runtime:notarize] Creating notarization archive ${zipPath}`); + await run("ditto", ["-c", "-k", "--keepParent", binaryPath, zipPath]); + + console.log(`[runtime:notarize] Submitting ${path.basename(binaryPath)} to notarytool`); + await run("xcrun", buildNotarytoolArgs(zipPath)); + + console.log(`[runtime:notarize] Stapling ${binaryPath}`); + await run("xcrun", ["stapler", "staple", binaryPath]); + await run("spctl", ["--assess", "--type", "execute", "--verbose=4", binaryPath]); +} finally { + await fs.rm(workDir, { recursive: true, force: true }); +} diff --git a/apps/ade-cli/scripts/package-native-deps.mjs b/apps/ade-cli/scripts/package-native-deps.mjs new file mode 100644 index 000000000..57c45ae39 --- /dev/null +++ b/apps/ade-cli/scripts/package-native-deps.mjs @@ -0,0 +1,195 @@ +import { createWriteStream } from "node:fs"; +import fs from "node:fs/promises"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { pipeline } from "node:stream/promises"; +import { createGzip } from "node:zlib"; +import { spawn } from "node:child_process"; + +const packageRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); +const nodeModulesRoot = path.join(packageRoot, "node_modules"); +const defaultOutDir = path.join(packageRoot, "dist-static"); + +function parseArgs(argv) { + const args = { target: null, outDir: defaultOutDir }; + for (let i = 0; i < argv.length; i += 1) { + const token = argv[i]; + if (token === "--target") { + args.target = argv[++i] ?? null; + } else if (token === "--out-dir") { + args.outDir = path.resolve(argv[++i] ?? ""); + } else if (token === "--help" || token === "-h") { + printHelp(); + process.exit(0); + } else { + throw new Error(`Unknown argument: ${token}`); + } + } + args.target ??= currentTarget(); + validateTarget(args.target); + return args; +} + +function printHelp() { + process.stdout.write(`Usage: node scripts/package-native-deps.mjs [--target darwin-arm64] [--out-dir dist-static]\n`); +} + +function currentTarget() { + const platform = process.platform === "darwin" ? "darwin" : process.platform === "linux" ? "linux" : process.platform; + const arch = process.arch === "x64" ? "x64" : process.arch === "arm64" ? "arm64" : process.arch; + return `${platform}-${arch}`; +} + +function validateTarget(target) { + if (!/^(darwin|linux)-(arm64|x64)$/.test(target)) { + throw new Error(`Unsupported runtime target '${target}'. Expected darwin-arm64, darwin-x64, linux-arm64, or linux-x64.`); + } +} + +async function exists(filePath) { + try { + await fs.access(filePath); + return true; + } catch { + return false; + } +} + +async function readJson(filePath) { + return JSON.parse(await fs.readFile(filePath, "utf8")); +} + +function packagePath(packageName) { + return path.join(nodeModulesRoot, ...packageName.split("/")); +} + +async function readPackageManifest(packageName) { + const manifestPath = path.join(packagePath(packageName), "package.json"); + if (!(await exists(manifestPath))) return null; + return await readJson(manifestPath); +} + +async function collectRuntimePackages(target) { + const rootManifest = await readJson(path.join(packageRoot, "package.json")); + const platformCursorPackage = `@cursor/sdk-${target}`; + const queue = [ + ...Object.keys(rootManifest.dependencies ?? {}), + platformCursorPackage, + ]; + const visited = new Set(); + const packages = []; + + while (queue.length > 0) { + const packageName = queue.shift(); + if (!packageName || visited.has(packageName)) continue; + visited.add(packageName); + const manifest = await readPackageManifest(packageName); + if (!manifest) continue; + packages.push(packageName); + + const deps = { + ...(manifest.dependencies ?? {}), + ...(manifest.optionalDependencies ?? {}), + }; + for (const dependencyName of Object.keys(deps)) { + if (dependencyName.startsWith("@cursor/sdk-") && dependencyName !== platformCursorPackage) { + continue; + } + if (!visited.has(dependencyName)) queue.push(dependencyName); + } + } + + return packages.sort((a, b) => a.localeCompare(b)); +} + +async function copyPackage(packageName, destinationRoot) { + const source = packagePath(packageName); + if (!(await exists(source))) return false; + const destination = path.join(destinationRoot, "node_modules", ...packageName.split("/")); + await fs.mkdir(path.dirname(destination), { recursive: true }); + await fs.rm(destination, { recursive: true, force: true }); + await fs.cp(source, destination, { + recursive: true, + filter: (entry) => { + const normalized = entry.split(path.sep).join("/"); + return !normalized.includes("/.cache/") + && !normalized.includes("/test/") + && !normalized.includes("/tests/") + && !normalized.endsWith(".map"); + }, + }); + return true; +} + +async function writeManifest(bundleRoot, target, packages) { + const manifest = { + target, + createdAt: new Date().toISOString(), + packages, + }; + await fs.writeFile(path.join(bundleRoot, "manifest.json"), `${JSON.stringify(manifest, null, 2)}\n`, "utf8"); +} + +async function chmodRuntimeExecutables(bundleRoot, target) { + if (!target.startsWith("darwin-")) return; + const helperPath = path.join(bundleRoot, "node_modules", "node-pty", "prebuilds", target, "spawn-helper"); + if (!(await exists(helperPath))) return; + const stat = await fs.stat(helperPath); + await fs.chmod(helperPath, stat.mode | 0o111); +} + +async function makeTarGz(sourceDir, outputPath) { + await fs.rm(outputPath, { force: true }); + const tar = spawn("tar", ["-cf", "-", "-C", sourceDir, "."], { + stdio: ["ignore", "pipe", "inherit"], + }); + let spawnError = null; + tar.once("error", (error) => { + spawnError = error; + tar.stdout.destroy(error); + }); + const out = createWriteStream(outputPath, { mode: 0o644 }); + try { + await pipeline(tar.stdout, createGzip({ level: 9 }), out); + } catch (error) { + if (spawnError?.code === "ENOENT") { + throw new Error("The 'tar' command is required to package native runtime dependencies."); + } + throw error; + } + const exitCode = await new Promise((resolve) => tar.once("close", resolve)); + if (exitCode !== 0) { + throw new Error(`tar exited with status ${exitCode}`); + } +} + +async function main() { + const args = parseArgs(process.argv.slice(2)); + const bundleRoot = path.join(args.outDir, `ade-${args.target}.native`); + await fs.rm(bundleRoot, { recursive: true, force: true }); + await fs.mkdir(bundleRoot, { recursive: true }); + + const packageNames = await collectRuntimePackages(args.target); + const copied = []; + for (const packageName of packageNames) { + if (await copyPackage(packageName, bundleRoot)) { + copied.push(packageName); + } + } + await chmodRuntimeExecutables(bundleRoot, args.target); + await writeManifest(bundleRoot, args.target, copied); + + const archivePath = path.join(args.outDir, `ade-${args.target}.native.tar.gz`); + await makeTarGz(bundleRoot, archivePath); + process.stdout.write(`${JSON.stringify({ + target: args.target, + archivePath, + bundleRoot, + packages: copied, + }, null, 2)}\n`); +} + +main().catch((error) => { + process.stderr.write(`[package-native-deps] ${error instanceof Error ? error.message : String(error)}\n`); + process.exit(1); +}); diff --git a/apps/ade-cli/scripts/verify-built-cli.mjs b/apps/ade-cli/scripts/verify-built-cli.mjs index d4d0d075e..fc95d1009 100644 --- a/apps/ade-cli/scripts/verify-built-cli.mjs +++ b/apps/ade-cli/scripts/verify-built-cli.mjs @@ -7,6 +7,7 @@ import { promisify } from "node:util"; const execFileAsync = promisify(execFile); const packageRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); const cliPath = path.join(packageRoot, "dist", "cli.cjs"); +const packageJsonPath = path.join(packageRoot, "package.json"); async function runHelp(command, args) { const { stdout } = await execFileAsync(command, args, { @@ -18,7 +19,24 @@ async function runHelp(command, args) { } } +async function assertVersion(command, args, expectedVersion) { + const { stdout } = await execFileAsync(command, args, { + cwd: packageRoot, + env: process.env, + }); + const actual = stdout.trim().replace(/^ade\s+/i, ""); + if (actual !== expectedVersion) { + throw new Error(`[ade-cli:build] CLI version mismatch: expected ${expectedVersion}, got ${actual || ""}`); + } +} + const contents = await fs.readFile(cliPath, "utf8"); +const packageJson = JSON.parse(await fs.readFile(packageJsonPath, "utf8")); +const expectedVersion = process.env.ADE_CLI_VERSION?.trim() || packageJson.version; +if (!expectedVersion) { + throw new Error("[ade-cli:build] Unable to resolve expected CLI version from ADE_CLI_VERSION or package.json"); +} + if (!contents.startsWith("#!/usr/bin/env node")) { throw new Error("[ade-cli:build] dist/cli.cjs is missing the node shebang"); } @@ -34,9 +52,11 @@ if (process.platform !== "win32" && (stat.mode & 0o111) === 0) { } await runHelp(process.execPath, [cliPath, "--help"]); +await assertVersion(process.execPath, [cliPath, "--version"], expectedVersion); if (process.platform !== "win32") { await runHelp(cliPath, ["--help"]); + await assertVersion(cliPath, ["--version"], expectedVersion); } console.log("[ade-cli:build] verified dist/cli.cjs binary"); diff --git a/apps/ade-cli/src/adeRpcServer.test.ts b/apps/ade-cli/src/adeRpcServer.test.ts index 4f314c0e4..669561d4f 100644 --- a/apps/ade-cli/src/adeRpcServer.test.ts +++ b/apps/ade-cli/src/adeRpcServer.test.ts @@ -216,6 +216,9 @@ function createRuntime() { get: vi.fn(), readTranscriptTail: vi.fn(() => "") }, + sessionDeltaService: { + getSessionDelta: vi.fn((sessionId: string) => ({ sessionId, filesChanged: 2 })), + }, operationService: { start: operationStart, finish: operationFinish, @@ -784,6 +787,7 @@ function createRuntime() { getBackendStatus: vi.fn(() => ({ backends: [] })), listArtifacts: vi.fn(() => []), ingest: vi.fn(() => ({ artifacts: [] })), + readArtifactPreview: vi.fn(async () => "data:image/png;base64,AAAA"), } as any, macosVmService: { getStatus: vi.fn(async ({ laneId }: { laneId?: string | null } = {}) => ({ @@ -3951,7 +3955,7 @@ describe("adeRpcServer", () => { const fixture = createRuntime(); const handler = createAdeRpcRequestHandler({ runtime: fixture.runtime, serverVersion: "test" }); - await initialize(handler, { callerId: "agent-1", role: "agent" }); + await initialize(handler, { callerId: "cto-1", role: "cto" }); const response = await callTool(handler, "commit_changes", { laneId: "lane-1", @@ -4127,6 +4131,31 @@ describe("adeRpcServer", () => { expect(allDomains.structuredContent.actions.some((entry: { domain: string }) => entry.domain === "graph_state")).toBe(true); }); + it("hides memory tools and actions when the runtime disables memory", async () => { + const fixture = createRuntime(); + fixture.runtime.capabilities = { memory: false }; + const handler = createAdeRpcRequestHandler({ runtime: fixture.runtime, serverVersion: "test" }); + await initialize(handler, { callerId: "agent-1", role: "agent" }); + + const listed = await handler({ + jsonrpc: "2.0", + id: 2, + method: "ade/actions/list", + params: {}, + }) as { actions: Array<{ name: string }> }; + expect(listed.actions.some((entry) => entry.name.startsWith("memory_"))).toBe(false); + + const memoryCall = await callTool(handler, "memory_add", { + content: "Remember this", + category: "fact", + }); + expect(memoryCall.isError).toBe(true); + expect(String(memoryCall.error?.message ?? "")).toContain("Tool not available"); + + const actionList = await callTool(handler, "list_ade_actions", { domain: "all" }); + expect(actionList.structuredContent.actions.some((entry: { domain: string }) => entry.domain === "memory")).toBe(false); + }); + it("invokes ADE actions dynamically and returns status hints", async () => { const fixture = createRuntime(); const handler = createAdeRpcRequestHandler({ runtime: fixture.runtime, serverVersion: "test" }); @@ -4173,6 +4202,27 @@ describe("adeRpcServer", () => { }); expect(layoutGet?.isError).toBeUndefined(); expect(layoutGet.structuredContent.result).toEqual({ left: 100, right: 0 }); + + const delta = await callTool(handler, "run_ade_action", { + domain: "session", + action: "getDelta", + args: { sessionId: "session-1" }, + }); + expect(delta?.isError).toBeUndefined(); + expect(fixture.runtime.sessionDeltaService.getSessionDelta).toHaveBeenCalledWith("session-1"); + expect(delta.structuredContent.result).toEqual({ sessionId: "session-1", filesChanged: 2 }); + + const preview = await callTool(handler, "run_ade_action", { + domain: "computer_use_artifacts", + action: "readArtifactPreview", + args: { uri: ".ade/artifacts/proof.png" }, + }); + expect(preview?.isError).toBeUndefined(); + expect(fixture.runtime.computerUseArtifactBrokerService.readArtifactPreview).toHaveBeenCalledWith({ + uri: ".ade/artifacts/proof.png", + }); + expect(preview.structuredContent.result).toBe("data:image/png;base64,AAAA"); + }); it("binds service method context when invoking dynamic ADE actions", async () => { @@ -4197,6 +4247,40 @@ describe("adeRpcServer", () => { expect(response.structuredContent.statusHints.missionId).toBe("mission-new"); }); + it("compacts orchestrator ADE action results for runtime transport", async () => { + const fixture = createRuntime(); + const docs = Array.from({ length: 16 }, (_, index) => ({ + path: index === 0 ? ".ade/internal.md" : `docs/${index}.md`, + bytes: index + 1, + sha256: `sha-${index}`, + })); + fixture.runtime.orchestratorService.listRuns.mockReturnValueOnce([ + { + id: "run-compact", + missionId: "mission-1", + status: "running", + metadata: { runtimeCursor: { docs } }, + }, + ]); + const handler = createAdeRpcRequestHandler({ runtime: fixture.runtime, serverVersion: "test" }); + await initialize(handler, { callerId: "agent-1", role: "agent" }); + + const response = await callTool(handler, "run_ade_action", { + domain: "orchestrator_core", + action: "listRuns", + args: { limit: 10 }, + }); + + expect(response?.isError).toBeUndefined(); + expect(fixture.runtime.orchestratorService.listRuns).toHaveBeenCalledWith({ limit: 10 }); + const runs = response.structuredContent.result; + expect(runs).toHaveLength(1); + const cursor = runs[0].metadata.runtimeCursor; + expect(cursor.docs).toHaveLength(12); + expect(cursor.docs.map((entry: { path: string }) => entry.path)).not.toContain(".ade/internal.md"); + expect(cursor.docsOmittedCount).toBe(4); + }); + it("does not expose unlisted service methods through dynamic ADE actions", async () => { const fixture = createRuntime(); const handler = createAdeRpcRequestHandler({ runtime: fixture.runtime, serverVersion: "test" }); diff --git a/apps/ade-cli/src/adeRpcServer.ts b/apps/ade-cli/src/adeRpcServer.ts index effbdca0c..2378e45e3 100644 --- a/apps/ade-cli/src/adeRpcServer.ts +++ b/apps/ade-cli/src/adeRpcServer.ts @@ -2142,6 +2142,12 @@ const ALL_TOOL_SPECS: ToolSpec[] = [ ...COORDINATOR_TOOL_SPECS, ]; const COORDINATOR_TOOL_NAMES = new Set(COORDINATOR_TOOL_SPECS.map((tool) => tool.name)); +const MEMORY_TOOL_NAMES = new Set([ + "memory_add", + "memory_update_core", + "memory_search", + "memory_pin", +]); const READ_ONLY_TOOLS = new Set([ "check_conflicts", @@ -3455,6 +3461,7 @@ function isLocalComputerUseAllowed(callerCtx: CallerContext): boolean { async function listToolSpecsForSession(runtime: AdeRuntime, session: SessionState): Promise { const callerCtx = await resolveEffectiveCallerContext(runtime, session); + const memoryAllowed = runtime.capabilities?.memory !== false; const externalComputerUseAvailable = runtime.computerUseArtifactBrokerService ?.getBackendStatus() ?.backends.some((backend) => backend.available) ?? false; @@ -3464,6 +3471,7 @@ async function listToolSpecsForSession(runtime: AdeRuntime, session: SessionStat const keepVisibleTool = (tool: ToolSpec): boolean => ( (!shouldHideLocalComputerUse || !LOCAL_COMPUTER_USE_TOOL_NAMES.has(tool.name)) && (macosVmAllowed || !MACOS_VM_TOOL_NAMES.has(tool.name)) + && (memoryAllowed || !MEMORY_TOOL_NAMES.has(tool.name)) ); const visibleBaseTools = TOOL_SPECS.filter(keepVisibleTool); const visibleCoordinatorTools = COORDINATOR_TOOL_SPECS.filter(keepVisibleTool); @@ -4623,6 +4631,9 @@ async function runTool(args: { }): Promise { const { runtime, session, name, toolArgs } = args; const callerCtx = await resolveEffectiveCallerContext(runtime, session); + if (runtime.capabilities?.memory === false && MEMORY_TOOL_NAMES.has(name)) { + throw new JsonRpcError(JsonRpcErrorCode.methodNotFound, `Tool not available in this runtime: ${name}`); + } if (isToolHiddenForStandaloneChat(name, callerCtx)) { throw new JsonRpcError(JsonRpcErrorCode.methodNotFound, `Unsupported tool: ${name}`); } @@ -6871,8 +6882,20 @@ async function runTool(args: { commandPreviewParts.push("--sandbox", "workspace-write", "--ask-for-approval", "untrusted"); } } else { - const claudePermission = - permissionMode === "plan" ? "plan" : permissionMode === "full-auto" ? "bypassPermissions" : permissionMode === "edit" ? "acceptEdits" : "default"; + let claudePermission: string; + switch (permissionMode) { + case "plan": + claudePermission = "plan"; + break; + case "full-auto": + claudePermission = "bypassPermissions"; + break; + case "edit": + claudePermission = "acceptEdits"; + break; + default: + claudePermission = "default"; + } commandArgs.push("--permission-mode", claudePermission); commandPreviewParts.push("--permission-mode", previewShellEscapeArg(claudePermission)); @@ -7640,6 +7663,69 @@ export function createAdeRpcRequestHandler(args: { return { pong: true, at: nowIso() }; } + if (method.startsWith("sync.")) { + const syncService = runtime.syncService; + if (!syncService) { + throw new JsonRpcError(JsonRpcErrorCode.invalidRequest, "Sync service is not available."); + } + if (method === "sync.getStatus") { + return await syncService.getStatus({ + includeTransferReadiness: params.includeTransferReadiness === true, + forceTransferReadiness: params.forceTransferReadiness === true, + }); + } + if (method === "sync.refreshDiscovery") { + return await syncService.refreshDiscovery(); + } + if (method === "sync.listDevices") { + return await syncService.listDevices(); + } + if (method === "sync.updateLocalDevice") { + const name = typeof params.name === "string" ? params.name : undefined; + const deviceType = typeof params.deviceType === "string" ? params.deviceType : undefined; + return await syncService.updateLocalDevice({ + ...(name !== undefined ? { name } : {}), + ...(deviceType !== undefined ? { deviceType: deviceType as never } : {}), + }); + } + if (method === "sync.connectToBrain") { + return await syncService.connectToBrain(params as Parameters[0]); + } + if (method === "sync.disconnectFromBrain") { + return await syncService.disconnectFromBrain(); + } + if (method === "sync.forgetDevice") { + const deviceId = typeof params.deviceId === "string" ? params.deviceId : ""; + return await syncService.forgetDevice(deviceId); + } + if (method === "sync.getTransferReadiness") { + return await syncService.getTransferReadiness(); + } + if (method === "sync.transferBrainToLocal") { + return await syncService.transferBrainToLocal(); + } + if (method === "sync.getPin") { + return { pin: syncService.getPin() }; + } + if (method === "sync.setPin") { + const pin = typeof params.pin === "string" ? params.pin : ""; + return await syncService.setPin(pin); + } + if (method === "sync.generatePin") { + return await syncService.generatePin(); + } + if (method === "sync.clearPin") { + return await syncService.clearPin(); + } + if (method === "sync.setActiveLanePresence") { + const laneIds = Array.isArray(params.laneIds) + ? params.laneIds.filter((laneId): laneId is string => typeof laneId === "string") + : []; + await syncService.setActiveLanePresence(laneIds); + return null; + } + } + if (method === "ade/actions/list") { return await listActions(); } diff --git a/apps/ade-cli/src/bootstrap.test.ts b/apps/ade-cli/src/bootstrap.test.ts index 58d7fadc0..0354607c5 100644 --- a/apps/ade-cli/src/bootstrap.test.ts +++ b/apps/ade-cli/src/bootstrap.test.ts @@ -144,4 +144,43 @@ describe("createEventBuffer", () => { expect(result.events[i]!.payload).toEqual({ kind: categories[i] }); } }); + + it("notifies subscribers for newly pushed events until unsubscribed", () => { + const buffer = createEventBuffer(); + const seen: BufferedEvent[] = []; + + const unsubscribe = buffer.subscribe((event) => seen.push(event)); + buffer.push({ timestamp: "t1", category: "runtime", payload: { n: 1 } }); + unsubscribe(); + buffer.push({ timestamp: "t2", category: "runtime", payload: { n: 2 } }); + + expect(seen).toEqual([ + expect.objectContaining({ + id: 1, + category: "runtime", + payload: { n: 1 }, + }), + ]); + }); + + it("keeps notifying subscribers when one listener throws", () => { + const buffer = createEventBuffer(); + const seen: BufferedEvent[] = []; + + buffer.subscribe(() => { + throw new Error("listener failed"); + }); + buffer.subscribe((event) => seen.push(event)); + + expect(() => { + buffer.push({ timestamp: "t1", category: "runtime", payload: { n: 1 } }); + }).not.toThrow(); + expect(seen).toEqual([ + expect.objectContaining({ + id: 1, + category: "runtime", + payload: { n: 1 }, + }), + ]); + }); }); diff --git a/apps/ade-cli/src/bootstrap.ts b/apps/ade-cli/src/bootstrap.ts index 6538c75ce..0a9907ff3 100644 --- a/apps/ade-cli/src/bootstrap.ts +++ b/apps/ade-cli/src/bootstrap.ts @@ -6,7 +6,11 @@ import * as nodePty from "node-pty"; import { createFileLogger, type Logger } from "../../desktop/src/main/services/logging/logger"; import { openKvDb, type AdeDb } from "../../desktop/src/main/services/state/kvDb"; import { detectDefaultBaseRef, toProjectInfo, upsertProjectRow } from "../../desktop/src/main/services/projects/projectService"; -import { initializeOrRepairAdeProject } from "../../desktop/src/main/services/projects/adeProjectService"; +import { + createAdeProjectService, + initializeOrRepairAdeProject, +} from "../../desktop/src/main/services/projects/adeProjectService"; +import { createConfigReloadService } from "../../desktop/src/main/services/projects/configReloadService"; import { createOperationService } from "../../desktop/src/main/services/history/operationService"; import { createLaneService } from "../../desktop/src/main/services/lanes/laneService"; import { createSessionService } from "../../desktop/src/main/services/sessions/sessionService"; @@ -18,19 +22,19 @@ import { createMissionService } from "../../desktop/src/main/services/missions/m import type { createMissionPreflightService } from "../../desktop/src/main/services/missions/missionPreflightService"; import { createPtyService } from "../../desktop/src/main/services/pty/ptyService"; import { createTestService } from "../../desktop/src/main/services/tests/testService"; -import type { createKeybindingsService } from "../../desktop/src/main/services/keybindings/keybindingsService"; +import { createKeybindingsService } from "../../desktop/src/main/services/keybindings/keybindingsService"; import type { createAgentToolsService } from "../../desktop/src/main/services/agentTools/agentToolsService"; import type { createAdeCliService } from "../../desktop/src/main/services/cli/adeCliService"; import type { createDevToolsService } from "../../desktop/src/main/services/devTools/devToolsService"; -import type { createOnboardingService } from "../../desktop/src/main/services/onboarding/onboardingService"; -import type { createLaneEnvironmentService } from "../../desktop/src/main/services/lanes/laneEnvironmentService"; -import type { createLaneTemplateService } from "../../desktop/src/main/services/lanes/laneTemplateService"; -import type { createPortAllocationService } from "../../desktop/src/main/services/lanes/portAllocationService"; -import type { createLaneProxyService } from "../../desktop/src/main/services/lanes/laneProxyService"; -import type { createOAuthRedirectService } from "../../desktop/src/main/services/lanes/oauthRedirectService"; -import type { createRuntimeDiagnosticsService } from "../../desktop/src/main/services/lanes/runtimeDiagnosticsService"; -import type { createRebaseSuggestionService } from "../../desktop/src/main/services/lanes/rebaseSuggestionService"; -import type { createAutoRebaseService } from "../../desktop/src/main/services/lanes/autoRebaseService"; +import { createOnboardingService } from "../../desktop/src/main/services/onboarding/onboardingService"; +import { createLaneEnvironmentService } from "../../desktop/src/main/services/lanes/laneEnvironmentService"; +import { createLaneTemplateService } from "../../desktop/src/main/services/lanes/laneTemplateService"; +import { createPortAllocationService } from "../../desktop/src/main/services/lanes/portAllocationService"; +import { createLaneProxyService } from "../../desktop/src/main/services/lanes/laneProxyService"; +import { createOAuthRedirectService } from "../../desktop/src/main/services/lanes/oauthRedirectService"; +import { createRuntimeDiagnosticsService } from "../../desktop/src/main/services/lanes/runtimeDiagnosticsService"; +import { createRebaseSuggestionService } from "../../desktop/src/main/services/lanes/rebaseSuggestionService"; +import { createAutoRebaseService } from "../../desktop/src/main/services/lanes/autoRebaseService"; import { createProcessService } from "../../desktop/src/main/services/processes/processService"; import { augmentProcessPathWithShellAndKnownCliDirs, setPathEnvValue } from "../../desktop/src/main/services/ai/cliExecutableResolver"; import { createAgentChatService } from "../../desktop/src/main/services/chat/agentChatService"; @@ -47,7 +51,7 @@ import type { createWorkerRevisionService } from "../../desktop/src/main/service import type { createWorkerHeartbeatService } from "../../desktop/src/main/services/cto/workerHeartbeatService"; import type { createWorkerTaskSessionService } from "../../desktop/src/main/services/cto/workerTaskSessionService"; import type { createLinearCredentialService } from "../../desktop/src/main/services/cto/linearCredentialService"; -import type { createOpenclawBridgeService } from "../../desktop/src/main/services/cto/openclawBridgeService"; +import { createLinearOAuthService } from "../../desktop/src/main/services/cto/linearOAuthService"; import type { createFlowPolicyService } from "../../desktop/src/main/services/cto/flowPolicyService"; import type { createLinearDispatcherService } from "../../desktop/src/main/services/cto/linearDispatcherService"; import type { createLinearIssueTracker } from "../../desktop/src/main/services/cto/linearIssueTracker"; @@ -57,15 +61,17 @@ import type { createLinearSyncService } from "../../desktop/src/main/services/ct import { createOrchestratorService } from "../../desktop/src/main/services/orchestrator/orchestratorService"; import { createAiOrchestratorService } from "../../desktop/src/main/services/orchestrator/aiOrchestratorService"; import { createAiIntegrationService } from "../../desktop/src/main/services/ai/aiIntegrationService"; +import { initApiKeyStore } from "../../desktop/src/main/services/ai/apiKeyStore"; import { createMissionBudgetService } from "../../desktop/src/main/services/orchestrator/missionBudgetService"; -import type { createSyncService } from "../../desktop/src/main/services/sync/syncService"; -import type { createSyncHostService } from "../../desktop/src/main/services/sync/syncHostService"; +import type { createSyncService } from "./services/sync/syncService"; +import type { createSyncHostService, SyncRuntimeKind } from "./services/sync/syncHostService"; import type { createAutomationIngressService } from "../../desktop/src/main/services/automations/automationIngressService"; import type { createGithubService } from "../../desktop/src/main/services/github/githubService"; -import type { createFeedbackReporterService } from "../../desktop/src/main/services/feedback/feedbackReporterService"; +import { createFeedbackReporterService } from "../../desktop/src/main/services/feedback/feedbackReporterService"; import type { createUsageTrackingService } from "../../desktop/src/main/services/usage/usageTrackingService"; import type { createBudgetCapService } from "../../desktop/src/main/services/usage/budgetCapService"; -import type { createSessionDeltaService } from "../../desktop/src/main/services/sessions/sessionDeltaService"; +import { createSessionDeltaService } from "../../desktop/src/main/services/sessions/sessionDeltaService"; +import { createReviewService } from "../../desktop/src/main/services/review/reviewService"; import type { createAutoUpdateService } from "../../desktop/src/main/services/updates/autoUpdateService"; import { createComputerUseArtifactBrokerService, @@ -82,7 +88,7 @@ import { import { createMacosVmService } from "../../desktop/src/main/services/macosVm/macosVmService"; import type { BuiltInBrowserService } from "../../desktop/src/main/services/builtInBrowser/builtInBrowserService"; import type { createFileService } from "../../desktop/src/main/services/files/fileService"; -import type { AppNavigationRequest, AppNavigationResult } from "../../desktop/src/shared/types"; +import type { AppNavigationRequest, AppNavigationResult, PortLease } from "../../desktop/src/shared/types"; import { createAutomationService, type AutomationAdeActionRegistry, @@ -96,6 +102,7 @@ import { } from "../../desktop/src/main/services/adeActions/registry"; import { createLaneWorktreeLockService, type LaneWorktreeLockService } from "../../desktop/src/main/services/lanes/laneWorktreeLockService"; import { createHeadlessLinearServices } from "./headlessLinearServices"; +import { EncryptedFileCredentialStore } from "./services/credentials/credentialStore"; import { createEventBuffer, type BufferedEvent, type EventBuffer } from "./eventBuffer"; export { createEventBuffer, type BufferedEvent, type EventBuffer }; @@ -118,10 +125,27 @@ export type AdeRuntimePaths = { missionStateDir: string; }; +export type AdeRuntimeSyncOptions = { + enabled?: boolean; + hostStartupEnabled?: boolean; + hostDiscoveryEnabled?: boolean; + forceHostRole?: boolean; + runtimeKind?: SyncRuntimeKind; + appVersion?: string; + registryProjectId?: string; + localDeviceIdPath?: string; + phonePairingStateDir?: string; + projectCatalogProvider?: Parameters[0]["projectCatalogProvider"]; + remoteCommandExecutor?: Parameters[0]["remoteCommandExecutor"]; +}; + export type AdeRuntime = { projectRoot: string; workspaceRoot: string; projectId: string; + capabilities?: { + memory?: boolean; + }; project: { rootPath: string; displayName: string; baseRef: string }; paths: AdeRuntimePaths; logger: Logger; @@ -131,6 +155,7 @@ export type AdeRuntime = { adeCliService?: ReturnType | null; devToolsService?: ReturnType | null; onboardingService?: ReturnType | null; + adeProjectService?: ReturnType | null; laneService: ReturnType; laneWorktreeLockService?: LaneWorktreeLockService | null; laneEnvironmentService?: ReturnType | null; @@ -167,7 +192,7 @@ export type AdeRuntime = { workerHeartbeatService?: ReturnType | null; workerTaskSessionService?: ReturnType | null; linearCredentialService?: ReturnType | null; - openclawBridgeService?: ReturnType | null; + linearOAuthService?: ReturnType | null; flowPolicyService?: ReturnType | null; linearDispatcherService?: ReturnType | null; linearIssueTracker?: ReturnType | null; @@ -193,6 +218,7 @@ export type AdeRuntime = { usageTrackingService?: ReturnType | null; budgetCapService?: ReturnType | null; sessionDeltaService?: ReturnType | null; + reviewService?: ReturnType | null; autoUpdateService?: ReturnType | null; appNavigationService?: { navigate(args: AppNavigationRequest): Promise; @@ -311,6 +337,10 @@ export async function createAdeRuntime(args: { workspaceRoot?: string; chatRuntime?: "headless-stub" | "agent"; runtimeProfile?: "full" | "chat"; + syncRuntime?: AdeRuntimeSyncOptions; + capabilities?: { + memory?: boolean; + }; } | string): Promise { const resolvedArgs = typeof args === "string" ? { projectRoot: args, workspaceRoot: args } @@ -325,8 +355,10 @@ export async function createAdeRuntime(args: { throw new Error(`Workspace root does not exist: ${workspaceRoot}`); } + const hadAdeDb = fs.existsSync(path.join(projectRoot, ".ade", "ade.db")); const baseRef = await detectDefaultBaseRef(projectRoot); const paths = ensureAdePaths(projectRoot); + initApiKeyStore(projectRoot, { credentialStore: new EncryptedFileCredentialStore() }); const logger = createFileLogger(path.join(paths.logsDir, "ade-cli.jsonl")); const db = await openKvDb(paths.dbPath, logger); @@ -339,6 +371,16 @@ export async function createAdeRuntime(args: { }); const operationService = createOperationService({ db, projectId }); + const keybindingsService = createKeybindingsService({ db }); + const eventBuffer = createEventBuffer(); + + function pushEvent(category: BufferedEvent["category"], payload: Record): void { + eventBuffer.push({ timestamp: new Date().toISOString(), category, payload }); + } + + let conflictServiceRef: ReturnType | null = null; + let rebaseSuggestionServiceRef: ReturnType | null = null; + let autoRebaseServiceRef: ReturnType | null = null; const laneService = createLaneService({ db, @@ -346,15 +388,37 @@ export async function createAdeRuntime(args: { projectId, defaultBaseRef: baseRef, worktreesDir: paths.worktreesDir, - operationService + operationService, + onHeadChanged: (event) => { + pushEvent("runtime", { type: "lane_head_changed", ...event }); + void rebaseSuggestionServiceRef?.onParentHeadChanged(event).catch(() => {}); + void autoRebaseServiceRef?.onHeadChanged(event).catch(() => {}); + }, + onRebaseEvent: (event) => { + pushEvent("runtime", { type: "lane_rebase_event", event }); + if (event.type === "rebase-run-updated" && event.run.state !== "running") { + void conflictServiceRef?.scanRebaseNeeds().catch(() => {}); + } + }, + onDeleteEvent: (event) => pushEvent("runtime", { type: "lane_delete_event", event }), + logger, }); await laneService.ensurePrimaryLane(); const sessionService = createSessionService({ db }); + sessionService.onChanged((event) => { + pushEvent("runtime", { type: "terminal_session_changed", event }); + }); sessionService.reconcileStaleRunningSessions({ status: "disposed", excludeToolTypes: ["claude-chat", "codex-chat", "opencode-chat", "cursor", "droid-chat"], }); + const sessionDeltaService = createSessionDeltaService({ + db, + projectId, + laneService, + sessionService, + }); const projectConfigService = createProjectConfigService({ projectRoot, @@ -363,6 +427,85 @@ export async function createAdeRuntime(args: { db, logger }); + const onboardingService = createOnboardingService({ + db, + logger, + projectRoot, + projectId, + baseRef, + freshProject: !hadAdeDb, + laneService, + projectConfigService, + }); + + const laneEnvironmentService = createLaneEnvironmentService({ + projectRoot, + adeDir: paths.adeDir, + logger, + broadcastEvent: (event) => pushEvent("runtime", { type: "lane_env_event", event }), + }); + + const laneTemplateService = createLaneTemplateService({ + projectConfigService, + logger, + }); + + const portAllocationService = createPortAllocationService({ + logger, + broadcastEvent: (event) => pushEvent("runtime", { type: "lane_port_event", event }), + persistLeases: (leases) => db.setJson("port_leases", leases), + loadLeases: () => db.getJson("port_leases") ?? [], + }); + portAllocationService.restore(); + + const recoverPortAllocations = async () => { + const lanes = await laneService.list({ includeArchived: false, includeStatus: false }); + const validIds = new Set(lanes.map((lane) => lane.id)); + portAllocationService.recoverOrphans(validIds); + for (const lane of lanes) { + const lease = portAllocationService.getLease(lane.id); + if (lease?.status === "active") continue; + try { + portAllocationService.acquire(lane.id); + } catch (error) { + logger.warn("port_allocation.headless_startup_acquire_failed", { + laneId: lane.id, + error: error instanceof Error ? error.message : String(error), + }); + } + } + portAllocationService.detectConflicts(); + }; + await recoverPortAllocations().catch((error) => { + logger.warn("port_allocation.headless_startup_recovery_failed", { + error: error instanceof Error ? error.message : String(error), + }); + }); + + const laneProxyService = createLaneProxyService({ + logger, + broadcastEvent: (event) => pushEvent("runtime", { type: "lane_proxy_event", event }), + }); + + const oauthRedirectService = createOAuthRedirectService({ + logger, + broadcastEvent: (event) => pushEvent("runtime", { type: "lane_oauth_event", event }), + getRoutes: () => laneProxyService.listRoutes(), + getProxyPort: () => laneProxyService.getConfig().proxyPort, + getHostnameSuffix: () => laneProxyService.getConfig().hostnameSuffix, + forwardToPort: (req, res, port) => laneProxyService.forwardToPort(req, res, port), + }); + laneProxyService.registerInterceptor((req, res) => oauthRedirectService.handleRequest(req, res)); + + const runtimeDiagnosticsService = createRuntimeDiagnosticsService({ + logger, + broadcastEvent: (event) => pushEvent("runtime", { type: "lane_diagnostics_event", event }), + getPortLease: (laneId) => portAllocationService.getLease(laneId), + getPortConflicts: () => portAllocationService.listConflicts(), + detectPortConflicts: () => portAllocationService.detectConflicts(), + getProxyStatus: () => laneProxyService.getStatus(), + getProxyRoute: (laneId) => laneProxyService.getRoute(laneId), + }); const aiIntegrationService = createAiIntegrationService({ db, @@ -381,8 +524,30 @@ export async function createAdeRuntime(args: { projectConfigService, operationService, conflictPacksDir: path.join(paths.packsDir, "conflicts"), - onEvent: () => {} + onEvent: (event) => pushEvent("runtime", { type: "conflict_event", event }) + }); + conflictServiceRef = conflictService; + + const rebaseSuggestionService = createRebaseSuggestionService({ + db, + logger, + projectId, + projectRoot, + laneService, + onEvent: (event) => pushEvent("runtime", { type: "lane_rebase_suggestions_event", event }), + }); + rebaseSuggestionServiceRef = rebaseSuggestionService; + + const autoRebaseService = createAutoRebaseService({ + db, + logger, + laneService, + conflictService, + projectConfigService, + onEvent: (event) => pushEvent("runtime", { type: "lane_auto_rebase_event", event }), }); + autoRebaseServiceRef = autoRebaseService; + void autoRebaseService.emit().catch(() => {}); const gitService = createGitOperationsService({ laneService, @@ -397,7 +562,7 @@ export async function createAdeRuntime(args: { const missionService = createMissionService({ db, projectId, - onEvent: () => {} + onEvent: (event) => pushEvent("mission", event as unknown as Record) }); const ptyService = createPtyService({ @@ -406,8 +571,8 @@ export async function createAdeRuntime(args: { laneService, sessionService, logger, - broadcastData: () => {}, - broadcastExit: () => {}, + broadcastData: (event) => pushEvent("runtime", { type: "pty_data", event }), + broadcastExit: (event) => pushEvent("runtime", { type: "pty_exit", event }), onSessionEnded: () => {}, getAdeCliAgentEnv: createHeadlessAdeCliAgentEnv, loadPty: () => nodePty @@ -420,47 +585,23 @@ export async function createAdeRuntime(args: { logger, laneService, projectConfigService, - broadcastEvent: () => {} + broadcastEvent: (event) => pushEvent("runtime", event as unknown as Record) }); const issueInventoryService = createIssueInventoryService({ db }); const laneWorktreeLockService = createLaneWorktreeLockService({ db, logger }); - const eventBuffer = createEventBuffer(); - function pushEvent(category: BufferedEvent["category"], payload: Record): void { - eventBuffer.push({ timestamp: new Date().toISOString(), category, payload }); - } - - // Headless lane runtime env. Unlike the desktop path (which leases ports via - // portAllocationService and builds collision-safe hostnames via - // laneProxyService), headless has no persistent allocator wired in — so we - // derive ports and hostname suffix from a stable hash of the laneId. This is - // (a) independent of the lane's current list position (archival/reordering - // no longer shifts a lane's PORT) and (b) resistant to slug collisions - // between lanes whose display names slugify to the same string. - // Range matches desktop: basePort=3000, portsPerLane=100, maxPort=9999 → 70 slots. - const HEADLESS_BASE_PORT = 3000; - const HEADLESS_PORTS_PER_LANE = 100; - const HEADLESS_MAX_SLOTS = 70; + // Headless lane runtime env uses the same persistent allocator/proxy hostname + // services as desktop so a remote runtime presents the same PORT and preview + // surface to process definitions. const getHeadlessLaneRuntimeEnv = async (laneId: string): Promise> => { const lanes = await laneService.list({ includeArchived: false, includeStatus: false }); const lane = lanes.find((entry) => entry.id === laneId); - const laneHash = createHash("sha256").update(laneId).digest(); - const slotIndex = laneHash.readUInt32BE(0) % HEADLESS_MAX_SLOTS; - const portStart = HEADLESS_BASE_PORT + slotIndex * HEADLESS_PORTS_PER_LANE; - const portEnd = portStart + HEADLESS_PORTS_PER_LANE - 1; - const baseSlug = (lane?.name ?? lane?.branchRef ?? laneId) - .trim() - .toLowerCase() - .replace(/[^a-z0-9]+/g, "-") - .replace(/^-+|-+$/g, "") || "lane"; - // 6-char suffix from the laneId hash keeps hostnames readable while making - // two lanes with identical slugs resolve to distinct hostnames. - const idSuffix = laneHash.toString("hex").slice(0, 6); - const hostname = `${baseSlug}-${idSuffix}.localhost`; + const lease = portAllocationService.getLease(laneId) ?? portAllocationService.acquire(laneId); + const hostname = laneProxyService.generateHostname(laneId, lane?.name ?? lane?.branchRef); return { - PORT: String(portStart), - PORT_RANGE_START: String(portStart), - PORT_RANGE_END: String(portEnd), + PORT: String(lease.rangeStart), + PORT_RANGE_START: String(lease.rangeStart), + PORT_RANGE_END: String(lease.rangeEnd), HOSTNAME: hostname, PROXY_HOSTNAME: hostname, }; @@ -514,6 +655,15 @@ export async function createAdeRuntime(args: { projectId, adeDir: paths.adeDir, }); + const adeProjectService = createAdeProjectService({ + projectRoot, + db, + projectId, + logger, + projectConfigService, + ctoStateService, + workerAgentService, + }); const workerBudgetService = createWorkerBudgetService({ db, projectId, @@ -672,6 +822,19 @@ export async function createAdeRuntime(args: { orchestratorService, openExternal: async () => {}, }); + const linearOAuthService = createLinearOAuthService({ + credentials: headlessLinearServices.linearCredentialService as never, + logger, + }); + + const feedbackReporterService = createFeedbackReporterService({ + db, + logger, + projectRoot, + aiIntegrationService, + githubService: headlessLinearServices.githubService as never, + onSubmissionUpdated: (event) => pushEvent("runtime", { type: "feedback_submission_event", event }), + }); let automationServiceRef: ReturnType | null = null; let agentChatService = headlessLinearServices.agentChatService as unknown as ReturnType | null; @@ -725,16 +888,48 @@ export async function createAdeRuntime(args: { } } agentChatServiceHolder.current = agentChatService; - // The headless agent-chat stub returns less-typed payloads than the full - // agentChatService. Cast through the orchestrator's expected shape (rather - // than `never`) so that any future tightening of PathToMergeDeps surfaces - // as a type error here. + if (typeof (aiOrchestratorService as { setAgentChatService?: (svc: typeof agentChatService) => void }).setAgentChatService === "function") { + (aiOrchestratorService as { setAgentChatService: (svc: typeof agentChatService) => void }).setAgentChatService(agentChatService); + } + if (resolvedArgs.chatRuntime === "agent" && !agentChatService) { + throw new Error("Agent chat runtime was requested but the agent chat service was not initialized."); + } + if (resolvedArgs.chatRuntime === "agent" && agentChatService) { + setImmediate(() => { + try { + aiOrchestratorService.resumeActiveTeamRuntimes(); + } catch (error) { + logger.warn("bootstrap.resume_active_team_runtimes_failed", { + error: error instanceof Error ? error.message : String(error), + }); + } + }); + } + const reviewService = agentChatService + ? createReviewService({ + db, + logger, + projectId, + projectRoot, + projectDefaultBranch: baseRef, + laneService, + gitService, + agentChatService, + sessionService, + sessionDeltaService, + testService, + issueInventoryService, + prService: headlessLinearServices.prService, + embeddingService: null, + onEvent: (event) => pushEvent("runtime", { type: "review_event", event }), + }) + : null; type PathToMergeAgentChatService = Parameters[0]["agentChatService"]; const pathToMergeOrchestrator = createPathToMergeOrchestrator({ logger, prService: headlessLinearServices.prService, laneService, - agentChatService: headlessLinearServices.agentChatService as unknown as PathToMergeAgentChatService, + agentChatService: agentChatService as unknown as PathToMergeAgentChatService, sessionService, issueInventoryService, conflictService, @@ -757,6 +952,23 @@ export async function createAdeRuntime(args: { onEvent: (event) => pushEvent("runtime", { ...event, source: "automations" }), }); automationServiceRef = automationService; + const configReloadService = createConfigReloadService({ + paths: { + sharedPath: adeProjectService.paths.sharedConfigPath, + localPath: adeProjectService.paths.localConfigPath, + secretPath: adeProjectService.paths.secretConfigPath, + }, + projectConfigService, + adeProjectService, + automationService, + logger, + onEvent: (event) => pushEvent("runtime", { type: "project_state_event", event }), + }); + void configReloadService.start().catch((error) => { + logger.warn("project.config_reload_start_failed", { + error: error instanceof Error ? error.message : String(error), + }); + }); const automationPlannerService = createAutomationPlannerService({ logger, projectRoot, @@ -765,16 +977,89 @@ export async function createAdeRuntime(args: { automationService, }); + let syncService: ReturnType | null = null; + if (resolvedArgs.syncRuntime?.enabled && agentChatService) { + const { createSyncService } = await import("./services/sync/syncService"); + syncService = createSyncService({ + db, + logger, + projectId: resolvedArgs.syncRuntime.registryProjectId ?? projectId, + projectRoot, + appVersion: resolvedArgs.syncRuntime.appVersion ?? "ade-cli", + runtimeKind: resolvedArgs.syncRuntime.runtimeKind ?? "headless", + localDeviceIdPath: resolvedArgs.syncRuntime.localDeviceIdPath, + phonePairingStateDir: resolvedArgs.syncRuntime.phonePairingStateDir, + fileService: headlessLinearServices.fileService, + laneService, + gitService, + diffService, + conflictService, + prService: headlessLinearServices.prService, + issueInventoryService, + pathToMergeOrchestrator, + sessionService, + ptyService, + projectConfigService, + portAllocationService, + laneEnvironmentService, + laneTemplateService, + rebaseSuggestionService, + autoRebaseService, + computerUseArtifactBrokerService, + missionService, + agentChatService, + workerAgentService, + workerBudgetService, + workerHeartbeatService: headlessLinearServices.workerHeartbeatService, + ctoStateService, + flowPolicyService: headlessLinearServices.flowPolicyService, + getLinearIngressService: () => headlessLinearServices.linearIngressService, + getLinearIssueTracker: () => headlessLinearServices.linearIssueTracker, + getLinearSyncService: () => headlessLinearServices.linearSyncService, + processService, + hostStartupEnabled: resolvedArgs.syncRuntime.hostStartupEnabled ?? true, + hostDiscoveryEnabled: resolvedArgs.syncRuntime.hostDiscoveryEnabled ?? true, + forceHostRole: resolvedArgs.syncRuntime.forceHostRole ?? true, + projectCatalogProvider: resolvedArgs.syncRuntime.projectCatalogProvider, + remoteCommandExecutor: resolvedArgs.syncRuntime.remoteCommandExecutor, + onStatusChanged: (snapshot) => pushEvent("runtime", { type: "sync-status", snapshot }), + }); + } + + if (syncService) { + try { + await syncService.initialize(); + } catch (error) { + logger.warn("sync.runtime_initialize_failed", { + error: error instanceof Error ? error.message : String(error), + }); + } + } + const runtime: AdeRuntime = { projectRoot, workspaceRoot, projectId, + capabilities: { + memory: resolvedArgs.capabilities?.memory ?? true, + }, project, paths, logger, db, + keybindingsService, laneService, + laneEnvironmentService, + laneTemplateService, + portAllocationService, + laneProxyService, + oauthRedirectService, + runtimeDiagnosticsService, + rebaseSuggestionService, + autoRebaseService, sessionService, + sessionDeltaService, + onboardingService, operationService, projectConfigService, conflictService, @@ -782,9 +1067,12 @@ export async function createAdeRuntime(args: { diffService, missionService, missionBudgetService, + syncService, + syncHostService: syncService?.getHostService() ?? null, laneWorktreeLockService, ptyService, testService, + reviewService, aiIntegrationService, agentChatService, issueInventoryService, @@ -792,11 +1080,13 @@ export async function createAdeRuntime(args: { memoryService, ctoStateService, workerAgentService, + adeProjectService, workerBudgetService, githubService: headlessLinearServices.githubService as never, workerTaskSessionService: headlessLinearServices.workerTaskSessionService, workerHeartbeatService: headlessLinearServices.workerHeartbeatService, linearCredentialService: headlessLinearServices.linearCredentialService as never, + linearOAuthService, prService: headlessLinearServices.prService, fileService: headlessLinearServices.fileService, flowPolicyService: headlessLinearServices.flowPolicyService, @@ -806,6 +1096,7 @@ export async function createAdeRuntime(args: { linearIngressService: headlessLinearServices.linearIngressService, linearRoutingService: headlessLinearServices.linearRoutingService, processService, + feedbackReporterService, automationService, automationPlannerService, computerUseArtifactBrokerService, @@ -817,12 +1108,19 @@ export async function createAdeRuntime(args: { eventBuffer, dispose: () => { const swallow = (fn: () => void) => { try { fn(); } catch { /* ignore */ } }; + void configReloadService.dispose().catch(() => {}); swallow(() => automationService.dispose()); + swallow(() => syncService?.dispose()); swallow(() => pathToMergeOrchestrator.dispose()); swallow(() => processService.disposeAll()); + swallow(() => runtimeDiagnosticsService.dispose()); + swallow(() => oauthRedirectService.dispose()); + void laneProxyService.dispose().catch(() => {}); + swallow(() => portAllocationService.dispose()); swallow(() => iosSimulatorService?.dispose()); swallow(() => appControlService?.dispose()); swallow(() => macosVmService?.dispose()); + swallow(() => linearOAuthService.dispose()); swallow(() => headlessLinearServices.dispose()); swallow(() => aiOrchestratorService.dispose()); swallow(() => testService.disposeAll()); diff --git a/apps/ade-cli/src/cli.test.ts b/apps/ade-cli/src/cli.test.ts index aa10a397c..3f77c827e 100644 --- a/apps/ade-cli/src/cli.test.ts +++ b/apps/ade-cli/src/cli.test.ts @@ -8,9 +8,11 @@ import { findProjectRoots, formatOutput, graphWaitState, + isFailedServiceManagerResult, parseCliArgs, renderLaneGraph, resolveRoots, + shouldAutoRegisterProjectForPlan, shouldAttemptDesktopSocketConnection, summarizeExecution, unwrapToolResult, @@ -18,7 +20,10 @@ import { type ResolveRootsOptions = Parameters[0]; -function baseResolveOpts(): Omit { +function baseResolveOpts(): Omit< + ResolveRootsOptions, + "projectRoot" | "workspaceRoot" +> { return { role: "external", headless: true, @@ -45,11 +50,22 @@ describe("ADE CLI", () => { expect(parsed.options.projectRoot).toBe("/tmp/project"); expect(parsed.options.role).toBe("cto"); - expect(parsed.command).toEqual(["actions", "run", "git.stageFile", "--arg", "laneId=lane-1"]); + expect(parsed.command).toEqual([ + "actions", + "run", + "git.stageFile", + "--arg", + "laneId=lane-1", + ]); }); it("maps ade code to the terminal Work chat launcher", () => { - const parsed = parseCliArgs(["--project-root", "/tmp/project", "code", "--print-state"]); + const parsed = parseCliArgs([ + "--project-root", + "/tmp/project", + "code", + "--print-state", + ]); expect(parsed.options.projectRoot).toBe("/tmp/project"); expect(parsed.command).toEqual(["code", "--print-state"]); @@ -57,31 +73,238 @@ describe("ADE CLI", () => { expect(plan).toEqual({ kind: "ade-code", rest: ["--print-state"] }); }); - it("forwards resolved roots and socket intent to ade code", () => { - const args = buildAdeCodeArgs(["--print-state"], { - ...baseResolveOpts(), - projectRoot: "/tmp/project", - workspaceRoot: null, - headless: false, - requireSocket: true, + it("shows help for bare ade invocations", () => { + expect(buildCliPlan([])).toEqual({ + kind: "help", + text: expect.stringContaining( + "Agent-focused command-line interface for ADE", + ), }); + }); - expect(args).toEqual([ - "--project-root", - "/tmp/project", - "--workspace-root", - "/tmp/project", - "--socket", - "/tmp/project/.ade/ade.sock", - "--require-socket", - "--print-state", + it("keeps global help on the help surface", () => { + const plan = buildCliPlan(["--help"]); + expect(plan.kind).toBe("help"); + }); + + it("keeps global version on the version surface", () => { + expect(buildCliPlan(["--version"])).toEqual({ + kind: "help", + text: "ade 0.0.0\n", + }); + expect(buildCliPlan(["-v"])).toEqual({ kind: "help", text: "ade 0.0.0\n" }); + }); + + it("builds runtime daemon and stdio RPC commands", () => { + expect(buildCliPlan(["runtime", "status"])).toEqual({ + kind: "runtime", + rest: ["status"], + }); + expect( + buildCliPlan(["runtime", "start", "--socket", "/tmp/ade.sock"]), + ).toEqual({ + kind: "runtime", + rest: ["start", "--socket", "/tmp/ade.sock"], + }); + expect(buildCliPlan(["desktop"])).toEqual({ + kind: "desktop", + rest: [], + }); + expect( + buildCliPlan(["serve", "--socket", "/tmp/ade.sock", "--port", "7777"]), + ).toEqual({ + kind: "serve", + rest: ["--socket", "/tmp/ade.sock", "--port", "7777"], + }); + expect(buildCliPlan(["serve", "--service-status"])).toEqual({ + kind: "serve", + rest: ["--service-status"], + }); + expect(buildCliPlan(["rpc", "--stdio"])).toEqual({ + kind: "rpc-stdio", + rest: [], + }); + expect(buildCliPlan(["rpc", "stdio", "--trace"])).toEqual({ + kind: "rpc-stdio", + rest: ["--trace"], + }); + }); + + it("marks failed service manager results as CLI failures", () => { + expect( + isFailedServiceManagerResult({ + ok: false, + serviceName: "com.ade.runtime", + action: "install", + path: "/tmp/com.ade.runtime.plist", + message: "launchctl failed", + }), + ).toBe(true); + expect( + isFailedServiceManagerResult({ + ok: true, + serviceName: "com.ade.runtime", + action: "install", + path: "/tmp/com.ade.runtime.plist", + message: "installed", + }), + ).toBe(false); + }); + + it("builds project init command", () => { + expect(buildCliPlan(["init", "/tmp/project"])).toEqual({ + kind: "init", + targetPath: "/tmp/project", + }); + expect(buildCliPlan(["init"])).toEqual({ + kind: "init", + targetPath: null, + }); + }); + + it("builds machine project registry commands", () => { + expect(buildCliPlan(["projects", "list"])).toEqual({ + kind: "execute", + label: "projects list", + formatter: "projects-list", + steps: [{ key: "result", method: "projects.list" }], + }); + expect(buildCliPlan(["project", "add", "/tmp/project"])).toEqual({ + kind: "execute", + label: "projects add", + formatter: "projects-list", + steps: [ + { + key: "result", + method: "projects.add", + params: { rootPath: "/tmp/project" }, + }, + ], + }); + expect(buildCliPlan(["projects", "remove", "project_abc"])).toEqual({ + kind: "execute", + label: "projects remove", + steps: [ + { + key: "result", + method: "projects.remove", + params: { projectId: "project_abc" }, + }, + ], + }); + expect( + buildCliPlan(["projects", "touch", "--project-id", "project_abc"]), + ).toEqual({ + kind: "execute", + label: "projects touch", + formatter: "projects-list", + steps: [ + { + key: "result", + method: "projects.touch", + params: { projectId: "project_abc" }, + }, + ], + }); + }); + + it("does not auto-register cwd for machine-scoped registry commands", () => { + const projects = buildCliPlan(["projects", "list"]); + expect(projects.kind).toBe("execute"); + if (projects.kind !== "execute") return; + expect(shouldAutoRegisterProjectForPlan(projects)).toBe(false); + + const lanes = buildCliPlan(["lanes", "list"]); + expect(lanes.kind).toBe("execute"); + if (lanes.kind !== "execute") return; + expect(shouldAutoRegisterProjectForPlan(lanes)).toBe(true); + }); + + it("builds sync status and pairing PIN commands", () => { + const status = buildCliPlan([ + "sync", + "status", + "--include-transfer-readiness", ]); + expect(status.kind).toBe("execute"); + if (status.kind !== "execute") return; + expect(status.steps).toEqual([ + { + key: "result", + method: "sync.getStatus", + params: { + includeTransferReadiness: true, + forceTransferReadiness: false, + }, + }, + ]); + + const setPin = buildCliPlan(["sync", "pin", "set", "123456"]); + expect(setPin.kind).toBe("execute"); + if (setPin.kind !== "execute") return; + expect(setPin.steps).toEqual([ + { + key: "result", + method: "sync.setPin", + params: { pin: "123456" }, + }, + ]); + + const generatePin = buildCliPlan(["sync", "pin", "generate"]); + expect(generatePin.kind).toBe("execute"); + if (generatePin.kind !== "execute") return; + expect(generatePin.steps).toEqual([ + { + key: "result", + method: "sync.generatePin", + }, + ]); + }); + + it("forwards resolved roots and socket intent to ade code", () => { + const previous = process.env.ADE_RUNTIME_SOCKET_PATH; + process.env.ADE_RUNTIME_SOCKET_PATH = "/tmp/ade-runtime.sock"; + try { + const args = buildAdeCodeArgs(["--print-state"], { + ...baseResolveOpts(), + projectRoot: "/tmp/project", + workspaceRoot: null, + headless: false, + requireSocket: true, + }); + + expect(args).toEqual([ + "--project-root", + "/tmp/project", + "--workspace-root", + "/tmp/project", + "--socket", + "/tmp/ade-runtime.sock", + "--require-socket", + "--print-state", + ]); + } finally { + if (previous === undefined) delete process.env.ADE_RUNTIME_SOCKET_PATH; + else process.env.ADE_RUNTIME_SOCKET_PATH = previous; + } }); it("preserves command-local value flags that overlap global flags", () => { - const parsed = parseCliArgs(["files", "write", "src/index.ts", "--text", "hello"]); + const parsed = parseCliArgs([ + "files", + "write", + "src/index.ts", + "--text", + "hello", + ]); expect(parsed.options.text).toBe(false); - expect(parsed.command).toEqual(["files", "write", "src/index.ts", "--text", "hello"]); + expect(parsed.command).toEqual([ + "files", + "write", + "src/index.ts", + "--text", + "hello", + ]); const plan = buildCliPlan(parsed.command); expect(plan.kind).toBe("execute"); @@ -99,13 +322,27 @@ describe("ADE CLI", () => { }, }); - const typed = parseCliArgs(["ios-sim", "type", "--value", "hello", "--text"]); + const typed = parseCliArgs([ + "ios-sim", + "type", + "--value", + "hello", + "--text", + ]); expect(typed.options.text).toBe(true); expect(typed.command).toEqual(["ios-sim", "type", "--value", "hello"]); }); it("builds a generic ADE action invocation", () => { - const plan = buildCliPlan(["actions", "run", "git.stageFile", "--arg", "laneId=lane-1", "--arg", "path=src/index.ts"]); + const plan = buildCliPlan([ + "actions", + "run", + "git.stageFile", + "--arg", + "laneId=lane-1", + "--arg", + "path=src/index.ts", + ]); expect(plan.kind).toBe("execute"); if (plan.kind !== "execute") return; @@ -130,7 +367,15 @@ describe("ADE CLI", () => { }); it("builds a diff patch invocation with an explicit path flag", () => { - const parsed = parseCliArgs(["diff", "patch", "--lane", "main", "--path", "file.txt", "--text"]); + const parsed = parseCliArgs([ + "diff", + "patch", + "--lane", + "main", + "--path", + "file.txt", + "--text", + ]); expect(parsed.options.text).toBe(true); const plan = buildCliPlan(parsed.command); @@ -177,7 +422,7 @@ describe("ADE CLI", () => { "--arg", "filters.clean=false", "--arg-json", - "metadata.tags=[\"review\"]", + 'metadata.tags=["review"]', ]); expect(plan.kind).toBe("execute"); if (plan.kind !== "execute") return; @@ -205,7 +450,7 @@ describe("ADE CLI", () => { "run", "git.push", "--input-json", - "{\"laneId\":\"lane-1\",\"setUpstream\":true}", + '{"laneId":"lane-1","setUpstream":true}', ]); expect(objectCall.kind).toBe("execute"); if (objectCall.kind !== "execute") return; @@ -226,7 +471,7 @@ describe("ADE CLI", () => { "run", "issue_inventory.savePipelineSettings", "--args-list-json", - "[\"pr-1\",{\"maxRounds\":3}]", + '["pr-1",{"maxRounds":3}]', ]); expect(argsListCall.kind).toBe("execute"); if (argsListCall.kind !== "execute") return; @@ -239,7 +484,13 @@ describe("ADE CLI", () => { }, }); - const scalarCall = buildCliPlan(["actions", "run", "mission.get", "--scalar", "mission-1"]); + const scalarCall = buildCliPlan([ + "actions", + "run", + "mission.get", + "--scalar", + "mission-1", + ]); expect(scalarCall.kind).toBe("execute"); if (scalarCall.kind !== "execute") return; expect(scalarCall.steps[0]?.params).toEqual({ @@ -253,11 +504,27 @@ describe("ADE CLI", () => { }); it("builds typed mission create with custom phase and planned-step payload files", () => { - const root = fs.mkdtempSync(path.join(os.tmpdir(), "ade-cli-mission-plan-")); + const root = fs.mkdtempSync( + path.join(os.tmpdir(), "ade-cli-mission-plan-"), + ); const phasesPath = path.join(root, "phases.json"); const stepsPath = path.join(root, "steps.json"); - fs.writeFileSync(phasesPath, JSON.stringify([{ phaseKey: "planning", name: "Planning", position: 0 }])); - fs.writeFileSync(stepsPath, JSON.stringify([{ index: 0, title: "Plan", detail: "Plan it", kind: "planning", metadata: {} }])); + fs.writeFileSync( + phasesPath, + JSON.stringify([{ phaseKey: "planning", name: "Planning", position: 0 }]), + ); + fs.writeFileSync( + stepsPath, + JSON.stringify([ + { + index: 0, + title: "Plan", + detail: "Plan it", + kind: "planning", + metadata: {}, + }, + ]), + ); const plan = buildCliPlan([ "missions", @@ -282,8 +549,18 @@ describe("ADE CLI", () => { args: expect.objectContaining({ prompt: "Try the mission backend", launchMode: "manual", - phaseOverride: [{ phaseKey: "planning", name: "Planning", position: 0 }], - plannedSteps: [{ index: 0, title: "Plan", detail: "Plan it", kind: "planning", metadata: {} }], + phaseOverride: [ + { phaseKey: "planning", name: "Planning", position: 0 }, + ], + plannedSteps: [ + { + index: 0, + title: "Plan", + detail: "Plan it", + kind: "planning", + metadata: {}, + }, + ], }), }, }); @@ -292,14 +569,16 @@ describe("ADE CLI", () => { it("reports unreadable JSON payload files as CLI usage errors", () => { const missingPath = path.join(os.tmpdir(), "ade-cli-missing-phases.json"); - expect(() => buildCliPlan([ - "missions", - "create", - "--prompt", - "Try the mission backend", - "--phase-override-file", - missingPath, - ])).toThrow(/Could not read --phase-override-file file/); + expect(() => + buildCliPlan([ + "missions", + "create", + "--prompt", + "Try the mission backend", + "--phase-override-file", + missingPath, + ]), + ).toThrow(/Could not read --phase-override-file file/); }); it("builds typed mission launch with a dependent start step", () => { @@ -330,15 +609,16 @@ describe("ADE CLI", () => { }, }); expect(typeof plan.steps[1]?.params).toBe("function"); - const params = typeof plan.steps[1]?.params === "function" - ? plan.steps[1].params({ - created: { - domain: "mission", - action: "create", - result: { id: "mission-1" }, - }, - }) - : null; + const params = + typeof plan.steps[1]?.params === "function" + ? plan.steps[1].params({ + created: { + domain: "mission", + action: "create", + result: { id: "mission-1" }, + }, + }) + : null; expect(params).toEqual({ name: "run_ade_action", arguments: { @@ -368,15 +648,16 @@ describe("ADE CLI", () => { if (plan.kind !== "execute") return; expect(plan.steps).toHaveLength(4); expect(typeof plan.steps[2]?.params).toBe("function"); - const missionParams = typeof plan.steps[2]?.params === "function" - ? plan.steps[2].params({ - created: { - domain: "mission", - action: "create", - result: { id: "mission-1" }, - }, - }) - : null; + const missionParams = + typeof plan.steps[2]?.params === "function" + ? plan.steps[2].params({ + created: { + domain: "mission", + action: "create", + result: { id: "mission-1" }, + }, + }) + : null; expect(missionParams).toEqual({ name: "run_ade_action", arguments: { @@ -390,18 +671,19 @@ describe("ADE CLI", () => { method: "ade-cli/wait-run-graph", }); expect(typeof plan.steps[3]?.params).toBe("function"); - const graphParams = typeof plan.steps[3]?.params === "function" - ? plan.steps[3].params({ - started: { - domain: "orchestrator", - action: "startMissionRun", - result: { - started: { run: { id: "run-1" } }, - mission: { id: "mission-1" }, + const graphParams = + typeof plan.steps[3]?.params === "function" + ? plan.steps[3].params({ + started: { + domain: "orchestrator", + action: "startMissionRun", + result: { + started: { run: { id: "run-1" } }, + mission: { id: "mission-1" }, + }, }, - }, - }) - : null; + }) + : null; expect(graphParams).toEqual({ runId: "run-1", waitMs: 5000, @@ -458,9 +740,10 @@ describe("ADE CLI", () => { method: "ade-cli/wait-run-graph", }); expect(typeof plan.steps[1]?.params).toBe("function"); - const graphParams = typeof plan.steps[1]?.params === "function" - ? plan.steps[1].params({}) - : null; + const graphParams = + typeof plan.steps[1]?.params === "function" + ? plan.steps[1].params({}) + : null; expect(graphParams).toEqual({ runId: "run-1", waitMs: 5000, @@ -487,7 +770,13 @@ describe("ADE CLI", () => { }); it("builds mission cancel with a run id for graceful cancellation", () => { - const plan = buildCliPlan(["missions", "cancel", "run-1", "--reason", "superseded"]); + const plan = buildCliPlan([ + "missions", + "cancel", + "run-1", + "--reason", + "superseded", + ]); expect(plan.kind).toBe("execute"); if (plan.kind !== "execute") return; @@ -539,12 +828,18 @@ describe("ADE CLI", () => { }); it("rejects invalid JSON action shapes before execution", () => { - expect(() => buildCliPlan(["actions", "run", "git.push", "--input-json", "[1,2]"])).toThrow( - /--input-json must be a JSON object/, - ); - expect(() => buildCliPlan(["actions", "run", "git.push", "--args-list-json", "{\"laneId\":\"lane-1\"}"])).toThrow( - /--args-list-json must be a JSON array/, - ); + expect(() => + buildCliPlan(["actions", "run", "git.push", "--input-json", "[1,2]"]), + ).toThrow(/--input-json must be a JSON object/); + expect(() => + buildCliPlan([ + "actions", + "run", + "git.push", + "--args-list-json", + '{"laneId":"lane-1"}', + ]), + ).toThrow(/--args-list-json must be a JSON array/); }); it("builds chat create with both model and modelId plus generic args", () => { @@ -616,17 +911,33 @@ describe("ADE CLI", () => { }); it("requires a chat session id for chat show", () => { - expect(() => buildCliPlan(["chat", "show"])).toThrow(/sessionId is required/); + expect(() => buildCliPlan(["chat", "show"])).toThrow( + /sessionId is required/, + ); }); it("rejects prototype-sensitive generic ADE action arg paths", () => { expect(({} as Record).polluted).toBeUndefined(); - for (const arg of ["__proto__.polluted=true", "safe.__proto__.polluted=true", "constructor.prototype.polluted=true"]) { - expect(() => buildCliPlan(["actions", "run", "git.status", "--arg", arg])).toThrow(/not allowed/); + for (const arg of [ + "__proto__.polluted=true", + "safe.__proto__.polluted=true", + "constructor.prototype.polluted=true", + ]) { + expect(() => + buildCliPlan(["actions", "run", "git.status", "--arg", arg]), + ).toThrow(/not allowed/); } - expect(() => buildCliPlan(["actions", "run", "git.status", "--arg-json", "prototype.polluted=true"])).toThrow(/not allowed/); + expect(() => + buildCliPlan([ + "actions", + "run", + "git.status", + "--arg-json", + "prototype.polluted=true", + ]), + ).toThrow(/not allowed/); expect(({} as Record).polluted).toBeUndefined(); }); @@ -772,13 +1083,27 @@ describe("ADE CLI", () => { it("validates required arguments before service execution", () => { expect(() => buildCliPlan(["lanes", "create"])).toThrow(/name is required/); - expect(() => buildCliPlan(["lanes", "child", "--name", "child"])).toThrow(/parent lane is required/); - expect(() => buildCliPlan(["diff", "file", "--lane", "main"])).toThrow(/path is required/); - expect(() => buildCliPlan(["diff", "patch", "--lane", "main"])).toThrow(/path is required/); - expect(() => buildCliPlan(["files", "write", "src/index.ts"])).toThrow(/--text, --from-file, or --stdin/); - expect(() => buildCliPlan(["chat", "send", "hello"])).toThrow(/message text is required/); - expect(() => buildCliPlan(["agent", "spawn", "--prompt", "fix it"])).toThrow(/laneId is required/); - expect(() => buildCliPlan(["tests", "run", "--lane", "main"])).toThrow(/--suite or --command/); + expect(() => buildCliPlan(["lanes", "child", "--name", "child"])).toThrow( + /parent lane is required/, + ); + expect(() => buildCliPlan(["diff", "file", "--lane", "main"])).toThrow( + /path is required/, + ); + expect(() => buildCliPlan(["diff", "patch", "--lane", "main"])).toThrow( + /path is required/, + ); + expect(() => buildCliPlan(["files", "write", "src/index.ts"])).toThrow( + /--text, --from-file, or --stdin/, + ); + expect(() => buildCliPlan(["chat", "send", "hello"])).toThrow( + /message text is required/, + ); + expect(() => + buildCliPlan(["agent", "spawn", "--prompt", "fix it"]), + ).toThrow(/laneId is required/); + expect(() => buildCliPlan(["tests", "run", "--lane", "main"])).toThrow( + /--suite or --command/, + ); }); it("unwraps typed ADE action results while preserving actions run envelopes", () => { @@ -817,7 +1142,11 @@ describe("ADE CLI", () => { }, }, } as any); - expect(escapeHatch).toMatchObject({ domain: "git", action: "getStatus", result: { clean: true } }); + expect(escapeHatch).toMatchObject({ + domain: "git", + action: "getStatus", + result: { clean: true }, + }); }); it("summarizes mission launch from post-wait mission and graph snapshots", () => { @@ -850,14 +1179,22 @@ describe("ADE CLI", () => { mission: { domain: "mission", action: "get", - result: { id: "mission-1", status: "intervention_required", lastError: "Model not found" }, + result: { + id: "mission-1", + status: "intervention_required", + lastError: "Model not found", + }, }, graph: { domain: "orchestrator_core", action: "getRunGraph", result: { graph: { - run: { id: "run-1", status: "paused", lastError: "Model not found" }, + run: { + id: "run-1", + status: "paused", + lastError: "Model not found", + }, steps: [], }, }, @@ -866,49 +1203,70 @@ describe("ADE CLI", () => { } as any); expect(summarized).toMatchObject({ - mission: { id: "mission-1", status: "intervention_required", lastError: "Model not found" }, + mission: { + id: "mission-1", + status: "intervention_required", + lastError: "Model not found", + }, run: { id: "run-1", status: "paused", lastError: "Model not found" }, }); }); it("turns ADE action failure envelopes into CLI tool errors", () => { - expect(() => unwrapToolResult({ - ok: false, - error: { - code: -32011, - message: "Action 'git.nonexistent_action' is not callable.", - }, - })).toThrow(/not callable/); + expect(() => + unwrapToolResult({ + ok: false, + error: { + code: -32011, + message: "Action 'git.nonexistent_action' is not callable.", + }, + }), + ).toThrow(/not callable/); }); it("renders richer doctor text", () => { - const output = formatOutput({ - ok: true, - cliVersion: "0.0.0", - mode: "headless", - projectRoot: "/tmp/project", - workspaceRoot: "/tmp/project", - project: { projectInitialized: true }, - desktop: { socketAvailable: false, socketPath: "/tmp/project/.ade/ade.sock" }, - actions: { rpcActionCount: 10, actionCount: 42 }, - git: { message: "Git repository detected on main." }, - github: { message: "GitHub remote detected and a local auth mechanism is available." }, - linear: { message: "Linear credentials are present locally." }, - providers: { message: "AI provider configuration or provider CLI availability was detected locally." }, - computerUse: { message: "Local macOS computer-use fallback commands are available." }, - path: { message: "ade is available on PATH." }, - recommendation: "Using live ADE desktop state.", - recommendations: [], - }, { - projectRoot: null, - workspaceRoot: null, - role: "agent", - headless: false, - requireSocket: false, - pretty: true, - text: true, - timeoutMs: 1000, - }, "doctor"); + const output = formatOutput( + { + ok: true, + cliVersion: "0.0.0", + mode: "headless", + projectRoot: "/tmp/project", + workspaceRoot: "/tmp/project", + project: { projectInitialized: true }, + desktop: { + socketAvailable: false, + socketPath: "/tmp/project/.ade/ade.sock", + }, + actions: { rpcActionCount: 10, actionCount: 42 }, + git: { message: "Git repository detected on main." }, + github: { + message: + "GitHub remote detected and a local auth mechanism is available.", + }, + linear: { message: "Linear credentials are present locally." }, + providers: { + message: + "AI provider configuration or provider CLI availability was detected locally.", + }, + computerUse: { + message: "Local macOS computer-use fallback commands are available.", + }, + path: { message: "ade is available on PATH." }, + recommendation: "Using live ADE desktop state.", + recommendations: [], + }, + { + projectRoot: null, + workspaceRoot: null, + role: "agent", + headless: false, + requireSocket: false, + pretty: true, + text: true, + timeoutMs: 1000, + }, + "doctor", + ); expect(output).toContain("ADE doctor"); expect(output).toContain("cli version"); @@ -917,7 +1275,9 @@ describe("ADE CLI", () => { }); it("attempts Windows named-pipe desktop sockets without filesystem existence checks", () => { - expect(shouldAttemptDesktopSocketConnection("\\\\.\\pipe\\ade-123")).toBe(true); + expect(shouldAttemptDesktopSocketConnection("\\\\.\\pipe\\ade-123")).toBe( + true, + ); expect(shouldAttemptDesktopSocketConnection("//./pipe/ade-123")).toBe(true); }); @@ -925,8 +1285,18 @@ describe("ADE CLI", () => { const graph = renderLaneGraph({ lanes: [ { id: "main", name: "main", branchRef: "main" }, - { id: "child", name: "child", branchRef: "feature", parentLaneId: "main" }, - { id: "sibling", name: "sibling", branchRef: "feature-2", parentLaneId: "main" }, + { + id: "child", + name: "child", + branchRef: "feature", + parentLaneId: "main", + }, + { + id: "sibling", + name: "sibling", + branchRef: "feature-2", + parentLaneId: "main", + }, ], }); @@ -937,8 +1307,20 @@ describe("ADE CLI", () => { }); it("accepts --option=value syntax equivalently to --option value", () => { - const spaced = parseCliArgs(["--project-root", "/tmp/project", "--role", "cto", "lanes", "list"]); - const joined = parseCliArgs(["--project-root=/tmp/project", "--role=cto", "lanes", "list"]); + const spaced = parseCliArgs([ + "--project-root", + "/tmp/project", + "--role", + "cto", + "lanes", + "list", + ]); + const joined = parseCliArgs([ + "--project-root=/tmp/project", + "--role=cto", + "lanes", + "list", + ]); expect(joined.options.projectRoot).toBe(spaced.options.projectRoot); expect(joined.options.role).toBe("cto"); expect(joined.command).toEqual(["lanes", "list"]); @@ -946,7 +1328,16 @@ describe("ADE CLI", () => { it("prefers headless mode for local proof capture commands", () => { const screenshot = buildCliPlan(["proof", "screenshot"]); - const capture = buildCliPlan(["proof", "capture", "--caption", "Done", "--owner-kind", "chat", "--owner-id", "chat-1"]); + const capture = buildCliPlan([ + "proof", + "capture", + "--caption", + "Done", + "--owner-kind", + "chat", + "--owner-id", + "chat-1", + ]); const record = buildCliPlan(["proof", "record", "--seconds", "3"]); const list = buildCliPlan(["proof", "list"]); @@ -954,7 +1345,13 @@ describe("ADE CLI", () => { expect(capture.kind).toBe("execute"); expect(record.kind).toBe("execute"); expect(list.kind).toBe("execute"); - if (screenshot.kind !== "execute" || capture.kind !== "execute" || record.kind !== "execute" || list.kind !== "execute") return; + if ( + screenshot.kind !== "execute" || + capture.kind !== "execute" || + record.kind !== "execute" || + list.kind !== "execute" + ) + return; expect(screenshot.preferHeadless).toBe(true); expect(capture.preferHeadless).toBe(true); @@ -971,7 +1368,17 @@ describe("ADE CLI", () => { }); it("maps proof attach to visual artifact ingestion", () => { - const plan = buildCliPlan(["proof", "attach", "/tmp/done.png", "--caption", "Checkout complete", "--owner-kind", "chat", "--owner-id", "chat-1"]); + const plan = buildCliPlan([ + "proof", + "attach", + "/tmp/done.png", + "--caption", + "Checkout complete", + "--owner-kind", + "chat", + "--owner-id", + "chat-1", + ]); expect(plan.kind).toBe("execute"); if (plan.kind !== "execute") return; @@ -983,12 +1390,14 @@ describe("ADE CLI", () => { toolName: "proof attach", ownerKind: "chat", ownerId: "chat-1", - inputs: [{ - kind: "screenshot", - title: "Checkout complete", - description: "Checkout complete", - path: "/tmp/done.png", - }], + inputs: [ + { + kind: "screenshot", + title: "Checkout complete", + description: "Checkout complete", + path: "/tmp/done.png", + }, + ], }, }); }); @@ -1044,6 +1453,145 @@ describe("ADE CLI", () => { }); }); + it("maps discoverable git status, sync, and conflict helpers to existing actions", () => { + const fullStatus = buildCliPlan([ + "git", + "status", + "--full", + "--lane", + "lane-1", + ]); + expect(fullStatus.kind).toBe("execute"); + if (fullStatus.kind !== "execute") return; + expect(fullStatus.label).toBe("lane status"); + expect(fullStatus.steps[0]?.params).toEqual({ + name: "get_lane_status", + arguments: { laneId: "lane-1" }, + }); + + const sync = buildCliPlan([ + "git", + "sync", + "--lane", + "lane-1", + "--rebase", + "--base", + "main", + ]); + expect(sync.kind).toBe("execute"); + if (sync.kind !== "execute") return; + expect(sync.steps[0]?.params).toEqual({ + name: "run_ade_action", + arguments: { + domain: "git", + action: "sync", + args: { laneId: "lane-1", mode: "rebase", baseRef: "main" }, + }, + }); + + const conflictShow = buildCliPlan([ + "git", + "conflict", + "show", + "--lane", + "lane-1", + ]); + expect(conflictShow.kind).toBe("execute"); + if (conflictShow.kind !== "execute") return; + expect(conflictShow.steps[0]?.params).toEqual({ + name: "get_lane_conflict_state", + arguments: { laneId: "lane-1" }, + }); + + const conflictResolve = buildCliPlan([ + "git", + "conflict", + "resolve", + "--lane", + "lane-1", + "--kind", + "rebase", + ]); + expect(conflictResolve.kind).toBe("execute"); + if (conflictResolve.kind !== "execute") return; + expect(conflictResolve.steps[0]?.params).toEqual({ + name: "rebase_continue", + arguments: { laneId: "lane-1" }, + }); + + const push = buildCliPlan([ + "git", + "push", + "--lane", + "lane-1", + "--set-upstream", + "--force-with-lease", + ]); + expect(push.kind).toBe("execute"); + if (push.kind !== "execute") return; + expect(push.steps[0]?.params).toEqual({ + name: "git_push", + arguments: { laneId: "lane-1", forceWithLease: true, setUpstream: true }, + }); + }); + + it("preserves the public git push --set-upstream flag", () => { + const plan = buildCliPlan([ + "git", + "push", + "--lane", + "lane-1", + "--set-upstream", + "--force-with-lease", + ]); + expect(plan.kind).toBe("execute"); + if (plan.kind !== "execute") return; + expect(plan.steps[0]?.params).toEqual({ + name: "git_push", + arguments: { + laneId: "lane-1", + forceWithLease: true, + setUpstream: true, + }, + }); + }); + + it("maps action and operation wait aliases to the ADE status poller", () => { + const actionWait = buildCliPlan([ + "actions", + "wait", + "--operation", + "op-1", + "--previous-hash", + "abc", + ]); + expect(actionWait.kind).toBe("execute"); + if (actionWait.kind !== "execute") return; + expect(actionWait.steps[0]?.params).toEqual({ + name: "get_ade_action_status", + arguments: { + operationId: "op-1", + previousHash: "abc", + waitForMs: 30_000, + }, + }); + + const operationStatus = buildCliPlan([ + "operations", + "status", + "--test-run", + "test-1", + "--wait-ms", + "5000", + ]); + expect(operationStatus.kind).toBe("execute"); + if (operationStatus.kind !== "execute") return; + expect(operationStatus.steps[0]?.params).toEqual({ + name: "get_ade_action_status", + arguments: { testRunId: "test-1", waitForMs: 5000 }, + }); + }); + it("uses the parent ADE project when invoked inside an ADE-managed lane worktree", () => { const rawRoot = fs.mkdtempSync(path.join(os.tmpdir(), "ade-cli-roots-")); // findProjectRoots canonicalizes symlinks (e.g. /var -> /private/var on macOS). @@ -1124,7 +1672,14 @@ describe("ADE CLI", () => { }); it("maps PR link arguments to the service contract", () => { - const plan = buildCliPlan(["prs", "link", "--lane", "lane-1", "--url", "https://github.com/acme/ade/pull/123"]); + const plan = buildCliPlan([ + "prs", + "link", + "--lane", + "lane-1", + "--url", + "https://github.com/acme/ade/pull/123", + ]); expect(plan.kind).toBe("execute"); if (plan.kind !== "execute") return; @@ -1142,7 +1697,13 @@ describe("ADE CLI", () => { }); it("maps `git checkout ` to git_checkout_branch with mode=existing by default", () => { - const plan = buildCliPlan(["git", "checkout", "feature/foo", "--lane", "lane-1"]); + const plan = buildCliPlan([ + "git", + "checkout", + "feature/foo", + "--lane", + "lane-1", + ]); expect(plan.kind).toBe("execute"); if (plan.kind !== "execute") return; @@ -1159,12 +1720,16 @@ describe("ADE CLI", () => { it("maps `git checkout --create` to mode=create with optional --from/--base", () => { const plan = buildCliPlan([ - "git", "checkout", + "git", + "checkout", "feature/new", - "--lane", "lane-1", + "--lane", + "lane-1", "--create", - "--from", "main", - "--base", "main", + "--from", + "main", + "--base", + "main", "--ack-active-work", ]); expect(plan.kind).toBe("execute"); @@ -1184,26 +1749,44 @@ describe("ADE CLI", () => { }); it("accepts the `-b` short flag as an alias for --create", () => { - const plan = buildCliPlan(["git", "checkout", "topic-1", "--lane", "lane-1", "-b"]); + const plan = buildCliPlan([ + "git", + "checkout", + "topic-1", + "--lane", + "lane-1", + "-b", + ]); expect(plan.kind).toBe("execute"); if (plan.kind !== "execute") return; - const args = (plan.steps[0]?.params as { arguments: Record }).arguments; + const args = ( + plan.steps[0]?.params as { arguments: Record } + ).arguments; expect(args.mode).toBe("create"); expect(args.branchName).toBe("topic-1"); }); it("omits startPoint and baseRef from the call when not supplied", () => { - const plan = buildCliPlan(["git", "checkout", "feature/x", "--lane", "lane-1"]); + const plan = buildCliPlan([ + "git", + "checkout", + "feature/x", + "--lane", + "lane-1", + ]); expect(plan.kind).toBe("execute"); if (plan.kind !== "execute") return; - const args = (plan.steps[0]?.params as { arguments: Record }).arguments; + const args = ( + plan.steps[0]?.params as { arguments: Record } + ).arguments; expect(args).not.toHaveProperty("startPoint"); expect(args).not.toHaveProperty("baseRef"); }); it("rejects `git checkout` without a branch name", () => { - expect(() => buildCliPlan(["git", "checkout", "--lane", "lane-1"])) - .toThrow(/branchName/); + expect(() => buildCliPlan(["git", "checkout", "--lane", "lane-1"])).toThrow( + /branchName/, + ); }); it("shows command help from subcommand help flags", () => { @@ -1262,7 +1845,7 @@ describe("ADE CLI", () => { "--branch-name", "ade-123-linked-lane", "--linear-issue-json", - "{\"id\":\"issue-1\",\"identifier\":\"ADE-123\",\"title\":\"Linked lane\"}", + '{"id":"issue-1","identifier":"ADE-123","title":"Linked lane"}', ]); expect(plan.kind).toBe("execute"); @@ -1352,7 +1935,13 @@ describe("ADE CLI", () => { expect(aliasHelp.text).toContain("iOS Simulator: snapshot"); expect(aliasHelp.text).toContain("ADEInspector/accessibility"); - const targetHelp = buildCliPlan(["ios-sim", "launch", "--target", "preview-target", "--help"]); + const targetHelp = buildCliPlan([ + "ios-sim", + "launch", + "--target", + "preview-target", + "--help", + ]); expect(targetHelp.kind).toBe("help"); if (targetHelp.kind !== "help") return; expect(targetHelp.text).toContain("iOS Simulator: launch"); @@ -1372,7 +1961,16 @@ describe("ADE CLI", () => { }); it("shell-escapes argv tokens after -- when building shell start commands", () => { - const plan = buildCliPlan(["shell", "start", "--lane", "lane-1", "--", "cat", "file with spaces.txt", "literal&name"]); + const plan = buildCliPlan([ + "shell", + "start", + "--lane", + "lane-1", + "--", + "cat", + "file with spaces.txt", + "literal&name", + ]); expect(plan.kind).toBe("execute"); if (plan.kind !== "execute") return; expect(plan.steps[0]?.params).toEqual({ @@ -1422,14 +2020,16 @@ describe("ADE CLI", () => { }); it("does not treat option values as start-cli providers", () => { - expect(() => buildCliPlan([ - "shell", - "start-cli", - "--lane", - "lane-1", - "--permission-mode", - "edit", - ])).toThrow("provider is required"); + expect(() => + buildCliPlan([ + "shell", + "start-cli", + "--lane", + "lane-1", + "--permission-mode", + "edit", + ]), + ).toThrow("provider is required"); }); it("finds a start-cli provider after value-taking options", () => { @@ -1531,7 +2131,11 @@ describe("ADE CLI", () => { if (byPositional.kind !== "execute") return; expect(byPositional.steps[0]?.params).toEqual({ name: "run_ade_action", - arguments: { domain: "automations", action: "get", args: { id: "rule-42" } }, + arguments: { + domain: "automations", + action: "get", + args: { id: "rule-42" }, + }, }); const byFlag = buildCliPlan(["automations", "show", "--id", "rule-42"]); @@ -1539,7 +2143,11 @@ describe("ADE CLI", () => { if (byFlag.kind !== "execute") return; expect(byFlag.steps[0]?.params).toEqual({ name: "run_ade_action", - arguments: { domain: "automations", action: "get", args: { id: "rule-42" } }, + arguments: { + domain: "automations", + action: "get", + args: { id: "rule-42" }, + }, }); }); @@ -1632,25 +2240,49 @@ describe("ADE CLI", () => { if (plan.kind !== "execute") return; expect(plan.steps[0]?.params).toEqual({ name: "run_ade_action", - arguments: { domain: "automations", action: "deleteRule", args: { id: "rule-42" } }, + arguments: { + domain: "automations", + action: "deleteRule", + args: { id: "rule-42" }, + }, }); }); it("automations toggle requires --enabled true|false and coerces to boolean", () => { - const enabled = buildCliPlan(["automations", "toggle", "rule-42", "--enabled", "true"]); + const enabled = buildCliPlan([ + "automations", + "toggle", + "rule-42", + "--enabled", + "true", + ]); expect(enabled.kind).toBe("execute"); if (enabled.kind !== "execute") return; expect(enabled.steps[0]?.params).toEqual({ name: "run_ade_action", - arguments: { domain: "automations", action: "toggleRule", args: { id: "rule-42", enabled: true } }, + arguments: { + domain: "automations", + action: "toggleRule", + args: { id: "rule-42", enabled: true }, + }, }); - const disabled = buildCliPlan(["automations", "toggle", "rule-42", "--enabled", "false"]); + const disabled = buildCliPlan([ + "automations", + "toggle", + "rule-42", + "--enabled", + "false", + ]); expect(disabled.kind).toBe("execute"); if (disabled.kind !== "execute") return; expect(disabled.steps[0]?.params).toEqual({ name: "run_ade_action", - arguments: { domain: "automations", action: "toggleRule", args: { id: "rule-42", enabled: false } }, + arguments: { + domain: "automations", + action: "toggleRule", + args: { id: "rule-42", enabled: false }, + }, }); }); @@ -1784,7 +2416,8 @@ describe("ADE CLI", () => { execution: { laneMode: "create", laneNamePreset: "custom", - laneNameTemplate: "{{trigger.issue.author}}/{{trigger.issue.title}}", + laneNameTemplate: + "{{trigger.issue.author}}/{{trigger.issue.title}}", }, }, }, @@ -1836,7 +2469,9 @@ describe("ADE CLI", () => { "--lane-name-template", "{{trigger.issue.title}}", ]), - ).toThrow(/--lane-name-template is only valid with --lane-name-preset custom/); + ).toThrow( + /--lane-name-template is only valid with --lane-name-preset custom/, + ); }); it("automations create rejects unknown --lane-mode value", () => { @@ -1853,7 +2488,14 @@ describe("ADE CLI", () => { }); it("automations runs accepts a --status filter", () => { - const plan = buildCliPlan(["automations", "runs", "--rule", "r1", "--status", "failed"]); + const plan = buildCliPlan([ + "automations", + "runs", + "--rule", + "r1", + "--status", + "failed", + ]); expect(plan.kind).toBe("execute"); if (plan.kind !== "execute") return; expect(plan.steps[0]?.params).toEqual({ @@ -1914,7 +2556,13 @@ describe("ADE CLI", () => { id: "legacy-rule", actions: [{ type: "create-lane", laneNameTemplate: "x" }], }); - const plan = buildCliPlan(["automations", "create", "--text", draft, "--allow-legacy"]); + const plan = buildCliPlan([ + "automations", + "create", + "--text", + draft, + "--allow-legacy", + ]); expect(plan.kind).toBe("execute"); if (plan.kind !== "execute") return; expect(plan.steps[0]?.params).toMatchObject({ @@ -1942,9 +2590,9 @@ describe("ADE CLI", () => { }); it("automations toggle rejects invalid --enabled values", () => { - expect(() => buildCliPlan(["automations", "toggle", "rule-42", "--enabled", "maybe"])).toThrow( - /must be true or false/, - ); + expect(() => + buildCliPlan(["automations", "toggle", "rule-42", "--enabled", "maybe"]), + ).toThrow(/must be true or false/); }); it("automations run passes dryRun only when --dry-run is set", () => { @@ -1953,7 +2601,11 @@ describe("ADE CLI", () => { if (plain.kind !== "execute") return; expect(plain.steps[0]?.params).toEqual({ name: "run_ade_action", - arguments: { domain: "automations", action: "triggerManually", args: { id: "rule-42" } }, + arguments: { + domain: "automations", + action: "triggerManually", + args: { id: "rule-42" }, + }, }); const dry = buildCliPlan(["automations", "run", "rule-42", "--dry-run"]); @@ -1970,7 +2622,13 @@ describe("ADE CLI", () => { }); it("automations run forwards --lane as laneId", () => { - const plan = buildCliPlan(["automations", "run", "rule-42", "--lane", "lane-7"]); + const plan = buildCliPlan([ + "automations", + "run", + "rule-42", + "--lane", + "lane-7", + ]); expect(plan.kind).toBe("execute"); if (plan.kind !== "execute") return; expect(plan.steps[0]?.params).toMatchObject({ @@ -1979,7 +2637,13 @@ describe("ADE CLI", () => { }); it("automations trigger aliases run and forwards --lane as laneId", () => { - const plan = buildCliPlan(["automations", "trigger", "rule-42", "--lane", "lane-7"]); + const plan = buildCliPlan([ + "automations", + "trigger", + "rule-42", + "--lane", + "lane-7", + ]); expect(plan.kind).toBe("execute"); if (plan.kind !== "execute") return; expect(plan.steps[0]?.params).toMatchObject({ @@ -2098,7 +2762,14 @@ describe("ADE CLI", () => { it("ios-sim inspect requires both coordinates and forwards them", () => { expect(() => buildCliPlan(["ios-sim", "inspect"])).toThrow(/--x|--y/); - const plan = buildCliPlan(["ios-sim", "inspect", "--x", "120", "--y", "420"]); + const plan = buildCliPlan([ + "ios-sim", + "inspect", + "--x", + "120", + "--y", + "420", + ]); expect(plan.kind).toBe("execute"); if (plan.kind !== "execute") return; expect(plan.steps[0]?.params).toMatchObject({ @@ -2111,7 +2782,14 @@ describe("ADE CLI", () => { }); it("ios-sim preview commands map to Xcode preview actions", () => { - const status = buildCliPlan(["ios-sim", "preview-status", "--source", "Views/HomeView.swift", "--line", "42"]); + const status = buildCliPlan([ + "ios-sim", + "preview-status", + "--source", + "Views/HomeView.swift", + "--line", + "42", + ]); expect(status.kind).toBe("execute"); if (status.kind !== "execute") return; expect(status.steps[0]?.params).toMatchObject({ @@ -2122,7 +2800,12 @@ describe("ADE CLI", () => { }, }); - const list = buildCliPlan(["ios-sim", "previews", "--source", "Views/HomeView.swift"]); + const list = buildCliPlan([ + "ios-sim", + "previews", + "--source", + "Views/HomeView.swift", + ]); expect(list.kind).toBe("execute"); if (list.kind !== "execute") return; expect(list.steps[0]?.params).toMatchObject({ @@ -2133,7 +2816,12 @@ describe("ADE CLI", () => { }, }); - const open = buildCliPlan(["ios-sim", "preview-open", "--project-root", "/tmp/app"]); + const open = buildCliPlan([ + "ios-sim", + "preview-open", + "--project-root", + "/tmp/app", + ]); expect(open.kind).toBe("execute"); if (open.kind !== "execute") return; expect(open.steps[0]?.params).toMatchObject({ @@ -2146,7 +2834,9 @@ describe("ADE CLI", () => { }); it("ios-sim preview-render requires a source file and forwards render options", () => { - expect(() => buildCliPlan(["ios-sim", "preview-render"])).toThrow(/sourceFilePath/); + expect(() => buildCliPlan(["ios-sim", "preview-render"])).toThrow( + /sourceFilePath/, + ); const plan = buildCliPlan([ "ios-sim", @@ -2183,7 +2873,9 @@ describe("ADE CLI", () => { expect(plain.steps[0]?.params).toMatchObject({ arguments: { domain: "ios_simulator", action: "shutdown" }, }); - expect((plain.steps[0]?.params as any).arguments.args.force ?? false).toBe(false); + expect((plain.steps[0]?.params as any).arguments.args.force ?? false).toBe( + false, + ); const forced = buildCliPlan(["ios-sim", "shutdown", "--force"]); expect(forced.kind).toBe("execute"); @@ -2198,7 +2890,15 @@ describe("ADE CLI", () => { }); it("keeps shell --command when an argument terminator has no trailing tokens", () => { - const plan = buildCliPlan(["shell", "start", "--lane", "lane-1", "--command", "npm test", "--"]); + const plan = buildCliPlan([ + "shell", + "start", + "--lane", + "lane-1", + "--command", + "npm test", + "--", + ]); expect(plan.kind).toBe("execute"); if (plan.kind !== "execute") return; expect(plan.steps[0]?.params).toMatchObject({ @@ -2213,7 +2913,16 @@ describe("ADE CLI", () => { }); it("keeps start-cli --message when an argument terminator has no trailing tokens", () => { - const plan = buildCliPlan(["shell", "start-cli", "codex", "--lane", "lane-1", "--message", "hello", "--"]); + const plan = buildCliPlan([ + "shell", + "start-cli", + "codex", + "--lane", + "lane-1", + "--message", + "hello", + "--", + ]); expect(plan.kind).toBe("execute"); if (plan.kind !== "execute") return; expect(plan.steps[0]?.params).toMatchObject({ @@ -2226,7 +2935,13 @@ describe("ADE CLI", () => { }); it("ios-sim type accepts clear text payload aliases without shadowing output --text", () => { - const withValue = buildCliPlan(["ios-sim", "type", "--value", "hello", "--text"]); + const withValue = buildCliPlan([ + "ios-sim", + "type", + "--value", + "hello", + "--text", + ]); expect(withValue.kind).toBe("execute"); if (withValue.kind !== "execute") return; expect(withValue.steps[0]?.params).toMatchObject({ @@ -2237,7 +2952,12 @@ describe("ADE CLI", () => { }, }); - const withPositional = buildCliPlan(["ios-sim", "type", "hello world", "--text"]); + const withPositional = buildCliPlan([ + "ios-sim", + "type", + "hello world", + "--text", + ]); expect(withPositional.kind).toBe("execute"); if (withPositional.kind !== "execute") return; expect(withPositional.steps[0]?.params).toMatchObject({ @@ -2253,7 +2973,14 @@ describe("ADE CLI", () => { const previous = process.env.ADE_CHAT_SESSION_ID; try { process.env.ADE_CHAT_SESSION_ID = "chat-env-1"; - const plan = buildCliPlan(["shell", "start", "--lane", "lane-1", "--command", "npm test"]); + const plan = buildCliPlan([ + "shell", + "start", + "--lane", + "lane-1", + "--command", + "npm test", + ]); expect(plan.kind).toBe("execute"); if (plan.kind !== "execute") return; expect(plan.steps[0]?.params).toMatchObject({ @@ -2308,7 +3035,14 @@ describe("ADE CLI", () => { const previous = process.env.ADE_CHAT_SESSION_ID; try { process.env.ADE_CHAT_SESSION_ID = " "; - const plan = buildCliPlan(["shell", "start", "--lane", "lane-1", "--command", "npm test"]); + const plan = buildCliPlan([ + "shell", + "start", + "--lane", + "lane-1", + "--command", + "npm test", + ]); expect(plan.kind).toBe("execute"); if (plan.kind !== "execute") return; expect(plan.steps[0]?.params).toMatchObject({ @@ -2348,7 +3082,15 @@ describe("ADE CLI", () => { }); it("app-control launch requires a command and supports aliases", () => { - const launch = buildCliPlan(["app-control", "launch", "--command", "npm run dev", "--debug-port", "9333", "--force"]); + const launch = buildCliPlan([ + "app-control", + "launch", + "--command", + "npm run dev", + "--debug-port", + "9333", + "--force", + ]); expect(launch.kind).toBe("execute"); if (launch.kind !== "execute") return; expect(launch.steps[0]?.params).toMatchObject({ @@ -2420,7 +3162,13 @@ describe("ADE CLI", () => { }, }); - const start = buildCliPlan(["mac-vm", "start", "lane-1", "--create", "--no-display"]); + const start = buildCliPlan([ + "mac-vm", + "start", + "lane-1", + "--create", + "--no-display", + ]); expect(start.kind).toBe("execute"); if (start.kind !== "execute") return; expect(start.steps[0]?.params).toMatchObject({ @@ -2458,7 +3206,14 @@ describe("ADE CLI", () => { }); it("macos-vm window control commands map to VM computer-use actions", () => { - const screenshot = buildCliPlan(["macos-vm", "screenshot", "--lane", "lane-1", "--output", "/tmp/vm.png"]); + const screenshot = buildCliPlan([ + "macos-vm", + "screenshot", + "--lane", + "lane-1", + "--output", + "/tmp/vm.png", + ]); expect(screenshot.kind).toBe("execute"); if (screenshot.kind !== "execute") return; expect(screenshot.steps[0]?.params).toMatchObject({ @@ -2472,7 +3227,14 @@ describe("ADE CLI", () => { }, }); - const click = buildCliPlan(["macos-vm", "click", "--lane", "lane-1", "120", "420"]); + const click = buildCliPlan([ + "macos-vm", + "click", + "--lane", + "lane-1", + "120", + "420", + ]); expect(click.kind).toBe("execute"); if (click.kind !== "execute") return; expect(click.steps[0]?.params).toMatchObject({ @@ -2487,7 +3249,16 @@ describe("ADE CLI", () => { }, }); - const select = buildCliPlan(["macos-vm", "select", "--lane", "lane-1", "--x", "120", "--y", "420"]); + const select = buildCliPlan([ + "macos-vm", + "select", + "--lane", + "lane-1", + "--x", + "120", + "--y", + "420", + ]); expect(select.kind).toBe("execute"); if (select.kind !== "execute") return; expect(select.steps[0]?.params).toMatchObject({ @@ -2502,7 +3273,14 @@ describe("ADE CLI", () => { }, }); - const type = buildCliPlan(["macos-vm", "type", "--lane", "lane-1", "--value", "hello"]); + const type = buildCliPlan([ + "macos-vm", + "type", + "--lane", + "lane-1", + "--value", + "hello", + ]); expect(type.kind).toBe("execute"); if (type.kind !== "execute") return; expect(type.steps[0]?.params).toMatchObject({ @@ -2518,7 +3296,14 @@ describe("ADE CLI", () => { }); it("terminal read and write map to terminal actions", () => { - const read = buildCliPlan(["terminal", "read", "--chat-session", "chat-1", "--max-bytes", "500"]); + const read = buildCliPlan([ + "terminal", + "read", + "--chat-session", + "chat-1", + "--max-bytes", + "500", + ]); expect(read.kind).toBe("execute"); if (read.kind !== "execute") return; expect(read.steps[0]?.params).toMatchObject({ @@ -2529,7 +3314,14 @@ describe("ADE CLI", () => { }, }); - const write = buildCliPlan(["terminal", "write", "--terminal", "term-1", "--data", "y\n"]); + const write = buildCliPlan([ + "terminal", + "write", + "--terminal", + "term-1", + "--data", + "y\n", + ]); expect(write.kind).toBe("execute"); if (write.kind !== "execute") return; expect(write.steps[0]?.params).toMatchObject({ @@ -2553,7 +3345,13 @@ describe("ADE CLI", () => { }, }); - const write = buildCliPlan(["app-control", "terminal", "write", "--data", "y\n"]); + const write = buildCliPlan([ + "app-control", + "terminal", + "write", + "--data", + "y\n", + ]); expect(write.kind).toBe("execute"); if (write.kind !== "execute") return; expect(write.steps[0]?.params).toMatchObject({ @@ -2566,7 +3364,13 @@ describe("ADE CLI", () => { }); it("app-control connect, select, click, and type map to App Control actions", () => { - const connect = buildCliPlan(["app-control", "connect", "--cdp-port", "9222", "--force"]); + const connect = buildCliPlan([ + "app-control", + "connect", + "--cdp-port", + "9222", + "--force", + ]); expect(connect.kind).toBe("execute"); if (connect.kind !== "execute") return; expect(connect.steps[0]?.params).toMatchObject({ @@ -2581,47 +3385,103 @@ describe("ADE CLI", () => { expect(positionalConnect.kind).toBe("execute"); if (positionalConnect.kind !== "execute") return; expect(positionalConnect.steps[0]?.params).toMatchObject({ - arguments: { domain: "app_control", action: "connect", args: { cdpPort: 9333 } }, + arguments: { + domain: "app_control", + action: "connect", + args: { cdpPort: 9333 }, + }, }); - const select = buildCliPlan(["app-control", "select", "--x", "120", "--y", "420"]); + const select = buildCliPlan([ + "app-control", + "select", + "--x", + "120", + "--y", + "420", + ]); expect(select.kind).toBe("execute"); if (select.kind !== "execute") return; expect(select.steps[0]?.params).toMatchObject({ - arguments: { domain: "app_control", action: "selectPoint", args: { x: 120, y: 420 } }, + arguments: { + domain: "app_control", + action: "selectPoint", + args: { x: 120, y: 420 }, + }, }); const click = buildCliPlan(["app", "click", "120", "420"]); expect(click.kind).toBe("execute"); if (click.kind !== "execute") return; expect(click.steps[0]?.params).toMatchObject({ - arguments: { domain: "app_control", action: "click", args: { x: 120, y: 420 } }, + arguments: { + domain: "app_control", + action: "click", + args: { x: 120, y: 420 }, + }, }); - const type = buildCliPlan(["app-control", "type", "--value", "hello", "--text"]); + const type = buildCliPlan([ + "app-control", + "type", + "--value", + "hello", + "--text", + ]); expect(type.kind).toBe("execute"); if (type.kind !== "execute") return; expect(type.steps[0]?.params).toMatchObject({ - arguments: { domain: "app_control", action: "typeText", args: { text: "hello" } }, + arguments: { + domain: "app_control", + action: "typeText", + args: { text: "hello" }, + }, }); - const scroll = buildCliPlan(["app-control", "scroll", "--x", "120", "--y", "420", "--delta-y", "600"]); + const scroll = buildCliPlan([ + "app-control", + "scroll", + "--x", + "120", + "--y", + "420", + "--delta-y", + "600", + ]); expect(scroll.kind).toBe("execute"); if (scroll.kind !== "execute") return; expect(scroll.steps[0]?.params).toMatchObject({ - arguments: { domain: "app_control", action: "scroll", args: { x: 120, y: 420, deltaY: 600 } }, + arguments: { + domain: "app_control", + action: "scroll", + args: { x: 120, y: 420, deltaY: 600 }, + }, }); - const attachTarget = buildCliPlan(["app-control", "attach-target", "--target", "target-1"]); + const attachTarget = buildCliPlan([ + "app-control", + "attach-target", + "--target", + "target-1", + ]); expect(attachTarget.kind).toBe("execute"); if (attachTarget.kind !== "execute") return; expect(attachTarget.steps[0]?.params).toMatchObject({ - arguments: { domain: "app_control", action: "attachToTarget", argsList: ["target-1"] }, + arguments: { + domain: "app_control", + action: "attachToTarget", + argsList: ["target-1"], + }, }); }); it("browser commands map to built-in browser actions", () => { - const open = buildCliPlan(["browser", "open", "localhost:5173", "--new-tab"]); + const open = buildCliPlan([ + "browser", + "open", + "localhost:5173", + "--new-tab", + ]); expect(open.kind).toBe("execute"); if (open.kind !== "execute") return; expect(open.steps[0]?.params).toMatchObject({ @@ -2639,14 +3499,29 @@ describe("ADE CLI", () => { arguments: { domain: "built_in_browser", action: "showPanel", args: {} }, }); - const panelWithUrl = buildCliPlan(["browser", "panel", "--url", "localhost:5173"]); + const panelWithUrl = buildCliPlan([ + "browser", + "panel", + "--url", + "localhost:5173", + ]); expect(panelWithUrl.kind).toBe("execute"); if (panelWithUrl.kind !== "execute") return; expect(panelWithUrl.steps[0]?.params).toMatchObject({ - arguments: { domain: "built_in_browser", action: "showPanel", args: { url: "localhost:5173" } }, + arguments: { + domain: "built_in_browser", + action: "showPanel", + args: { url: "localhost:5173" }, + }, }); - const targetedOpen = buildCliPlan(["browser", "open", "https://example.com", "--tab", "tab-1"]); + const targetedOpen = buildCliPlan([ + "browser", + "open", + "https://example.com", + "--tab", + "tab-1", + ]); expect(targetedOpen.kind).toBe("execute"); if (targetedOpen.kind !== "execute") return; expect(targetedOpen.steps[0]?.params).toMatchObject({ @@ -2657,7 +3532,12 @@ describe("ADE CLI", () => { }, }); - const hiddenOpen = buildCliPlan(["browser", "open", "https://example.com", "--no-panel"]); + const hiddenOpen = buildCliPlan([ + "browser", + "open", + "https://example.com", + "--no-panel", + ]); expect(hiddenOpen.kind).toBe("execute"); if (hiddenOpen.kind !== "execute") return; expect(hiddenOpen.steps[0]?.params).toMatchObject({ @@ -2668,7 +3548,13 @@ describe("ADE CLI", () => { }, }); - const openWithGenericArg = buildCliPlan(["browser", "open", "https://example.com", "--arg", "openPanel=false"]); + const openWithGenericArg = buildCliPlan([ + "browser", + "open", + "https://example.com", + "--arg", + "openPanel=false", + ]); expect(openWithGenericArg.kind).toBe("execute"); if (openWithGenericArg.kind !== "execute") return; expect(openWithGenericArg.steps[0]?.params).toMatchObject({ @@ -2679,7 +3565,12 @@ describe("ADE CLI", () => { }, }); - const openFromGenericUrl = buildCliPlan(["browser", "open", "--arg", "url=https://example.com"]); + const openFromGenericUrl = buildCliPlan([ + "browser", + "open", + "--arg", + "url=https://example.com", + ]); expect(openFromGenericUrl.kind).toBe("execute"); if (openFromGenericUrl.kind !== "execute") return; expect(openFromGenericUrl.steps[0]?.params).toMatchObject({ @@ -2690,7 +3581,12 @@ describe("ADE CLI", () => { }, }); - const backgroundTab = buildCliPlan(["browser", "new-tab", "https://example.com", "--background"]); + const backgroundTab = buildCliPlan([ + "browser", + "new-tab", + "https://example.com", + "--background", + ]); expect(backgroundTab.kind).toBe("execute"); if (backgroundTab.kind !== "execute") return; expect(backgroundTab.steps[0]?.params).toMatchObject({ @@ -2705,10 +3601,22 @@ describe("ADE CLI", () => { expect(switchTab.kind).toBe("execute"); if (switchTab.kind !== "execute") return; expect(switchTab.steps[0]?.params).toMatchObject({ - arguments: { domain: "built_in_browser", action: "switchTab", args: { tabId: "tab-1", openPanel: true } }, + arguments: { + domain: "built_in_browser", + action: "switchTab", + args: { tabId: "tab-1", openPanel: true }, + }, }); - const selectPoint = buildCliPlan(["browser", "select", "--x", "120", "--y", "420", "--no-screenshot"]); + const selectPoint = buildCliPlan([ + "browser", + "select", + "--x", + "120", + "--y", + "420", + "--no-screenshot", + ]); expect(selectPoint.kind).toBe("execute"); if (selectPoint.kind !== "execute") return; expect(selectPoint.steps[0]?.params).toMatchObject({ @@ -2746,7 +3654,11 @@ describe("ADE CLI", () => { expect(dismiss.kind).toBe("execute"); if (dismiss.kind !== "execute") return; expect(dismiss.steps[0]?.params).toMatchObject({ - arguments: { domain: "update", action: "dismissInstalledNotice", args: {} }, + arguments: { + domain: "update", + action: "dismissInstalledNotice", + args: {}, + }, }); const actions = buildCliPlan(["update", "actions"]); @@ -2768,13 +3680,23 @@ describe("ADE CLI", () => { close: () => {}, }; const summarized = summarizeExecution({ - plan: { kind: "execute", label: "lanes list", steps: [], visualizer: "lanes" }, + plan: { + kind: "execute", + label: "lanes list", + steps: [], + visualizer: "lanes", + }, connection, values: { result: { lanes: [ { id: "main", name: "main", branchRef: "main" }, - { id: "child", name: "child", branchRef: "feature", parentLaneId: "main" }, + { + id: "child", + name: "child", + branchRef: "feature", + parentLaneId: "main", + }, ], }, }, @@ -2783,6 +3705,8 @@ describe("ADE CLI", () => { lanes: expect.any(Array), }); expect((summarized as any).visual).toContain("\\- main (id: main) [main]"); - expect((summarized as any).visual).toContain("\\- child (id: child) [feature]"); + expect((summarized as any).visual).toContain( + "\\- child (id: child) [feature]", + ); }); }); diff --git a/apps/ade-cli/src/cli.ts b/apps/ade-cli/src/cli.ts index cfb7a2b9a..fa436febd 100644 --- a/apps/ade-cli/src/cli.ts +++ b/apps/ade-cli/src/cli.ts @@ -1,15 +1,17 @@ #!/usr/bin/env node import { Buffer } from "node:buffer"; -import { spawnSync } from "node:child_process"; +import { spawn, spawnSync } from "node:child_process"; import fs from "node:fs"; import net from "node:net"; import path from "node:path"; +import { pathToFileURL } from "node:url"; import YAML from "yaml"; import { CURSOR_CLOUD_HELP, CursorCloudUsageError, runCursorCloud, } from "./cursorCloud"; +import { resolveMachineAdeLayout } from "./services/projects/machineLayout"; import { JsonRpcError, JsonRpcErrorCode, @@ -27,6 +29,11 @@ import { validateLaunchProfilePermissionMode, type LaunchProfile, } from "../../desktop/src/shared/cliLaunch"; +import type { + SyncMobileProjectSummary, + SyncProjectSwitchRequestPayload, + SyncProjectSwitchResultPayload, +} from "../../desktop/src/shared/types/sync"; type JsonObject = Record; @@ -58,6 +65,7 @@ type FormatterId = | "status" | "doctor" | "auth" + | "projects-list" | "linear-quick-view" | "lanes" | "lane-detail" @@ -104,13 +112,26 @@ type FormatterId = type CliPlan = | { kind: "help"; text: string } - | { kind: "execute"; label: string; steps: InvocationStep[]; visualizer?: "lanes"; summary?: "status" | "doctor" | "auth"; formatter?: FormatterId; preferHeadless?: boolean } + | { + kind: "execute"; + label: string; + steps: InvocationStep[]; + visualizer?: "lanes"; + summary?: "status" | "doctor" | "auth"; + formatter?: FormatterId; + preferHeadless?: boolean; + } | { kind: "ade-code"; rest: string[] } + | { kind: "desktop"; rest: string[] } + | { kind: "runtime"; rest: string[] } + | { kind: "serve"; rest: string[] } + | { kind: "rpc-stdio"; rest: string[] } + | { kind: "init"; targetPath: string | null } | { kind: "cursor-cloud"; rest: string[] } | { kind: "mcp" }; type CliConnection = { - mode: "desktop-socket" | "headless"; + mode: "desktop-socket" | "runtime-socket" | "headless"; projectRoot: string; workspaceRoot: string; socketPath: string; @@ -146,10 +167,16 @@ type ReadinessCheck = { details?: JsonObject; }; -const VERSION = "0.0.0"; +declare const __ADE_VERSION__: string | undefined; + +const VERSION = + typeof __ADE_VERSION__ === "string" && __ADE_VERSION__.trim() + ? __ADE_VERSION__ + : process.env.ADE_CLI_VERSION?.trim() || "0.0.0"; const PROTOCOL_VERSION = "2025-06-18"; const SOURCE_FALLBACK_ENV = "ADE_CLI_SOURCE_FALLBACK_ACTIVE"; -const CLI_ENTRY_PATH = typeof process.argv[1] === "string" ? path.resolve(process.argv[1]) : ""; +const CLI_ENTRY_PATH = + typeof process.argv[1] === "string" ? path.resolve(process.argv[1]) : ""; const CLI_PACKAGE_ROOT = resolveCliPackageRoot(CLI_ENTRY_PATH); const CLI_DIST_PATH = path.join(CLI_PACKAGE_ROOT, "dist", "cli.cjs"); const COORDINATOR_MCP_TOOL_NAMES = new Set([ @@ -205,10 +232,7 @@ const WORKER_MISSION_TOOL_CLI_NAMES = new Set([ function resolveCliPackageRoot(entryPath: string): string { const seen = new Set(); - const starts = [ - entryPath ? path.dirname(entryPath) : null, - process.cwd(), - ]; + const starts = [entryPath ? path.dirname(entryPath) : null, process.cwd()]; for (const start of starts) { if (!start) continue; let cursor = path.resolve(start); @@ -232,19 +256,25 @@ function isSourceCliEntryPath(modulePath: string): boolean { } function isSourceRuntimeInteropError(value: unknown): boolean { - const message = typeof value === "string" - ? value - : value instanceof Error - ? value.message - : ""; + const message = + typeof value === "string" + ? value + : value instanceof Error + ? value.message + : ""; if (!message.length) return false; const lower = message.toLowerCase(); - return lower.includes("__filename is not defined in es module scope") - || lower.includes("__filename is not defined") - || lower.includes("__dirname is not defined"); + return ( + lower.includes("__filename is not defined in es module scope") || + lower.includes("__filename is not defined") || + lower.includes("__dirname is not defined") + ); } -function formatSpawnFailure(result: ReturnType, fallbackCommand: string): string { +function formatSpawnFailure( + result: ReturnType, + fallbackCommand: string, +): string { if (result.error) { return result.error.message; } @@ -289,11 +319,17 @@ function isBuiltCliFresh(): boolean { } } -function maybeRunBuiltCliFallback(error: unknown, argv: string[]): { stdout: string; stderr: string; exitCode: number } | null { +function maybeRunBuiltCliFallback( + error: unknown, + argv: string[], +): { stdout: string; stderr: string; exitCode: number } | null { if (!(error instanceof CliExecutionError)) return null; if (process.env[SOURCE_FALLBACK_ENV] === "1") return null; if (!isSourceCliEntryPath(CLI_ENTRY_PATH)) return null; - if (!isSourceRuntimeInteropError(asString(error.details.cause) ?? error.message)) return null; + if ( + !isSourceRuntimeInteropError(asString(error.details.cause) ?? error.message) + ) + return null; if (!isBuiltCliFresh()) { const npmCommand = process.platform === "win32" ? "npm.cmd" : "npm"; @@ -303,8 +339,12 @@ function maybeRunBuiltCliFallback(error: unknown, argv: string[]): { stdout: str encoding: "utf8", }); if (buildResult.error || buildResult.status !== 0 || !isBuiltCliFresh()) { - error.details.nextAction = "Run `npm --prefix apps/ade-cli run build` and retry the command."; - error.details.fallback = formatSpawnFailure(buildResult, "npm run build --silent"); + error.details.nextAction = + "Run `npm --prefix apps/ade-cli run build` and retry the command."; + error.details.fallback = formatSpawnFailure( + buildResult, + "npm run build --silent", + ); return null; } } @@ -318,7 +358,8 @@ function maybeRunBuiltCliFallback(error: unknown, argv: string[]): { stdout: str encoding: "utf8", }); if (rerun.error) { - error.details.nextAction = "Run `node apps/ade-cli/dist/cli.cjs ...` directly to inspect the runtime failure."; + error.details.nextAction = + "Run `node apps/ade-cli/dist/cli.cjs ...` directly to inspect the runtime failure."; error.details.fallback = rerun.error.message; return null; } @@ -341,16 +382,24 @@ const ADE_BANNER = String.raw` const TOP_LEVEL_HELP = `${ADE_BANNER} Agent-focused command-line interface for ADE. - ADE CLI commands operate on the same project database and live desktop socket - used by the ADE app. By default the CLI connects to the app socket when it is - running; otherwise it falls back to a headless runtime for local-safe actions. + ADE CLI commands operate through the machine ADE runtime daemon by default. + If the daemon is not running, the CLI starts it, registers the selected + project, and routes project actions through that runtime. $ ade help Display help for a command $ ade auth status Check local ADE CLI readiness $ ade code Open ADE Work chat in the terminal + $ ade desktop Launch the installed desktop app + $ ade runtime start | stop | status Manage the machine runtime daemon + $ ade serve Run the ADE runtime daemon in foreground + $ ade rpc --stdio Speak ADE JSON-RPC over stdin/stdout + $ ade init [path] Register a project with this machine runtime + $ ade projects list List projects registered on this machine + $ ade sync status | pin generate Manage machine sync and phone pairing $ ade doctor Inspect project, socket, runtime, and tool availability $ ade lanes list | show | create | child Work with lanes and lane stacks $ ade git status | commit | push | stash Run ADE-aware git operations + $ ade operations status | wait Poll operation/test/chat/run/mission status $ ade diff changes | file | patch Inspect lane diffs (including raw git patch text) $ ade files tree | read | write | search Read and edit lane workspaces $ ade missions launch | watch | graph Create, start, and inspect mission runs @@ -373,7 +422,7 @@ const TOP_LEVEL_HELP = `${ADE_BANNER} $ ade memory add | search | pin Use ADE memory $ ade settings action Call project config actions $ ade update status | check | install | dismiss Read auto-update state and drive install - $ ade actions list | run | status Escape hatch for every ADE service action + $ ade actions list | run | status | wait Escape hatch for every ADE service action $ ade mcp Expose ADE actions over stdio MCP $ ade cursor cloud agents | runs | artifacts | repos | models | me Drive Cursor Cloud agents via @cursor/sdk @@ -381,8 +430,8 @@ const TOP_LEVEL_HELP = `${ADE_BANNER} Global options: --project-root ADE project root. Inside .ade/worktrees/, this resolves to the parent project. --workspace-root Lane/worktree to treat as the active workspace. - --headless Skip the desktop socket and run an in-process ADE runtime. - --socket Require the desktop socket; fail instead of falling back to headless. + --headless Skip the runtime daemon and run an in-process ADE runtime. + --socket Require a live ADE socket; fail instead of falling back to headless. --json Print machine-readable JSON. This is the default output mode. --text Print a compact human-readable summary when a formatter exists. --timeout-ms Per-request timeout. Long agent/PR workflows may need several minutes. @@ -392,6 +441,8 @@ const TOP_LEVEL_HELP = `${ADE_BANNER} $ ade lanes list --text $ ade lanes create --name fix-login --description "Repair login redirect" $ ade git status --lane --text + $ ade git status --full --lane --text + $ ade git sync --lane --rebase --base main $ ade git stage --lane src/index.ts $ ade git commit --lane -m "Fix login redirect" $ ade missions launch --prompt "Fix onboarding" --manual --text @@ -755,6 +806,80 @@ const IOS_SIMULATOR_HELP_ALIASES: Record = { }; const HELP_BY_COMMAND: Record = { + desktop: `${ADE_BANNER} + ADE Desktop + + Launch the installed ADE desktop app. The desktop app attaches to the normal + machine runtime and starts it if needed. + + $ ade desktop + $ ade desktop open + + Flags: + --app-name macOS app name to open. Defaults to ADE, ADE Beta, + or ADE Alpha based on the installed CLI wrapper. +`, + runtime: `${ADE_BANNER} + ADE Runtime + + Manage the normal machine ADE runtime daemon used by desktop, ade code, and + socket-backed CLI commands. + + $ ade runtime status --text + $ ade runtime start + $ ade runtime stop + + Notes: + "start" launches the daemon in the background if it is missing. + "stop" shuts down the daemon on the selected socket. + Use "ade serve" when you want to run the runtime in the foreground. +`, + serve: `${ADE_BANNER} + ADE Runtime Daemon + + Runs the machine-scoped ADE runtime in the foreground. The daemon listens on + a local socket and can lazily serve any project registered with "ade init". + + $ ade serve + $ ade serve --socket ~/.ade/sock/ade.sock + $ ade serve --port 8787 + + Flags: + --socket Unix socket or Windows named pipe to listen on. + --port Also listen for local TCP JSON-RPC on 127.0.0.1:n. + --no-sync Disable machine sync discovery for this daemon run. + --install-service Register the per-user login service and exit. + --uninstall-service Remove the per-user login service and exit. + --service-status Print per-user login service status and exit. +`, + rpc: `${ADE_BANNER} + ADE JSON-RPC + + Attaches to the machine runtime daemon and speaks ADE JSON-RPC over stdio. + If the daemon is not running, ADE starts it before accepting requests. This + mode is used by SSH transports. + + $ ade rpc --stdio +`, + init: `${ADE_BANNER} + ADE Project Init + + Registers a project with this machine runtime and creates its .ade directory + if needed. + + $ ade init + $ ade init /path/to/project +`, + projects: `${ADE_BANNER} + ADE projects + + Manage the machine-scoped ADE project registry used by the runtime daemon. + + $ ade projects list --text + $ ade projects add /path/to/project + $ ade projects remove + $ ade projects touch +`, code: `${ADE_BANNER} ADE Code @@ -791,16 +916,39 @@ const HELP_BY_COMMAND: Record = { refresh lane state. Use --lane for anything other than the active workspace. $ ade git status --lane --text Show ADE-aware sync status + $ ade git status --full --lane --text Show full lane status, diff, and conflict state + $ ade git fetch --lane Fetch remote refs + $ ade git pull --lane Pull with ADE's ff-only lane operation + $ ade git sync --lane --rebase --base main + Sync the lane with its base branch $ ade git stage --lane src/file.ts Stage one file $ ade git stage-all --lane Stage all current changes $ ade git unstage --lane src/file.ts Unstage one file $ ade git commit --lane [-m ] Commit, adding Refs on linked Linear lanes $ ade git push --lane --set-upstream Push through ADE + $ ade git push --lane --force-with-lease Force-push through ADE with lease $ ade git branches --lane --text List branches with last-commit metadata $ ade git user-identity --lane --text Read lane checkout's git user.name/email $ ade git stash push|list|apply|pop Use ADE lane stash actions $ ade git rebase --lane --ai Rebase with ADE conflict support + $ ade git rebase continue --lane Continue an in-progress rebase + $ ade git conflict show --lane --text Inspect merge/rebase conflict state + $ ade git conflict resolve --kind rebase Continue after manual conflict resolution $ ade diff changes --lane --text Inspect changed files +`, + operations: `${ADE_BANNER} + Operations + + Poll status for long-running ADE operations that returned an operation, + test run, chat session, run graph, mission, or PR id. + + $ ade operations status --operation --text + $ ade operations wait --operation --wait-ms 30000 --text + $ ade actions wait --test-run --wait-ms 30000 --text + + Generic operation logs are not persisted by the operation table. Use + "ade tests logs", "ade run logs", or terminal/app-control log commands for + surfaces that own logs. `, diff: `${ADE_BANNER} Diffs @@ -866,8 +1014,8 @@ const HELP_BY_COMMAND: Record = { run: `${ADE_BANNER} Run tab - Run tab commands mirror ADE desktop process definitions and runtime state. - They require the desktop socket when live process state is needed. + Run tab commands mirror ADE process definitions and runtime state. They use + the machine runtime daemon when live process state is needed. $ ade run defs --text List configured run commands $ ade run ps --lane --text List process runtime state @@ -896,7 +1044,7 @@ const HELP_BY_COMMAND: Record = { Chat terminal Terminal commands control the active in-chat terminal for an ADE chat. Use - desktop socket mode when you want the same terminal the user sees in the app. + attached runtime mode when you want the same terminal the app is viewing. $ ade terminal list --chat-session --text List terminals for a chat $ ade terminal active --chat-session --text Show the active chat terminal @@ -924,7 +1072,7 @@ const HELP_BY_COMMAND: Record = { Work chats Chat commands use ADE agent chat sessions. Live provider-backed chat normally - requires the desktop socket because the app owns provider/session state. + requires an attached runtime because the daemon owns provider/session state. $ ade chat list --text List chat sessions $ ade chat create --lane --provider codex --model [--fast] @@ -948,8 +1096,8 @@ const HELP_BY_COMMAND: Record = { Prefer screenshots/images, screen recordings, and browser captures/traces. Console logs are supporting diagnostics, not a replacement for visual proof. Local screenshot/video fallback is macOS-only and runs headless by default - unless --socket is explicitly requested. Desktop socket mode has the best - parity for UI-owned proof state. + unless --socket is explicitly requested. Runtime socket mode has the best + parity for shared proof state. $ ade proof status --text Show proof backend capabilities $ ade proof list --text List captured artifacts @@ -964,7 +1112,7 @@ const HELP_BY_COMMAND: Record = { iOS simulator commands build, launch, mirror, inspect, and control the ADE drawer simulator. Aliases: \`ade ios\` and \`ade simulator\` route to the same - surface. For drawer/shared session state, prefer desktop socket mode + surface. For drawer/shared session state, prefer runtime socket mode (--socket) so launch/select/tap operate on the same long-lived ADE service. Launch is headless by default; use --foreground only when you need the native Simulator window in front. idb is optional for direct @@ -1017,7 +1165,7 @@ const HELP_BY_COMMAND: Record = { macOS VM commands provision and control lane-tied Apple silicon macOS guests through Lume. ADE mounts the lane worktree into the guest with a - shared directory so host and guest edits stay in sync. Use desktop socket + shared directory so host and guest edits stay in sync. Use runtime socket mode when the Work sidebar and agents should observe the same live VM state. Discovery and lifecycle: @@ -1278,7 +1426,9 @@ function isRecord(value: unknown): value is JsonObject { } function asString(value: unknown): string | null { - return typeof value === "string" && value.trim().length > 0 ? value.trim() : null; + return typeof value === "string" && value.trim().length > 0 + ? value.trim() + : null; } function parseBooleanEnv(value: string | undefined): boolean { @@ -1302,7 +1452,9 @@ function parseJson(value: string, label: string): unknown { try { return JSON.parse(value); } catch (error) { - throw new CliUsageError(`${label} must be valid JSON: ${error instanceof Error ? error.message : String(error)}`); + throw new CliUsageError( + `${label} must be valid JSON: ${error instanceof Error ? error.message : String(error)}`, + ); } } @@ -1314,7 +1466,10 @@ function parseObjectJson(value: string, label: string): JsonObject { return parsed; } -function parseAssignment(value: string, label: string): { key: string; value: string } { +function parseAssignment( + value: string, + label: string, +): { key: string; value: string } { const index = value.indexOf("="); if (index <= 0) { throw new CliUsageError(`${label} must use key=value syntax.`); @@ -1326,16 +1481,25 @@ function parseAssignment(value: string, label: string): { key: string; value: st return { key, value: value.slice(index + 1) }; } -const UNSAFE_ARG_PATH_SEGMENTS = new Set(["__proto__", "constructor", "prototype"]); +const UNSAFE_ARG_PATH_SEGMENTS = new Set([ + "__proto__", + "constructor", + "prototype", +]); function setPath(target: JsonObject, key: string, value: unknown): void { - const parts = key.split(".").map((part) => part.trim()).filter(Boolean); + const parts = key + .split(".") + .map((part) => part.trim()) + .filter(Boolean); if (parts.length === 0) { throw new CliUsageError("Argument key cannot be empty."); } const unsafePart = parts.find((part) => UNSAFE_ARG_PATH_SEGMENTS.has(part)); if (unsafePart) { - throw new CliUsageError(`Argument key segment "${unsafePart}" is not allowed.`); + throw new CliUsageError( + `Argument key segment "${unsafePart}" is not allowed.`, + ); } let cursor: JsonObject = target; for (const part of parts.slice(0, -1)) { @@ -1355,7 +1519,9 @@ function readValue(args: string[], names: string[]): string | null { for (let index = 0; index < args.length; index += 1) { const token = args[index]; if (!token) continue; - const matchedName = names.find((name) => token === name || token.startsWith(`${name}=`)); + const matchedName = names.find( + (name) => token === name || token.startsWith(`${name}=`), + ); if (!matchedName) continue; if (token.includes("=")) { args.splice(index, 1); @@ -1384,7 +1550,9 @@ function readCommandTextValue(args: string[], names: string[]): string | null { for (let index = 0; index < args.length; index += 1) { const token = args[index]; if (!token) continue; - const matchedName = names.find((name) => token === name || token.startsWith(`${name}=`)); + const matchedName = names.find( + (name) => token === name || token.startsWith(`${name}=`), + ); if (!matchedName) continue; if (token.includes("=")) { args.splice(index, 1); @@ -1417,8 +1585,11 @@ function firstStandalonePositional(args: string[]): string | null { continue; } if (token.startsWith("-")) { - const flagName = token.includes("=") ? token.slice(0, token.indexOf("=")) : token; - previousTokenWasValueCarrier = !token.includes("=") && VALUE_CARRIER_FLAGS.has(flagName); + const flagName = token.includes("=") + ? token.slice(0, token.indexOf("=")) + : token; + previousTokenWasValueCarrier = + !token.includes("=") && VALUE_CARRIER_FLAGS.has(flagName); continue; } const [value] = args.splice(index, 1); @@ -1451,17 +1622,28 @@ function buildCursorHelp(args: string[]): string { positionals.push(token.toLowerCase()); } // Drop a leading "cursor" / "cloud" if present so we land on the group token. - while (positionals.length && (positionals[0] === "cursor" || positionals[0] === "cloud")) { + while ( + positionals.length && + (positionals[0] === "cursor" || positionals[0] === "cloud") + ) { positionals.shift(); } const group = positionals[0]; const aliasMap: Record = { - agents: "agents", agent: "agents", - runs: "runs", run: "runs", - artifacts: "artifacts", artifact: "artifacts", - repos: "repos", repo: "repos", repositories: "repos", - models: "models", model: "models", - me: "me", whoami: "me", user: "me", + agents: "agents", + agent: "agents", + runs: "runs", + run: "runs", + artifacts: "artifacts", + artifact: "artifacts", + repos: "repos", + repo: "repos", + repositories: "repos", + models: "models", + model: "models", + me: "me", + whoami: "me", + user: "me", }; if (group && aliasMap[group] && CURSOR_CLOUD_HELP[aliasMap[group]]) { return `${ADE_BANNER}${CURSOR_CLOUD_HELP[aliasMap[group]]}`; @@ -1472,7 +1654,7 @@ function buildCursorHelp(args: string[]): string { function buildIosSimulatorHelp(args: string[]): string { const rawSubcommand = peekFirstPositional(args)?.toLowerCase() ?? ""; const canonical = rawSubcommand - ? IOS_SIMULATOR_HELP_ALIASES[rawSubcommand] ?? rawSubcommand + ? (IOS_SIMULATOR_HELP_ALIASES[rawSubcommand] ?? rawSubcommand) : ""; if (canonical && IOS_SIMULATOR_SUBCOMMAND_HELP[canonical]) { return IOS_SIMULATOR_SUBCOMMAND_HELP[canonical]; @@ -1495,10 +1677,17 @@ function buildAppControlHelp(args: string[]): string { return focused; } -function collectGenericObjectArgs(args: string[], base: JsonObject = {}): JsonObject { +function collectGenericObjectArgs( + args: string[], + base: JsonObject = {}, +): JsonObject { const input: JsonObject = { ...base }; while (true) { - const inputJson = readValue(args, ["--input-json", "--json-input", "--input"]); + const inputJson = readValue(args, [ + "--input-json", + "--json-input", + "--input", + ]); if (inputJson != null) { Object.assign(input, parseObjectJson(inputJson, "--input-json")); continue; @@ -1531,7 +1720,11 @@ function readPrId(args: string[]): string | null { return readValue(args, ["--pr", "--pr-id"]) ?? null; } -function readIntOption(args: string[], names: string[], fallback?: number): number | undefined { +function readIntOption( + args: string[], + names: string[], + fallback?: number, +): number | undefined { const value = readValue(args, names); if (value == null) return fallback; const parsed = Number.parseInt(value, 10); @@ -1541,7 +1734,11 @@ function readIntOption(args: string[], names: string[], fallback?: number): numb return parsed; } -function readNumberOption(args: string[], names: string[], fallback?: number): number | undefined { +function readNumberOption( + args: string[], + names: string[], + fallback?: number, +): number | undefined { const value = readValue(args, names); if (value == null) return fallback; const parsed = Number(value); @@ -1551,12 +1748,20 @@ function readNumberOption(args: string[], names: string[], fallback?: number): n return parsed; } -function readJsonOption(args: string[], names: string[], label: string): unknown | undefined { +function readJsonOption( + args: string[], + names: string[], + label: string, +): unknown | undefined { const value = readValue(args, names); return value == null ? undefined : parseJson(value, label); } -function readJsonFileOption(args: string[], names: string[], label: string): unknown | undefined { +function readJsonFileOption( + args: string[], + names: string[], + label: string, +): unknown | undefined { const filePath = readValue(args, names); if (filePath == null) return undefined; const resolvedPath = path.resolve(filePath); @@ -1565,16 +1770,25 @@ function readJsonFileOption(args: string[], names: string[], label: string): unk text = fs.readFileSync(resolvedPath, "utf8"); } catch (error) { const message = error instanceof Error ? error.message : String(error); - throw new CliUsageError(`Could not read ${names[0]} file '${filePath}': ${message}`); + throw new CliUsageError( + `Could not read ${names[0]} file '${filePath}': ${message}`, + ); } return parseJson(text, label); } -function readJsonPayloadOption(args: string[], jsonNames: string[], fileNames: string[], label: string): unknown | undefined { +function readJsonPayloadOption( + args: string[], + jsonNames: string[], + fileNames: string[], + label: string, +): unknown | undefined { const inline = readJsonOption(args, jsonNames, label); const fromFile = readJsonFileOption(args, fileNames, label); if (inline !== undefined && fromFile !== undefined) { - throw new CliUsageError(`Use either ${jsonNames[0]} or ${fileNames[0]}, not both.`); + throw new CliUsageError( + `Use either ${jsonNames[0]} or ${fileNames[0]}, not both.`, + ); } return inline ?? fromFile; } @@ -1588,7 +1802,11 @@ function sleep(ms: number): Promise { return new Promise((resolve) => setTimeout(resolve, ms)); } -function isCommandTextValue(argv: string[], index: number, command: string[]): boolean { +function isCommandTextValue( + argv: string[], + index: number, + command: string[], +): boolean { if (command.length === 0) return false; const token = argv[index]; if (token?.startsWith("--text=")) return true; @@ -1628,10 +1846,10 @@ function readPipelineSettingsPatch(args: string[]): JsonObject { const conflictStrategy = readValue(args, ["--conflict-strategy"]); if (conflictStrategy) { if ( - conflictStrategy !== "pause" - && conflictStrategy !== "rebase" - && conflictStrategy !== "merge" - && conflictStrategy !== "auto" + conflictStrategy !== "pause" && + conflictStrategy !== "rebase" && + conflictStrategy !== "merge" && + conflictStrategy !== "auto" ) { throw new CliUsageError( "--conflict-strategy must be one of pause, rebase, merge, or auto.", @@ -1643,20 +1861,21 @@ function readPipelineSettingsPatch(args: string[]): JsonObject { const forceFinalize = readValue(args, ["--force-finalize"]); if (forceFinalize) { if ( - forceFinalize !== "off" - && forceFinalize !== "conditional" - && forceFinalize !== "unconditional" + forceFinalize !== "off" && + forceFinalize !== "conditional" && + forceFinalize !== "unconditional" ) { throw new CliUsageError( "--force-finalize must be one of off, conditional, or unconditional.", ); } patch.forceFinalizeMode = forceFinalize; - patch.atCapPolicy = forceFinalize === "off" - ? "stop" - : forceFinalize === "unconditional" - ? "force_merge" - : "ci_retry_once"; + patch.atCapPolicy = + forceFinalize === "off" + ? "stop" + : forceFinalize === "unconditional" + ? "force_merge" + : "ci_retry_once"; } const requireNoCi = readFlag(args, ["--force-finalize-require-no-ci"]); @@ -1674,40 +1893,48 @@ function readPipelineSettingsPatch(args: string[]): JsonObject { const atCapPolicy = readValue(args, ["--at-cap-policy"]); if (atCapPolicy) { if ( - atCapPolicy !== "stop" - && atCapPolicy !== "wait_for_ci" - && atCapPolicy !== "ci_retry_once" - && atCapPolicy !== "ci_retry_loop" - && atCapPolicy !== "force_merge" + atCapPolicy !== "stop" && + atCapPolicy !== "wait_for_ci" && + atCapPolicy !== "ci_retry_once" && + atCapPolicy !== "ci_retry_loop" && + atCapPolicy !== "force_merge" ) { throw new CliUsageError( "--at-cap-policy must be one of stop, wait_for_ci, ci_retry_once, ci_retry_loop, or force_merge.", ); } patch.atCapPolicy = atCapPolicy; - patch.forceFinalizeMode = atCapPolicy === "stop" - ? "off" - : atCapPolicy === "force_merge" - ? "unconditional" - : "conditional"; + patch.forceFinalizeMode = + atCapPolicy === "stop" + ? "off" + : atCapPolicy === "force_merge" + ? "unconditional" + : "conditional"; } const atCapWaitMinutes = readIntOption(args, ["--at-cap-wait-minutes"]); if (atCapWaitMinutes != null) { - if (atCapWaitMinutes < 1) throw new CliUsageError("--at-cap-wait-minutes must be at least 1."); + if (atCapWaitMinutes < 1) + throw new CliUsageError("--at-cap-wait-minutes must be at least 1."); patch.atCapWaitMinutes = atCapWaitMinutes; } const atCapCiRetryMax = readIntOption(args, ["--at-cap-ci-retry-max"]); if (atCapCiRetryMax != null) { - if (atCapCiRetryMax < 1) throw new CliUsageError("--at-cap-ci-retry-max must be at least 1."); + if (atCapCiRetryMax < 1) + throw new CliUsageError("--at-cap-ci-retry-max must be at least 1."); patch.atCapCiRetryMax = atCapCiRetryMax; } - const forceMergeConfirm = readFlag(args, ["--force-merge-requires-confirmation"]); - const noForceMergeConfirm = readFlag(args, ["--no-force-merge-requires-confirmation"]); + const forceMergeConfirm = readFlag(args, [ + "--force-merge-requires-confirmation", + ]); + const noForceMergeConfirm = readFlag(args, [ + "--no-force-merge-requires-confirmation", + ]); if (forceMergeConfirm || noForceMergeConfirm) { - patch.forceMergeRequiresConfirmation = forceMergeConfirm && !noForceMergeConfirm; + patch.forceMergeRequiresConfirmation = + forceMergeConfirm && !noForceMergeConfirm; } return patch; @@ -1718,7 +1945,10 @@ function parseCliArgs(argv: string[]): ParsedCli { const options: GlobalOptions = { projectRoot: null, workspaceRoot: null, - role: (asString(process.env.ADE_DEFAULT_ROLE) as GlobalOptions["role"] | null) ?? "agent", + role: + (asString(process.env.ADE_DEFAULT_ROLE) as + | GlobalOptions["role"] + | null) ?? "agent", headless: parseBooleanEnv(process.env.ADE_CLI_HEADLESS), requireSocket: false, pretty: true, @@ -1734,21 +1964,32 @@ function parseCliArgs(argv: string[]): ParsedCli { break; } if (inGlobalPrefix && token === "--project-root") { - options.projectRoot = path.resolve(requireValue(argv[index + 1] ?? null, "--project-root")); + options.projectRoot = path.resolve( + requireValue(argv[index + 1] ?? null, "--project-root"), + ); index += 1; continue; } if (inGlobalPrefix && token.startsWith("--project-root=")) { - options.projectRoot = path.resolve(requireValue(token.slice("--project-root=".length), "--project-root")); + options.projectRoot = path.resolve( + requireValue(token.slice("--project-root=".length), "--project-root"), + ); continue; } if (inGlobalPrefix && token === "--workspace-root") { - options.workspaceRoot = path.resolve(requireValue(argv[index + 1] ?? null, "--workspace-root")); + options.workspaceRoot = path.resolve( + requireValue(argv[index + 1] ?? null, "--workspace-root"), + ); index += 1; continue; } if (inGlobalPrefix && token.startsWith("--workspace-root=")) { - options.workspaceRoot = path.resolve(requireValue(token.slice("--workspace-root=".length), "--workspace-root")); + options.workspaceRoot = path.resolve( + requireValue( + token.slice("--workspace-root=".length), + "--workspace-root", + ), + ); continue; } if (inGlobalPrefix && token === "--role") { @@ -1757,7 +1998,9 @@ function parseCliArgs(argv: string[]): ParsedCli { continue; } if (inGlobalPrefix && token.startsWith("--role=")) { - options.role = parseRole(requireValue(token.slice("--role=".length), "--role")); + options.role = parseRole( + requireValue(token.slice("--role=".length), "--role"), + ); continue; } if (inGlobalPrefix && (token === "--headless" || token === "--no-socket")) { @@ -1790,7 +2033,10 @@ function parseCliArgs(argv: string[]): ParsedCli { continue; } if (inGlobalPrefix && token === "--timeout-ms") { - const parsed = Number.parseInt(requireValue(argv[index + 1] ?? null, "--timeout-ms"), 10); + const parsed = Number.parseInt( + requireValue(argv[index + 1] ?? null, "--timeout-ms"), + 10, + ); if (!Number.isFinite(parsed) || parsed <= 0) { throw new CliUsageError("--timeout-ms must be a positive integer."); } @@ -1799,7 +2045,10 @@ function parseCliArgs(argv: string[]): ParsedCli { continue; } if (inGlobalPrefix && token.startsWith("--timeout-ms=")) { - const parsed = Number.parseInt(requireValue(token.slice("--timeout-ms=".length), "--timeout-ms"), 10); + const parsed = Number.parseInt( + requireValue(token.slice("--timeout-ms=".length), "--timeout-ms"), + 10, + ); if (!Number.isFinite(parsed) || parsed <= 0) { throw new CliUsageError("--timeout-ms must be a positive integer."); } @@ -1813,10 +2062,18 @@ function parseCliArgs(argv: string[]): ParsedCli { } function parseRole(value: string): GlobalOptions["role"] { - if (value === "cto" || value === "orchestrator" || value === "agent" || value === "external" || value === "evaluator") { + if ( + value === "cto" || + value === "orchestrator" || + value === "agent" || + value === "external" || + value === "evaluator" + ) { return value; } - throw new CliUsageError("--role must be one of cto, orchestrator, agent, external, or evaluator."); + throw new CliUsageError( + "--role must be one of cto, orchestrator, agent, external, or evaluator.", + ); } function shellEscapeToken(value: string): string { @@ -1825,7 +2082,11 @@ function shellEscapeToken(value: string): string { return `'${value.replace(/'/g, `'"'"'`)}'`; } -function actionCallStep(key: string, name: string, args: JsonObject = {}): InvocationStep { +function actionCallStep( + key: string, + name: string, + args: JsonObject = {}, +): InvocationStep { return { key, method: "ade/actions/call", @@ -1834,15 +2095,30 @@ function actionCallStep(key: string, name: string, args: JsonObject = {}): Invoc }; } -function actionStep(key: string, domain: string, action: string, args: JsonObject = {}): InvocationStep { +function actionStep( + key: string, + domain: string, + action: string, + args: JsonObject = {}, +): InvocationStep { return actionCallStep(key, "run_ade_action", { domain, action, args }); } -function actionArgsListStep(key: string, domain: string, action: string, argsList: unknown[]): InvocationStep { +function actionArgsListStep( + key: string, + domain: string, + action: string, + argsList: unknown[], +): InvocationStep { return actionCallStep(key, "run_ade_action", { domain, action, argsList }); } -function actionScalarStep(key: string, domain: string, action: string, arg: unknown): InvocationStep { +function actionScalarStep( + key: string, + domain: string, + action: string, + arg: unknown, +): InvocationStep { return actionCallStep(key, "run_ade_action", { domain, action, arg }); } @@ -1853,8 +2129,12 @@ function waitRunGraphStep(args: { timelineLimit: number; untilTerminal: boolean; }): InvocationStep | null { - if ((args.waitMs == null || args.waitMs <= 0) && !args.untilTerminal) return null; - const waitMs = Math.min(30 * 60 * 1000, Math.max(0, Math.floor(args.waitMs ?? 30 * 60 * 1000))); + if ((args.waitMs == null || args.waitMs <= 0) && !args.untilTerminal) + return null; + const waitMs = Math.min( + 30 * 60 * 1000, + Math.max(0, Math.floor(args.waitMs ?? 30 * 60 * 1000)), + ); return { key: args.key, method: "ade-cli/wait-run-graph", @@ -1873,7 +2153,10 @@ function listActionsStep(key: string, domain?: string): InvocationStep { function buildActionRunStep(args: string[]): InvocationStep { const target = firstPositional(args); - if (!target) throw new CliUsageError("actions run requires or ."); + if (!target) + throw new CliUsageError( + "actions run requires or .", + ); let domain: string; let action: string; @@ -1889,18 +2172,31 @@ function buildActionRunStep(args: string[]): InvocationStep { const argsListJson = readValue(args, ["--args-list-json", "--params-json"]); if (argsListJson != null) { const argsList = parseJson(argsListJson, "--args-list-json"); - if (!Array.isArray(argsList)) throw new CliUsageError("--args-list-json must be a JSON array."); - return actionCallStep("result", "run_ade_action", { domain, action, argsList }); + if (!Array.isArray(argsList)) + throw new CliUsageError("--args-list-json must be a JSON array."); + return actionCallStep("result", "run_ade_action", { + domain, + action, + argsList, + }); } const scalarJson = readValue(args, ["--scalar-json", "--arg-value-json"]); if (scalarJson != null) { - return actionCallStep("result", "run_ade_action", { domain, action, arg: parseJson(scalarJson, "--scalar-json") }); + return actionCallStep("result", "run_ade_action", { + domain, + action, + arg: parseJson(scalarJson, "--scalar-json"), + }); } const scalar = readValue(args, ["--scalar", "--arg-value"]); if (scalar != null) { - return actionCallStep("result", "run_ade_action", { domain, action, arg: parsePrimitive(scalar) }); + return actionCallStep("result", "run_ade_action", { + domain, + action, + arg: parsePrimitive(scalar), + }); } return actionStep("result", domain, action, collectGenericObjectArgs(args)); @@ -1945,12 +2241,26 @@ function buildWorkerMissionToolPlan(name: string, args: string[]): CliPlan { }); } if (name === "message_worker") { - const toWorkerId = readValue(args, ["--to-worker", "--to-worker-id", "--worker", "--worker-id", "--to"]) - ?? firstPositional(args); - const content = readValue(args, ["--content", "--message", "--body"]) - ?? args.filter((entry) => entry !== "--" && !entry.startsWith("-")).join(" ").trim(); + const toWorkerId = + readValue(args, [ + "--to-worker", + "--to-worker-id", + "--worker", + "--worker-id", + "--to", + ]) ?? firstPositional(args); + const content = + readValue(args, ["--content", "--message", "--body"]) ?? + args + .filter((entry) => entry !== "--" && !entry.startsWith("-")) + .join(" ") + .trim(); return collectGenericObjectArgs(args, { - fromWorkerId: readValue(args, ["--from-worker", "--from-worker-id", "--from"]), + fromWorkerId: readValue(args, [ + "--from-worker", + "--from-worker-id", + "--from", + ]), toWorkerId, content, priority: readValue(args, ["--priority"]) ?? "normal", @@ -1969,10 +2279,18 @@ function buildWorkerMissionToolPlan(name: string, args: string[]): CliPlan { function buildLanePlan(args: string[]): CliPlan { const sub = firstPositional(args) ?? "list"; if (sub === "actions") { - return { kind: "execute", label: "lane actions", steps: [listActionsStep("actions", "lane")] }; + return { + kind: "execute", + label: "lane actions", + steps: [listActionsStep("actions", "lane")], + }; } if (sub === "action") { - return { kind: "execute", label: "lane action", steps: [buildActionRunStep(["lane", ...args])] }; + return { + kind: "execute", + label: "lane action", + steps: [buildActionRunStep(["lane", ...args])], + }; } if (sub === "list" || sub === "ls") { const input = collectGenericObjectArgs(args, { @@ -1988,129 +2306,502 @@ function buildLanePlan(args: string[]): CliPlan { }; } if (sub === "show" || sub === "status") { - const laneId = requireValue(readLaneId(args) ?? firstPositional(args), "laneId"); - return { kind: "execute", label: "lane status", steps: [actionCallStep("result", "get_lane_status", { laneId })] }; + const laneId = requireValue( + readLaneId(args) ?? firstPositional(args), + "laneId", + ); + return { + kind: "execute", + label: "lane status", + steps: [actionCallStep("result", "get_lane_status", { laneId })], + }; } if (sub === "merge") { - const laneId = requireValue(readLaneId(args) ?? firstPositional(args), "laneId"); - return { kind: "execute", label: "lane merge", steps: [actionCallStep("result", "merge_lane", collectGenericObjectArgs(args, { laneId, message: readValue(args, ["--message", "-m"]), deleteSourceLane: readFlag(args, ["--delete-source-lane", "--delete-source"]) }))] }; + const laneId = requireValue( + readLaneId(args) ?? firstPositional(args), + "laneId", + ); + return { + kind: "execute", + label: "lane merge", + steps: [ + actionCallStep( + "result", + "merge_lane", + collectGenericObjectArgs(args, { + laneId, + message: readValue(args, ["--message", "-m"]), + deleteSourceLane: readFlag(args, [ + "--delete-source-lane", + "--delete-source", + ]), + }), + ), + ], + }; } if (sub === "conflicts") { const mode = firstPositional(args) ?? "check"; - if (mode !== "check") return { kind: "execute", label: `lane conflicts ${mode}`, steps: [actionStep("result", "conflicts", mode, collectGenericObjectArgs(args, { laneId: readLaneId(args) }))] }; + if (mode !== "check") + return { + kind: "execute", + label: `lane conflicts ${mode}`, + steps: [ + actionStep( + "result", + "conflicts", + mode, + collectGenericObjectArgs(args, { laneId: readLaneId(args) }), + ), + ], + }; const ids = args.filter((entry) => !entry.startsWith("-")); - return { kind: "execute", label: "lane conflicts check", steps: [actionCallStep("result", "check_conflicts", collectGenericObjectArgs(args, { laneId: readLaneId(args), ...(ids.length ? { laneIds: ids } : {}), force: readFlag(args, ["--force"]) }))] }; + return { + kind: "execute", + label: "lane conflicts check", + steps: [ + actionCallStep( + "result", + "check_conflicts", + collectGenericObjectArgs(args, { + laneId: readLaneId(args), + ...(ids.length ? { laneIds: ids } : {}), + force: readFlag(args, ["--force"]), + }), + ), + ], + }; } if (sub === "create" || sub === "child") { const name = readValue(args, ["--name"]) ?? firstPositional(args); const input: JsonObject = {}; input.name = requireValue(name, "name"); - maybePut(input, "description", readValue(args, ["--description", "--desc"])); - maybePut(input, "parentLaneId", readValue(args, ["--parent", "--parent-lane", "--parent-lane-id"]) ?? (sub === "child" ? readLaneId(args) : null)); + maybePut( + input, + "description", + readValue(args, ["--description", "--desc"]), + ); + maybePut( + input, + "parentLaneId", + readValue(args, ["--parent", "--parent-lane", "--parent-lane-id"]) ?? + (sub === "child" ? readLaneId(args) : null), + ); maybePut(input, "baseBranch", readValue(args, ["--base", "--base-branch"])); maybePut(input, "branchName", readValue(args, ["--branch-name"])); const linearIssueJson = readValue(args, ["--linear-issue-json"]); if (linearIssueJson) { const parsed = parseJson(linearIssueJson, "--linear-issue-json"); - if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) { - throw new CliUsageError("--linear-issue-json must decode to a non-null JSON object"); + if ( + parsed === null || + typeof parsed !== "object" || + Array.isArray(parsed) + ) { + throw new CliUsageError( + "--linear-issue-json must decode to a non-null JSON object", + ); } input.linearIssue = parsed as JsonObject; } - if (sub === "child" && !input.parentLaneId) throw new CliUsageError("parent lane is required. Use --lane or --parent ."); - return { kind: "execute", label: "lane create", steps: [actionCallStep("result", "create_lane", collectGenericObjectArgs(args, input))] }; + if (sub === "child" && !input.parentLaneId) + throw new CliUsageError( + "parent lane is required. Use --lane or --parent .", + ); + return { + kind: "execute", + label: "lane create", + steps: [ + actionCallStep( + "result", + "create_lane", + collectGenericObjectArgs(args, input), + ), + ], + }; } if (sub === "children") { - const laneId = requireValue(readLaneId(args) ?? firstPositional(args), "laneId"); - return { kind: "execute", label: "lane children", steps: [actionArgsListStep("result", "lane", "getChildren", [laneId])] }; + const laneId = requireValue( + readLaneId(args) ?? firstPositional(args), + "laneId", + ); + return { + kind: "execute", + label: "lane children", + steps: [actionArgsListStep("result", "lane", "getChildren", [laneId])], + }; } if (sub === "stack") { - const laneId = requireValue(readLaneId(args) ?? firstPositional(args), "laneId"); - return { kind: "execute", label: "lane stack", steps: [actionArgsListStep("result", "lane", "getStackChain", [laneId])] }; + const laneId = requireValue( + readLaneId(args) ?? firstPositional(args), + "laneId", + ); + return { + kind: "execute", + label: "lane stack", + steps: [actionArgsListStep("result", "lane", "getStackChain", [laneId])], + }; } if (sub === "refresh") { - return { kind: "execute", label: "lane refresh", steps: [actionStep("result", "lane", "refreshSnapshots", collectGenericObjectArgs(args, { includeArchived: readFlag(args, ["--archived", "--include-archived"]) }))] }; + return { + kind: "execute", + label: "lane refresh", + steps: [ + actionStep( + "result", + "lane", + "refreshSnapshots", + collectGenericObjectArgs(args, { + includeArchived: readFlag(args, [ + "--archived", + "--include-archived", + ]), + }), + ), + ], + }; } if (sub === "rename") { - const laneId = requireValue(readLaneId(args) ?? firstPositional(args), "laneId"); - return { kind: "execute", label: "lane rename", steps: [actionStep("result", "lane", "rename", collectGenericObjectArgs(args, { laneId, name: readValue(args, ["--name"]) ?? firstPositional(args) }))] }; + const laneId = requireValue( + readLaneId(args) ?? firstPositional(args), + "laneId", + ); + return { + kind: "execute", + label: "lane rename", + steps: [ + actionStep( + "result", + "lane", + "rename", + collectGenericObjectArgs(args, { + laneId, + name: readValue(args, ["--name"]) ?? firstPositional(args), + }), + ), + ], + }; } if (sub === "reparent") { - const laneId = requireValue(readLaneId(args) ?? firstPositional(args), "laneId"); - return { kind: "execute", label: "lane reparent", steps: [actionStep("result", "lane", "reparent", collectGenericObjectArgs(args, { laneId, newParentLaneId: readValue(args, ["--parent", "--parent-lane", "--parent-lane-id"]) ?? firstPositional(args) }))] }; + const laneId = requireValue( + readLaneId(args) ?? firstPositional(args), + "laneId", + ); + return { + kind: "execute", + label: "lane reparent", + steps: [ + actionStep( + "result", + "lane", + "reparent", + collectGenericObjectArgs(args, { + laneId, + newParentLaneId: + readValue(args, [ + "--parent", + "--parent-lane", + "--parent-lane-id", + ]) ?? firstPositional(args), + }), + ), + ], + }; } if (sub === "appearance") { - const laneId = requireValue(readLaneId(args) ?? firstPositional(args), "laneId"); - return { kind: "execute", label: "lane appearance", steps: [actionStep("result", "lane", "updateAppearance", collectGenericObjectArgs(args, { laneId, color: readValue(args, ["--color"]), icon: readValue(args, ["--icon"]) }))] }; + const laneId = requireValue( + readLaneId(args) ?? firstPositional(args), + "laneId", + ); + return { + kind: "execute", + label: "lane appearance", + steps: [ + actionStep( + "result", + "lane", + "updateAppearance", + collectGenericObjectArgs(args, { + laneId, + color: readValue(args, ["--color"]), + icon: readValue(args, ["--icon"]), + }), + ), + ], + }; } if (sub === "archive" || sub === "unarchive") { - const laneId = requireValue(readLaneId(args) ?? firstPositional(args), "laneId"); - return { kind: "execute", label: `lane ${sub}`, steps: [actionStep("result", "lane", sub, collectGenericObjectArgs(args, { laneId }))] }; + const laneId = requireValue( + readLaneId(args) ?? firstPositional(args), + "laneId", + ); + return { + kind: "execute", + label: `lane ${sub}`, + steps: [ + actionStep( + "result", + "lane", + sub, + collectGenericObjectArgs(args, { laneId }), + ), + ], + }; } if (sub === "delete" || sub === "rm") { - const laneId = requireValue(readLaneId(args) ?? firstPositional(args), "laneId"); - return { kind: "execute", label: "lane delete", steps: [actionStep("result", "lane", "delete", collectGenericObjectArgs(args, { laneId, force: readFlag(args, ["--force"]), deleteBranch: readFlag(args, ["--delete-branch"]), deleteRemoteBranch: readFlag(args, ["--delete-remote-branch"]) }))] }; + const laneId = requireValue( + readLaneId(args) ?? firstPositional(args), + "laneId", + ); + return { + kind: "execute", + label: "lane delete", + steps: [ + actionStep( + "result", + "lane", + "delete", + collectGenericObjectArgs(args, { + laneId, + force: readFlag(args, ["--force"]), + deleteBranch: readFlag(args, ["--delete-branch"]), + deleteRemoteBranch: readFlag(args, ["--delete-remote-branch"]), + }), + ), + ], + }; } if (sub === "attach") { - return { kind: "execute", label: "lane attach", steps: [actionStep("result", "lane", "attach", collectGenericObjectArgs(args, { worktreePath: readValue(args, ["--path"]) ?? firstPositional(args), name: readValue(args, ["--name"]) }))] }; + return { + kind: "execute", + label: "lane attach", + steps: [ + actionStep( + "result", + "lane", + "attach", + collectGenericObjectArgs(args, { + worktreePath: readValue(args, ["--path"]) ?? firstPositional(args), + name: readValue(args, ["--name"]), + }), + ), + ], + }; } if (sub === "adopt-attached") { - const laneId = requireValue(readLaneId(args) ?? firstPositional(args), "laneId"); - return { kind: "execute", label: "lane adopt attached", steps: [actionStep("result", "lane", "adoptAttached", collectGenericObjectArgs(args, { laneId }))] }; + const laneId = requireValue( + readLaneId(args) ?? firstPositional(args), + "laneId", + ); + return { + kind: "execute", + label: "lane adopt attached", + steps: [ + actionStep( + "result", + "lane", + "adoptAttached", + collectGenericObjectArgs(args, { laneId }), + ), + ], + }; } if (sub === "split-unstaged") { - return { kind: "execute", label: "lane split unstaged", steps: [actionStep("result", "lane", "createFromUnstaged", collectGenericObjectArgs(args, { sourceLaneId: readValue(args, ["--source", "--source-lane"]) ?? readLaneId(args), name: readValue(args, ["--name"]) ?? firstPositional(args) }))] }; + return { + kind: "execute", + label: "lane split unstaged", + steps: [ + actionStep( + "result", + "lane", + "createFromUnstaged", + collectGenericObjectArgs(args, { + sourceLaneId: + readValue(args, ["--source", "--source-lane"]) ?? + readLaneId(args), + name: readValue(args, ["--name"]) ?? firstPositional(args), + }), + ), + ], + }; } if (sub === "import" || sub === "import-branch") { const input: JsonObject = {}; - input.branchRef = requireValue(readValue(args, ["--branch", "--branch-ref"]) ?? firstPositional(args), "branchRef"); + input.branchRef = requireValue( + readValue(args, ["--branch", "--branch-ref"]) ?? firstPositional(args), + "branchRef", + ); maybePut(input, "name", readValue(args, ["--name"])); - maybePut(input, "description", readValue(args, ["--description", "--desc"])); + maybePut( + input, + "description", + readValue(args, ["--description", "--desc"]), + ); maybePut(input, "baseBranch", readValue(args, ["--base", "--base-branch"])); - return { kind: "execute", label: "lane import", steps: [actionCallStep("result", "import_lane", collectGenericObjectArgs(args, input))] }; + return { + kind: "execute", + label: "lane import", + steps: [ + actionCallStep( + "result", + "import_lane", + collectGenericObjectArgs(args, input), + ), + ], + }; } if (sub === "unregistered" || sub === "list-unregistered") { - return { kind: "execute", label: "unregistered lanes", steps: [actionCallStep("result", "list_unregistered_lanes", collectGenericObjectArgs(args))] }; + return { + kind: "execute", + label: "unregistered lanes", + steps: [ + actionCallStep( + "result", + "list_unregistered_lanes", + collectGenericObjectArgs(args), + ), + ], + }; } - return { kind: "execute", label: `lane ${sub}`, steps: [actionStep("result", "lane", sub, collectGenericObjectArgs(args))] }; + return { + kind: "execute", + label: `lane ${sub}`, + steps: [actionStep("result", "lane", sub, collectGenericObjectArgs(args))], + }; } function buildGitPlan(args: string[]): CliPlan { const sub = firstPositional(args) ?? "status"; if (sub === "actions") { - return { kind: "execute", label: "git actions", steps: [listActionsStep("actions", "git")] }; + return { + kind: "execute", + label: "git actions", + steps: [listActionsStep("actions", "git")], + }; } if (sub === "action") { - return { kind: "execute", label: "git action", steps: [buildActionRunStep(["git", ...args])] }; + return { + kind: "execute", + label: "git action", + steps: [buildActionRunStep(["git", ...args])], + }; } const laneId = readLaneId(args); - const withLane = (base: JsonObject = {}) => collectGenericObjectArgs(args, { ...base, ...(laneId ? { laneId } : {}) }); - - if (sub === "status" || sub === "sync-status") return { kind: "execute", label: "git status", steps: [actionCallStep("result", "git_get_sync_status", withLane())] }; - if (sub === "fetch") return { kind: "execute", label: "git fetch", steps: [actionCallStep("result", "git_fetch", withLane())] }; - if (sub === "pull") return { kind: "execute", label: "git pull", steps: [actionCallStep("result", "git_pull", withLane())] }; + const withLane = (base: JsonObject = {}) => + collectGenericObjectArgs(args, { ...base, ...(laneId ? { laneId } : {}) }); + + if (sub === "status" || sub === "sync-status") { + const full = + readFlag(args, ["--full"]) || peekFirstPositional(args) === "full"; + if (full && peekFirstPositional(args) === "full") firstPositional(args); + if (full) + return { + kind: "execute", + label: "lane status", + steps: [actionCallStep("result", "get_lane_status", withLane())], + }; + return { + kind: "execute", + label: "git status", + steps: [actionCallStep("result", "git_get_sync_status", withLane())], + }; + } + if (sub === "fetch") + return { + kind: "execute", + label: "git fetch", + steps: [actionCallStep("result", "git_fetch", withLane())], + }; + if (sub === "pull") + return { + kind: "execute", + label: "git pull", + steps: [actionCallStep("result", "git_pull", withLane())], + }; + if (sub === "sync") { + const explicitMode = readValue(args, ["--mode"]); + const mode = readFlag(args, ["--rebase"]) + ? "rebase" + : readFlag(args, ["--merge"]) + ? "merge" + : explicitMode; + if (mode && mode !== "merge" && mode !== "rebase") { + throw new CliUsageError("--mode must be either merge or rebase."); + } + const baseRef = readValue(args, ["--base", "--base-ref"]); + return { + kind: "execute", + label: "git sync", + steps: [ + actionStep( + "result", + "git", + "sync", + withLane({ + ...(mode ? { mode } : {}), + ...(baseRef ? { baseRef } : {}), + }), + ), + ], + }; + } if (sub === "push") { const forceWithLease = readFlag(args, ["--force", "--force-with-lease"]); const setUpstream = readFlag(args, ["--set-upstream", "-u"]); - return { kind: "execute", label: "git push", steps: [actionCallStep("result", "git_push", withLane({ forceWithLease, setUpstream }))] }; + return { + kind: "execute", + label: "git push", + steps: [ + actionCallStep( + "result", + "git_push", + withLane({ forceWithLease, setUpstream }), + ), + ], + }; } if (sub === "commit") { const input: JsonObject = {}; maybePut(input, "message", readValue(args, ["--message", "-m"])); maybePut(input, "amend", readFlag(args, ["--amend"])); input.stageAll = !readFlag(args, ["--no-stage-all"]); - return { kind: "execute", label: "git commit", steps: [actionCallStep("result", "commit_changes", withLane(input))] }; + return { + kind: "execute", + label: "git commit", + steps: [actionCallStep("result", "commit_changes", withLane(input))], + }; } if (sub === "generate-message") { - return { kind: "execute", label: "git commit message", steps: [actionCallStep("result", "generate_commit_message", withLane({ amend: readFlag(args, ["--amend"]) }))] }; + return { + kind: "execute", + label: "git commit message", + steps: [ + actionCallStep( + "result", + "generate_commit_message", + withLane({ amend: readFlag(args, ["--amend"]) }), + ), + ], + }; } - if (sub === "branches" || sub === "branch") return { kind: "execute", label: "git branches", steps: [actionCallStep("result", "git_list_branches", withLane())] }; + if (sub === "branches" || sub === "branch") + return { + kind: "execute", + label: "git branches", + steps: [actionCallStep("result", "git_list_branches", withLane())], + }; if (sub === "user-identity" || sub === "user" || sub === "identity") { - return { kind: "execute", label: "git user identity", steps: [actionCallStep("result", "git_get_user_identity", withLane())] }; + return { + kind: "execute", + label: "git user identity", + steps: [actionCallStep("result", "git_get_user_identity", withLane())], + }; } if (sub === "checkout") { - const branchName = requireValue(readValue(args, ["--branch", "--branch-name"]) ?? firstPositional(args), "branchName"); + const branchName = requireValue( + readValue(args, ["--branch", "--branch-name"]) ?? firstPositional(args), + "branchName", + ); const create = readFlag(args, ["--create", "-b"]); const startPoint = readValue(args, ["--start-point", "--from"]); const baseRef = readValue(args, ["--base", "--base-ref"]); @@ -2118,30 +2809,131 @@ function buildGitPlan(args: string[]): CliPlan { return { kind: "execute", label: "git checkout", - steps: [actionCallStep("result", "git_checkout_branch", withLane({ - branchName, - mode: create ? "create" : "existing", - ...(startPoint ? { startPoint } : {}), - ...(baseRef ? { baseRef } : {}), - acknowledgeActiveWork, - }))] + steps: [ + actionCallStep( + "result", + "git_checkout_branch", + withLane({ + branchName, + mode: create ? "create" : "existing", + ...(startPoint ? { startPoint } : {}), + ...(baseRef ? { baseRef } : {}), + acknowledgeActiveWork, + }), + ), + ], }; } - if (sub === "conflicts") return { kind: "execute", label: "git conflicts", steps: [actionCallStep("result", "get_lane_conflict_state", withLane())] }; + if (sub === "conflict" || sub === "conflicts") { + const action = firstPositional(args) ?? "show"; + if (action === "show" || action === "status") { + return { + kind: "execute", + label: "git conflicts", + steps: [ + actionCallStep("result", "get_lane_conflict_state", withLane()), + ], + }; + } + if (action === "resolve" || action === "continue") { + const kind = + readValue(args, ["--kind"]) ?? + (readFlag(args, ["--merge"]) + ? "merge" + : readFlag(args, ["--rebase"]) + ? "rebase" + : null); + if (kind === "rebase") + return { + kind: "execute", + label: "rebase continue", + steps: [actionCallStep("result", "rebase_continue", withLane())], + }; + if (kind === "merge") + return { + kind: "execute", + label: "merge continue", + steps: [actionStep("result", "git", "mergeContinue", withLane())], + }; + throw new CliUsageError( + "git conflict resolve requires --kind rebase or --kind merge.", + ); + } + if (action === "abort") { + const kind = + readValue(args, ["--kind"]) ?? + (readFlag(args, ["--merge"]) + ? "merge" + : readFlag(args, ["--rebase"]) + ? "rebase" + : null); + if (kind === "rebase") + return { + kind: "execute", + label: "rebase abort", + steps: [actionCallStep("result", "rebase_abort", withLane())], + }; + if (kind === "merge") + return { + kind: "execute", + label: "merge abort", + steps: [actionStep("result", "git", "mergeAbort", withLane())], + }; + throw new CliUsageError( + "git conflict abort requires --kind rebase or --kind merge.", + ); + } + throw new CliUsageError( + "git conflict supports show, resolve, continue, or abort.", + ); + } if (sub === "rebase") { const mode = firstPositional(args); - if (mode === "continue") return { kind: "execute", label: "rebase continue", steps: [actionCallStep("result", "rebase_continue", withLane())] }; - if (mode === "abort") return { kind: "execute", label: "rebase abort", steps: [actionCallStep("result", "rebase_abort", withLane())] }; - return { kind: "execute", label: "rebase lane", steps: [actionCallStep("result", "rebase_lane", withLane({ aiAssisted: readFlag(args, ["--ai", "--ai-assisted"]) }))] }; - } - if (sub === "merge") { - const mode = requireValue(firstPositional(args), "merge action"); - if (mode !== "continue" && mode !== "abort") throw new CliUsageError("git merge supports continue or abort."); - return { kind: "execute", label: `merge ${mode}`, steps: [actionStep("result", "git", mode === "continue" ? "mergeContinue" : "mergeAbort", withLane())] }; - } - if (sub === "stash") { - const action = firstPositional(args) ?? "list"; - const stashRef = readValue(args, ["--ref", "--stash-ref"]) ?? firstPositional(args); + if (mode === "continue") + return { + kind: "execute", + label: "rebase continue", + steps: [actionCallStep("result", "rebase_continue", withLane())], + }; + if (mode === "abort") + return { + kind: "execute", + label: "rebase abort", + steps: [actionCallStep("result", "rebase_abort", withLane())], + }; + return { + kind: "execute", + label: "rebase lane", + steps: [ + actionCallStep( + "result", + "rebase_lane", + withLane({ aiAssisted: readFlag(args, ["--ai", "--ai-assisted"]) }), + ), + ], + }; + } + if (sub === "merge") { + const mode = requireValue(firstPositional(args), "merge action"); + if (mode !== "continue" && mode !== "abort") + throw new CliUsageError("git merge supports continue or abort."); + return { + kind: "execute", + label: `merge ${mode}`, + steps: [ + actionStep( + "result", + "git", + mode === "continue" ? "mergeContinue" : "mergeAbort", + withLane(), + ), + ], + }; + } + if (sub === "stash") { + const action = firstPositional(args) ?? "list"; + const stashRef = + readValue(args, ["--ref", "--stash-ref"]) ?? firstPositional(args); const message = readValue(args, ["--message", "-m"]); const common = withLane({ ...(stashRef ? { stashRef } : {}), @@ -2160,58 +2952,158 @@ function buildGitPlan(args: string[]): CliPlan { }; const toolName = toolNameByAction[action]; if (!toolName) throw new CliUsageError(`Unknown stash action '${action}'.`); - return { kind: "execute", label: `git stash ${action}`, steps: [actionCallStep("result", toolName, common)] }; + return { + kind: "execute", + label: `git stash ${action}`, + steps: [actionCallStep("result", toolName, common)], + }; } if (sub === "diff") { return buildDiffPlan([...(laneId ? ["--lane", laneId] : []), ...args]); } - if (sub === "stage" || sub === "unstage" || sub === "discard" || sub === "restore") { - const pathArg = requireValue(readValue(args, ["--path"]) ?? firstPositional(args), "path"); + if ( + sub === "stage" || + sub === "unstage" || + sub === "discard" || + sub === "restore" + ) { + const pathArg = requireValue( + readValue(args, ["--path"]) ?? firstPositional(args), + "path", + ); const actionBySub: Record = { stage: "stageFile", unstage: "unstageFile", discard: "discardFile", restore: "restoreStagedFile", }; - return { kind: "execute", label: `git ${sub}`, steps: [actionStep("result", "git", actionBySub[sub]!, withLane({ path: pathArg }))] }; + return { + kind: "execute", + label: `git ${sub}`, + steps: [ + actionStep( + "result", + "git", + actionBySub[sub]!, + withLane({ path: pathArg }), + ), + ], + }; } if (sub === "stage-all" || sub === "unstage-all") { const paths = args.filter((entry) => !entry.startsWith("-")); const action = sub === "stage-all" ? "stageAll" : "unstageAll"; - return { kind: "execute", label: `git ${sub}`, steps: [actionStep("result", "git", action, withLane({ paths }))] }; + return { + kind: "execute", + label: `git ${sub}`, + steps: [actionStep("result", "git", action, withLane({ paths }))], + }; } if (sub === "files" || sub === "commit-files") { - const commitSha = requireValue(readValue(args, ["--commit", "--sha"]) ?? firstPositional(args), "commitSha"); - return { kind: "execute", label: "git commit files", steps: [actionStep("result", "git", "listCommitFiles", withLane({ commitSha }))] }; + const commitSha = requireValue( + readValue(args, ["--commit", "--sha"]) ?? firstPositional(args), + "commitSha", + ); + return { + kind: "execute", + label: "git commit files", + steps: [ + actionStep("result", "git", "listCommitFiles", withLane({ commitSha })), + ], + }; } if (sub === "message" || sub === "commit-message" || sub === "show-message") { - const commitSha = readValue(args, ["--commit", "--sha"]) ?? firstPositional(args); - if (commitSha) return { kind: "execute", label: "git commit message", steps: [actionStep("result", "git", "getCommitMessage", withLane({ commitSha }))] }; - return { kind: "execute", label: "git commit message", steps: [actionCallStep("result", "generate_commit_message", withLane({ amend: readFlag(args, ["--amend"]) }))] }; + const commitSha = + readValue(args, ["--commit", "--sha"]) ?? firstPositional(args); + if (commitSha) + return { + kind: "execute", + label: "git commit message", + steps: [ + actionStep( + "result", + "git", + "getCommitMessage", + withLane({ commitSha }), + ), + ], + }; + return { + kind: "execute", + label: "git commit message", + steps: [ + actionCallStep( + "result", + "generate_commit_message", + withLane({ amend: readFlag(args, ["--amend"]) }), + ), + ], + }; } if (sub === "history" || sub === "file-history") { - const filePath = requireValue(readValue(args, ["--path"]) ?? firstPositional(args), "path"); - return { kind: "execute", label: "git file history", steps: [actionStep("result", "git", "getFileHistory", withLane({ path: filePath, limit: readIntOption(args, ["--limit"]) }))] }; + const filePath = requireValue( + readValue(args, ["--path"]) ?? firstPositional(args), + "path", + ); + return { + kind: "execute", + label: "git file history", + steps: [ + actionStep( + "result", + "git", + "getFileHistory", + withLane({ path: filePath, limit: readIntOption(args, ["--limit"]) }), + ), + ], + }; } if (sub === "revert" || sub === "cherry-pick") { - const commitSha = requireValue(readValue(args, ["--commit", "--sha"]) ?? firstPositional(args), "commitSha"); - return { kind: "execute", label: `git ${sub}`, steps: [actionStep("result", "git", sub === "revert" ? "revertCommit" : "cherryPickCommit", withLane({ commitSha }))] }; + const commitSha = requireValue( + readValue(args, ["--commit", "--sha"]) ?? firstPositional(args), + "commitSha", + ); + return { + kind: "execute", + label: `git ${sub}`, + steps: [ + actionStep( + "result", + "git", + sub === "revert" ? "revertCommit" : "cherryPickCommit", + withLane({ commitSha }), + ), + ], + }; } const actionAliases: Record = { commits: "listRecentCommits", sync: "sync", }; - return { kind: "execute", label: `git ${sub}`, steps: [actionStep("result", "git", actionAliases[sub] ?? sub, withLane())] }; + return { + kind: "execute", + label: `git ${sub}`, + steps: [actionStep("result", "git", actionAliases[sub] ?? sub, withLane())], + }; } function buildDiffPlan(args: string[]): CliPlan { const sub = firstPositional(args) ?? "changes"; - if (sub === "actions") return { kind: "execute", label: "diff actions", steps: [listActionsStep("actions", "diff")] }; + if (sub === "actions") + return { + kind: "execute", + label: "diff actions", + steps: [listActionsStep("actions", "diff")], + }; const laneId = readLaneId(args); - const withLane = (base: JsonObject = {}) => collectGenericObjectArgs(args, { ...base, ...(laneId ? { laneId } : {}) }); + const withLane = (base: JsonObject = {}) => + collectGenericObjectArgs(args, { ...base, ...(laneId ? { laneId } : {}) }); if (sub === "changes" || sub === "summary") { - const id = requireValue(laneId ?? readValue(args, ["--lane", "--lane-id"]), "laneId"); + const id = requireValue( + laneId ?? readValue(args, ["--lane", "--lane-id"]), + "laneId", + ); return { kind: "execute", label: "diff changes", @@ -2219,51 +3111,113 @@ function buildDiffPlan(args: string[]): CliPlan { }; } if (sub === "file") { - const filePath = requireValue(readValue(args, ["--path"]) ?? firstPositional(args), "path"); + const filePath = requireValue( + readValue(args, ["--path"]) ?? firstPositional(args), + "path", + ); return { kind: "execute", label: "diff file", - steps: [actionStep("result", "diff", "getFileDiff", withLane({ - filePath, - mode: readValue(args, ["--mode"]) ?? "unstaged", - compareRef: readValue(args, ["--compare-ref", "--base"]), - compareTo: readValue(args, ["--compare-to", "--head"]), - }))], + steps: [ + actionStep( + "result", + "diff", + "getFileDiff", + withLane({ + filePath, + mode: readValue(args, ["--mode"]) ?? "unstaged", + compareRef: readValue(args, ["--compare-ref", "--base"]), + compareTo: readValue(args, ["--compare-to", "--head"]), + }), + ), + ], }; } if (sub === "patch") { - const filePath = requireValue(readValue(args, ["--path"]) ?? firstPositional(args), "path"); + const filePath = requireValue( + readValue(args, ["--path"]) ?? firstPositional(args), + "path", + ); return { kind: "execute", label: "diff patch", - steps: [actionStep("result", "diff", "getFilePatch", withLane({ - filePath, - mode: readValue(args, ["--mode"]) ?? "unstaged", - compareRef: readValue(args, ["--compare-ref", "--base"]), - compareTo: readValue(args, ["--compare-to", "--head"]), - }))], + steps: [ + actionStep( + "result", + "diff", + "getFilePatch", + withLane({ + filePath, + mode: readValue(args, ["--mode"]) ?? "unstaged", + compareRef: readValue(args, ["--compare-ref", "--base"]), + compareTo: readValue(args, ["--compare-to", "--head"]), + }), + ), + ], }; } - return { kind: "execute", label: `diff ${sub}`, steps: [actionStep("result", "diff", sub, withLane())] }; + return { + kind: "execute", + label: `diff ${sub}`, + steps: [actionStep("result", "diff", sub, withLane())], + }; } function buildPrPlan(args: string[]): CliPlan { const sub = firstPositional(args) ?? "list"; - if (sub === "actions") return { kind: "execute", label: "PR actions", steps: [listActionsStep("actions", "pr")] }; - if (sub === "action") return { kind: "execute", label: "PR action", steps: [buildActionRunStep(["pr", ...args])] }; + if (sub === "actions") + return { + kind: "execute", + label: "PR actions", + steps: [listActionsStep("actions", "pr")], + }; + if (sub === "action") + return { + kind: "execute", + label: "PR action", + steps: [buildActionRunStep(["pr", ...args])], + }; const prId = readPrId(args); - const withPr = (base: JsonObject = {}) => collectGenericObjectArgs(args, { ...base, ...(prId ? { prId } : {}) }); + const withPr = (base: JsonObject = {}) => + collectGenericObjectArgs(args, { ...base, ...(prId ? { prId } : {}) }); - if (sub === "list" || sub === "ls") return { kind: "execute", label: "PR list", steps: [actionStep("result", "pr", "listAll", collectGenericObjectArgs(args))] }; + if (sub === "list" || sub === "ls") + return { + kind: "execute", + label: "PR list", + steps: [ + actionStep("result", "pr", "listAll", collectGenericObjectArgs(args)), + ], + }; if (sub === "list-open" || sub === "open" || sub === "list-repo-open") { - return { kind: "execute", label: "PR list open", steps: [actionCallStep("result", "prs_list_open", {})] }; + return { + kind: "execute", + label: "PR list open", + steps: [actionCallStep("result", "prs_list_open", {})], + }; } if (sub === "show" || sub === "detail" || sub === "view") { const id = requireValue(prId ?? firstPositional(args), "prId"); - return { kind: "execute", label: "PR detail", steps: [actionArgsListStep("result", "pr", "getDetail", [id])] }; + return { + kind: "execute", + label: "PR detail", + steps: [actionArgsListStep("result", "pr", "getDetail", [id])], + }; } - if (sub === "refresh") return { kind: "execute", label: "PR refresh", steps: [actionStep("result", "pr", "refresh", withPr({ prId: prId ?? firstPositional(args) }))] }; + if (sub === "refresh") + return { + kind: "execute", + label: "PR refresh", + steps: [ + actionStep( + "result", + "pr", + "refresh", + withPr({ prId: prId ?? firstPositional(args) }), + ), + ], + }; if (sub === "create") { const laneId = readLaneId(args) ?? readValue(args, ["--lane-id"]); const input: JsonObject = {}; @@ -2277,30 +3231,163 @@ function buildPrPlan(args: string[]): CliPlan { "--close-linear", "--fixes-linear-issue", ]); - return { kind: "execute", label: "PR create", steps: [actionCallStep("result", "create_pr_from_lane", collectGenericObjectArgs(args, input))] }; - } - if (sub === "health") return { kind: "execute", label: "PR health", steps: [actionCallStep("result", "get_pr_health", withPr({ prId: prId ?? firstPositional(args) }))] }; - if (sub === "checks") return { kind: "execute", label: "PR checks", steps: [actionCallStep("result", "pr_get_checks", withPr({ prId: requireValue(prId ?? firstPositional(args), "prId") }))] }; - if (sub === "comments" || sub === "review-comments") return { kind: "execute", label: "PR comments", steps: [actionCallStep("result", "pr_get_review_comments", withPr({ prId: requireValue(prId ?? firstPositional(args), "prId") }))] }; - if (sub === "rerun" || sub === "rerun-failed-checks") return { kind: "execute", label: "PR rerun failed checks", steps: [actionCallStep("result", "pr_rerun_failed_checks", withPr({ prId: prId ?? firstPositional(args) }))] }; - if (sub === "comment") return { kind: "execute", label: "PR comment", steps: [actionCallStep("result", "pr_add_comment", withPr({ prId: prId ?? firstPositional(args), body: readValue(args, ["--body"]) }))] }; - if (sub === "reply") return { kind: "execute", label: "PR thread reply", steps: [actionCallStep("result", "pr_reply_to_review_thread", withPr({ prId: prId ?? firstPositional(args), threadId: readValue(args, ["--thread", "--thread-id"]), body: readValue(args, ["--body"]) }))] }; - if (sub === "resolve-thread") return { kind: "execute", label: "PR resolve thread", steps: [actionCallStep("result", "pr_resolve_review_thread", withPr({ prId: requireValue(prId ?? firstPositional(args), "prId"), threadId: requireValue(readValue(args, ["--thread", "--thread-id"]), "threadId") }))] }; - if (sub === "title" || sub === "update-title") return { kind: "execute", label: "PR update title", steps: [actionCallStep("result", "pr_update_title", withPr({ prId: prId ?? firstPositional(args), title: readValue(args, ["--title"]) }))] }; - if (sub === "body" || sub === "update-body") return { kind: "execute", label: "PR update body", steps: [actionCallStep("result", "pr_update_body", withPr({ prId: prId ?? firstPositional(args), body: readValue(args, ["--body"]) ?? "" }))] }; + return { + kind: "execute", + label: "PR create", + steps: [ + actionCallStep( + "result", + "create_pr_from_lane", + collectGenericObjectArgs(args, input), + ), + ], + }; + } + if (sub === "health") + return { + kind: "execute", + label: "PR health", + steps: [ + actionCallStep( + "result", + "get_pr_health", + withPr({ prId: prId ?? firstPositional(args) }), + ), + ], + }; + if (sub === "checks") + return { + kind: "execute", + label: "PR checks", + steps: [ + actionCallStep( + "result", + "pr_get_checks", + withPr({ prId: requireValue(prId ?? firstPositional(args), "prId") }), + ), + ], + }; + if (sub === "comments" || sub === "review-comments") + return { + kind: "execute", + label: "PR comments", + steps: [ + actionCallStep( + "result", + "pr_get_review_comments", + withPr({ prId: requireValue(prId ?? firstPositional(args), "prId") }), + ), + ], + }; + if (sub === "rerun" || sub === "rerun-failed-checks") + return { + kind: "execute", + label: "PR rerun failed checks", + steps: [ + actionCallStep( + "result", + "pr_rerun_failed_checks", + withPr({ prId: prId ?? firstPositional(args) }), + ), + ], + }; + if (sub === "comment") + return { + kind: "execute", + label: "PR comment", + steps: [ + actionCallStep( + "result", + "pr_add_comment", + withPr({ + prId: prId ?? firstPositional(args), + body: readValue(args, ["--body"]), + }), + ), + ], + }; + if (sub === "reply") + return { + kind: "execute", + label: "PR thread reply", + steps: [ + actionCallStep( + "result", + "pr_reply_to_review_thread", + withPr({ + prId: prId ?? firstPositional(args), + threadId: readValue(args, ["--thread", "--thread-id"]), + body: readValue(args, ["--body"]), + }), + ), + ], + }; + if (sub === "resolve-thread") + return { + kind: "execute", + label: "PR resolve thread", + steps: [ + actionCallStep( + "result", + "pr_resolve_review_thread", + withPr({ + prId: requireValue(prId ?? firstPositional(args), "prId"), + threadId: requireValue( + readValue(args, ["--thread", "--thread-id"]), + "threadId", + ), + }), + ), + ], + }; + if (sub === "title" || sub === "update-title") + return { + kind: "execute", + label: "PR update title", + steps: [ + actionCallStep( + "result", + "pr_update_title", + withPr({ + prId: prId ?? firstPositional(args), + title: readValue(args, ["--title"]), + }), + ), + ], + }; + if (sub === "body" || sub === "update-body") + return { + kind: "execute", + label: "PR update body", + steps: [ + actionCallStep( + "result", + "pr_update_body", + withPr({ + prId: prId ?? firstPositional(args), + body: readValue(args, ["--body"]) ?? "", + }), + ), + ], + }; if (sub === "link") { const laneId = readLaneId(args) ?? firstPositional(args); const prUrlOrNumber = - readValue(args, ["--url", "--pr-url", "--number", "--pr-number"]) - ?? firstPositional(args); + readValue(args, ["--url", "--pr-url", "--number", "--pr-number"]) ?? + firstPositional(args); return { kind: "execute", label: "PR link", steps: [ - actionStep("result", "pr", "linkToLane", collectGenericObjectArgs(args, { - laneId: requireValue(laneId, "laneId"), - prUrlOrNumber: requireValue(prUrlOrNumber, "prUrlOrNumber"), - })), + actionStep( + "result", + "pr", + "linkToLane", + collectGenericObjectArgs(args, { + laneId: requireValue(laneId, "laneId"), + prUrlOrNumber: requireValue(prUrlOrNumber, "prUrlOrNumber"), + }), + ), ], }; } @@ -2319,69 +3406,290 @@ function buildPrPlan(args: string[]): CliPlan { }; if (scalarPrActions[sub]) { const id = requireValue(prId ?? firstPositional(args), "prId"); - return { kind: "execute", label: `PR ${sub}`, steps: [actionArgsListStep("result", "pr", scalarPrActions[sub]!, [id])] }; + return { + kind: "execute", + label: `PR ${sub}`, + steps: [actionArgsListStep("result", "pr", scalarPrActions[sub]!, [id])], + }; } - if (sub === "draft-description") return { kind: "execute", label: "PR draft description", steps: [actionStep("result", "pr", "draftDescription", collectGenericObjectArgs(args, { laneId: readLaneId(args) ?? firstPositional(args) }))] }; - if (sub === "update-description") return { kind: "execute", label: "PR update description", steps: [actionStep("result", "pr", "updateDescription", withPr({ prId: prId ?? firstPositional(args), title: readValue(args, ["--title"]), body: readValue(args, ["--body"]) }))] }; - if (sub === "delete" || sub === "land" || sub === "close" || sub === "reopen") { + if (sub === "draft-description") + return { + kind: "execute", + label: "PR draft description", + steps: [ + actionStep( + "result", + "pr", + "draftDescription", + collectGenericObjectArgs(args, { + laneId: readLaneId(args) ?? firstPositional(args), + }), + ), + ], + }; + if (sub === "update-description") + return { + kind: "execute", + label: "PR update description", + steps: [ + actionStep( + "result", + "pr", + "updateDescription", + withPr({ + prId: prId ?? firstPositional(args), + title: readValue(args, ["--title"]), + body: readValue(args, ["--body"]), + }), + ), + ], + }; + if ( + sub === "delete" || + sub === "land" || + sub === "close" || + sub === "reopen" + ) { const id = requireValue(prId ?? firstPositional(args), "prId"); - const actionBySub: Record = { delete: "delete", land: "land", close: "closePr", reopen: "reopenPr" }; - return { kind: "execute", label: `PR ${sub}`, steps: [actionStep("result", "pr", actionBySub[sub]!, collectGenericObjectArgs(args, { prId: id, method: readValue(args, ["--method"]) }))] }; + const actionBySub: Record = { + delete: "delete", + land: "land", + close: "closePr", + reopen: "reopenPr", + }; + return { + kind: "execute", + label: `PR ${sub}`, + steps: [ + actionStep( + "result", + "pr", + actionBySub[sub]!, + collectGenericObjectArgs(args, { + prId: id, + method: readValue(args, ["--method"]), + }), + ), + ], + }; } if (sub === "land-stack" || sub === "land-stack-enhanced") { - return { kind: "execute", label: `PR ${sub}`, steps: [actionStep("result", "pr", sub === "land-stack" ? "landStack" : "landStackEnhanced", collectGenericObjectArgs(args, { rootLaneId: readValue(args, ["--root", "--root-lane"]) ?? firstPositional(args) }))] }; + return { + kind: "execute", + label: `PR ${sub}`, + steps: [ + actionStep( + "result", + "pr", + sub === "land-stack" ? "landStack" : "landStackEnhanced", + collectGenericObjectArgs(args, { + rootLaneId: + readValue(args, ["--root", "--root-lane"]) ?? + firstPositional(args), + }), + ), + ], + }; } if (sub === "labels") { const mode = firstPositional(args) ?? "set"; if (mode !== "set") throw new CliUsageError("prs labels supports set."); const id = requireValue(prId ?? firstPositional(args), "prId"); - return { kind: "execute", label: "PR labels set", steps: [actionStep("result", "pr", "setLabels", collectGenericObjectArgs(args, { prId: id, labels: args.filter((entry) => !entry.startsWith("-")) }))] }; + return { + kind: "execute", + label: "PR labels set", + steps: [ + actionStep( + "result", + "pr", + "setLabels", + collectGenericObjectArgs(args, { + prId: id, + labels: args.filter((entry) => !entry.startsWith("-")), + }), + ), + ], + }; } if (sub === "reviewers") { const mode = firstPositional(args) ?? "request"; - if (mode !== "request") throw new CliUsageError("prs reviewers supports request."); + if (mode !== "request") + throw new CliUsageError("prs reviewers supports request."); const id = requireValue(prId ?? firstPositional(args), "prId"); - return { kind: "execute", label: "PR reviewers request", steps: [actionStep("result", "pr", "requestReviewers", collectGenericObjectArgs(args, { prId: id, reviewers: args.filter((entry) => !entry.startsWith("-")) }))] }; + return { + kind: "execute", + label: "PR reviewers request", + steps: [ + actionStep( + "result", + "pr", + "requestReviewers", + collectGenericObjectArgs(args, { + prId: id, + reviewers: args.filter((entry) => !entry.startsWith("-")), + }), + ), + ], + }; } if (sub === "review") { const mode = firstPositional(args) ?? "submit"; - if (mode !== "submit") throw new CliUsageError("prs review supports submit."); + if (mode !== "submit") + throw new CliUsageError("prs review supports submit."); const id = requireValue(prId ?? firstPositional(args), "prId"); - return { kind: "execute", label: "PR review submit", steps: [actionStep("result", "pr", "submitReview", collectGenericObjectArgs(args, { prId: id, event: readValue(args, ["--event"]) ?? "comment", body: readValue(args, ["--body"]) ?? "" }))] }; + return { + kind: "execute", + label: "PR review submit", + steps: [ + actionStep( + "result", + "pr", + "submitReview", + collectGenericObjectArgs(args, { + prId: id, + event: readValue(args, ["--event"]) ?? "comment", + body: readValue(args, ["--body"]) ?? "", + }), + ), + ], + }; } if (sub === "comment-react") { const id = requireValue(prId ?? firstPositional(args), "prId"); - return { kind: "execute", label: "PR comment react", steps: [actionStep("result", "pr", "reactToComment", collectGenericObjectArgs(args, { prId: id, commentId: readValue(args, ["--comment", "--comment-id"]), content: readValue(args, ["--content"]) }))] }; + return { + kind: "execute", + label: "PR comment react", + steps: [ + actionStep( + "result", + "pr", + "reactToComment", + collectGenericObjectArgs(args, { + prId: id, + commentId: readValue(args, ["--comment", "--comment-id"]), + content: readValue(args, ["--content"]), + }), + ), + ], + }; } if (sub === "review-comment") { const mode = firstPositional(args) ?? "post"; - if (mode !== "post") throw new CliUsageError("prs review-comment supports post."); + if (mode !== "post") + throw new CliUsageError("prs review-comment supports post."); const id = requireValue(prId ?? firstPositional(args), "prId"); - return { kind: "execute", label: "PR review comment post", steps: [actionStep("result", "pr", "postReviewComment", collectGenericObjectArgs(args, { prId: id, threadId: readValue(args, ["--thread", "--thread-id"]), body: readValue(args, ["--body"]) }))] }; + return { + kind: "execute", + label: "PR review comment post", + steps: [ + actionStep( + "result", + "pr", + "postReviewComment", + collectGenericObjectArgs(args, { + prId: id, + threadId: readValue(args, ["--thread", "--thread-id"]), + body: readValue(args, ["--body"]), + }), + ), + ], + }; } if (sub === "thread") { const mode = firstPositional(args) ?? "set-resolved"; - if (mode !== "set-resolved") throw new CliUsageError("prs thread supports set-resolved."); + if (mode !== "set-resolved") + throw new CliUsageError("prs thread supports set-resolved."); const id = requireValue(prId ?? firstPositional(args), "prId"); - return { kind: "execute", label: "PR thread set resolved", steps: [actionStep("result", "pr", "setReviewThreadResolved", collectGenericObjectArgs(args, { prId: id, threadId: readValue(args, ["--thread", "--thread-id"]), resolved: !readFlag(args, ["--unresolved"]) }))] }; + return { + kind: "execute", + label: "PR thread set resolved", + steps: [ + actionStep( + "result", + "pr", + "setReviewThreadResolved", + collectGenericObjectArgs(args, { + prId: id, + threadId: readValue(args, ["--thread", "--thread-id"]), + resolved: !readFlag(args, ["--unresolved"]), + }), + ), + ], + }; } - if (sub === "ai-review-summary") return { kind: "execute", label: "PR AI review summary", steps: [actionStep("result", "pr", "aiReviewSummary", withPr({ prId: prId ?? firstPositional(args) }))] }; - if (sub === "mobile-snapshot") return { kind: "execute", label: "PR mobile snapshot", steps: [actionArgsListStep("result", "pr", "getMobileSnapshot", [])] }; + if (sub === "ai-review-summary") + return { + kind: "execute", + label: "PR AI review summary", + steps: [ + actionStep( + "result", + "pr", + "aiReviewSummary", + withPr({ prId: prId ?? firstPositional(args) }), + ), + ], + }; + if (sub === "mobile-snapshot") + return { + kind: "execute", + label: "PR mobile snapshot", + steps: [actionArgsListStep("result", "pr", "getMobileSnapshot", [])], + }; if (sub === "snapshots") { const mode = firstPositional(args) ?? "list"; const action = mode === "refresh" ? "refreshSnapshots" : "listSnapshots"; - return { kind: "execute", label: `PR snapshots ${mode}`, steps: [actionStep("result", "pr", action, withPr({ prId: prId ?? firstPositional(args) }))] }; + return { + kind: "execute", + label: `PR snapshots ${mode}`, + steps: [ + actionStep( + "result", + "pr", + action, + withPr({ prId: prId ?? firstPositional(args) }), + ), + ], + }; } - if (sub === "github-snapshot") return { kind: "execute", label: "PR GitHub snapshot", steps: [actionStep("result", "pr", "getGithubSnapshot", collectGenericObjectArgs(args, { force: readFlag(args, ["--force"]) }))] }; + if (sub === "github-snapshot") + return { + kind: "execute", + label: "PR GitHub snapshot", + steps: [ + actionStep( + "result", + "pr", + "getGithubSnapshot", + collectGenericObjectArgs(args, { + force: readFlag(args, ["--force"]), + }), + ), + ], + }; if (sub === "conflicts") { const mode = firstPositional(args) ?? "list"; - if (mode === "list") return { kind: "execute", label: "PR conflicts list", steps: [actionArgsListStep("result", "pr", "listWithConflicts", [])] }; + if (mode === "list") + return { + kind: "execute", + label: "PR conflicts list", + steps: [actionArgsListStep("result", "pr", "listWithConflicts", [])], + }; const id = requireValue(prId ?? firstPositional(args), "prId"); - const action = mode === "analysis" ? "getConflictAnalysis" : "getMergeContext"; - return { kind: "execute", label: `PR conflicts ${mode}`, steps: [actionArgsListStep("result", "pr", action, [id])] }; + const action = + mode === "analysis" ? "getConflictAnalysis" : "getMergeContext"; + return { + kind: "execute", + label: `PR conflicts ${mode}`, + steps: [actionArgsListStep("result", "pr", action, [id])], + }; } - if (sub === "path-to-merge" || sub === "resolve" || sub === "issue-resolution") { + if ( + sub === "path-to-merge" || + sub === "resolve" || + sub === "issue-resolution" + ) { let mode = "start"; let positionalPrId = firstPositional(args); if (positionalPrId === "start" || positionalPrId === "preview") { @@ -2390,15 +3698,26 @@ function buildPrPlan(args: string[]): CliPlan { } const id = requireValue(prId ?? positionalPrId, "prId"); const scope = readValue(args, ["--scope"]) ?? "both"; - const modelId = requireValue(readValue(args, ["--model", "--model-id"]), "--model"); + const modelId = requireValue( + readValue(args, ["--model", "--model-id"]), + "--model", + ); const input: JsonObject = { prId: id, scope, modelId, }; maybePut(input, "reasoning", readValue(args, ["--reasoning"])); - maybePut(input, "permissionMode", readValue(args, ["--permission-mode", "--permissions"])); - maybePut(input, "additionalInstructions", readValue(args, ["--instructions", "--additional-instructions"])); + maybePut( + input, + "permissionMode", + readValue(args, ["--permission-mode", "--permissions"]), + ); + maybePut( + input, + "additionalInstructions", + readValue(args, ["--instructions", "--additional-instructions"]), + ); // Path to Merge orchestrator reads conflictStrategy / forceFinalizeMode / // earlyMergeOnGreen / autoMerge / maxRounds / mergeMethod from saved // PipelineSettings, not from the launch args. Persist any user-supplied @@ -2406,15 +3725,32 @@ function buildPrPlan(args: string[]): CliPlan { const pipelinePatch = readPipelineSettingsPatch(args); const steps: InvocationStep[] = []; if (Object.keys(pipelinePatch).length > 0) { - steps.push(actionArgsListStep("pipelineSettings", "issue_inventory", "savePipelineSettings", [ - id, - pipelinePatch, - ])); + steps.push( + actionArgsListStep( + "pipelineSettings", + "issue_inventory", + "savePipelineSettings", + [id, pipelinePatch], + ), + ); } if (mode === "preview") { - steps.push(actionCallStep("result", "pr_preview_issue_resolution_prompt", collectGenericObjectArgs(args, input))); + steps.push( + actionCallStep( + "result", + "pr_preview_issue_resolution_prompt", + collectGenericObjectArgs(args, input), + ), + ); } else { - steps.push(actionStep("result", "path_to_merge", "startPathToMerge", collectGenericObjectArgs(args, input))); + steps.push( + actionStep( + "result", + "path_to_merge", + "startPathToMerge", + collectGenericObjectArgs(args, input), + ), + ); } return { kind: "execute", label: `PR path-to-merge ${mode}`, steps }; } @@ -2422,25 +3758,117 @@ function buildPrPlan(args: string[]): CliPlan { if (sub === "pipeline") { const mode = firstPositional(args) ?? "get"; const id = requireValue(prId ?? firstPositional(args), "prId"); - if (mode === "get") return { kind: "execute", label: "PR pipeline", steps: [actionArgsListStep("result", "issue_inventory", "getPipelineSettings", [id])] }; - if (mode === "delete") return { kind: "execute", label: "PR pipeline delete", steps: [actionArgsListStep("result", "issue_inventory", "deletePipelineSettings", [id])] }; - const settings = collectGenericObjectArgs(args, readPipelineSettingsPatch(args)); - return { kind: "execute", label: "PR pipeline save", steps: [actionArgsListStep("result", "issue_inventory", "savePipelineSettings", [id, settings])] }; + if (mode === "get") + return { + kind: "execute", + label: "PR pipeline", + steps: [ + actionArgsListStep( + "result", + "issue_inventory", + "getPipelineSettings", + [id], + ), + ], + }; + if (mode === "delete") + return { + kind: "execute", + label: "PR pipeline delete", + steps: [ + actionArgsListStep( + "result", + "issue_inventory", + "deletePipelineSettings", + [id], + ), + ], + }; + const settings = collectGenericObjectArgs( + args, + readPipelineSettingsPatch(args), + ); + return { + kind: "execute", + label: "PR pipeline save", + steps: [ + actionArgsListStep( + "result", + "issue_inventory", + "savePipelineSettings", + [id, settings], + ), + ], + }; } if (sub === "queue") { const mode = firstPositional(args) ?? "create"; if (mode === "state" || mode === "list") { - const groupId = requireValue(readValue(args, ["--group", "--group-id"]) ?? firstPositional(args), "groupId"); - return { kind: "execute", label: `queue ${mode}`, steps: [actionArgsListStep("result", "pr", mode === "state" ? "getQueueState" : "listGroupPrs", [groupId])] }; + const groupId = requireValue( + readValue(args, ["--group", "--group-id"]) ?? firstPositional(args), + "groupId", + ); + return { + kind: "execute", + label: `queue ${mode}`, + steps: [ + actionArgsListStep( + "result", + "pr", + mode === "state" ? "getQueueState" : "listGroupPrs", + [groupId], + ), + ], + }; } if (mode === "reorder") { - return { kind: "execute", label: "queue reorder", steps: [actionStep("result", "pr", "reorderQueuePrs", collectGenericObjectArgs(args, { groupId: readValue(args, ["--group", "--group-id"]) ?? firstPositional(args) }))] }; - } - if (mode === "land-next") { - return { kind: "execute", label: "queue land next", steps: [actionCallStep("result", "land_queue_next", collectGenericObjectArgs(args, { groupId: readValue(args, ["--group", "--group-id"]) ?? firstPositional(args), method: readValue(args, ["--method"]) ?? "squash" }))] }; + return { + kind: "execute", + label: "queue reorder", + steps: [ + actionStep( + "result", + "pr", + "reorderQueuePrs", + collectGenericObjectArgs(args, { + groupId: + readValue(args, ["--group", "--group-id"]) ?? + firstPositional(args), + }), + ), + ], + }; + } + if (mode === "land-next") { + return { + kind: "execute", + label: "queue land next", + steps: [ + actionCallStep( + "result", + "land_queue_next", + collectGenericObjectArgs(args, { + groupId: + readValue(args, ["--group", "--group-id"]) ?? + firstPositional(args), + method: readValue(args, ["--method"]) ?? "squash", + }), + ), + ], + }; } - return { kind: "execute", label: "queue create", steps: [actionCallStep("result", "create_queue", collectGenericObjectArgs(args))] }; + return { + kind: "execute", + label: "queue create", + steps: [ + actionCallStep( + "result", + "create_queue", + collectGenericObjectArgs(args), + ), + ], + }; } if (sub === "integration") { @@ -2456,28 +3884,88 @@ function buildPrPlan(args: string[]): CliPlan { "recheck-step": "recheckIntegrationStep", }; if (integrationMap[mode]) { - return { kind: "execute", label: `integration ${mode}`, steps: [actionStep("result", "pr", integrationMap[mode]!, collectGenericObjectArgs(args))] }; + return { + kind: "execute", + label: `integration ${mode}`, + steps: [ + actionStep( + "result", + "pr", + integrationMap[mode]!, + collectGenericObjectArgs(args), + ), + ], + }; } if (mode === "lane") { const laneMode = firstPositional(args) ?? "create"; - if (laneMode !== "create") throw new CliUsageError("prs integration lane supports create."); - return { kind: "execute", label: "integration lane create", steps: [actionStep("result", "pr", "createIntegrationLane", collectGenericObjectArgs(args))] }; + if (laneMode !== "create") + throw new CliUsageError("prs integration lane supports create."); + return { + kind: "execute", + label: "integration lane create", + steps: [ + actionStep( + "result", + "pr", + "createIntegrationLane", + collectGenericObjectArgs(args), + ), + ], + }; } if (mode === "cleanup") { const cleanupMode = firstPositional(args) ?? "run"; - return { kind: "execute", label: `integration cleanup ${cleanupMode}`, steps: [actionStep("result", "pr", cleanupMode === "dismiss" ? "dismissIntegrationCleanup" : "cleanupIntegrationWorkflow", collectGenericObjectArgs(args))] }; + return { + kind: "execute", + label: `integration cleanup ${cleanupMode}`, + steps: [ + actionStep( + "result", + "pr", + cleanupMode === "dismiss" + ? "dismissIntegrationCleanup" + : "cleanupIntegrationWorkflow", + collectGenericObjectArgs(args), + ), + ], + }; } - const tool = mode === "create" ? "create_integration" : "simulate_integration"; - return { kind: "execute", label: `integration ${mode}`, steps: [actionCallStep("result", tool, collectGenericObjectArgs(args))] }; + const tool = + mode === "create" ? "create_integration" : "simulate_integration"; + return { + kind: "execute", + label: `integration ${mode}`, + steps: [actionCallStep("result", tool, collectGenericObjectArgs(args))], + }; } if (sub === "inventory") { const first = firstPositional(args); - const knownModes = new Set(["refresh", "get", "new", "mark-sent", "mark-fixed", "dismiss", "escalate", "reset"]); + const knownModes = new Set([ + "refresh", + "get", + "new", + "mark-sent", + "mark-fixed", + "dismiss", + "escalate", + "reset", + ]); const mode = first && knownModes.has(first) ? first : "refresh"; const positionalPrId = mode === "refresh" ? first : firstPositional(args); if (mode === "refresh") { - return { kind: "execute", label: "PR inventory", steps: [actionCallStep("result", "pr_refresh_issue_inventory", withPr({ prId: requireValue(prId ?? positionalPrId, "prId") }))] }; + return { + kind: "execute", + label: "PR inventory", + steps: [ + actionCallStep( + "result", + "pr_refresh_issue_inventory", + withPr({ prId: requireValue(prId ?? positionalPrId, "prId") }), + ), + ], + }; } const actionByMode: Record = { get: "getInventory", @@ -2489,19 +3977,38 @@ function buildPrPlan(args: string[]): CliPlan { reset: "resetInventory", }; const action = actionByMode[mode]; - if (!action) throw new CliUsageError("prs inventory supports get, new, mark-sent, mark-fixed, dismiss, escalate, or reset."); + if (!action) + throw new CliUsageError( + "prs inventory supports get, new, mark-sent, mark-fixed, dismiss, escalate, or reset.", + ); const id = requireValue(prId ?? positionalPrId, "prId"); const itemIds = args.filter((entry) => !entry.startsWith("-")); const argsListByMode: Record = { get: [id], new: [id], - "mark-sent": [id, itemIds, readValue(args, ["--session", "--session-id"]) ?? "", readIntOption(args, ["--round"], 0) ?? 0], + "mark-sent": [ + id, + itemIds, + readValue(args, ["--session", "--session-id"]) ?? "", + readIntOption(args, ["--round"], 0) ?? 0, + ], "mark-fixed": [id, itemIds], dismiss: [id, itemIds, readValue(args, ["--reason"]) ?? ""], escalate: [id, itemIds], reset: [id], }; - return { kind: "execute", label: `PR inventory ${mode}`, steps: [actionArgsListStep("result", "issue_inventory", action, argsListByMode[mode] ?? [id])] }; + return { + kind: "execute", + label: `PR inventory ${mode}`, + steps: [ + actionArgsListStep( + "result", + "issue_inventory", + action, + argsListByMode[mode] ?? [id], + ), + ], + }; } if (sub === "convergence") { @@ -2515,21 +4022,55 @@ function buildPrPlan(args: string[]): CliPlan { reconcile: "reconcileConvergenceSessionExit", }; const action = actionByMode[mode]; - if (!action) throw new CliUsageError("prs convergence supports status, runtime, save, reset, or reconcile."); + if (!action) + throw new CliUsageError( + "prs convergence supports status, runtime, save, reset, or reconcile.", + ); const id = requireValue(prId ?? firstPositional(args), "prId"); if (mode === "save") { - return { kind: "execute", label: "PR convergence save", steps: [actionArgsListStep("result", "issue_inventory", action, [id, collectGenericObjectArgs(args)])] }; + return { + kind: "execute", + label: "PR convergence save", + steps: [ + actionArgsListStep("result", "issue_inventory", action, [ + id, + collectGenericObjectArgs(args), + ]), + ], + }; } if (mode === "reconcile") { - return { kind: "execute", label: "PR convergence reconcile", steps: [actionStep("result", "issue_inventory", action, collectGenericObjectArgs(args, { prId: id }))] }; + return { + kind: "execute", + label: "PR convergence reconcile", + steps: [ + actionStep( + "result", + "issue_inventory", + action, + collectGenericObjectArgs(args, { prId: id }), + ), + ], + }; } - return { kind: "execute", label: `PR convergence ${mode}`, steps: [actionArgsListStep("result", "issue_inventory", action, [id])] }; + return { + kind: "execute", + label: `PR convergence ${mode}`, + steps: [actionArgsListStep("result", "issue_inventory", action, [id])], + }; } - return { kind: "execute", label: `PR ${sub}`, steps: [actionStep("result", "pr", sub, withPr())] }; + return { + kind: "execute", + label: `PR ${sub}`, + steps: [actionStep("result", "pr", sub, withPr())], + }; } -function collectMissionCreateArgs(args: string[], base: JsonObject = {}): JsonObject { +function collectMissionCreateArgs( + args: string[], + base: JsonObject = {}, +): JsonObject { const noAutostart = readFlag(args, ["--no-autostart", "--no-start"]); const autostartFlag = readFlag(args, ["--autostart"]); const manual = readFlag(args, ["--manual"]); @@ -2545,79 +4086,163 @@ function collectMissionCreateArgs(args: string[], base: JsonObject = {}): JsonOb laneId: readLaneId(args), priority: readValue(args, ["--priority"]), executionMode: readValue(args, ["--execution-mode"]), - targetMachineId: readValue(args, ["--target-machine", "--target-machine-id"]), + targetMachineId: readValue(args, [ + "--target-machine", + "--target-machine-id", + ]), plannerEngine: readValue(args, ["--planner", "--planner-engine"]), planningTimeoutMs: readIntOption(args, ["--planning-timeout-ms"]), - launchMode: readValue(args, ["--launch-mode", "--run-mode"]) ?? createBase.launchMode, - autopilotExecutor: readValue(args, ["--executor", "--autopilot-executor", "--default-executor"]), + launchMode: + readValue(args, ["--launch-mode", "--run-mode"]) ?? createBase.launchMode, + autopilotExecutor: readValue(args, [ + "--executor", + "--autopilot-executor", + "--default-executor", + ]), autostart: createBase.autostart, phaseProfileId: readValue(args, ["--phase-profile", "--phase-profile-id"]), - employeeAgentId: readValue(args, ["--employee-agent", "--employee-agent-id"]), + employeeAgentId: readValue(args, [ + "--employee-agent", + "--employee-agent-id", + ]), }); - const phaseOverride = readJsonPayloadOption(args, ["--phase-override-json"], ["--phase-override-file"], "--phase-override-json"); + const phaseOverride = readJsonPayloadOption( + args, + ["--phase-override-json"], + ["--phase-override-file"], + "--phase-override-json", + ); if (phaseOverride !== undefined) { - if (!Array.isArray(phaseOverride)) throw new CliUsageError("--phase-override-json must be a JSON array."); + if (!Array.isArray(phaseOverride)) + throw new CliUsageError("--phase-override-json must be a JSON array."); input.phaseOverride = phaseOverride; } - const plannedSteps = readJsonPayloadOption(args, ["--planned-steps-json"], ["--planned-steps-file"], "--planned-steps-json"); + const plannedSteps = readJsonPayloadOption( + args, + ["--planned-steps-json"], + ["--planned-steps-file"], + "--planned-steps-json", + ); if (plannedSteps !== undefined) { - if (!Array.isArray(plannedSteps)) throw new CliUsageError("--planned-steps-json must be a JSON array."); + if (!Array.isArray(plannedSteps)) + throw new CliUsageError("--planned-steps-json must be a JSON array."); input.plannedSteps = plannedSteps; } const jsonObjects: Array<[string, string[], string[], string]> = [ - ["modelConfig", ["--model-config-json"], ["--model-config-file"], "--model-config-json"], - ["executionPolicy", ["--execution-policy-json"], ["--execution-policy-file"], "--execution-policy-json"], - ["recoveryLoop", ["--recovery-loop-json"], ["--recovery-loop-file"], "--recovery-loop-json"], - ["teamRuntime", ["--team-runtime-json"], ["--team-runtime-file"], "--team-runtime-json"], - ["agentRuntime", ["--agent-runtime-json"], ["--agent-runtime-file"], "--agent-runtime-json"], - ["permissionConfig", ["--permission-config-json"], ["--permission-config-file"], "--permission-config-json"], + [ + "modelConfig", + ["--model-config-json"], + ["--model-config-file"], + "--model-config-json", + ], + [ + "executionPolicy", + ["--execution-policy-json"], + ["--execution-policy-file"], + "--execution-policy-json", + ], + [ + "recoveryLoop", + ["--recovery-loop-json"], + ["--recovery-loop-file"], + "--recovery-loop-json", + ], + [ + "teamRuntime", + ["--team-runtime-json"], + ["--team-runtime-file"], + "--team-runtime-json", + ], + [ + "agentRuntime", + ["--agent-runtime-json"], + ["--agent-runtime-file"], + "--agent-runtime-json", + ], + [ + "permissionConfig", + ["--permission-config-json"], + ["--permission-config-file"], + "--permission-config-json", + ], ]; for (const [key, inlineNames, fileNames, label] of jsonObjects) { const value = readJsonPayloadOption(args, inlineNames, fileNames, label); if (value === undefined) continue; - if (!isRecord(value)) throw new CliUsageError(`${label} must be a JSON object.`); + if (!isRecord(value)) + throw new CliUsageError(`${label} must be a JSON object.`); input[key] = value; } if (!asString(input.prompt)) { - const positionalPrompt = args.filter((entry) => entry !== "--" && !entry.startsWith("-")).join(" ").trim(); + const positionalPrompt = args + .filter((entry) => entry !== "--" && !entry.startsWith("-")) + .join(" ") + .trim(); if (positionalPrompt.length > 0) input.prompt = positionalPrompt; } input.prompt = requireValue(asString(input.prompt) ?? null, "prompt"); return input; } -function collectMissionStartArgs(args: string[], base: JsonObject = {}): JsonObject { +function collectMissionStartArgs( + args: string[], + base: JsonObject = {}, +): JsonObject { const manual = readFlag(args, ["--manual"]); - const runMode = manual ? "manual" : readValue(args, ["--run-mode", "--launch-mode"]); - const executor = readValue(args, ["--executor", "--default-executor", "--executor-kind"]); + const runMode = manual + ? "manual" + : readValue(args, ["--run-mode", "--launch-mode"]); + const executor = readValue(args, [ + "--executor", + "--default-executor", + "--executor-kind", + ]); const owner = readValue(args, ["--owner", "--owner-id", "--autopilot-owner"]); const input: JsonObject = { ...base }; if (runMode) input.runMode = runMode; - if (executor ?? base.defaultExecutorKind) input.defaultExecutorKind = executor ?? base.defaultExecutorKind; + if (executor ?? base.defaultExecutorKind) + input.defaultExecutorKind = executor ?? base.defaultExecutorKind; if (owner) input.autopilotOwnerId = owner; return collectGenericObjectArgs(args, input); } function buildMissionsPlan(args: string[]): CliPlan { const sub = firstPositional(args) ?? "list"; - if (sub === "actions") return { kind: "execute", label: "mission actions", steps: [listActionsStep("actions", "mission")] }; - if (sub === "action") return { kind: "execute", label: "mission action", steps: [buildActionRunStep(["mission", ...args])] }; + if (sub === "actions") + return { + kind: "execute", + label: "mission actions", + steps: [listActionsStep("actions", "mission")], + }; + if (sub === "action") + return { + kind: "execute", + label: "mission action", + steps: [buildActionRunStep(["mission", ...args])], + }; if (sub === "list" || sub === "ls") { return { kind: "execute", label: "mission list", formatter: "mission-list", - steps: [actionStep("result", "mission", "list", collectGenericObjectArgs(args, { - status: readValue(args, ["--status"]), - laneId: readLaneId(args), - limit: readIntOption(args, ["--limit"]), - includeArchived: readFlag(args, ["--include-archived"]), - }))], + steps: [ + actionStep( + "result", + "mission", + "list", + collectGenericObjectArgs(args, { + status: readValue(args, ["--status"]), + laneId: readLaneId(args), + limit: readIntOption(args, ["--limit"]), + includeArchived: readFlag(args, ["--include-archived"]), + }), + ), + ], }; } @@ -2626,13 +4251,28 @@ function buildMissionsPlan(args: string[]): CliPlan { kind: "execute", label: "mission create", formatter: "mission-detail", - steps: [actionStep("result", "mission", "create", collectMissionCreateArgs(args))], + steps: [ + actionStep( + "result", + "mission", + "create", + collectMissionCreateArgs(args), + ), + ], }; } if (sub === "launch") { - const waitUntilTerminal = readFlag(args, ["--wait", "--until-terminal", "--wait-until-terminal"]); - const waitMs = readIntOption(args, ["--wait-ms", "--hold-ms", "--wait-for-ms"], waitUntilTerminal ? 30 * 60 * 1000 : undefined); + const waitUntilTerminal = readFlag(args, [ + "--wait", + "--until-terminal", + "--wait-until-terminal", + ]); + const waitMs = readIntOption( + args, + ["--wait-ms", "--hold-ms", "--wait-for-ms"], + waitUntilTerminal ? 30 * 60 * 1000 : undefined, + ); const timelineLimit = readIntOption(args, ["--timeline-limit"], 120) ?? 120; const createArgs = collectMissionCreateArgs(args, { autostart: false }); const startArgs = collectMissionStartArgs(args, { @@ -2641,7 +4281,11 @@ function buildMissionsPlan(args: string[]): CliPlan { }); const waitGraphStep = waitRunGraphStep({ key: "graph", - runId: (values) => requireValue(asString(runFromStartResult(values.started)?.id) ?? null, "run id"), + runId: (values) => + requireValue( + asString(runFromStartResult(values.started)?.id) ?? null, + "run id", + ), waitMs, untilTerminal: waitUntilTerminal, timelineLimit, @@ -2687,17 +4331,30 @@ function buildMissionsPlan(args: string[]): CliPlan { } if (sub === "start" || sub === "run") { - const missionId = requireValue(readValue(args, ["--mission", "--mission-id"]) ?? firstPositional(args), "missionId"); + const missionId = requireValue( + readValue(args, ["--mission", "--mission-id"]) ?? firstPositional(args), + "missionId", + ); return { kind: "execute", label: "mission start", formatter: "mission-watch", - steps: [actionStep("result", "orchestrator", "startMissionRun", collectMissionStartArgs(args, { missionId }))], + steps: [ + actionStep( + "result", + "orchestrator", + "startMissionRun", + collectMissionStartArgs(args, { missionId }), + ), + ], }; } if (sub === "show" || sub === "get" || sub === "view") { - const missionId = requireValue(readValue(args, ["--mission", "--mission-id"]) ?? firstPositional(args), "missionId"); + const missionId = requireValue( + readValue(args, ["--mission", "--mission-id"]) ?? firstPositional(args), + "missionId", + ); return { kind: "execute", label: "mission show", @@ -2707,42 +4364,75 @@ function buildMissionsPlan(args: string[]): CliPlan { } if (sub === "runs" || sub === "attempts") { - const missionId = readValue(args, ["--mission", "--mission-id"]) ?? firstPositional(args); + const missionId = + readValue(args, ["--mission", "--mission-id"]) ?? firstPositional(args); return { kind: "execute", label: "mission runs", formatter: "mission-runs", - steps: [actionStep("result", "orchestrator_core", "listRuns", collectGenericObjectArgs(args, { - missionId: missionId ?? undefined, - status: readValue(args, ["--status"]), - limit: readIntOption(args, ["--limit"], 20), - }))], + steps: [ + actionStep( + "result", + "orchestrator_core", + "listRuns", + collectGenericObjectArgs(args, { + missionId: missionId ?? undefined, + status: readValue(args, ["--status"]), + limit: readIntOption(args, ["--limit"], 20), + }), + ), + ], }; } if (sub === "graph" || sub === "run-graph") { - const runId = requireValue(readValue(args, ["--run", "--run-id"]) ?? firstPositional(args), "runId"); + const runId = requireValue( + readValue(args, ["--run", "--run-id"]) ?? firstPositional(args), + "runId", + ); return { kind: "execute", label: "mission graph", formatter: "mission-graph", - steps: [actionStep("result", "orchestrator_core", "getRunGraph", collectGenericObjectArgs(args, { - runId, - timelineLimit: readIntOption(args, ["--timeline-limit"], 80), - }))], + steps: [ + actionStep( + "result", + "orchestrator_core", + "getRunGraph", + collectGenericObjectArgs(args, { + runId, + timelineLimit: readIntOption(args, ["--timeline-limit"], 80), + }), + ), + ], }; } if (sub === "watch" || sub === "monitor") { - const waitUntilTerminal = readFlag(args, ["--wait", "--until-terminal", "--wait-until-terminal"]); - const waitMs = readIntOption(args, ["--wait-ms", "--hold-ms", "--wait-for-ms"], waitUntilTerminal ? 30 * 60 * 1000 : undefined); + const waitUntilTerminal = readFlag(args, [ + "--wait", + "--until-terminal", + "--wait-until-terminal", + ]); + const waitMs = readIntOption( + args, + ["--wait-ms", "--hold-ms", "--wait-for-ms"], + waitUntilTerminal ? 30 * 60 * 1000 : undefined, + ); const runId = readValue(args, ["--run", "--run-id"]); - const missionId = readValue(args, ["--mission", "--mission-id"]) ?? (runId ? null : firstPositional(args)); + const missionId = + readValue(args, ["--mission", "--mission-id"]) ?? + (runId ? null : firstPositional(args)); const timelineLimit = readIntOption(args, ["--timeline-limit"], 80) ?? 80; const steps: InvocationStep[] = []; if (missionId) { steps.push(actionScalarStep("mission", "mission", "get", missionId)); - steps.push(actionStep("runs", "orchestrator_core", "listRuns", { missionId, limit: readIntOption(args, ["--limit"], 20) })); + steps.push( + actionStep("runs", "orchestrator_core", "listRuns", { + missionId, + limit: readIntOption(args, ["--limit"], 20), + }), + ); } const waitGraphStep = waitRunGraphStep({ key: "graph", @@ -2779,16 +4469,50 @@ function buildMissionsPlan(args: string[]): CliPlan { } if (sub === "pause") { - const runId = requireValue(readValue(args, ["--run", "--run-id"]) ?? firstPositional(args), "runId"); - return { kind: "execute", label: "mission pause", formatter: "mission-graph", steps: [actionStep("result", "orchestrator_core", "pauseRun", collectGenericObjectArgs(args, { runId, reason: readValue(args, ["--reason"]) }))] }; + const runId = requireValue( + readValue(args, ["--run", "--run-id"]) ?? firstPositional(args), + "runId", + ); + return { + kind: "execute", + label: "mission pause", + formatter: "mission-graph", + steps: [ + actionStep( + "result", + "orchestrator_core", + "pauseRun", + collectGenericObjectArgs(args, { + runId, + reason: readValue(args, ["--reason"]), + }), + ), + ], + }; } if (sub === "resume") { - const runId = requireValue(readValue(args, ["--run", "--run-id"]) ?? firstPositional(args), "runId"); - const waitUntilTerminal = readFlag(args, ["--wait", "--until-terminal", "--wait-until-terminal"]); - const waitMs = readIntOption(args, ["--wait-ms", "--hold-ms", "--wait-for-ms"], waitUntilTerminal ? 30 * 60 * 1000 : undefined); + const runId = requireValue( + readValue(args, ["--run", "--run-id"]) ?? firstPositional(args), + "runId", + ); + const waitUntilTerminal = readFlag(args, [ + "--wait", + "--until-terminal", + "--wait-until-terminal", + ]); + const waitMs = readIntOption( + args, + ["--wait-ms", "--hold-ms", "--wait-for-ms"], + waitUntilTerminal ? 30 * 60 * 1000 : undefined, + ); const steps: InvocationStep[] = [ - actionStep("result", "orchestrator", "resumeRun", collectGenericObjectArgs(args, { runId })), + actionStep( + "result", + "orchestrator", + "resumeRun", + collectGenericObjectArgs(args, { runId }), + ), ]; const waitGraphStep = waitRunGraphStep({ key: "graph", @@ -2807,52 +4531,187 @@ function buildMissionsPlan(args: string[]): CliPlan { } if (sub === "cancel") { - const runId = requireValue(readValue(args, ["--run", "--run-id"]) ?? readValue(args, ["--mission", "--mission-id"]) ?? firstPositional(args), "runId"); - return { kind: "execute", label: "mission cancel", formatter: "mission-detail", steps: [actionStep("result", "orchestrator", "cancelRunGracefully", collectGenericObjectArgs(args, { runId, reason: readValue(args, ["--reason"]) }))] }; + const runId = requireValue( + readValue(args, ["--run", "--run-id"]) ?? + readValue(args, ["--mission", "--mission-id"]) ?? + firstPositional(args), + "runId", + ); + return { + kind: "execute", + label: "mission cancel", + formatter: "mission-detail", + steps: [ + actionStep( + "result", + "orchestrator", + "cancelRunGracefully", + collectGenericObjectArgs(args, { + runId, + reason: readValue(args, ["--reason"]), + }), + ), + ], + }; } - return { kind: "execute", label: `mission ${sub}`, steps: [actionStep("result", "mission", sub, collectGenericObjectArgs(args))] }; + return { + kind: "execute", + label: `mission ${sub}`, + steps: [ + actionStep("result", "mission", sub, collectGenericObjectArgs(args)), + ], + }; } function buildRunPlan(args: string[]): CliPlan { const sub = firstPositional(args) ?? "ps"; - if (sub === "actions") return { kind: "execute", label: "run actions", steps: [listActionsStep("actions", "process")] }; - if (sub === "action") return { kind: "execute", label: "run action", steps: [buildActionRunStep(["process", ...args])] }; - if (sub === "defs" || sub === "definitions") return { kind: "execute", label: "process definitions", steps: [actionStep("result", "process", "listDefinitions", collectGenericObjectArgs(args))] }; + if (sub === "actions") + return { + kind: "execute", + label: "run actions", + steps: [listActionsStep("actions", "process")], + }; + if (sub === "action") + return { + kind: "execute", + label: "run action", + steps: [buildActionRunStep(["process", ...args])], + }; + if (sub === "defs" || sub === "definitions") + return { + kind: "execute", + label: "process definitions", + steps: [ + actionStep( + "result", + "process", + "listDefinitions", + collectGenericObjectArgs(args), + ), + ], + }; const laneId = readLaneId(args); - const processId = readValue(args, ["--process", "--process-id"]) ?? firstPositional(args); + const processId = + readValue(args, ["--process", "--process-id"]) ?? firstPositional(args); const runId = readValue(args, ["--run", "--run-id"]); - const withProcess = (base: JsonObject = {}) => collectGenericObjectArgs(args, { - ...base, - ...(laneId ? { laneId } : {}), - ...(processId ? { processId } : {}), - ...(runId ? { runId } : {}), - }); + const withProcess = (base: JsonObject = {}) => + collectGenericObjectArgs(args, { + ...base, + ...(laneId ? { laneId } : {}), + ...(processId ? { processId } : {}), + ...(runId ? { runId } : {}), + }); if (sub === "ps" || sub === "list" || sub === "runtime") { const id = requireValue(laneId, "laneId"); - return { kind: "execute", label: "process runtime", steps: [actionArgsListStep("result", "process", "listRuntime", [id])] }; + return { + kind: "execute", + label: "process runtime", + steps: [actionArgsListStep("result", "process", "listRuntime", [id])], + }; } - if (sub === "start" || sub === "stop" || sub === "restart" || sub === "kill") { - return { kind: "execute", label: `process ${sub}`, steps: [actionStep("result", "process", sub, withProcess({ laneId: requireValue(laneId, "laneId"), processId: requireValue(processId, "processId") }))] }; + if ( + sub === "start" || + sub === "stop" || + sub === "restart" || + sub === "kill" + ) { + return { + kind: "execute", + label: `process ${sub}`, + steps: [ + actionStep( + "result", + "process", + sub, + withProcess({ + laneId: requireValue(laneId, "laneId"), + processId: requireValue(processId, "processId"), + }), + ), + ], + }; } if (sub === "logs" || sub === "log") { - return { kind: "execute", label: "process logs", steps: [actionStep("result", "process", "getLogTail", withProcess({ laneId: requireValue(laneId, "laneId"), processId: requireValue(processId, "processId"), maxBytes: readIntOption(args, ["--max-bytes", "--tail-bytes"], 80_000) }))] }; + return { + kind: "execute", + label: "process logs", + steps: [ + actionStep( + "result", + "process", + "getLogTail", + withProcess({ + laneId: requireValue(laneId, "laneId"), + processId: requireValue(processId, "processId"), + maxBytes: readIntOption( + args, + ["--max-bytes", "--tail-bytes"], + 80_000, + ), + }), + ), + ], + }; } if (sub === "stack") { const mode = requireValue(firstPositional(args), "stack action"); - const stackId = requireValue(readValue(args, ["--stack", "--stack-id"]) ?? firstPositional(args), "stackId"); - const methodByMode: Record = { start: "startStack", stop: "stopStack", restart: "restartStack" }; + const stackId = requireValue( + readValue(args, ["--stack", "--stack-id"]) ?? firstPositional(args), + "stackId", + ); + const methodByMode: Record = { + start: "startStack", + stop: "stopStack", + restart: "restartStack", + }; const method = methodByMode[mode]; - if (!method) throw new CliUsageError("run stack supports start, stop, or restart."); - return { kind: "execute", label: `stack ${mode}`, steps: [actionStep("result", "process", method, collectGenericObjectArgs(args, { laneId: requireValue(laneId, "laneId"), stackId }))] }; + if (!method) + throw new CliUsageError("run stack supports start, stop, or restart."); + return { + kind: "execute", + label: `stack ${mode}`, + steps: [ + actionStep( + "result", + "process", + method, + collectGenericObjectArgs(args, { + laneId: requireValue(laneId, "laneId"), + stackId, + }), + ), + ], + }; } - if (sub === "start-all" || sub === "stop-all") return { kind: "execute", label: `process ${sub}`, steps: [actionStep("result", "process", sub === "start-all" ? "startAll" : "stopAll", collectGenericObjectArgs(args, { ...(laneId ? { laneId } : {}) }))] }; - return { kind: "execute", label: `process ${sub}`, steps: [actionStep("result", "process", sub, withProcess())] }; + if (sub === "start-all" || sub === "stop-all") + return { + kind: "execute", + label: `process ${sub}`, + steps: [ + actionStep( + "result", + "process", + sub === "start-all" ? "startAll" : "stopAll", + collectGenericObjectArgs(args, { ...(laneId ? { laneId } : {}) }), + ), + ], + }; + return { + kind: "execute", + label: `process ${sub}`, + steps: [actionStep("result", "process", sub, withProcess())], + }; } function buildShellPlan(args: string[]): CliPlan { const sub = firstPositional(args) ?? "start"; - if (sub === "actions") return { kind: "execute", label: "shell actions", steps: [listActionsStep("actions", "pty")] }; + if (sub === "actions") + return { + kind: "execute", + label: "shell actions", + steps: [listActionsStep("actions", "pty")], + }; if (sub === "start-cli" || sub === "cli" || sub === "agent-cli") { return buildCliSessionStartPlan(args); } @@ -2863,8 +4722,12 @@ function buildShellPlan(args: string[]): CliPlan { } const laneId = readLaneId(args); const chatSessionId = asString( - readValue(args, ["--chat-session", "--chat-session-id", "--session", "--session-id"]) - ?? process.env.ADE_CHAT_SESSION_ID, + readValue(args, [ + "--chat-session", + "--chat-session-id", + "--session", + "--session-id", + ]) ?? process.env.ADE_CHAT_SESSION_ID, ); const startupCommandArgs = takeArgsAfterTerminator(args); const startupCommand = startupCommandArgs @@ -2881,31 +4744,104 @@ function buildShellPlan(args: string[]): CliPlan { rows: readIntOption(args, ["--rows"], 36), tracked: !readFlag(args, ["--untracked"]), }); - return { kind: "execute", label: "shell start", steps: [actionStep("result", "pty", "create", input)] }; + return { + kind: "execute", + label: "shell start", + steps: [actionStep("result", "pty", "create", input)], + }; } - if (sub === "write") return { kind: "execute", label: "shell write", steps: [actionStep("result", "pty", "write", collectGenericObjectArgs(args, { ptyId: requireValue(readValue(args, ["--pty", "--pty-id"]) ?? firstPositional(args), "ptyId"), data: readValue(args, ["--data"]) ?? "" }))] }; - if (sub === "resize") return { kind: "execute", label: "shell resize", steps: [actionStep("result", "pty", "resize", collectGenericObjectArgs(args, { ptyId: requireValue(readValue(args, ["--pty", "--pty-id"]) ?? firstPositional(args), "ptyId"), cols: readIntOption(args, ["--cols"], 120), rows: readIntOption(args, ["--rows"], 36) }))] }; - if (sub === "close" || sub === "dispose") return { kind: "execute", label: "shell close", steps: [actionStep("result", "pty", "dispose", collectGenericObjectArgs(args, { ptyId: requireValue(readValue(args, ["--pty", "--pty-id"]) ?? firstPositional(args), "ptyId"), sessionId: readValue(args, ["--session", "--session-id"]) }))] }; - return { kind: "execute", label: `shell ${sub}`, steps: [actionStep("result", "pty", sub, collectGenericObjectArgs(args))] }; + if (sub === "write") + return { + kind: "execute", + label: "shell write", + steps: [ + actionStep( + "result", + "pty", + "write", + collectGenericObjectArgs(args, { + ptyId: requireValue( + readValue(args, ["--pty", "--pty-id"]) ?? firstPositional(args), + "ptyId", + ), + data: readValue(args, ["--data"]) ?? "", + }), + ), + ], + }; + if (sub === "resize") + return { + kind: "execute", + label: "shell resize", + steps: [ + actionStep( + "result", + "pty", + "resize", + collectGenericObjectArgs(args, { + ptyId: requireValue( + readValue(args, ["--pty", "--pty-id"]) ?? firstPositional(args), + "ptyId", + ), + cols: readIntOption(args, ["--cols"], 120), + rows: readIntOption(args, ["--rows"], 36), + }), + ), + ], + }; + if (sub === "close" || sub === "dispose") + return { + kind: "execute", + label: "shell close", + steps: [ + actionStep( + "result", + "pty", + "dispose", + collectGenericObjectArgs(args, { + ptyId: requireValue( + readValue(args, ["--pty", "--pty-id"]) ?? firstPositional(args), + "ptyId", + ), + sessionId: readValue(args, ["--session", "--session-id"]), + }), + ), + ], + }; + return { + kind: "execute", + label: `shell ${sub}`, + steps: [actionStep("result", "pty", sub, collectGenericObjectArgs(args))], + }; } -function buildCliSessionStartPlan(args: string[], providerArg?: string): CliPlan { +function buildCliSessionStartPlan( + args: string[], + providerArg?: string, +): CliPlan { const laneId = requireValue(readLaneId(args), "laneId"); const rawProvider = requireValue( - providerArg ?? readValue(args, ["--provider", "--profile"]) ?? firstStandalonePositional(args), + providerArg ?? + readValue(args, ["--provider", "--profile"]) ?? + firstStandalonePositional(args), "provider", ); if (!isLaunchProfile(rawProvider)) { - throw new CliUsageError("provider must be one of claude, codex, cursor, droid, opencode, or shell."); + throw new CliUsageError( + "provider must be one of claude, codex, cursor, droid, opencode, or shell.", + ); } const provider: LaunchProfile = rawProvider; const promptArgs = takeArgsAfterTerminator(args); const initialInput = promptArgs ? promptArgs.join(" ").trim() : readValue(args, ["--message", "--prompt", "--initial-input"]); - const permissionMode = readValue(args, ["--permission-mode", "--permissions"]) ?? "default"; + const permissionMode = + readValue(args, ["--permission-mode", "--permissions"]) ?? "default"; if (!isTrackedCliPermissionMode(permissionMode)) { - throw new CliUsageError("permissionMode must be one of default, plan, edit, full-auto, or config-toml."); + throw new CliUsageError( + "permissionMode must be one of default, plan, edit, full-auto, or config-toml.", + ); } validateLaunchProfilePermissionMode(provider, permissionMode); @@ -2913,129 +4849,435 @@ function buildCliSessionStartPlan(args: string[], providerArg?: string): CliPlan laneId, provider, permissionMode, - title: readValue(args, ["--title"]) ?? LAUNCH_PROFILE_TITLE[provider] ?? undefined, + title: + readValue(args, ["--title"]) ?? + LAUNCH_PROFILE_TITLE[provider] ?? + undefined, initialInput, cols: readIntOption(args, ["--cols"], 120), rows: readIntOption(args, ["--rows"], 36), cwd: readValue(args, ["--cwd"]), chatSessionId: readValue(args, ["--chat-session", "--chat-session-id"]), - resumeSessionId: readValue(args, ["--resume-session", "--resume-session-id"]), - resumeTargetId: readValue(args, ["--resume-target", "--resume-target-id", "--target"]), + resumeSessionId: readValue(args, [ + "--resume-session", + "--resume-session-id", + ]), + resumeTargetId: readValue(args, [ + "--resume-target", + "--resume-target-id", + "--target", + ]), tracked: !readFlag(args, ["--untracked"]), }); - return { kind: "execute", label: "shell start cli", steps: [actionCallStep("result", "start_cli_session", input)] }; + return { + kind: "execute", + label: "shell start cli", + steps: [actionCallStep("result", "start_cli_session", input)], + }; } function buildTerminalPlan(args: string[]): CliPlan { const sub = firstPositional(args) ?? "active"; - if (sub === "actions") return { kind: "execute", label: "terminal actions", steps: [listActionsStep("actions", "terminal")] }; - const chatSessionId = () => readValue(args, ["--chat-session", "--chat-session-id", "--session", "--session-id"]) ?? process.env.ADE_CHAT_SESSION_ID ?? null; + if (sub === "actions") + return { + kind: "execute", + label: "terminal actions", + steps: [listActionsStep("actions", "terminal")], + }; + const chatSessionId = () => + readValue(args, [ + "--chat-session", + "--chat-session-id", + "--session", + "--session-id", + ]) ?? + process.env.ADE_CHAT_SESSION_ID ?? + null; if (sub === "list" || sub === "ls") { - return { kind: "execute", label: "terminal list", steps: [actionStep("result", "terminal", "list", collectGenericObjectArgs(args, { - chatSessionId: chatSessionId(), - laneId: readValue(args, ["--lane", "--lane-id"]), - limit: readIntOption(args, ["--limit"], undefined), - }))] }; + return { + kind: "execute", + label: "terminal list", + steps: [ + actionStep( + "result", + "terminal", + "list", + collectGenericObjectArgs(args, { + chatSessionId: chatSessionId(), + laneId: readValue(args, ["--lane", "--lane-id"]), + limit: readIntOption(args, ["--limit"], undefined), + }), + ), + ], + }; } if (sub === "active" || sub === "current") { - return { kind: "execute", label: "terminal active", steps: [actionStep("result", "terminal", "activeForChat", collectGenericObjectArgs(args, { - chatSessionId: requireValue(chatSessionId(), "chatSessionId"), - }))] }; + return { + kind: "execute", + label: "terminal active", + steps: [ + actionStep( + "result", + "terminal", + "activeForChat", + collectGenericObjectArgs(args, { + chatSessionId: requireValue(chatSessionId(), "chatSessionId"), + }), + ), + ], + }; } if (sub === "read" || sub === "tail" || sub === "scrollback") { const terminal = readValue(args, ["--terminal", "--terminal-id"]); const chat = chatSessionId(); const maxBytes = readIntOption(args, ["--max-bytes"], undefined); const since = readIntOption(args, ["--since"], undefined); - return { kind: "execute", label: "terminal read", steps: [actionStep("result", "terminal", "read", collectGenericObjectArgs(args, { - terminalId: terminal ?? firstPositional(args), - chatSessionId: chat, - maxBytes, - since, - }))] }; + return { + kind: "execute", + label: "terminal read", + steps: [ + actionStep( + "result", + "terminal", + "read", + collectGenericObjectArgs(args, { + terminalId: terminal ?? firstPositional(args), + chatSessionId: chat, + maxBytes, + since, + }), + ), + ], + }; } if (sub === "write" || sub === "send" || sub === "input") { const terminal = readValue(args, ["--terminal", "--terminal-id"]); const ptyId = readValue(args, ["--pty", "--pty-id"]); const chat = chatSessionId(); - const data = readValue(args, ["--data", "--value", "--text"]) ?? args.join(" "); + const data = + readValue(args, ["--data", "--value", "--text"]) ?? args.join(" "); if (!data.length) throw new CliUsageError("data is required."); - return { kind: "execute", label: "terminal write", steps: [actionStep("result", "terminal", "write", collectGenericObjectArgs(args, { - terminalId: terminal ?? firstPositional(args), - ptyId, - chatSessionId: chat, - data, - }))] }; + return { + kind: "execute", + label: "terminal write", + steps: [ + actionStep( + "result", + "terminal", + "write", + collectGenericObjectArgs(args, { + terminalId: terminal ?? firstPositional(args), + ptyId, + chatSessionId: chat, + data, + }), + ), + ], + }; } if (sub === "signal" || sub === "interrupt" || sub === "stop") { const terminal = readValue(args, ["--terminal", "--terminal-id"]); const ptyId = readValue(args, ["--pty", "--pty-id"]); const chat = chatSessionId(); - const signal = readValue(args, ["--signal"]) ?? (sub === "stop" ? "SIGTERM" : "SIGINT"); - return { kind: "execute", label: "terminal signal", steps: [actionStep("result", "terminal", "signal", collectGenericObjectArgs(args, { - terminalId: terminal ?? firstPositional(args), - ptyId, - chatSessionId: chat, - signal, - }))] }; + const signal = + readValue(args, ["--signal"]) ?? (sub === "stop" ? "SIGTERM" : "SIGINT"); + return { + kind: "execute", + label: "terminal signal", + steps: [ + actionStep( + "result", + "terminal", + "signal", + collectGenericObjectArgs(args, { + terminalId: terminal ?? firstPositional(args), + ptyId, + chatSessionId: chat, + signal, + }), + ), + ], + }; } - return { kind: "execute", label: `terminal ${sub}`, steps: [actionStep("result", "terminal", sub, collectGenericObjectArgs(args))] }; + return { + kind: "execute", + label: `terminal ${sub}`, + steps: [ + actionStep("result", "terminal", sub, collectGenericObjectArgs(args)), + ], + }; } function buildChatPlan(args: string[]): CliPlan { const sub = firstPositional(args) ?? "list"; - if (sub === "actions") return { kind: "execute", label: "chat actions", steps: [listActionsStep("actions", "chat")] }; - const sessionId = readValue(args, ["--session", "--session-id"]) ?? (sub !== "create" && sub !== "list" ? firstPositional(args) : null); - const withSession = (base: JsonObject = {}) => collectGenericObjectArgs(args, { ...base, ...(sessionId ? { sessionId } : {}) }); - if (sub === "list" || sub === "ls") return { kind: "execute", label: "chat list", steps: [actionStep("result", "chat", "listSessions", collectGenericObjectArgs(args))] }; - if (sub === "show" || sub === "status") return { kind: "execute", label: "chat status", steps: [actionArgsListStep("result", "chat", "getSessionSummary", [requireValue(sessionId, "sessionId")])] }; + if (sub === "actions") + return { + kind: "execute", + label: "chat actions", + steps: [listActionsStep("actions", "chat")], + }; + const sessionId = + readValue(args, ["--session", "--session-id"]) ?? + (sub !== "create" && sub !== "list" ? firstPositional(args) : null); + const withSession = (base: JsonObject = {}) => + collectGenericObjectArgs(args, { + ...base, + ...(sessionId ? { sessionId } : {}), + }); + if (sub === "list" || sub === "ls") + return { + kind: "execute", + label: "chat list", + steps: [ + actionStep( + "result", + "chat", + "listSessions", + collectGenericObjectArgs(args), + ), + ], + }; + if (sub === "show" || sub === "status") + return { + kind: "execute", + label: "chat status", + steps: [ + actionArgsListStep("result", "chat", "getSessionSummary", [ + requireValue(sessionId, "sessionId"), + ]), + ], + }; if (sub === "create" || sub === "spawn") { const modelArg = readValue(args, ["--model", "--model-id"]); const fastRequested = readFlag(args, ["--fast", "--codex-fast"]); - const standardRequested = readFlag(args, ["--standard", "--no-fast", "--no-codex-fast"]); + const standardRequested = readFlag(args, [ + "--standard", + "--no-fast", + "--no-codex-fast", + ]); if (fastRequested && standardRequested) { throw new CliUsageError( "Use either --fast/--codex-fast or --standard/--no-fast/--no-codex-fast, not both.", ); } - const codexFastMode: boolean | undefined = fastRequested ? true : standardRequested ? false : undefined; - return { kind: "execute", label: "chat create", steps: [actionStep("result", "chat", "createSession", collectGenericObjectArgs(args, { laneId: readLaneId(args), provider: readValue(args, ["--provider"]), model: modelArg, modelId: modelArg, permissionMode: readValue(args, ["--permission-mode", "--permissions"]), droidPermissionMode: readValue(args, ["--droid-permission-mode", "--droid-autonomy", "--autonomy"]), title: readValue(args, ["--title"]), surface: readValue(args, ["--surface"]) ?? "work", ...(codexFastMode !== undefined ? { codexFastMode } : {}) }))] }; - } - if (sub === "send") return { kind: "execute", label: "chat send", steps: [actionStep("result", "chat", "sendMessage", withSession({ sessionId: requireValue(sessionId, "sessionId"), text: requireValue(readValue(args, ["--text", "--message"]) ?? args.join(" "), "message text") }))] }; - if (sub === "interrupt") return { kind: "execute", label: "chat interrupt", steps: [actionStep("result", "chat", "interrupt", withSession({ sessionId: requireValue(sessionId, "sessionId") }))] }; - if (sub === "resume") return { kind: "execute", label: "chat resume", steps: [actionStep("result", "chat", "resumeSession", withSession())] }; - if (sub === "delete" || sub === "rm") return { kind: "execute", label: "chat delete", steps: [actionStep("result", "chat", "deleteSession", withSession())] }; - if (sub === "models") return { kind: "execute", label: "chat models", steps: [actionStep("result", "chat", "getAvailableModels", collectGenericObjectArgs(args))] }; - if (sub === "slash") return { kind: "execute", label: "chat slash commands", steps: [actionStep("result", "chat", "getSlashCommands", collectGenericObjectArgs(args))] }; - return { kind: "execute", label: `chat ${sub}`, steps: [actionStep("result", "chat", sub, withSession())] }; -} - -function buildTestsPlan(args: string[]): CliPlan { - const sub = firstPositional(args) ?? "list"; - if (sub === "actions") return { kind: "execute", label: "test actions", steps: [listActionsStep("actions", "tests")] }; - if (sub === "list" || sub === "suites") return { kind: "execute", label: "test suites", steps: [actionStep("result", "tests", "listSuites", collectGenericObjectArgs(args))] }; - if (sub === "run") { - const laneId = requireValue(readLaneId(args), "laneId"); - const suiteId = readValue(args, ["--suite", "--suite-id"]) ?? firstPositional(args); - const command = readValue(args, ["--command", "-c"]); - if (!suiteId && !command) throw new CliUsageError("tests run requires --suite or --command ."); - const input = collectGenericObjectArgs(args, { - laneId, - suiteId, - command, - waitForCompletion: readFlag(args, ["--wait"]), - timeoutMs: readIntOption(args, ["--timeout-ms"]), - maxLogBytes: readIntOption(args, ["--max-log-bytes"]), - }); - return { kind: "execute", label: "test run", steps: [actionCallStep("result", "run_tests", input)] }; + const codexFastMode: boolean | undefined = fastRequested + ? true + : standardRequested + ? false + : undefined; + return { + kind: "execute", + label: "chat create", + steps: [ + actionStep( + "result", + "chat", + "createSession", + collectGenericObjectArgs(args, { + laneId: readLaneId(args), + provider: readValue(args, ["--provider"]), + model: modelArg, + modelId: modelArg, + permissionMode: readValue(args, [ + "--permission-mode", + "--permissions", + ]), + droidPermissionMode: readValue(args, [ + "--droid-permission-mode", + "--droid-autonomy", + "--autonomy", + ]), + title: readValue(args, ["--title"]), + surface: readValue(args, ["--surface"]) ?? "work", + ...(codexFastMode !== undefined ? { codexFastMode } : {}), + }), + ), + ], + }; } - if (sub === "stop") return { kind: "execute", label: "test stop", steps: [actionStep("result", "tests", "stop", collectGenericObjectArgs(args, { runId: requireValue(readValue(args, ["--run", "--run-id"]) ?? firstPositional(args), "runId") }))] }; - if (sub === "runs") return { kind: "execute", label: "test runs", steps: [actionStep("result", "tests", "listRuns", collectGenericObjectArgs(args, { laneId: readLaneId(args), suiteId: readValue(args, ["--suite", "--suite-id"]), limit: readIntOption(args, ["--limit"]) }))] }; - if (sub === "logs" || sub === "log") return { kind: "execute", label: "test logs", steps: [actionStep("result", "tests", "getLogTail", collectGenericObjectArgs(args, { runId: requireValue(readValue(args, ["--run", "--run-id"]) ?? firstPositional(args), "runId"), maxBytes: readIntOption(args, ["--max-bytes"], 220_000) }))] }; - return { kind: "execute", label: `tests ${sub}`, steps: [actionStep("result", "tests", sub, collectGenericObjectArgs(args))] }; -} - + if (sub === "send") + return { + kind: "execute", + label: "chat send", + steps: [ + actionStep( + "result", + "chat", + "sendMessage", + withSession({ + sessionId: requireValue(sessionId, "sessionId"), + text: requireValue( + readValue(args, ["--text", "--message"]) ?? args.join(" "), + "message text", + ), + }), + ), + ], + }; + if (sub === "interrupt") + return { + kind: "execute", + label: "chat interrupt", + steps: [ + actionStep( + "result", + "chat", + "interrupt", + withSession({ sessionId: requireValue(sessionId, "sessionId") }), + ), + ], + }; + if (sub === "resume") + return { + kind: "execute", + label: "chat resume", + steps: [actionStep("result", "chat", "resumeSession", withSession())], + }; + if (sub === "delete" || sub === "rm") + return { + kind: "execute", + label: "chat delete", + steps: [actionStep("result", "chat", "deleteSession", withSession())], + }; + if (sub === "models") + return { + kind: "execute", + label: "chat models", + steps: [ + actionStep( + "result", + "chat", + "getAvailableModels", + collectGenericObjectArgs(args), + ), + ], + }; + if (sub === "slash") + return { + kind: "execute", + label: "chat slash commands", + steps: [ + actionStep( + "result", + "chat", + "getSlashCommands", + collectGenericObjectArgs(args), + ), + ], + }; + return { + kind: "execute", + label: `chat ${sub}`, + steps: [actionStep("result", "chat", sub, withSession())], + }; +} + +function buildTestsPlan(args: string[]): CliPlan { + const sub = firstPositional(args) ?? "list"; + if (sub === "actions") + return { + kind: "execute", + label: "test actions", + steps: [listActionsStep("actions", "tests")], + }; + if (sub === "list" || sub === "suites") + return { + kind: "execute", + label: "test suites", + steps: [ + actionStep( + "result", + "tests", + "listSuites", + collectGenericObjectArgs(args), + ), + ], + }; + if (sub === "run") { + const laneId = requireValue(readLaneId(args), "laneId"); + const suiteId = + readValue(args, ["--suite", "--suite-id"]) ?? firstPositional(args); + const command = readValue(args, ["--command", "-c"]); + if (!suiteId && !command) + throw new CliUsageError( + "tests run requires --suite or --command .", + ); + const input = collectGenericObjectArgs(args, { + laneId, + suiteId, + command, + waitForCompletion: readFlag(args, ["--wait"]), + timeoutMs: readIntOption(args, ["--timeout-ms"]), + maxLogBytes: readIntOption(args, ["--max-log-bytes"]), + }); + return { + kind: "execute", + label: "test run", + steps: [actionCallStep("result", "run_tests", input)], + }; + } + if (sub === "stop") + return { + kind: "execute", + label: "test stop", + steps: [ + actionStep( + "result", + "tests", + "stop", + collectGenericObjectArgs(args, { + runId: requireValue( + readValue(args, ["--run", "--run-id"]) ?? firstPositional(args), + "runId", + ), + }), + ), + ], + }; + if (sub === "runs") + return { + kind: "execute", + label: "test runs", + steps: [ + actionStep( + "result", + "tests", + "listRuns", + collectGenericObjectArgs(args, { + laneId: readLaneId(args), + suiteId: readValue(args, ["--suite", "--suite-id"]), + limit: readIntOption(args, ["--limit"]), + }), + ), + ], + }; + if (sub === "logs" || sub === "log") + return { + kind: "execute", + label: "test logs", + steps: [ + actionStep( + "result", + "tests", + "getLogTail", + collectGenericObjectArgs(args, { + runId: requireValue( + readValue(args, ["--run", "--run-id"]) ?? firstPositional(args), + "runId", + ), + maxBytes: readIntOption(args, ["--max-bytes"], 220_000), + }), + ), + ], + }; + return { + kind: "execute", + label: `tests ${sub}`, + steps: [actionStep("result", "tests", sub, collectGenericObjectArgs(args))], + }; +} + function readFileTextInput(args: string[]): string | undefined { const text = readValue(args, ["--text"]); if (text != null) return text; @@ -3047,43 +5289,216 @@ function readFileTextInput(args: string[]): string | undefined { function buildFilesPlan(args: string[]): CliPlan { const sub = firstPositional(args) ?? "workspaces"; - if (sub === "actions") return { kind: "execute", label: "file actions", steps: [listActionsStep("actions", "file")] }; + if (sub === "actions") + return { + kind: "execute", + label: "file actions", + steps: [listActionsStep("actions", "file")], + }; const workspaceId = readValue(args, ["--workspace", "--workspace-id"]); - const withWorkspace = (base: JsonObject = {}) => collectGenericObjectArgs(args, { ...base, ...(workspaceId ? { workspaceId } : {}) }); + const withWorkspace = (base: JsonObject = {}) => + collectGenericObjectArgs(args, { + ...base, + ...(workspaceId ? { workspaceId } : {}), + }); if (sub === "workspaces" || sub === "workspace" || sub === "roots") { - return { kind: "execute", label: "file workspaces", steps: [actionStep("result", "file", "listWorkspaces", collectGenericObjectArgs(args, { laneId: readLaneId(args) }))] }; + return { + kind: "execute", + label: "file workspaces", + steps: [ + actionStep( + "result", + "file", + "listWorkspaces", + collectGenericObjectArgs(args, { laneId: readLaneId(args) }), + ), + ], + }; } if (sub === "tree" || sub === "ls") { - return { kind: "execute", label: "file tree", steps: [actionStep("result", "file", "listTree", withWorkspace({ parentPath: readValue(args, ["--path"]) ?? firstPositional(args), depth: readIntOption(args, ["--depth"]), includeIgnored: readFlag(args, ["--include-ignored"]) }))] }; + return { + kind: "execute", + label: "file tree", + steps: [ + actionStep( + "result", + "file", + "listTree", + withWorkspace({ + parentPath: readValue(args, ["--path"]) ?? firstPositional(args), + depth: readIntOption(args, ["--depth"]), + includeIgnored: readFlag(args, ["--include-ignored"]), + }), + ), + ], + }; } if (sub === "read" || sub === "cat") { - return { kind: "execute", label: "file read", steps: [actionStep("result", "file", "readFile", withWorkspace({ path: requireValue(readValue(args, ["--path"]) ?? firstPositional(args), "path") }))] }; + return { + kind: "execute", + label: "file read", + steps: [ + actionStep( + "result", + "file", + "readFile", + withWorkspace({ + path: requireValue( + readValue(args, ["--path"]) ?? firstPositional(args), + "path", + ), + }), + ), + ], + }; } if (sub === "write") { const text = readFileTextInput(args); - if (text == null) throw new CliUsageError("files write requires --text, --from-file, or --stdin."); - return { kind: "execute", label: "file write", steps: [actionStep("result", "file", "writeWorkspaceText", withWorkspace({ path: requireValue(readValue(args, ["--path"]) ?? firstPositional(args), "path"), text }))] }; + if (text == null) + throw new CliUsageError( + "files write requires --text, --from-file, or --stdin.", + ); + return { + kind: "execute", + label: "file write", + steps: [ + actionStep( + "result", + "file", + "writeWorkspaceText", + withWorkspace({ + path: requireValue( + readValue(args, ["--path"]) ?? firstPositional(args), + "path", + ), + text, + }), + ), + ], + }; } if (sub === "create") { - return { kind: "execute", label: "file create", steps: [actionStep("result", "file", "createFile", withWorkspace({ path: requireValue(readValue(args, ["--path"]) ?? firstPositional(args), "path"), content: readFileTextInput(args) ?? "" }))] }; + return { + kind: "execute", + label: "file create", + steps: [ + actionStep( + "result", + "file", + "createFile", + withWorkspace({ + path: requireValue( + readValue(args, ["--path"]) ?? firstPositional(args), + "path", + ), + content: readFileTextInput(args) ?? "", + }), + ), + ], + }; } if (sub === "mkdir") { - return { kind: "execute", label: "file mkdir", steps: [actionStep("result", "file", "createDirectory", withWorkspace({ path: requireValue(readValue(args, ["--path"]) ?? firstPositional(args), "path") }))] }; + return { + kind: "execute", + label: "file mkdir", + steps: [ + actionStep( + "result", + "file", + "createDirectory", + withWorkspace({ + path: requireValue( + readValue(args, ["--path"]) ?? firstPositional(args), + "path", + ), + }), + ), + ], + }; } if (sub === "rename" || sub === "mv") { - return { kind: "execute", label: "file rename", steps: [actionStep("result", "file", "rename", withWorkspace({ oldPath: readValue(args, ["--old", "--old-path"]) ?? firstPositional(args), newPath: readValue(args, ["--new", "--new-path"]) ?? firstPositional(args) }))] }; + return { + kind: "execute", + label: "file rename", + steps: [ + actionStep( + "result", + "file", + "rename", + withWorkspace({ + oldPath: + readValue(args, ["--old", "--old-path"]) ?? firstPositional(args), + newPath: + readValue(args, ["--new", "--new-path"]) ?? firstPositional(args), + }), + ), + ], + }; } if (sub === "delete" || sub === "rm") { - return { kind: "execute", label: "file delete", steps: [actionStep("result", "file", "deletePath", withWorkspace({ path: requireValue(readValue(args, ["--path"]) ?? firstPositional(args), "path") }))] }; + return { + kind: "execute", + label: "file delete", + steps: [ + actionStep( + "result", + "file", + "deletePath", + withWorkspace({ + path: requireValue( + readValue(args, ["--path"]) ?? firstPositional(args), + "path", + ), + }), + ), + ], + }; } if (sub === "quick-open") { - return { kind: "execute", label: "file quick-open", steps: [actionStep("result", "file", "quickOpen", withWorkspace({ query: readValue(args, ["--query", "-q"]) ?? args.join(" "), limit: readIntOption(args, ["--limit"]), includeIgnored: readFlag(args, ["--include-ignored"]) }))] }; + return { + kind: "execute", + label: "file quick-open", + steps: [ + actionStep( + "result", + "file", + "quickOpen", + withWorkspace({ + query: readValue(args, ["--query", "-q"]) ?? args.join(" "), + limit: readIntOption(args, ["--limit"]), + includeIgnored: readFlag(args, ["--include-ignored"]), + }), + ), + ], + }; } if (sub === "search") { - return { kind: "execute", label: "file search", steps: [actionStep("result", "file", "searchText", withWorkspace({ query: requireValue(readValue(args, ["--query", "-q"]) ?? args.join(" "), "query"), limit: readIntOption(args, ["--limit"]), includeIgnored: readFlag(args, ["--include-ignored"]) }))] }; + return { + kind: "execute", + label: "file search", + steps: [ + actionStep( + "result", + "file", + "searchText", + withWorkspace({ + query: requireValue( + readValue(args, ["--query", "-q"]) ?? args.join(" "), + "query", + ), + limit: readIntOption(args, ["--limit"]), + includeIgnored: readFlag(args, ["--include-ignored"]), + }), + ), + ], + }; } - return { kind: "execute", label: `files ${sub}`, steps: [actionStep("result", "file", sub, withWorkspace())] }; + return { + kind: "execute", + label: `files ${sub}`, + steps: [actionStep("result", "file", sub, withWorkspace())], + }; } function buildProofPlan(args: string[]): CliPlan { @@ -3098,193 +5513,664 @@ function buildProofPlan(args: string[]): CliPlan { }; const inferAttachedProofKind = (filePath: string): string => { const ext = path.extname(filePath).replace(/^\./, "").toLowerCase(); - if (["png", "jpg", "jpeg", "webp", "gif", "heic", "heif", "tif", "tiff"].includes(ext)) return "screenshot"; + if ( + [ + "png", + "jpg", + "jpeg", + "webp", + "gif", + "heic", + "heif", + "tif", + "tiff", + ].includes(ext) + ) + return "screenshot"; if (["mov", "mp4", "m4v", "webm"].includes(ext)) return "video_recording"; if (["zip", "har"].includes(ext)) return "browser_trace"; return "browser_verification"; }; - if (sub === "actions") return { kind: "execute", label: "proof actions", steps: [listActionsStep("actions", "computer_use_artifacts")] }; - if (sub === "status" || sub === "backends") return { kind: "execute", label: "proof backend status", steps: [actionCallStep("result", "get_computer_use_backend_status", collectGenericObjectArgs(args))] }; - if (sub === "environment") return { kind: "execute", label: "computer-use environment", steps: [actionCallStep("result", "get_environment_info", collectGenericObjectArgs(args, proofOwnerBase()))], preferHeadless: true }; - if (sub === "list" || sub === "ls") return { kind: "execute", label: "proof list", steps: [actionCallStep("result", "list_computer_use_artifacts", collectGenericObjectArgs(args))] }; - if (sub === "ingest") return { kind: "execute", label: "proof ingest", steps: [actionCallStep("result", "ingest_computer_use_artifacts", collectGenericObjectArgs(args))] }; + if (sub === "actions") + return { + kind: "execute", + label: "proof actions", + steps: [listActionsStep("actions", "computer_use_artifacts")], + }; + if (sub === "status" || sub === "backends") + return { + kind: "execute", + label: "proof backend status", + steps: [ + actionCallStep( + "result", + "get_computer_use_backend_status", + collectGenericObjectArgs(args), + ), + ], + }; + if (sub === "environment") + return { + kind: "execute", + label: "computer-use environment", + steps: [ + actionCallStep( + "result", + "get_environment_info", + collectGenericObjectArgs(args, proofOwnerBase()), + ), + ], + preferHeadless: true, + }; + if (sub === "list" || sub === "ls") + return { + kind: "execute", + label: "proof list", + steps: [ + actionCallStep( + "result", + "list_computer_use_artifacts", + collectGenericObjectArgs(args), + ), + ], + }; + if (sub === "ingest") + return { + kind: "execute", + label: "proof ingest", + steps: [ + actionCallStep( + "result", + "ingest_computer_use_artifacts", + collectGenericObjectArgs(args), + ), + ], + }; if (sub === "attach") { const caption = readValue(args, ["--caption", "--description", "--desc"]); - const attachedPath = requireValue(readValue(args, ["--path"]) ?? firstPositional(args), "path"); - const title = readValue(args, ["--title", "--name"]) ?? caption ?? path.basename(attachedPath); + const attachedPath = requireValue( + readValue(args, ["--path"]) ?? firstPositional(args), + "path", + ); + const title = + readValue(args, ["--title", "--name"]) ?? + caption ?? + path.basename(attachedPath); return { kind: "execute", label: "proof attach", - steps: [actionCallStep("result", "ingest_computer_use_artifacts", collectGenericObjectArgs(args, { - backendStyle: "manual", - backendName: "ade-cli", - toolName: "proof attach", - ...proofOwnerBase(), - inputs: [{ - kind: inferAttachedProofKind(attachedPath), - title, - ...(caption ? { description: caption } : {}), - path: attachedPath, - }], - }))], + steps: [ + actionCallStep( + "result", + "ingest_computer_use_artifacts", + collectGenericObjectArgs(args, { + backendStyle: "manual", + backendName: "ade-cli", + toolName: "proof attach", + ...proofOwnerBase(), + inputs: [ + { + kind: inferAttachedProofKind(attachedPath), + title, + ...(caption ? { description: caption } : {}), + path: attachedPath, + }, + ], + }), + ), + ], }; } if (sub === "screenshot" || sub === "capture") { const caption = readValue(args, ["--caption", "--description", "--desc"]); - return { kind: "execute", label: "computer-use screenshot", steps: [actionCallStep("result", "screenshot_environment", collectGenericObjectArgs(args, { ...proofOwnerBase(), name: readValue(args, ["--name", "--title"]) ?? caption }))], preferHeadless: true }; + return { + kind: "execute", + label: "computer-use screenshot", + steps: [ + actionCallStep( + "result", + "screenshot_environment", + collectGenericObjectArgs(args, { + ...proofOwnerBase(), + name: readValue(args, ["--name", "--title"]) ?? caption, + }), + ), + ], + preferHeadless: true, + }; } - if (sub === "record") return { kind: "execute", label: "computer-use record", steps: [actionCallStep("result", "record_environment", collectGenericObjectArgs(args, { ...proofOwnerBase(), name: readValue(args, ["--name", "--title"]) ?? readValue(args, ["--caption", "--description", "--desc"]), durationSec: readNumberOption(args, ["--seconds", "--duration-sec"]) }))], preferHeadless: true }; - if (sub === "launch") return { kind: "execute", label: "computer-use launch", steps: [actionCallStep("result", "launch_app", collectGenericObjectArgs(args, { app: readValue(args, ["--app"]) ?? firstPositional(args) }))], preferHeadless: true }; - if (sub === "interact") return { kind: "execute", label: "computer-use interact", steps: [actionCallStep("result", "interact_gui", collectGenericObjectArgs(args, proofOwnerBase()))], preferHeadless: true }; - return { kind: "execute", label: `proof ${sub}`, steps: [actionStep("result", "computer_use_artifacts", sub, collectGenericObjectArgs(args))] }; + if (sub === "record") + return { + kind: "execute", + label: "computer-use record", + steps: [ + actionCallStep( + "result", + "record_environment", + collectGenericObjectArgs(args, { + ...proofOwnerBase(), + name: + readValue(args, ["--name", "--title"]) ?? + readValue(args, ["--caption", "--description", "--desc"]), + durationSec: readNumberOption(args, [ + "--seconds", + "--duration-sec", + ]), + }), + ), + ], + preferHeadless: true, + }; + if (sub === "launch") + return { + kind: "execute", + label: "computer-use launch", + steps: [ + actionCallStep( + "result", + "launch_app", + collectGenericObjectArgs(args, { + app: readValue(args, ["--app"]) ?? firstPositional(args), + }), + ), + ], + preferHeadless: true, + }; + if (sub === "interact") + return { + kind: "execute", + label: "computer-use interact", + steps: [ + actionCallStep( + "result", + "interact_gui", + collectGenericObjectArgs(args, proofOwnerBase()), + ), + ], + preferHeadless: true, + }; + return { + kind: "execute", + label: `proof ${sub}`, + steps: [ + actionStep( + "result", + "computer_use_artifacts", + sub, + collectGenericObjectArgs(args), + ), + ], + }; } function buildIosSimulatorPlan(args: string[]): CliPlan { const sub = firstPositional(args) ?? "status"; - if (sub === "help") return { kind: "help", text: buildIosSimulatorHelp(args) }; - const numericPositionals = () => args.filter((value) => /^\d+(\.\d+)?$/.test(value)); + if (sub === "help") + return { kind: "help", text: buildIosSimulatorHelp(args) }; + const numericPositionals = () => + args.filter((value) => /^\d+(\.\d+)?$/.test(value)); const readCoordinate = (flag: string, index: number): number => { - const value = readNumberOption(args, [flag]) ?? Number(numericPositionals()[index]); - if (!Number.isFinite(value)) throw new CliUsageError(`${flag} is required and must be a number.`); + const value = + readNumberOption(args, [flag]) ?? Number(numericPositionals()[index]); + if (!Number.isFinite(value)) + throw new CliUsageError(`${flag} is required and must be a number.`); return value; }; - if (sub === "actions") return { kind: "execute", label: "iOS simulator actions", steps: [listActionsStep("actions", "ios_simulator")] }; - if (sub === "status") return { kind: "execute", label: "iOS simulator status", steps: [actionStep("result", "ios_simulator", "getStatus", collectGenericObjectArgs(args))] }; - if (sub === "devices" || sub === "list" || sub === "ls") return { kind: "execute", label: "iOS simulator devices", steps: [actionStep("result", "ios_simulator", "listDevices", collectGenericObjectArgs(args))] }; - if (sub === "apps" || sub === "targets" || sub === "launchable" || sub === "launchables") { - return { kind: "execute", label: "iOS simulator launchable apps", steps: [actionStep("result", "ios_simulator", "listLaunchTargets", collectGenericObjectArgs(args, { deviceUdid: readValue(args, ["--device", "--udid"]), projectRoot: readValue(args, ["--project-root", "--root"]) }))] }; + if (sub === "actions") + return { + kind: "execute", + label: "iOS simulator actions", + steps: [listActionsStep("actions", "ios_simulator")], + }; + if (sub === "status") + return { + kind: "execute", + label: "iOS simulator status", + steps: [ + actionStep( + "result", + "ios_simulator", + "getStatus", + collectGenericObjectArgs(args), + ), + ], + }; + if (sub === "devices" || sub === "list" || sub === "ls") + return { + kind: "execute", + label: "iOS simulator devices", + steps: [ + actionStep( + "result", + "ios_simulator", + "listDevices", + collectGenericObjectArgs(args), + ), + ], + }; + if ( + sub === "apps" || + sub === "targets" || + sub === "launchable" || + sub === "launchables" + ) { + return { + kind: "execute", + label: "iOS simulator launchable apps", + steps: [ + actionStep( + "result", + "ios_simulator", + "listLaunchTargets", + collectGenericObjectArgs(args, { + deviceUdid: readValue(args, ["--device", "--udid"]), + projectRoot: readValue(args, ["--project-root", "--root"]), + }), + ), + ], + }; } if (sub === "launch" || sub === "open") { return { kind: "execute", label: "iOS simulator launch", - steps: [actionStep("result", "ios_simulator", "launch", collectGenericObjectArgs(args, { - deviceUdid: readValue(args, ["--device", "--udid"]), - projectRoot: readValue(args, ["--project-root", "--root"]), - laneId: readValue(args, ["--lane", "--lane-id"]), - targetId: readValue(args, ["--target", "--target-id"]), - bundleId: readValue(args, ["--bundle-id", "--bundle"]), - appBundlePath: readValue(args, ["--app-bundle", "--app"]), - projectPath: readValue(args, ["--project", "--xcodeproj"]), - scheme: readValue(args, ["--scheme"]), - chatSessionId: readValue(args, ["--chat-session", "--session"]) ?? process.env.ADE_CHAT_SESSION_ID, - build: !readFlag(args, ["--no-build"]), - mode: readValue(args, ["--mode"]) ?? "live", - keepSimulatorInBackground: !readFlag(args, ["--foreground"]), - }))], + steps: [ + actionStep( + "result", + "ios_simulator", + "launch", + collectGenericObjectArgs(args, { + deviceUdid: readValue(args, ["--device", "--udid"]), + projectRoot: readValue(args, ["--project-root", "--root"]), + laneId: readValue(args, ["--lane", "--lane-id"]), + targetId: readValue(args, ["--target", "--target-id"]), + bundleId: readValue(args, ["--bundle-id", "--bundle"]), + appBundlePath: readValue(args, ["--app-bundle", "--app"]), + projectPath: readValue(args, ["--project", "--xcodeproj"]), + scheme: readValue(args, ["--scheme"]), + chatSessionId: + readValue(args, ["--chat-session", "--session"]) ?? + process.env.ADE_CHAT_SESSION_ID, + build: !readFlag(args, ["--no-build"]), + mode: readValue(args, ["--mode"]) ?? "live", + keepSimulatorInBackground: !readFlag(args, ["--foreground"]), + }), + ), + ], }; } if (sub === "screenshot" || sub === "capture") { - return { kind: "execute", label: "iOS simulator screenshot", steps: [actionStep("result", "ios_simulator", "screenshot", collectGenericObjectArgs(args, { deviceUdid: readValue(args, ["--device", "--udid"]) }))] }; + return { + kind: "execute", + label: "iOS simulator screenshot", + steps: [ + actionStep( + "result", + "ios_simulator", + "screenshot", + collectGenericObjectArgs(args, { + deviceUdid: readValue(args, ["--device", "--udid"]), + }), + ), + ], + }; } if (sub === "inspector") { - return { kind: "execute", label: "iOS simulator inspector snapshot", steps: [actionStep("result", "ios_simulator", "getInspectorSnapshot", collectGenericObjectArgs(args, { deviceUdid: readValue(args, ["--device", "--udid"]) }))] }; + return { + kind: "execute", + label: "iOS simulator inspector snapshot", + steps: [ + actionStep( + "result", + "ios_simulator", + "getInspectorSnapshot", + collectGenericObjectArgs(args, { + deviceUdid: readValue(args, ["--device", "--udid"]), + }), + ), + ], + }; } if (sub === "preview-status" || sub === "preview-doctor") { - return { kind: "execute", label: "iOS simulator preview status", steps: [actionStep("result", "ios_simulator", "getPreviewCapability", collectGenericObjectArgs(args, { projectRoot: readValue(args, ["--project-root", "--root"]), sourceFile: readValue(args, ["--source", "--file"]), sourceLine: readNumberOption(args, ["--line"]) }))] }; + return { + kind: "execute", + label: "iOS simulator preview status", + steps: [ + actionStep( + "result", + "ios_simulator", + "getPreviewCapability", + collectGenericObjectArgs(args, { + projectRoot: readValue(args, ["--project-root", "--root"]), + sourceFile: readValue(args, ["--source", "--file"]), + sourceLine: readNumberOption(args, ["--line"]), + }), + ), + ], + }; } if (sub === "previews" || sub === "preview-list" || sub === "list-previews") { - return { kind: "execute", label: "iOS simulator previews", steps: [actionStep("result", "ios_simulator", "listPreviewTargets", collectGenericObjectArgs(args, { projectRoot: readValue(args, ["--project-root", "--root"]), sourceFile: readValue(args, ["--source", "--file"]), sourceLine: readNumberOption(args, ["--line"]) }))] }; + return { + kind: "execute", + label: "iOS simulator previews", + steps: [ + actionStep( + "result", + "ios_simulator", + "listPreviewTargets", + collectGenericObjectArgs(args, { + projectRoot: readValue(args, ["--project-root", "--root"]), + sourceFile: readValue(args, ["--source", "--file"]), + sourceLine: readNumberOption(args, ["--line"]), + }), + ), + ], + }; } - if (sub === "preview-render" || sub === "render-preview" || sub === "preview") { - return { kind: "execute", label: "iOS simulator preview render", steps: [actionStep("result", "ios_simulator", "renderPreview", collectGenericObjectArgs(args, { - projectRoot: readValue(args, ["--project-root", "--root"]), - sourceFilePath: requireValue(readValue(args, ["--source", "--file"]), "sourceFilePath"), - previewDefinitionIndexInFile: readNumberOption(args, ["--index"], 0), - tabIdentifier: readValue(args, ["--tab", "--tab-identifier"]), - timeoutSec: readNumberOption(args, ["--timeout"], 120), - }))] }; + if ( + sub === "preview-render" || + sub === "render-preview" || + sub === "preview" + ) { + return { + kind: "execute", + label: "iOS simulator preview render", + steps: [ + actionStep( + "result", + "ios_simulator", + "renderPreview", + collectGenericObjectArgs(args, { + projectRoot: readValue(args, ["--project-root", "--root"]), + sourceFilePath: requireValue( + readValue(args, ["--source", "--file"]), + "sourceFilePath", + ), + previewDefinitionIndexInFile: readNumberOption( + args, + ["--index"], + 0, + ), + tabIdentifier: readValue(args, ["--tab", "--tab-identifier"]), + timeoutSec: readNumberOption(args, ["--timeout"], 120), + }), + ), + ], + }; } - if (sub === "preview-open" || sub === "open-preview-workspace" || sub === "open-xcode") { - return { kind: "execute", label: "iOS simulator preview open", steps: [actionStep("result", "ios_simulator", "openPreviewWorkspace", collectGenericObjectArgs(args, { projectRoot: readValue(args, ["--project-root", "--root"]) }))] }; + if ( + sub === "preview-open" || + sub === "open-preview-workspace" || + sub === "open-xcode" + ) { + return { + kind: "execute", + label: "iOS simulator preview open", + steps: [ + actionStep( + "result", + "ios_simulator", + "openPreviewWorkspace", + collectGenericObjectArgs(args, { + projectRoot: readValue(args, ["--project-root", "--root"]), + }), + ), + ], + }; } if (sub === "snapshot" || sub === "screen" || sub === "elements") { - return { kind: "execute", label: "iOS simulator screen snapshot", steps: [actionStep("result", "ios_simulator", "getScreenSnapshot", collectGenericObjectArgs(args, { deviceUdid: readValue(args, ["--device", "--udid"]), projectRoot: readValue(args, ["--project-root", "--root"]) }))] }; + return { + kind: "execute", + label: "iOS simulator screen snapshot", + steps: [ + actionStep( + "result", + "ios_simulator", + "getScreenSnapshot", + collectGenericObjectArgs(args, { + deviceUdid: readValue(args, ["--device", "--udid"]), + projectRoot: readValue(args, ["--project-root", "--root"]), + }), + ), + ], + }; } if (sub === "inspect" || sub === "hit-test" || sub === "hover") { - return { kind: "execute", label: "iOS simulator inspect point", steps: [actionStep("result", "ios_simulator", "inspectPoint", collectGenericObjectArgs(args, { - deviceUdid: readValue(args, ["--device", "--udid"]), - projectRoot: readValue(args, ["--project-root", "--root"]), - x: readCoordinate("--x", 0), - y: readCoordinate("--y", 1), - includeScreenshot: readFlag(args, ["--screenshot", "--include-screenshot"]), - }))] }; - } - if (sub === "stream-start" || sub === "start-stream" || sub === "stream" || sub === "preview-start" || sub === "start-preview" || sub === "live-start" || sub === "start-live" || sub === "window-start" || sub === "start-window" || sub === "mirror-start" || sub === "start-mirror") { - const forcedBackend = sub === "preview-start" || sub === "start-preview" - ? "simctl-screenshot-poll" - : sub === "window-start" || sub === "start-window" || sub === "mirror-start" || sub === "start-mirror" + return { + kind: "execute", + label: "iOS simulator inspect point", + steps: [ + actionStep( + "result", + "ios_simulator", + "inspectPoint", + collectGenericObjectArgs(args, { + deviceUdid: readValue(args, ["--device", "--udid"]), + projectRoot: readValue(args, ["--project-root", "--root"]), + x: readCoordinate("--x", 0), + y: readCoordinate("--y", 1), + includeScreenshot: readFlag(args, [ + "--screenshot", + "--include-screenshot", + ]), + }), + ), + ], + }; + } + if ( + sub === "stream-start" || + sub === "start-stream" || + sub === "stream" || + sub === "preview-start" || + sub === "start-preview" || + sub === "live-start" || + sub === "start-live" || + sub === "window-start" || + sub === "start-window" || + sub === "mirror-start" || + sub === "start-mirror" + ) { + const forcedBackend = + sub === "preview-start" || sub === "start-preview" + ? "simctl-screenshot-poll" + : sub === "window-start" || + sub === "start-window" || + sub === "mirror-start" || + sub === "start-mirror" + ? "simulator-window-capture" + : sub === "live-start" || sub === "start-live" + ? "auto" + : undefined; + const requestedBackend = + forcedBackend ?? + (readFlag(args, ["--window", "--mirror"]) ? "simulator-window-capture" - : sub === "live-start" || sub === "start-live" - ? "auto" - : undefined; - const requestedBackend = forcedBackend - ?? (readFlag(args, ["--window", "--mirror"]) ? "simulator-window-capture" : readFlag(args, ["--idb", "--live"]) ? "auto" : readFlag(args, ["--simctl", "--preview"]) ? "simctl-screenshot-poll" : readValue(args, ["--backend"]) ?? "auto"); - const defaultFps = requestedBackend === "simulator-window-capture" - ? 60 - : requestedBackend === "iosurface-indigo" || requestedBackend === "idb-mjpeg" || requestedBackend === "idb-h264-ffmpeg-mjpeg" - ? 30 - : requestedBackend === "simctl-screenshot-poll" - ? 8 - : undefined; - return { kind: "execute", label: "iOS simulator stream start", steps: [actionStep("result", "ios_simulator", "startStream", collectGenericObjectArgs(args, { - deviceUdid: readValue(args, ["--device", "--udid"]), - fps: readNumberOption(args, ["--fps"], defaultFps), - backend: requestedBackend, - }))] }; - } - if (sub === "stream-stop" || sub === "stop-stream" || sub === "preview-stop" || sub === "stop-preview" || sub === "live-stop" || sub === "stop-live") { - return { kind: "execute", label: "iOS simulator stream stop", steps: [actionStep("result", "ios_simulator", "stopStream", collectGenericObjectArgs(args))] }; + : readFlag(args, ["--idb", "--live"]) + ? "auto" + : readFlag(args, ["--simctl", "--preview"]) + ? "simctl-screenshot-poll" + : (readValue(args, ["--backend"]) ?? "auto")); + const defaultFps = + requestedBackend === "simulator-window-capture" + ? 60 + : requestedBackend === "iosurface-indigo" || + requestedBackend === "idb-mjpeg" || + requestedBackend === "idb-h264-ffmpeg-mjpeg" + ? 30 + : requestedBackend === "simctl-screenshot-poll" + ? 8 + : undefined; + return { + kind: "execute", + label: "iOS simulator stream start", + steps: [ + actionStep( + "result", + "ios_simulator", + "startStream", + collectGenericObjectArgs(args, { + deviceUdid: readValue(args, ["--device", "--udid"]), + fps: readNumberOption(args, ["--fps"], defaultFps), + backend: requestedBackend, + }), + ), + ], + }; + } + if ( + sub === "stream-stop" || + sub === "stop-stream" || + sub === "preview-stop" || + sub === "stop-preview" || + sub === "live-stop" || + sub === "stop-live" + ) { + return { + kind: "execute", + label: "iOS simulator stream stop", + steps: [ + actionStep( + "result", + "ios_simulator", + "stopStream", + collectGenericObjectArgs(args), + ), + ], + }; } if (sub === "stream-status") { - return { kind: "execute", label: "iOS simulator stream status", steps: [actionStep("result", "ios_simulator", "getStreamStatus", collectGenericObjectArgs(args))] }; + return { + kind: "execute", + label: "iOS simulator stream status", + steps: [ + actionStep( + "result", + "ios_simulator", + "getStreamStatus", + collectGenericObjectArgs(args), + ), + ], + }; } if (sub === "tap") { - return { kind: "execute", label: "iOS simulator tap", steps: [actionStep("result", "ios_simulator", "tap", collectGenericObjectArgs(args, { - deviceUdid: readValue(args, ["--device", "--udid"]), - projectRoot: readValue(args, ["--project-root", "--root"]), - x: readCoordinate("--x", 0), - y: readCoordinate("--y", 1), - }))] }; + return { + kind: "execute", + label: "iOS simulator tap", + steps: [ + actionStep( + "result", + "ios_simulator", + "tap", + collectGenericObjectArgs(args, { + deviceUdid: readValue(args, ["--device", "--udid"]), + projectRoot: readValue(args, ["--project-root", "--root"]), + x: readCoordinate("--x", 0), + y: readCoordinate("--y", 1), + }), + ), + ], + }; } if (sub === "drag" || sub === "swipe") { - return { kind: "execute", label: `iOS simulator ${sub}`, steps: [actionStep("result", "ios_simulator", sub, collectGenericObjectArgs(args, { - deviceUdid: readValue(args, ["--device", "--udid"]), - projectRoot: readValue(args, ["--project-root", "--root"]), - startX: readCoordinate("--start-x", 0), - startY: readCoordinate("--start-y", 1), - endX: readCoordinate("--end-x", 2), - endY: readCoordinate("--end-y", 3), - durationMs: readNumberOption(args, ["--duration-ms", "--duration"]), - }))] }; + return { + kind: "execute", + label: `iOS simulator ${sub}`, + steps: [ + actionStep( + "result", + "ios_simulator", + sub, + collectGenericObjectArgs(args, { + deviceUdid: readValue(args, ["--device", "--udid"]), + projectRoot: readValue(args, ["--project-root", "--root"]), + startX: readCoordinate("--start-x", 0), + startY: readCoordinate("--start-y", 1), + endX: readCoordinate("--end-x", 2), + endY: readCoordinate("--end-y", 3), + durationMs: readNumberOption(args, ["--duration-ms", "--duration"]), + }), + ), + ], + }; + } + if (sub === "select") { + return { + kind: "execute", + label: "iOS simulator select", + steps: [ + actionStep( + "result", + "ios_simulator", + "selectPoint", + collectGenericObjectArgs(args, { + deviceUdid: readValue(args, ["--device", "--udid"]), + projectRoot: readValue(args, ["--project-root", "--root"]), + x: readCoordinate("--x", 0), + y: readCoordinate("--y", 1), + }), + ), + ], + }; + } + if (sub === "type" || sub === "text") { + return { + kind: "execute", + label: "iOS simulator type", + steps: [ + actionStep( + "result", + "ios_simulator", + "typeText", + collectGenericObjectArgs(args, { + deviceUdid: readValue(args, ["--device", "--udid"]), + projectRoot: readValue(args, ["--project-root", "--root"]), + text: requireValue( + readValue(args, ["--value", "--message", "--input-text"]) ?? + readCommandTextValue(args, ["--text"]) ?? + args.filter((arg) => arg !== "--text").join(" "), + "text", + ), + }), + ), + ], + }; } - if (sub === "select") { - return { kind: "execute", label: "iOS simulator select", steps: [actionStep("result", "ios_simulator", "selectPoint", collectGenericObjectArgs(args, { - deviceUdid: readValue(args, ["--device", "--udid"]), - projectRoot: readValue(args, ["--project-root", "--root"]), - x: readCoordinate("--x", 0), - y: readCoordinate("--y", 1), - }))] }; + if ( + sub === "shutdown" || + sub === "stop" || + sub === "teardown" || + sub === "end" || + sub === "end-session" + ) { + return { + kind: "execute", + label: "iOS simulator shutdown", + steps: [ + actionStep( + "result", + "ios_simulator", + "shutdown", + collectGenericObjectArgs(args, { + deviceUdid: readValue(args, ["--device", "--udid"]), + force: readFlag(args, ["--force", "-f"]) ? true : undefined, + }), + ), + ], + }; } - if (sub === "type" || sub === "text") { - return { kind: "execute", label: "iOS simulator type", steps: [actionStep("result", "ios_simulator", "typeText", collectGenericObjectArgs(args, { - deviceUdid: readValue(args, ["--device", "--udid"]), - projectRoot: readValue(args, ["--project-root", "--root"]), - text: requireValue( - readValue(args, ["--value", "--message", "--input-text"]) - ?? readCommandTextValue(args, ["--text"]) - ?? args.filter((arg) => arg !== "--text").join(" "), - "text", + return { + kind: "execute", + label: `ios-sim ${sub}`, + steps: [ + actionStep( + "result", + "ios_simulator", + sub, + collectGenericObjectArgs(args), ), - }))] }; - } - if (sub === "shutdown" || sub === "stop" || sub === "teardown" || sub === "end" || sub === "end-session") { - return { kind: "execute", label: "iOS simulator shutdown", steps: [actionStep("result", "ios_simulator", "shutdown", collectGenericObjectArgs(args, { - deviceUdid: readValue(args, ["--device", "--udid"]), - force: readFlag(args, ["--force", "-f"]) ? true : undefined, - }))] }; - } - return { kind: "execute", label: `ios-sim ${sub}`, steps: [actionStep("result", "ios_simulator", sub, collectGenericObjectArgs(args))] }; + ], + }; } function readTrailingCommand(args: string[]): string | null { @@ -3304,39 +6190,108 @@ function readTrailingCommand(args: string[]): string | null { function buildAppControlPlan(args: string[]): CliPlan { const sub = firstPositional(args) ?? "status"; if (sub === "help") return { kind: "help", text: buildAppControlHelp(args) }; - const numericPositionals = () => args.filter((value) => /^\d+(\.\d+)?$/.test(value)); + const numericPositionals = () => + args.filter((value) => /^\d+(\.\d+)?$/.test(value)); const readCoordinate = (flag: string, index: number): number => { - const value = readNumberOption(args, [flag]) ?? Number(numericPositionals()[index]); - if (!Number.isFinite(value)) throw new CliUsageError(`${flag} is required and must be a number.`); + const value = + readNumberOption(args, [flag]) ?? Number(numericPositionals()[index]); + if (!Number.isFinite(value)) + throw new CliUsageError(`${flag} is required and must be a number.`); return value; }; - if (sub === "actions") return { kind: "execute", label: "App Control actions", steps: [listActionsStep("actions", "app_control")] }; - if (sub === "status") return { kind: "execute", label: "App Control status", steps: [actionStep("result", "app_control", "getStatus", collectGenericObjectArgs(args))] }; + if (sub === "actions") + return { + kind: "execute", + label: "App Control actions", + steps: [listActionsStep("actions", "app_control")], + }; + if (sub === "status") + return { + kind: "execute", + label: "App Control status", + steps: [ + actionStep( + "result", + "app_control", + "getStatus", + collectGenericObjectArgs(args), + ), + ], + }; if (sub === "logs" || sub === "log" || sub === "read" || sub === "tail") { - return { kind: "execute", label: "terminal read", steps: [actionStep("result", "app_control", "readTerminal", collectGenericObjectArgs(args, { - maxBytes: readIntOption(args, ["--max-bytes"], undefined), - since: readIntOption(args, ["--since"], undefined), - }))] }; + return { + kind: "execute", + label: "terminal read", + steps: [ + actionStep( + "result", + "app_control", + "readTerminal", + collectGenericObjectArgs(args, { + maxBytes: readIntOption(args, ["--max-bytes"], undefined), + since: readIntOption(args, ["--since"], undefined), + }), + ), + ], + }; } if (sub === "terminal") { const mode = firstPositional(args) ?? "read"; if (mode === "read" || mode === "logs" || mode === "tail") { - return { kind: "execute", label: "terminal read", steps: [actionStep("result", "app_control", "readTerminal", collectGenericObjectArgs(args, { - maxBytes: readIntOption(args, ["--max-bytes"], undefined), - since: readIntOption(args, ["--since"], undefined), - }))] }; + return { + kind: "execute", + label: "terminal read", + steps: [ + actionStep( + "result", + "app_control", + "readTerminal", + collectGenericObjectArgs(args, { + maxBytes: readIntOption(args, ["--max-bytes"], undefined), + since: readIntOption(args, ["--since"], undefined), + }), + ), + ], + }; } if (mode === "write" || mode === "send" || mode === "input") { - const data = readValue(args, ["--data", "--value", "--text"]) ?? args.join(" "); + const data = + readValue(args, ["--data", "--value", "--text"]) ?? args.join(" "); if (!data.length) throw new CliUsageError("data is required."); - return { kind: "execute", label: "terminal write", steps: [actionStep("result", "app_control", "writeTerminal", collectGenericObjectArgs(args, { data }))] }; + return { + kind: "execute", + label: "terminal write", + steps: [ + actionStep( + "result", + "app_control", + "writeTerminal", + collectGenericObjectArgs(args, { data }), + ), + ], + }; } if (mode === "signal" || mode === "interrupt" || mode === "stop") { - return { kind: "execute", label: "terminal signal", steps: [actionStep("result", "app_control", "signalTerminal", collectGenericObjectArgs(args, { - signal: readValue(args, ["--signal"]) ?? (mode === "stop" ? "SIGTERM" : "SIGINT"), - }))] }; + return { + kind: "execute", + label: "terminal signal", + steps: [ + actionStep( + "result", + "app_control", + "signalTerminal", + collectGenericObjectArgs(args, { + signal: + readValue(args, ["--signal"]) ?? + (mode === "stop" ? "SIGTERM" : "SIGINT"), + }), + ), + ], + }; } - throw new CliUsageError("app-control terminal supports read, write, or signal."); + throw new CliUsageError( + "app-control terminal supports read, write, or signal.", + ); } if (sub === "launch" || sub === "open" || sub === "start") { const trailingCommand = readTrailingCommand(args); @@ -3348,120 +6303,299 @@ function buildAppControlPlan(args: string[]): CliPlan { const debugPort = readNumberOption(args, ["--debug-port", "--port"]); const cdpPort = readNumberOption(args, ["--cdp-port"]); const label = readValue(args, ["--label", "--name"]); - const chatSessionId = readValue(args, ["--chat-session", "--chat-session-id", "--session", "--session-id"]) ?? process.env.ADE_CHAT_SESSION_ID; + const chatSessionId = + readValue(args, [ + "--chat-session", + "--chat-session-id", + "--session", + "--session-id", + ]) ?? process.env.ADE_CHAT_SESSION_ID; const force = readFlag(args, ["--force", "-f"]) ? true : undefined; - const positionalCommand = args.filter((arg) => arg !== "--" && !arg.startsWith("-")).join(" ").trim(); - const launchCommand = command ?? (positionalCommand.length ? positionalCommand : null); - if (!launchCommand) throw new CliUsageError("app-control launch requires a command, for example: ade app-control launch --command \"pnpm dev\"."); + const positionalCommand = args + .filter((arg) => arg !== "--" && !arg.startsWith("-")) + .join(" ") + .trim(); + const launchCommand = + command ?? (positionalCommand.length ? positionalCommand : null); + if (!launchCommand) + throw new CliUsageError( + 'app-control launch requires a command, for example: ade app-control launch --command "pnpm dev".', + ); return { kind: "execute", label: "App Control launch", - steps: [actionStep("result", "app_control", "launch", collectGenericObjectArgs(args, { - appKind, - projectRoot, - laneId, - command: launchCommand, - cwd, - debugPort, - cdpPort, - label, - chatSessionId, - force, - }))], + steps: [ + actionStep( + "result", + "app_control", + "launch", + collectGenericObjectArgs(args, { + appKind, + projectRoot, + laneId, + command: launchCommand, + cwd, + debugPort, + cdpPort, + label, + chatSessionId, + force, + }), + ), + ], }; } if (sub === "connect" || sub === "attach") { - return { kind: "execute", label: "App Control connect", steps: [actionStep("result", "app_control", "connect", collectGenericObjectArgs(args, { - appKind: readValue(args, ["--kind", "--app-kind"]) ?? "electron", - projectRoot: readValue(args, ["--project-root", "--root"]), - laneId: readValue(args, ["--lane", "--lane-id"]), - cdpPort: readNumberOption(args, ["--cdp-port", "--port"]) ?? Number(numericPositionals()[0]), - label: readValue(args, ["--label", "--name"]), - chatSessionId: readValue(args, ["--chat-session", "--session"]) ?? process.env.ADE_CHAT_SESSION_ID, - force: readFlag(args, ["--force", "-f"]) ? true : undefined, - }))] }; + return { + kind: "execute", + label: "App Control connect", + steps: [ + actionStep( + "result", + "app_control", + "connect", + collectGenericObjectArgs(args, { + appKind: readValue(args, ["--kind", "--app-kind"]) ?? "electron", + projectRoot: readValue(args, ["--project-root", "--root"]), + laneId: readValue(args, ["--lane", "--lane-id"]), + cdpPort: + readNumberOption(args, ["--cdp-port", "--port"]) ?? + Number(numericPositionals()[0]), + label: readValue(args, ["--label", "--name"]), + chatSessionId: + readValue(args, ["--chat-session", "--session"]) ?? + process.env.ADE_CHAT_SESSION_ID, + force: readFlag(args, ["--force", "-f"]) ? true : undefined, + }), + ), + ], + }; } if (sub === "targets" || sub === "list-targets") { - return { kind: "execute", label: "App Control targets", steps: [actionStep("result", "app_control", "listTargets", collectGenericObjectArgs(args))] }; + return { + kind: "execute", + label: "App Control targets", + steps: [ + actionStep( + "result", + "app_control", + "listTargets", + collectGenericObjectArgs(args), + ), + ], + }; } if (sub === "attach-target" || sub === "target") { - const targetId = requireValue(readValue(args, ["--target", "--target-id"]) ?? firstPositional(args), "targetId"); - return { kind: "execute", label: "App Control attach target", steps: [actionArgsListStep("result", "app_control", "attachToTarget", [targetId])] }; + const targetId = requireValue( + readValue(args, ["--target", "--target-id"]) ?? firstPositional(args), + "targetId", + ); + return { + kind: "execute", + label: "App Control attach target", + steps: [ + actionArgsListStep("result", "app_control", "attachToTarget", [ + targetId, + ]), + ], + }; } - if (sub === "stop" || sub === "shutdown" || sub === "teardown" || sub === "close") { - return { kind: "execute", label: "App Control stop", steps: [actionStep("result", "app_control", "stop", collectGenericObjectArgs(args, { force: readFlag(args, ["--force", "-f"]) ? true : undefined }))] }; + if ( + sub === "stop" || + sub === "shutdown" || + sub === "teardown" || + sub === "close" + ) { + return { + kind: "execute", + label: "App Control stop", + steps: [ + actionStep( + "result", + "app_control", + "stop", + collectGenericObjectArgs(args, { + force: readFlag(args, ["--force", "-f"]) ? true : undefined, + }), + ), + ], + }; } if (sub === "screenshot" || sub === "capture") { - return { kind: "execute", label: "App Control screenshot", steps: [actionStep("result", "app_control", "screenshot", collectGenericObjectArgs(args))] }; + return { + kind: "execute", + label: "App Control screenshot", + steps: [ + actionStep( + "result", + "app_control", + "screenshot", + collectGenericObjectArgs(args), + ), + ], + }; } if (sub === "snapshot" || sub === "screen" || sub === "elements") { - return { kind: "execute", label: "App Control snapshot", steps: [actionStep("result", "app_control", "getSnapshot", collectGenericObjectArgs(args, { projectRoot: readValue(args, ["--project-root", "--root"]) }))] }; + return { + kind: "execute", + label: "App Control snapshot", + steps: [ + actionStep( + "result", + "app_control", + "getSnapshot", + collectGenericObjectArgs(args, { + projectRoot: readValue(args, ["--project-root", "--root"]), + }), + ), + ], + }; } if (sub === "inspect" || sub === "hit-test" || sub === "hover") { - return { kind: "execute", label: "App Control inspect point", steps: [actionStep("result", "app_control", "inspectPoint", collectGenericObjectArgs(args, { - projectRoot: readValue(args, ["--project-root", "--root"]), - x: readCoordinate("--x", 0), - y: readCoordinate("--y", 1), - includeScreenshot: readFlag(args, ["--screenshot", "--include-screenshot"]), - }))] }; + return { + kind: "execute", + label: "App Control inspect point", + steps: [ + actionStep( + "result", + "app_control", + "inspectPoint", + collectGenericObjectArgs(args, { + projectRoot: readValue(args, ["--project-root", "--root"]), + x: readCoordinate("--x", 0), + y: readCoordinate("--y", 1), + includeScreenshot: readFlag(args, [ + "--screenshot", + "--include-screenshot", + ]), + }), + ), + ], + }; } if (sub === "select") { - return { kind: "execute", label: "App Control select", steps: [actionStep("result", "app_control", "selectPoint", collectGenericObjectArgs(args, { - projectRoot: readValue(args, ["--project-root", "--root"]), - x: readCoordinate("--x", 0), - y: readCoordinate("--y", 1), - }))] }; + return { + kind: "execute", + label: "App Control select", + steps: [ + actionStep( + "result", + "app_control", + "selectPoint", + collectGenericObjectArgs(args, { + projectRoot: readValue(args, ["--project-root", "--root"]), + x: readCoordinate("--x", 0), + y: readCoordinate("--y", 1), + }), + ), + ], + }; } if (sub === "click" || sub === "tap") { - return { kind: "execute", label: "App Control click", steps: [actionStep("result", "app_control", "click", collectGenericObjectArgs(args, { - x: readCoordinate("--x", 0), - y: readCoordinate("--y", 1), - }))] }; + return { + kind: "execute", + label: "App Control click", + steps: [ + actionStep( + "result", + "app_control", + "click", + collectGenericObjectArgs(args, { + x: readCoordinate("--x", 0), + y: readCoordinate("--y", 1), + }), + ), + ], + }; } if (sub === "scroll" || sub === "wheel") { - return { kind: "execute", label: "App Control scroll", steps: [actionStep("result", "app_control", "scroll", collectGenericObjectArgs(args, { - x: readCoordinate("--x", 0), - y: readCoordinate("--y", 1), - deltaX: readNumberOption(args, ["--delta-x", "--dx"]) ?? 0, - deltaY: readNumberOption(args, ["--delta-y", "--dy"]) ?? 0, - scale: readNumberOption(args, ["--scale"]), - }))] }; + return { + kind: "execute", + label: "App Control scroll", + steps: [ + actionStep( + "result", + "app_control", + "scroll", + collectGenericObjectArgs(args, { + x: readCoordinate("--x", 0), + y: readCoordinate("--y", 1), + deltaX: readNumberOption(args, ["--delta-x", "--dx"]) ?? 0, + deltaY: readNumberOption(args, ["--delta-y", "--dy"]) ?? 0, + scale: readNumberOption(args, ["--scale"]), + }), + ), + ], + }; } if (sub === "key" || sub === "dispatch-key") { const key = readValue(args, ["--key"]) ?? firstPositional(args); - return { kind: "execute", label: "App Control key", steps: [actionStep("result", "app_control", "dispatchKey", collectGenericObjectArgs(args, { - type: readValue(args, ["--event-type", "--type"]) ?? "keyDown", - key: requireValue(key, "key"), - code: readValue(args, ["--code"]), - text: readValue(args, ["--text"]), - modifiers: readNumberOption(args, ["--modifiers"]), - }))] }; + return { + kind: "execute", + label: "App Control key", + steps: [ + actionStep( + "result", + "app_control", + "dispatchKey", + collectGenericObjectArgs(args, { + type: readValue(args, ["--event-type", "--type"]) ?? "keyDown", + key: requireValue(key, "key"), + code: readValue(args, ["--code"]), + text: readValue(args, ["--text"]), + modifiers: readNumberOption(args, ["--modifiers"]), + }), + ), + ], + }; } if (sub === "type" || sub === "text") { - return { kind: "execute", label: "App Control type", steps: [actionStep("result", "app_control", "typeText", collectGenericObjectArgs(args, { - text: requireValue( - readValue(args, ["--value", "--message", "--input-text"]) - ?? readCommandTextValue(args, ["--text"]) - ?? args.filter((arg) => arg !== "--text").join(" "), - "text", - ), - }))] }; + return { + kind: "execute", + label: "App Control type", + steps: [ + actionStep( + "result", + "app_control", + "typeText", + collectGenericObjectArgs(args, { + text: requireValue( + readValue(args, ["--value", "--message", "--input-text"]) ?? + readCommandTextValue(args, ["--text"]) ?? + args.filter((arg) => arg !== "--text").join(" "), + "text", + ), + }), + ), + ], + }; } - return { kind: "execute", label: `app-control ${sub}`, steps: [actionStep("result", "app_control", sub, collectGenericObjectArgs(args))] }; + return { + kind: "execute", + label: `app-control ${sub}`, + steps: [ + actionStep("result", "app_control", sub, collectGenericObjectArgs(args)), + ], + }; } function buildMacosVmPlan(args: string[]): CliPlan { const sub = firstPositional(args) ?? "status"; - if (sub === "help") return { kind: "help", text: HELP_BY_COMMAND["macos-vm"] }; - const numericPositionals = () => args.filter((value) => /^\d+(\.\d+)?$/.test(value)); + if (sub === "help") + return { kind: "help", text: HELP_BY_COMMAND["macos-vm"] }; + const numericPositionals = () => + args.filter((value) => /^\d+(\.\d+)?$/.test(value)); const readCoordinate = (flag: string, index: number): number => { - const value = readNumberOption(args, [flag]) ?? Number(numericPositionals()[index]); - if (!Number.isFinite(value)) throw new CliUsageError(`${flag} is required and must be a number.`); + const value = + readNumberOption(args, [flag]) ?? Number(numericPositionals()[index]); + if (!Number.isFinite(value)) + throw new CliUsageError(`${flag} is required and must be a number.`); return value; }; const readVmLaneId = (required: boolean): string | null => { - const laneId = readValue(args, ["--lane", "--lane-id"]) ?? firstPositional(args); + const laneId = + readValue(args, ["--lane", "--lane-id"]) ?? firstPositional(args); if (required) return requireValue(laneId, "laneId"); return laneId; }; @@ -3476,250 +6610,897 @@ function buildMacosVmPlan(args: string[]): CliPlan { mode: readValue(args, ["--mode"]), ipsw: readValue(args, ["--ipsw"]), sourceImage: readValue(args, ["--image", "--source-image"]), - unattendedPreset: readValue(args, ["--unattended", "--unattended-preset"]), + unattendedPreset: readValue(args, [ + "--unattended", + "--unattended-preset", + ]), }; - return Object.fromEntries(Object.entries(options).filter(([, value]) => value !== undefined && value !== null && value !== "")); + return Object.fromEntries( + Object.entries(options).filter( + ([, value]) => value !== undefined && value !== null && value !== "", + ), + ); }; - if (sub === "actions") return { kind: "execute", label: "macOS VM actions", steps: [listActionsStep("actions", "macos_vm")] }; + if (sub === "actions") + return { + kind: "execute", + label: "macOS VM actions", + steps: [listActionsStep("actions", "macos_vm")], + }; if (sub === "status" || sub === "list" || sub === "ls") { - return { kind: "execute", label: "macOS VM status", steps: [actionStep("result", "macos_vm", "getStatus", collectGenericObjectArgs(args, { laneId: readVmLaneId(false) }))] }; + return { + kind: "execute", + label: "macOS VM status", + steps: [ + actionStep( + "result", + "macos_vm", + "getStatus", + collectGenericObjectArgs(args, { laneId: readVmLaneId(false) }), + ), + ], + }; } if (sub === "share" || sub === "share-policy") { - return { kind: "execute", label: "macOS VM share policy", steps: [actionStep("result", "macos_vm", "getSharePolicy", collectGenericObjectArgs(args, { laneId: readVmLaneId(true) }))] }; + return { + kind: "execute", + label: "macOS VM share policy", + steps: [ + actionStep( + "result", + "macos_vm", + "getSharePolicy", + collectGenericObjectArgs(args, { laneId: readVmLaneId(true) }), + ), + ], + }; } if (sub === "provision" || sub === "create" || sub === "pull") { const provisionOptions = readProvisionOptions(); - const mode = sub === "create" ? "create" : sub === "pull" ? "pull-image" : provisionOptions.mode; - return { kind: "execute", label: "macOS VM provision", steps: [actionStep("result", "macos_vm", "provision", collectGenericObjectArgs(args, { - laneId: readVmLaneId(true), - ...provisionOptions, - mode, - force: readFlag(args, ["--force", "-f"]) ? true : undefined, - }))] }; + const mode = + sub === "create" + ? "create" + : sub === "pull" + ? "pull-image" + : provisionOptions.mode; + return { + kind: "execute", + label: "macOS VM provision", + steps: [ + actionStep( + "result", + "macos_vm", + "provision", + collectGenericObjectArgs(args, { + laneId: readVmLaneId(true), + ...provisionOptions, + mode, + force: readFlag(args, ["--force", "-f"]) ? true : undefined, + }), + ), + ], + }; } if (sub === "start" || sub === "run" || sub === "open") { const noDisplay = readFlag(args, ["--no-display", "--headless"]); - const openDisplay = noDisplay ? false : readFlag(args, ["--open-display", "--display-window"]) ? true : undefined; - return { kind: "execute", label: "macOS VM start", steps: [actionStep("result", "macos_vm", "start", collectGenericObjectArgs(args, { - laneId: readVmLaneId(true), - ...readProvisionOptions(), - openDisplay, - createIfMissing: readFlag(args, ["--create", "--create-if-missing"]) ? true : undefined, - }))] }; + const openDisplay = noDisplay + ? false + : readFlag(args, ["--open-display", "--display-window"]) + ? true + : undefined; + return { + kind: "execute", + label: "macOS VM start", + steps: [ + actionStep( + "result", + "macos_vm", + "start", + collectGenericObjectArgs(args, { + laneId: readVmLaneId(true), + ...readProvisionOptions(), + openDisplay, + createIfMissing: readFlag(args, ["--create", "--create-if-missing"]) + ? true + : undefined, + }), + ), + ], + }; } if (sub === "stop" || sub === "shutdown") { - return { kind: "execute", label: "macOS VM stop", steps: [actionStep("result", "macos_vm", "stop", collectGenericObjectArgs(args, { - laneId: readVmLaneId(true), - force: readFlag(args, ["--force", "-f"]) ? true : undefined, - }))] }; + return { + kind: "execute", + label: "macOS VM stop", + steps: [ + actionStep( + "result", + "macos_vm", + "stop", + collectGenericObjectArgs(args, { + laneId: readVmLaneId(true), + force: readFlag(args, ["--force", "-f"]) ? true : undefined, + }), + ), + ], + }; } - if (sub === "delete" || sub === "rm" || sub === "remove" || sub === "destroy") { - return { kind: "execute", label: "macOS VM delete", steps: [actionStep("result", "macos_vm", "delete", collectGenericObjectArgs(args, { - laneId: readVmLaneId(true), - force: readFlag(args, ["--force", "-f"]) ? true : undefined, - }))] }; + if ( + sub === "delete" || + sub === "rm" || + sub === "remove" || + sub === "destroy" + ) { + return { + kind: "execute", + label: "macOS VM delete", + steps: [ + actionStep( + "result", + "macos_vm", + "delete", + collectGenericObjectArgs(args, { + laneId: readVmLaneId(true), + force: readFlag(args, ["--force", "-f"]) ? true : undefined, + }), + ), + ], + }; } - if (sub === "guide" || sub === "agent-guide" || sub === "handoff" || sub === "target") { - return { kind: "execute", label: "macOS VM guide", steps: [actionStep("result", "macos_vm", "getAgentGuide", collectGenericObjectArgs(args, { laneId: readVmLaneId(true) }))] }; + if ( + sub === "guide" || + sub === "agent-guide" || + sub === "handoff" || + sub === "target" + ) { + return { + kind: "execute", + label: "macOS VM guide", + steps: [ + actionStep( + "result", + "macos_vm", + "getAgentGuide", + collectGenericObjectArgs(args, { laneId: readVmLaneId(true) }), + ), + ], + }; } if (sub === "focus" || sub === "focus-window") { - return { kind: "execute", label: "macOS VM focus", steps: [actionStep("result", "macos_vm", "focusWindow", collectGenericObjectArgs(args, { - laneId: readVmLaneId(true), - windowTitleQuery: readValue(args, ["--window-title", "--title-query"]), - }))] }; + return { + kind: "execute", + label: "macOS VM focus", + steps: [ + actionStep( + "result", + "macos_vm", + "focusWindow", + collectGenericObjectArgs(args, { + laneId: readVmLaneId(true), + windowTitleQuery: readValue(args, [ + "--window-title", + "--title-query", + ]), + }), + ), + ], + }; } if (sub === "screenshot" || sub === "capture") { - return { kind: "execute", label: "macOS VM screenshot", steps: [actionStep("result", "macos_vm", "captureScreenshot", collectGenericObjectArgs(args, { - laneId: readVmLaneId(true), - windowTitleQuery: readValue(args, ["--window-title", "--title-query"]), - outputPath: readValue(args, ["--output", "--path"]), - }))] }; + return { + kind: "execute", + label: "macOS VM screenshot", + steps: [ + actionStep( + "result", + "macos_vm", + "captureScreenshot", + collectGenericObjectArgs(args, { + laneId: readVmLaneId(true), + windowTitleQuery: readValue(args, [ + "--window-title", + "--title-query", + ]), + outputPath: readValue(args, ["--output", "--path"]), + }), + ), + ], + }; } if (sub === "select" || sub === "select-point" || sub === "inspect") { - return { kind: "execute", label: "macOS VM select", steps: [actionStep("result", "macos_vm", "selectPoint", collectGenericObjectArgs(args, { - laneId: readVmLaneId(true), - x: readCoordinate("--x", 0), - y: readCoordinate("--y", 1), - coordinateSpace: readValue(args, ["--coordinate-space", "--coords"]), - windowTitleQuery: readValue(args, ["--window-title", "--title-query"]), - includeScreenshot: readFlag(args, ["--no-screenshot"]) ? false : undefined, - }))] }; + return { + kind: "execute", + label: "macOS VM select", + steps: [ + actionStep( + "result", + "macos_vm", + "selectPoint", + collectGenericObjectArgs(args, { + laneId: readVmLaneId(true), + x: readCoordinate("--x", 0), + y: readCoordinate("--y", 1), + coordinateSpace: readValue(args, [ + "--coordinate-space", + "--coords", + ]), + windowTitleQuery: readValue(args, [ + "--window-title", + "--title-query", + ]), + includeScreenshot: readFlag(args, ["--no-screenshot"]) + ? false + : undefined, + }), + ), + ], + }; } if (sub === "click" || sub === "tap") { - return { kind: "execute", label: "macOS VM click", steps: [actionStep("result", "macos_vm", "click", collectGenericObjectArgs(args, { - laneId: readVmLaneId(true), - x: readCoordinate("--x", 0), - y: readCoordinate("--y", 1), - coordinateSpace: readValue(args, ["--coordinate-space", "--coords"]), - windowTitleQuery: readValue(args, ["--window-title", "--title-query"]), - }))] }; + return { + kind: "execute", + label: "macOS VM click", + steps: [ + actionStep( + "result", + "macos_vm", + "click", + collectGenericObjectArgs(args, { + laneId: readVmLaneId(true), + x: readCoordinate("--x", 0), + y: readCoordinate("--y", 1), + coordinateSpace: readValue(args, [ + "--coordinate-space", + "--coords", + ]), + windowTitleQuery: readValue(args, [ + "--window-title", + "--title-query", + ]), + }), + ), + ], + }; } if (sub === "type" || sub === "text") { - return { kind: "execute", label: "macOS VM type", steps: [actionStep("result", "macos_vm", "typeText", collectGenericObjectArgs(args, { - laneId: readVmLaneId(true), - text: requireValue( - readValue(args, ["--value", "--message", "--input-text"]) - ?? readCommandTextValue(args, ["--text"]) - ?? args.filter((arg) => arg !== "--text").join(" "), - "text", - ), - windowTitleQuery: readValue(args, ["--window-title", "--title-query"]), - }))] }; + return { + kind: "execute", + label: "macOS VM type", + steps: [ + actionStep( + "result", + "macos_vm", + "typeText", + collectGenericObjectArgs(args, { + laneId: readVmLaneId(true), + text: requireValue( + readValue(args, ["--value", "--message", "--input-text"]) ?? + readCommandTextValue(args, ["--text"]) ?? + args.filter((arg) => arg !== "--text").join(" "), + "text", + ), + windowTitleQuery: readValue(args, [ + "--window-title", + "--title-query", + ]), + }), + ), + ], + }; } - return { kind: "execute", label: `macos-vm ${sub}`, steps: [actionStep("result", "macos_vm", sub, collectGenericObjectArgs(args))] }; + return { + kind: "execute", + label: `macos-vm ${sub}`, + steps: [ + actionStep("result", "macos_vm", sub, collectGenericObjectArgs(args)), + ], + }; } function buildBrowserPlan(args: string[]): CliPlan { const sub = firstPositional(args) ?? "status"; if (sub === "help") return { kind: "help", text: HELP_BY_COMMAND.browser }; - if (sub === "actions") return { kind: "execute", label: "browser actions", steps: [listActionsStep("actions", "built_in_browser")] }; + if (sub === "actions") + return { + kind: "execute", + label: "browser actions", + steps: [listActionsStep("actions", "built_in_browser")], + }; if (sub === "status" || sub === "tabs" || sub === "list") { - return { kind: "execute", label: "browser status", steps: [actionStep("result", "built_in_browser", "getStatus", collectGenericObjectArgs(args))] }; + return { + kind: "execute", + label: "browser status", + steps: [ + actionStep( + "result", + "built_in_browser", + "getStatus", + collectGenericObjectArgs(args), + ), + ], + }; } - if (sub === "panel" || sub === "show" || sub === "open-panel" || sub === "reveal") { + if ( + sub === "panel" || + sub === "show" || + sub === "open-panel" || + sub === "reveal" + ) { const panelArgs: JsonObject = {}; maybePut(panelArgs, "url", readValue(args, ["--url"])); maybePut(panelArgs, "tabId", readValue(args, ["--tab", "--tab-id"])); - return { kind: "execute", label: "browser panel", steps: [actionStep("result", "built_in_browser", "showPanel", collectGenericObjectArgs(args, panelArgs))] }; + return { + kind: "execute", + label: "browser panel", + steps: [ + actionStep( + "result", + "built_in_browser", + "showPanel", + collectGenericObjectArgs(args, panelArgs), + ), + ], + }; } if (sub === "open" || sub === "navigate" || sub === "go") { const explicitUrl = readValue(args, ["--url"]); const tabId = readValue(args, ["--tab", "--tab-id"]); - const activeTab = readFlag(args, ["--active-tab", "--current-tab", "--same-tab"]); + const activeTab = readFlag(args, [ + "--active-tab", + "--current-tab", + "--same-tab", + ]); const newTab = readFlag(args, ["--new-tab"]); const noPanel = readFlag(args, ["--no-panel", "--hidden"]); const genericArgs = collectGenericObjectArgs(args); - const genericUrl = typeof genericArgs.url === "string" ? genericArgs.url : null; + const genericUrl = + typeof genericArgs.url === "string" ? genericArgs.url : null; const url = explicitUrl ?? genericUrl ?? args.join(" "); if (!url.trim()) throw new CliUsageError("browser open requires a URL."); - return { kind: "execute", label: "browser open", steps: [actionStep("result", "built_in_browser", "navigate", { - url, - tabId, - newTab: newTab && !activeTab ? true : undefined, - openPanel: !noPanel, - ...genericArgs, - })] }; + return { + kind: "execute", + label: "browser open", + steps: [ + actionStep("result", "built_in_browser", "navigate", { + url, + tabId, + newTab: newTab && !activeTab ? true : undefined, + openPanel: !noPanel, + ...genericArgs, + }), + ], + }; } if (sub === "new-tab" || sub === "tab" || sub === "new") { const background = readFlag(args, ["--background"]); const noPanel = readFlag(args, ["--no-panel", "--hidden"]); const explicitUrl = readValue(args, ["--url"]); const genericArgs = collectGenericObjectArgs(args); - const genericUrl = typeof genericArgs.url === "string" ? genericArgs.url : null; - const url = explicitUrl ?? genericUrl ?? (args.length ? args.join(" ") : undefined); - return { kind: "execute", label: "browser new tab", steps: [actionStep("result", "built_in_browser", "createTab", { - url, - activate: background ? false : undefined, - openPanel: !noPanel, - ...genericArgs, - })] }; + const genericUrl = + typeof genericArgs.url === "string" ? genericArgs.url : null; + const url = + explicitUrl ?? genericUrl ?? (args.length ? args.join(" ") : undefined); + return { + kind: "execute", + label: "browser new tab", + steps: [ + actionStep("result", "built_in_browser", "createTab", { + url, + activate: background ? false : undefined, + openPanel: !noPanel, + ...genericArgs, + }), + ], + }; } if (sub === "switch" || sub === "activate") { const noPanel = readFlag(args, ["--no-panel", "--hidden"]); const explicitTabId = readValue(args, ["--tab", "--tab-id"]); const genericArgs = collectGenericObjectArgs(args); - const genericTabId = typeof genericArgs.tabId === "string" ? genericArgs.tabId : null; - return { kind: "execute", label: "browser switch", steps: [actionStep("result", "built_in_browser", "switchTab", { - tabId: requireValue(explicitTabId ?? genericTabId ?? firstPositional(args), "tabId"), - openPanel: !noPanel, - ...genericArgs, - })] }; + const genericTabId = + typeof genericArgs.tabId === "string" ? genericArgs.tabId : null; + return { + kind: "execute", + label: "browser switch", + steps: [ + actionStep("result", "built_in_browser", "switchTab", { + tabId: requireValue( + explicitTabId ?? genericTabId ?? firstPositional(args), + "tabId", + ), + openPanel: !noPanel, + ...genericArgs, + }), + ], + }; } if (sub === "close" || sub === "close-tab") { const explicitTabId = readValue(args, ["--tab", "--tab-id"]); const genericArgs = collectGenericObjectArgs(args); - const genericTabId = typeof genericArgs.tabId === "string" ? genericArgs.tabId : null; - return { kind: "execute", label: "browser close", steps: [actionStep("result", "built_in_browser", "closeTab", { - tabId: requireValue(explicitTabId ?? genericTabId ?? firstPositional(args), "tabId"), - ...genericArgs, - })] }; - } - if (sub === "reload" || sub === "refresh") return { kind: "execute", label: "browser reload", steps: [actionStep("result", "built_in_browser", "reload", collectGenericObjectArgs(args))] }; - if (sub === "back") return { kind: "execute", label: "browser back", steps: [actionStep("result", "built_in_browser", "goBack", collectGenericObjectArgs(args))] }; - if (sub === "forward") return { kind: "execute", label: "browser forward", steps: [actionStep("result", "built_in_browser", "goForward", collectGenericObjectArgs(args))] }; - if (sub === "stop") return { kind: "execute", label: "browser stop", steps: [actionStep("result", "built_in_browser", "stop", collectGenericObjectArgs(args))] }; - if (sub === "screenshot" || sub === "capture") return { kind: "execute", label: "browser screenshot", steps: [actionStep("result", "built_in_browser", "captureScreenshot", collectGenericObjectArgs(args))] }; + const genericTabId = + typeof genericArgs.tabId === "string" ? genericArgs.tabId : null; + return { + kind: "execute", + label: "browser close", + steps: [ + actionStep("result", "built_in_browser", "closeTab", { + tabId: requireValue( + explicitTabId ?? genericTabId ?? firstPositional(args), + "tabId", + ), + ...genericArgs, + }), + ], + }; + } + if (sub === "reload" || sub === "refresh") + return { + kind: "execute", + label: "browser reload", + steps: [ + actionStep( + "result", + "built_in_browser", + "reload", + collectGenericObjectArgs(args), + ), + ], + }; + if (sub === "back") + return { + kind: "execute", + label: "browser back", + steps: [ + actionStep( + "result", + "built_in_browser", + "goBack", + collectGenericObjectArgs(args), + ), + ], + }; + if (sub === "forward") + return { + kind: "execute", + label: "browser forward", + steps: [ + actionStep( + "result", + "built_in_browser", + "goForward", + collectGenericObjectArgs(args), + ), + ], + }; + if (sub === "stop") + return { + kind: "execute", + label: "browser stop", + steps: [ + actionStep( + "result", + "built_in_browser", + "stop", + collectGenericObjectArgs(args), + ), + ], + }; + if (sub === "screenshot" || sub === "capture") + return { + kind: "execute", + label: "browser screenshot", + steps: [ + actionStep( + "result", + "built_in_browser", + "captureScreenshot", + collectGenericObjectArgs(args), + ), + ], + }; if (sub === "select" || sub === "select-point" || sub === "point") { const x = readNumberOption(args, ["--x"]); const y = readNumberOption(args, ["--y"]); - if (x == null || y == null) throw new CliUsageError("browser select requires --x and --y."); - return { kind: "execute", label: "browser selection", steps: [actionStep("result", "built_in_browser", "selectPoint", collectGenericObjectArgs(args, { - x, - y, - includeScreenshot: readFlag(args, ["--no-screenshot"]) ? false : undefined, - }))] }; + if (x == null || y == null) + throw new CliUsageError("browser select requires --x and --y."); + return { + kind: "execute", + label: "browser selection", + steps: [ + actionStep( + "result", + "built_in_browser", + "selectPoint", + collectGenericObjectArgs(args, { + x, + y, + includeScreenshot: readFlag(args, ["--no-screenshot"]) + ? false + : undefined, + }), + ), + ], + }; } - if (sub === "inspect-start" || sub === "start-inspect" || sub === "inspect") return { kind: "execute", label: "browser inspect start", steps: [actionStep("result", "built_in_browser", "startInspect", collectGenericObjectArgs(args))] }; - if (sub === "inspect-stop" || sub === "stop-inspect") return { kind: "execute", label: "browser inspect stop", steps: [actionStep("result", "built_in_browser", "stopInspect", collectGenericObjectArgs(args))] }; - if (sub === "select-current" || sub === "selection" || sub === "selected") return { kind: "execute", label: "browser selection", steps: [actionStep("result", "built_in_browser", "selectCurrent", collectGenericObjectArgs(args))] }; - if (sub === "clear-selection" || sub === "clear") return { kind: "execute", label: "browser clear selection", steps: [actionStep("result", "built_in_browser", "clearSelection", collectGenericObjectArgs(args))] }; - return { kind: "execute", label: `browser ${sub}`, steps: [actionStep("result", "built_in_browser", sub, collectGenericObjectArgs(args))] }; + if (sub === "inspect-start" || sub === "start-inspect" || sub === "inspect") + return { + kind: "execute", + label: "browser inspect start", + steps: [ + actionStep( + "result", + "built_in_browser", + "startInspect", + collectGenericObjectArgs(args), + ), + ], + }; + if (sub === "inspect-stop" || sub === "stop-inspect") + return { + kind: "execute", + label: "browser inspect stop", + steps: [ + actionStep( + "result", + "built_in_browser", + "stopInspect", + collectGenericObjectArgs(args), + ), + ], + }; + if (sub === "select-current" || sub === "selection" || sub === "selected") + return { + kind: "execute", + label: "browser selection", + steps: [ + actionStep( + "result", + "built_in_browser", + "selectCurrent", + collectGenericObjectArgs(args), + ), + ], + }; + if (sub === "clear-selection" || sub === "clear") + return { + kind: "execute", + label: "browser clear selection", + steps: [ + actionStep( + "result", + "built_in_browser", + "clearSelection", + collectGenericObjectArgs(args), + ), + ], + }; + return { + kind: "execute", + label: `browser ${sub}`, + steps: [ + actionStep( + "result", + "built_in_browser", + sub, + collectGenericObjectArgs(args), + ), + ], + }; } function buildMemoryPlan(args: string[]): CliPlan { const sub = firstPositional(args) ?? "search"; - if (sub === "actions") return { kind: "execute", label: "memory actions", steps: [listActionsStep("actions", "memory")] }; - if (sub === "add") return { kind: "execute", label: "memory add", steps: [actionCallStep("result", "memory_add", collectGenericObjectArgs(args, { content: requireValue(readValue(args, ["--content"]) ?? args.join(" "), "content"), category: requireValue(readValue(args, ["--category"]), "category"), scope: readValue(args, ["--scope"]) }))] }; - if (sub === "search") return { kind: "execute", label: "memory search", steps: [actionCallStep("result", "memory_search", collectGenericObjectArgs(args, { query: requireValue(readValue(args, ["--query", "-q"]) ?? args.join(" "), "query") }))] }; - if (sub === "pin") return { kind: "execute", label: "memory pin", steps: [actionCallStep("result", "memory_pin", collectGenericObjectArgs(args, { id: requireValue(readValue(args, ["--memory", "--memory-id", "--id"]) ?? firstPositional(args), "memory id") }))] }; - if (sub === "core") return { kind: "execute", label: "memory core", steps: [actionCallStep("result", "memory_update_core", collectGenericObjectArgs(args))] }; - return { kind: "execute", label: `memory ${sub}`, steps: [actionStep("result", "memory", sub, collectGenericObjectArgs(args))] }; + if (sub === "actions") + return { + kind: "execute", + label: "memory actions", + steps: [listActionsStep("actions", "memory")], + }; + if (sub === "add") + return { + kind: "execute", + label: "memory add", + steps: [ + actionCallStep( + "result", + "memory_add", + collectGenericObjectArgs(args, { + content: requireValue( + readValue(args, ["--content"]) ?? args.join(" "), + "content", + ), + category: requireValue(readValue(args, ["--category"]), "category"), + scope: readValue(args, ["--scope"]), + }), + ), + ], + }; + if (sub === "search") + return { + kind: "execute", + label: "memory search", + steps: [ + actionCallStep( + "result", + "memory_search", + collectGenericObjectArgs(args, { + query: requireValue( + readValue(args, ["--query", "-q"]) ?? args.join(" "), + "query", + ), + }), + ), + ], + }; + if (sub === "pin") + return { + kind: "execute", + label: "memory pin", + steps: [ + actionCallStep( + "result", + "memory_pin", + collectGenericObjectArgs(args, { + id: requireValue( + readValue(args, ["--memory", "--memory-id", "--id"]) ?? + firstPositional(args), + "memory id", + ), + }), + ), + ], + }; + if (sub === "core") + return { + kind: "execute", + label: "memory core", + steps: [ + actionCallStep( + "result", + "memory_update_core", + collectGenericObjectArgs(args), + ), + ], + }; + return { + kind: "execute", + label: `memory ${sub}`, + steps: [ + actionStep("result", "memory", sub, collectGenericObjectArgs(args)), + ], + }; } function buildSettingsPlan(args: string[]): CliPlan { const sub = firstPositional(args) ?? "get"; - if (sub === "actions") return { kind: "execute", label: "settings actions", steps: [listActionsStep("actions", "project_config")] }; - if (sub === "action") return { kind: "execute", label: "settings action", steps: [buildActionRunStep(["project_config", ...args])] }; - return { kind: "execute", label: `settings ${sub}`, steps: [actionStep("result", "project_config", sub, collectGenericObjectArgs(args))] }; + if (sub === "actions") + return { + kind: "execute", + label: "settings actions", + steps: [listActionsStep("actions", "project_config")], + }; + if (sub === "action") + return { + kind: "execute", + label: "settings action", + steps: [buildActionRunStep(["project_config", ...args])], + }; + return { + kind: "execute", + label: `settings ${sub}`, + steps: [ + actionStep( + "result", + "project_config", + sub, + collectGenericObjectArgs(args), + ), + ], + }; +} + +function buildActionStatusArgs( + args: string[], + defaults: { waitForMs?: number } = {}, +): JsonObject { + const input: JsonObject = {}; + maybePut( + input, + "operationId", + readValue(args, ["--operation", "--operation-id"]), + ); + maybePut( + input, + "testRunId", + readValue(args, ["--test-run", "--test-run-id"]), + ); + maybePut( + input, + "chatSessionId", + readValue(args, ["--chat-session", "--chat-session-id"]), + ); + maybePut(input, "runId", readValue(args, ["--run", "--run-id"])); + maybePut(input, "missionId", readValue(args, ["--mission", "--mission-id"])); + maybePut(input, "prId", readValue(args, ["--pr", "--pr-id"])); + maybePut(input, "previousHash", readValue(args, ["--previous-hash"])); + maybePut( + input, + "waitForMs", + readIntOption(args, ["--wait-ms"], defaults.waitForMs), + ); + maybePut( + input, + "pollIntervalMs", + readIntOption(args, ["--poll-interval-ms"]), + ); + return collectGenericObjectArgs(args, input); +} + +function buildOperationsPlan(args: string[]): CliPlan { + const sub = firstPositional(args) ?? "status"; + if (sub === "status" || sub === "show") { + return { + kind: "execute", + label: "action status", + steps: [ + actionCallStep( + "result", + "get_ade_action_status", + buildActionStatusArgs(args), + ), + ], + }; + } + if (sub === "wait" || sub === "watch") { + return { + kind: "execute", + label: "action status", + steps: [ + actionCallStep( + "result", + "get_ade_action_status", + buildActionStatusArgs(args, { waitForMs: 30_000 }), + ), + ], + }; + } + if (sub === "logs" || sub === "log") { + throw new CliUsageError( + "Generic operation logs are not available; use tests logs, run logs, terminal read, or app-control logs for log-owning surfaces.", + ); + } + throw new CliUsageError("operations supports status or wait."); } function buildActionsPlan(args: string[]): CliPlan { const sub = firstPositional(args) ?? "list"; - if (sub === "list" || sub === "ls") return { kind: "execute", label: "actions list", steps: [listActionsStep("result", readValue(args, ["--domain"]) ?? firstPositional(args) ?? undefined)] }; + if (sub === "list" || sub === "ls") + return { + kind: "execute", + label: "actions list", + steps: [ + listActionsStep( + "result", + readValue(args, ["--domain"]) ?? firstPositional(args) ?? undefined, + ), + ], + }; if (sub === "call" || sub === "direct" || sub === "tool") { const toolName = requireValue(firstPositional(args), "toolName"); - return { kind: "execute", label: "action call", steps: [actionCallStep("result", toolName, collectGenericObjectArgs(args))] }; + return { + kind: "execute", + label: "action call", + steps: [ + actionCallStep("result", toolName, collectGenericObjectArgs(args)), + ], + }; } - if (sub === "run") return { kind: "execute", label: "action run", steps: [buildActionRunStep(args)] }; - if (sub === "status") return { kind: "execute", label: "action status", steps: [actionCallStep("result", "get_ade_action_status", collectGenericObjectArgs(args))] }; - throw new CliUsageError("actions supports list, run, call, or status."); + if (sub === "run") + return { + kind: "execute", + label: "action run", + steps: [buildActionRunStep(args)], + }; + if (sub === "status") + return { + kind: "execute", + label: "action status", + steps: [ + actionCallStep( + "result", + "get_ade_action_status", + buildActionStatusArgs(args), + ), + ], + }; + if (sub === "wait" || sub === "watch") + return { + kind: "execute", + label: "action status", + steps: [ + actionCallStep( + "result", + "get_ade_action_status", + buildActionStatusArgs(args, { waitForMs: 30_000 }), + ), + ], + }; + throw new CliUsageError("actions supports list, run, call, status, or wait."); } function buildAgentPlan(args: string[]): CliPlan { const sub = firstPositional(args) ?? "spawn"; if (sub === "spawn" || sub === "start") { const toolWhitelist = args - .filter((entry) => entry.startsWith("--tool=") || entry.startsWith("--allow-tool=")) + .filter( + (entry) => + entry.startsWith("--tool=") || entry.startsWith("--allow-tool="), + ) .map((entry) => entry.slice(entry.indexOf("=") + 1).trim()) .filter(Boolean); const laneId = requireValue(readLaneId(args), "laneId"); - const prompt = requireValue(readValue(args, ["--prompt"]) ?? args.join(" "), "prompt"); + const prompt = requireValue( + readValue(args, ["--prompt"]) ?? args.join(" "), + "prompt", + ); return { kind: "execute", label: "agent spawn", - steps: [actionCallStep("result", "spawn_agent", collectGenericObjectArgs(args, { - laneId, - provider: readValue(args, ["--provider"]) ?? "codex", - model: readValue(args, ["--model"]), - title: readValue(args, ["--title"]), - prompt, - permissionMode: readValue(args, ["--permission-mode", "--permissions"]), - contextFilePath: readValue(args, ["--context-file"]), - runId: readValue(args, ["--run", "--run-id"]), - stepId: readValue(args, ["--step", "--step-id"]), - attemptId: readValue(args, ["--attempt", "--attempt-id"]), - maxPromptChars: readIntOption(args, ["--max-prompt-chars"]), - ...(toolWhitelist.length ? { toolWhitelist } : {}), - }))], + steps: [ + actionCallStep( + "result", + "spawn_agent", + collectGenericObjectArgs(args, { + laneId, + provider: readValue(args, ["--provider"]) ?? "codex", + model: readValue(args, ["--model"]), + title: readValue(args, ["--title"]), + prompt, + permissionMode: readValue(args, [ + "--permission-mode", + "--permissions", + ]), + contextFilePath: readValue(args, ["--context-file"]), + runId: readValue(args, ["--run", "--run-id"]), + stepId: readValue(args, ["--step", "--step-id"]), + attemptId: readValue(args, ["--attempt", "--attempt-id"]), + maxPromptChars: readIntOption(args, ["--max-prompt-chars"]), + ...(toolWhitelist.length ? { toolWhitelist } : {}), + }), + ), + ], }; } - return { kind: "execute", label: `agent ${sub}`, steps: [actionCallStep("result", sub.replace(/-/g, "_"), collectGenericObjectArgs(args))] }; + return { + kind: "execute", + label: `agent ${sub}`, + steps: [ + actionCallStep( + "result", + sub.replace(/-/g, "_"), + collectGenericObjectArgs(args), + ), + ], + }; } function buildCtoPlan(args: string[]): CliPlan { const sub = firstPositional(args) ?? "state"; - if (sub === "state") return { kind: "execute", label: "CTO state", steps: [actionCallStep("result", "get_cto_state", collectGenericObjectArgs(args, { recentLimit: readIntOption(args, ["--recent-limit", "--limit"]) }))] }; + if (sub === "state") + return { + kind: "execute", + label: "CTO state", + steps: [ + actionCallStep( + "result", + "get_cto_state", + collectGenericObjectArgs(args, { + recentLimit: readIntOption(args, ["--recent-limit", "--limit"]), + }), + ), + ], + }; if (sub === "chats" || sub === "chat") { const mode = firstPositional(args) ?? "list"; const toolByMode: Record = { @@ -3733,16 +7514,49 @@ function buildCtoPlan(args: string[]): CliPlan { end: "endChat", }; const tool = toolByMode[mode]; - if (!tool) throw new CliUsageError("cto chats supports list, spawn, status, transcript, send, interrupt, resume, or end."); - return { kind: "execute", label: `CTO chats ${mode}`, steps: [actionCallStep("result", tool, collectGenericObjectArgs(args, { sessionId: readValue(args, ["--session", "--session-id"]) ?? firstPositional(args), text: readValue(args, ["--text", "--message"]) ?? args.join(" "), laneId: readLaneId(args), modelId: readValue(args, ["--model", "--model-id"]), initialPrompt: readValue(args, ["--prompt"]) }))] }; + if (!tool) + throw new CliUsageError( + "cto chats supports list, spawn, status, transcript, send, interrupt, resume, or end.", + ); + return { + kind: "execute", + label: `CTO chats ${mode}`, + steps: [ + actionCallStep( + "result", + tool, + collectGenericObjectArgs(args, { + sessionId: + readValue(args, ["--session", "--session-id"]) ?? + firstPositional(args), + text: readValue(args, ["--text", "--message"]) ?? args.join(" "), + laneId: readLaneId(args), + modelId: readValue(args, ["--model", "--model-id"]), + initialPrompt: readValue(args, ["--prompt"]), + }), + ), + ], + }; } - return { kind: "execute", label: `CTO ${sub}`, steps: [actionCallStep("result", sub.replace(/-/g, "_"), collectGenericObjectArgs(args))] }; + return { + kind: "execute", + label: `CTO ${sub}`, + steps: [ + actionCallStep( + "result", + sub.replace(/-/g, "_"), + collectGenericObjectArgs(args), + ), + ], + }; } function parseDraftInput(args: string[]): JsonObject { const text = readFileTextInput(args); if (text == null) { - throw new CliUsageError("Provide a rule body via --from-file, --stdin, or --text."); + throw new CliUsageError( + "Provide a rule body via --from-file, --stdin, or --text.", + ); } const trimmed = text.trim(); if (!trimmed.length) { @@ -3750,11 +7564,14 @@ function parseDraftInput(args: string[]): JsonObject { } let parsed: unknown; try { - parsed = trimmed.startsWith("{") || trimmed.startsWith("[") - ? JSON.parse(trimmed) - : YAML.parse(trimmed); + parsed = + trimmed.startsWith("{") || trimmed.startsWith("[") + ? JSON.parse(trimmed) + : YAML.parse(trimmed); } catch (error) { - throw new CliUsageError(`Failed to parse rule body: ${error instanceof Error ? error.message : String(error)}`); + throw new CliUsageError( + `Failed to parse rule body: ${error instanceof Error ? error.message : String(error)}`, + ); } if (!isRecord(parsed)) { throw new CliUsageError("Rule body must be an object."); @@ -3762,12 +7579,30 @@ function parseDraftInput(args: string[]): JsonObject { return parsed; } -const AUTOMATION_LANE_MODES = ["create", "reuse", "require-on-trigger"] as const; -const AUTOMATION_LANE_NAME_PRESETS = ["issue-title", "issue-num-title", "pr-title-author", "custom"] as const; -const AUTOMATION_RUN_STATUSES = ["queued", "running", "succeeded", "failed", "cancelled", "paused", "all"] as const; +const AUTOMATION_LANE_MODES = [ + "create", + "reuse", + "require-on-trigger", +] as const; +const AUTOMATION_LANE_NAME_PRESETS = [ + "issue-title", + "issue-num-title", + "pr-title-author", + "custom", +] as const; +const AUTOMATION_RUN_STATUSES = [ + "queued", + "running", + "succeeded", + "failed", + "cancelled", + "paused", + "all", +] as const; type AutomationLaneModeFlag = (typeof AUTOMATION_LANE_MODES)[number]; -type AutomationLaneNamePresetFlag = (typeof AUTOMATION_LANE_NAME_PRESETS)[number]; +type AutomationLaneNamePresetFlag = + (typeof AUTOMATION_LANE_NAME_PRESETS)[number]; function readEnumOption( args: string[], @@ -3784,31 +7619,56 @@ function readEnumOption( } function applyLaneFlagsToDraft(draft: JsonObject, args: string[]): JsonObject { - const laneMode = readEnumOption(args, ["--lane-mode"], AUTOMATION_LANE_MODES, "--lane-mode"); + const laneMode = readEnumOption( + args, + ["--lane-mode"], + AUTOMATION_LANE_MODES, + "--lane-mode", + ); const laneId = readLaneId(args); - const preset = readEnumOption(args, ["--lane-name-preset"], AUTOMATION_LANE_NAME_PRESETS, "--lane-name-preset"); + const preset = readEnumOption( + args, + ["--lane-name-preset"], + AUTOMATION_LANE_NAME_PRESETS, + "--lane-name-preset", + ); const template = readValue(args, ["--lane-name-template"]); - if (laneMode == null && laneId == null && preset == null && template == null) { + if ( + laneMode == null && + laneId == null && + preset == null && + template == null + ) { return draft; } const existingExecution = isRecord(draft.execution) ? draft.execution : {}; const effectiveLaneMode = - laneMode - ?? (asString(existingExecution.laneMode) as AutomationLaneModeFlag | null); + laneMode ?? + (asString(existingExecution.laneMode) as AutomationLaneModeFlag | null); - if (laneId != null && effectiveLaneMode != null && effectiveLaneMode !== "reuse") { + if ( + laneId != null && + effectiveLaneMode != null && + effectiveLaneMode !== "reuse" + ) { throw new CliUsageError("--lane is only valid with --lane-mode reuse."); } if (preset != null && effectiveLaneMode !== "create") { - throw new CliUsageError("--lane-name-preset is only valid with --lane-mode create."); + throw new CliUsageError( + "--lane-name-preset is only valid with --lane-mode create.", + ); } if (template != null && preset != null && preset !== "custom") { - throw new CliUsageError("--lane-name-template is only valid with --lane-name-preset custom."); + throw new CliUsageError( + "--lane-name-template is only valid with --lane-name-preset custom.", + ); } if (template != null && preset == null && effectiveLaneMode !== "create") { - throw new CliUsageError("--lane-name-template requires --lane-mode create (with --lane-name-preset custom)."); + throw new CliUsageError( + "--lane-name-template requires --lane-mode create (with --lane-name-preset custom).", + ); } const execution: JsonObject = { ...existingExecution }; @@ -3820,18 +7680,26 @@ function applyLaneFlagsToDraft(draft: JsonObject, args: string[]): JsonObject { return { ...draft, execution }; } -function migrateLegacyCreateLane(draft: JsonObject, opts: { allowLegacy: boolean }): JsonObject { +function migrateLegacyCreateLane( + draft: JsonObject, + opts: { allowLegacy: boolean }, +): JsonObject { const actions = Array.isArray(draft.actions) ? draft.actions : null; if (!actions || actions.length === 0) return draft; const first = actions[0]; if (!isRecord(first) || first.type !== "create-lane") return draft; if (opts.allowLegacy) return draft; const execution = isRecord(draft.execution) ? draft.execution : {}; - const template = typeof first.laneNameTemplate === "string" ? first.laneNameTemplate : undefined; + const template = + typeof first.laneNameTemplate === "string" + ? first.laneNameTemplate + : undefined; const migratedExecution: JsonObject = { ...execution, laneMode: "create", - ...(template ? { laneNamePreset: "custom", laneNameTemplate: template } : {}), + ...(template + ? { laneNamePreset: "custom", laneNameTemplate: template } + : {}), }; return { ...draft, execution: migratedExecution, actions: actions.slice(1) }; } @@ -3870,12 +7738,23 @@ function buildAutomationsPlan(args: string[]): CliPlan { const sub = firstPositional(args) ?? "list"; if (sub === "list") { - return { kind: "execute", label: "automations list", steps: [actionStep("result", "automations", "list")] }; + return { + kind: "execute", + label: "automations list", + steps: [actionStep("result", "automations", "list")], + }; } if (sub === "show" || sub === "get") { - const id = requireValue(readValue(args, ["--id"]) ?? firstPositional(args), "rule id"); - return { kind: "execute", label: `automations show ${id}`, steps: [actionStep("result", "automations", "get", { id })] }; + const id = requireValue( + readValue(args, ["--id"]) ?? firstPositional(args), + "rule id", + ); + return { + kind: "execute", + label: `automations show ${id}`, + steps: [actionStep("result", "automations", "get", { id })], + }; } if (sub === "example") { @@ -3885,7 +7764,10 @@ function buildAutomationsPlan(args: string[]): CliPlan { if (sub === "create") { const allowLegacy = readFlag(args, ["--allow-legacy"]); const raw = parseDraftInput(args); - const draft = applyLaneFlagsToDraft(migrateLegacyCreateLane(raw, { allowLegacy }), args); + const draft = applyLaneFlagsToDraft( + migrateLegacyCreateLane(raw, { allowLegacy }), + args, + ); return { kind: "execute", label: "automations create", @@ -3894,71 +7776,112 @@ function buildAutomationsPlan(args: string[]): CliPlan { } if (sub === "update") { - const id = requireValue(readValue(args, ["--id"]) ?? firstPositional(args), "rule id"); + const id = requireValue( + readValue(args, ["--id"]) ?? firstPositional(args), + "rule id", + ); const allowLegacy = readFlag(args, ["--allow-legacy"]); const raw = parseDraftInput(args); - const draft = applyLaneFlagsToDraft(migrateLegacyCreateLane(raw, { allowLegacy }), args); + const draft = applyLaneFlagsToDraft( + migrateLegacyCreateLane(raw, { allowLegacy }), + args, + ); return { kind: "execute", label: `automations update ${id}`, - steps: [actionStep("result", "automations", "saveRule", { draft: { ...draft, id } })], + steps: [ + actionStep("result", "automations", "saveRule", { + draft: { ...draft, id }, + }), + ], }; } if (sub === "delete") { - const id = requireValue(readValue(args, ["--id"]) ?? firstPositional(args), "rule id"); - return { kind: "execute", label: `automations delete ${id}`, steps: [actionStep("result", "automations", "deleteRule", { id })] }; + const id = requireValue( + readValue(args, ["--id"]) ?? firstPositional(args), + "rule id", + ); + return { + kind: "execute", + label: `automations delete ${id}`, + steps: [actionStep("result", "automations", "deleteRule", { id })], + }; } if (sub === "toggle") { - const id = requireValue(readValue(args, ["--id"]) ?? firstPositional(args), "rule id"); + const id = requireValue( + readValue(args, ["--id"]) ?? firstPositional(args), + "rule id", + ); const enabledRaw = readValue(args, ["--enabled"]); if (enabledRaw == null) { - throw new CliUsageError("automations toggle requires --enabled ."); + throw new CliUsageError( + "automations toggle requires --enabled .", + ); } if (enabledRaw !== "true" && enabledRaw !== "false") { - throw new CliUsageError("automations toggle --enabled must be true or false."); + throw new CliUsageError( + "automations toggle --enabled must be true or false.", + ); } const enabled = enabledRaw === "true"; return { kind: "execute", label: `automations toggle ${id}`, - steps: [actionStep("result", "automations", "toggleRule", { id, enabled })], + steps: [ + actionStep("result", "automations", "toggleRule", { id, enabled }), + ], }; } if (sub === "run" || sub === "trigger") { - const id = requireValue(readValue(args, ["--id"]) ?? firstPositional(args), "rule id"); + const id = requireValue( + readValue(args, ["--id"]) ?? firstPositional(args), + "rule id", + ); const dryRun = readFlag(args, ["--dry-run"]); const laneId = readLaneId(args); return { kind: "execute", label: `automations run ${id}`, - steps: [actionStep("result", "automations", "triggerManually", { - id, - ...(dryRun ? { dryRun: true } : {}), - ...(laneId ? { laneId } : {}), - })], + steps: [ + actionStep("result", "automations", "triggerManually", { + id, + ...(dryRun ? { dryRun: true } : {}), + ...(laneId ? { laneId } : {}), + }), + ], }; } if (sub === "runs") { const automationId = readValue(args, ["--rule", "--automation", "--id"]); const limit = readIntOption(args, ["--limit"]); - const status = readEnumOption(args, ["--status"], AUTOMATION_RUN_STATUSES, "--status"); + const status = readEnumOption( + args, + ["--status"], + AUTOMATION_RUN_STATUSES, + "--status", + ); return { kind: "execute", label: "automations runs", - steps: [actionStep("result", "automations", "listRuns", { - ...(automationId ? { automationId } : {}), - ...(typeof limit === "number" ? { limit } : {}), - ...(status ? { status } : {}), - })], + steps: [ + actionStep("result", "automations", "listRuns", { + ...(automationId ? { automationId } : {}), + ...(typeof limit === "number" ? { limit } : {}), + ...(status ? { status } : {}), + }), + ], }; } if (sub === "run-show" || sub === "run-detail") { - const runId = requireValue(readValue(args, ["--run", "--run-id"]) ?? firstPositional(args), "run id"); + const runId = requireValue( + readValue(args, ["--run", "--run-id"]) ?? firstPositional(args), + "run id", + ); return { kind: "execute", label: `automations run-show ${runId}`, @@ -3975,22 +7898,58 @@ function buildAutomationsPlan(args: string[]): CliPlan { function buildLinearPlan(args: string[]): CliPlan { const sub = firstPositional(args) ?? "workflows"; if (sub === "quick-view" || sub === "quick" || sub === "overview") { - return { kind: "execute", label: "Linear quick view", formatter: "linear-quick-view", steps: [actionCallStep("result", "getLinearQuickView", collectGenericObjectArgs(args))] }; + return { + kind: "execute", + label: "Linear quick view", + formatter: "linear-quick-view", + steps: [ + actionCallStep( + "result", + "getLinearQuickView", + collectGenericObjectArgs(args), + ), + ], + }; } if (sub === "picker-data" || sub === "picker") { - return { kind: "execute", label: "Linear picker data", steps: [actionCallStep("result", "getLinearIssuePickerData", collectGenericObjectArgs(args))] }; + return { + kind: "execute", + label: "Linear picker data", + steps: [ + actionCallStep( + "result", + "getLinearIssuePickerData", + collectGenericObjectArgs(args), + ), + ], + }; } if (sub === "search-issues" || sub === "search") { - const stateTypesValue = readValue(args, ["--state-type", "--state-types", "--state"]); + const stateTypesValue = readValue(args, [ + "--state-type", + "--state-types", + "--state", + ]); const stateTypes = stateTypesValue - ? stateTypesValue.split(",").map((entry) => entry.trim()).filter(Boolean) + ? stateTypesValue + .split(",") + .map((entry) => entry.trim()) + .filter(Boolean) : []; const input: JsonObject = {}; maybePut(input, "projectId", readValue(args, ["--project-id"])); - maybePut(input, "projectSlug", readValue(args, ["--project-slug", "--project"])); + maybePut( + input, + "projectSlug", + readValue(args, ["--project-slug", "--project"]), + ); maybePut(input, "teamKey", readValue(args, ["--team-key", "--team"])); if (stateTypes.length) input.stateTypes = stateTypes; - maybePut(input, "assigneeId", readValue(args, ["--assignee", "--assignee-id"])); + maybePut( + input, + "assigneeId", + readValue(args, ["--assignee", "--assignee-id"]), + ); const priority = readNumberOption(args, ["--priority"]); if (priority !== undefined) input.priority = priority; maybePut(input, "query", readValue(args, ["--query", "-q"])); @@ -3998,9 +7957,30 @@ function buildLinearPlan(args: string[]): CliPlan { if (first !== undefined) input.first = first; maybePut(input, "after", readValue(args, ["--after", "--cursor"])); if (readFlag(args, ["--include-archived"])) input.includeArchived = true; - return { kind: "execute", label: "Linear search issues", steps: [actionCallStep("result", "searchLinearIssues", collectGenericObjectArgs(args, input))] }; + return { + kind: "execute", + label: "Linear search issues", + steps: [ + actionCallStep( + "result", + "searchLinearIssues", + collectGenericObjectArgs(args, input), + ), + ], + }; } - if (sub === "workflows") return { kind: "execute", label: "Linear workflows", steps: [actionCallStep("result", "listLinearWorkflows", collectGenericObjectArgs(args))] }; + if (sub === "workflows") + return { + kind: "execute", + label: "Linear workflows", + steps: [ + actionCallStep( + "result", + "listLinearWorkflows", + collectGenericObjectArgs(args), + ), + ], + }; if (sub === "run") { const mode = firstPositional(args) ?? "status"; const toolByMode: Record = { @@ -4010,8 +7990,24 @@ function buildLinearPlan(args: string[]): CliPlan { reroute: "rerouteLinearRun", }; const tool = toolByMode[mode]; - if (!tool) throw new CliUsageError("linear run supports status, resolve, cancel, or reroute."); - return { kind: "execute", label: `Linear run ${mode}`, steps: [actionCallStep("result", tool, collectGenericObjectArgs(args, { runId: readValue(args, ["--run", "--run-id"]) ?? firstPositional(args) }))] }; + if (!tool) + throw new CliUsageError( + "linear run supports status, resolve, cancel, or reroute.", + ); + return { + kind: "execute", + label: `Linear run ${mode}`, + steps: [ + actionCallStep( + "result", + tool, + collectGenericObjectArgs(args, { + runId: + readValue(args, ["--run", "--run-id"]) ?? firstPositional(args), + }), + ), + ], + }; } if (sub === "route") { const mode = firstPositional(args) ?? "cto"; @@ -4021,8 +8017,13 @@ function buildLinearPlan(args: string[]): CliPlan { worker: "routeLinearIssueToWorker", }; const tool = toolByMode[mode]; - if (!tool) throw new CliUsageError("linear route supports cto, mission, or worker."); - return { kind: "execute", label: `Linear route ${mode}`, steps: [actionCallStep("result", tool, collectGenericObjectArgs(args))] }; + if (!tool) + throw new CliUsageError("linear route supports cto, mission, or worker."); + return { + kind: "execute", + label: `Linear route ${mode}`, + steps: [actionCallStep("result", tool, collectGenericObjectArgs(args))], + }; } if (sub === "sync") { const mode = firstPositional(args) ?? "dashboard"; @@ -4034,8 +8035,15 @@ function buildLinearPlan(args: string[]): CliPlan { detail: "getLinearWorkflowRunDetail", }; const tool = toolByMode[mode]; - if (!tool) throw new CliUsageError("linear sync supports dashboard, run, queue, resolve, or detail."); - return { kind: "execute", label: `Linear sync ${mode}`, steps: [actionCallStep("result", tool, collectGenericObjectArgs(args))] }; + if (!tool) + throw new CliUsageError( + "linear sync supports dashboard, run, queue, resolve, or detail.", + ); + return { + kind: "execute", + label: `Linear sync ${mode}`, + steps: [actionCallStep("result", tool, collectGenericObjectArgs(args))], + }; } if (sub === "ingress") { const mode = firstPositional(args) ?? "status"; @@ -4045,15 +8053,45 @@ function buildLinearPlan(args: string[]): CliPlan { webhook: "ensureLinearWebhook", }; const tool = toolByMode[mode]; - if (!tool) throw new CliUsageError("linear ingress supports status, events, or webhook."); - return { kind: "execute", label: `Linear ingress ${mode}`, steps: [actionCallStep("result", tool, collectGenericObjectArgs(args))] }; + if (!tool) + throw new CliUsageError( + "linear ingress supports status, events, or webhook.", + ); + return { + kind: "execute", + label: `Linear ingress ${mode}`, + steps: [actionCallStep("result", tool, collectGenericObjectArgs(args))], + }; } - return { kind: "execute", label: `Linear ${sub}`, steps: [actionStep("result", "linear_dispatcher", sub, collectGenericObjectArgs(args))] }; + return { + kind: "execute", + label: `Linear ${sub}`, + steps: [ + actionStep( + "result", + "linear_dispatcher", + sub, + collectGenericObjectArgs(args), + ), + ], + }; } function buildFlowPlan(args: string[]): CliPlan { const sub = firstPositional(args) ?? "policy"; - if (sub !== "policy") return { kind: "execute", label: `flow ${sub}`, steps: [actionStep("result", "flow_policy", sub, collectGenericObjectArgs(args))] }; + if (sub !== "policy") + return { + kind: "execute", + label: `flow ${sub}`, + steps: [ + actionStep( + "result", + "flow_policy", + sub, + collectGenericObjectArgs(args), + ), + ], + }; const mode = firstPositional(args) ?? "get"; const actionByMode: Record = { get: "getPolicy", @@ -4065,79 +8103,327 @@ function buildFlowPlan(args: string[]): CliPlan { diff: "diffPolicyPaths", }; const action = actionByMode[mode]; - if (!action) throw new CliUsageError("flow policy supports get, save, validate, normalize, revisions, rollback, or diff."); - return { kind: "execute", label: `flow policy ${mode}`, steps: [actionStep("result", "flow_policy", action, collectGenericObjectArgs(args))] }; + if (!action) + throw new CliUsageError( + "flow policy supports get, save, validate, normalize, revisions, rollback, or diff.", + ); + return { + kind: "execute", + label: `flow policy ${mode}`, + steps: [ + actionStep( + "result", + "flow_policy", + action, + collectGenericObjectArgs(args), + ), + ], + }; } function buildCoordinatorPlan(args: string[]): CliPlan { - const toolName = requireValue(firstPositional(args), "coordinator tool").replace(/-/g, "_"); - return { kind: "execute", label: `coordinator ${toolName}`, steps: [actionCallStep("result", toolName, collectGenericObjectArgs(args))] }; + const toolName = requireValue( + firstPositional(args), + "coordinator tool", + ).replace(/-/g, "_"); + return { + kind: "execute", + label: `coordinator ${toolName}`, + steps: [actionCallStep("result", toolName, collectGenericObjectArgs(args))], + }; } function buildUpdatePlan(args: string[]): CliPlan { const sub = firstPositional(args) ?? "status"; - if (sub === "actions") return { kind: "execute", label: "update actions", steps: [listActionsStep("actions", "update")] }; - if (sub === "status" || sub === "state" || sub === "snapshot" || sub === "show") { - return { kind: "execute", label: "update status", steps: [actionStep("result", "update", "getSnapshot", collectGenericObjectArgs(args))] }; + if (sub === "actions") + return { + kind: "execute", + label: "update actions", + steps: [listActionsStep("actions", "update")], + }; + if ( + sub === "status" || + sub === "state" || + sub === "snapshot" || + sub === "show" + ) { + return { + kind: "execute", + label: "update status", + steps: [ + actionStep( + "result", + "update", + "getSnapshot", + collectGenericObjectArgs(args), + ), + ], + }; } if (sub === "check" || sub === "check-for-updates" || sub === "check-now") { - return { kind: "execute", label: "update check", steps: [actionStep("result", "update", "checkForUpdates", collectGenericObjectArgs(args))] }; + return { + kind: "execute", + label: "update check", + steps: [ + actionStep( + "result", + "update", + "checkForUpdates", + collectGenericObjectArgs(args), + ), + ], + }; } if (sub === "install" || sub === "quit-and-install" || sub === "apply") { - return { kind: "execute", label: "update install", steps: [actionStep("result", "update", "quitAndInstall", collectGenericObjectArgs(args))] }; + return { + kind: "execute", + label: "update install", + steps: [ + actionStep( + "result", + "update", + "quitAndInstall", + collectGenericObjectArgs(args), + ), + ], + }; } - if (sub === "dismiss" || sub === "dismiss-installed" || sub === "dismiss-installed-notice") { - return { kind: "execute", label: "update dismiss", steps: [actionStep("result", "update", "dismissInstalledNotice", collectGenericObjectArgs(args))] }; + if ( + sub === "dismiss" || + sub === "dismiss-installed" || + sub === "dismiss-installed-notice" + ) { + return { + kind: "execute", + label: "update dismiss", + steps: [ + actionStep( + "result", + "update", + "dismissInstalledNotice", + collectGenericObjectArgs(args), + ), + ], + }; } - return { kind: "execute", label: `update ${sub}`, steps: [actionStep("result", "update", sub, collectGenericObjectArgs(args))] }; + return { + kind: "execute", + label: `update ${sub}`, + steps: [ + actionStep("result", "update", sub, collectGenericObjectArgs(args)), + ], + }; } const VALUE_CARRIER_FLAGS: ReadonlySet = new Set([ // Only flags that actually take a following value (readValue / readIntOption // callers) belong here. Boolean-only flags consumed via readFlag must be // excluded, otherwise the next positional would be swallowed as their value. - "-b", "-m", "-q", "-t", - "--additional-instructions", "--app", "--app-bundle", "--arg", "--arg-json", "--arg-value", - "--arg-value-json", "--args-list-json", "--attempt", "--attempt-id", - "--automation", "--autonomy", "--backend", "--base", "--base-branch", "--base-ref", "--body", "--branch", - "--branch-name", "--branch-ref", "--bundle", "--bundle-id", "--category", "--color", "--cols", - "--command", "--comment", "--comment-id", "--commit", "--compare-ref", - "--caption", "--cdp-port", "--chat-session", "--chat-session-id", "--compare-to", "--content", "--context-file", "--cwd", "--data", - "--cpu", "--cpu-cores", + "-b", + "-m", + "-q", + "-t", + "--additional-instructions", + "--app", + "--app-bundle", + "--arg", + "--arg-json", + "--arg-value", + "--arg-value-json", + "--args-list-json", + "--attempt", + "--attempt-id", + "--automation", + "--autonomy", + "--backend", + "--base", + "--base-branch", + "--base-ref", + "--body", + "--branch", + "--branch-name", + "--branch-ref", + "--bundle", + "--bundle-id", + "--category", + "--color", + "--cols", + "--command", + "--comment", + "--comment-id", + "--commit", + "--compare-ref", + "--caption", + "--cdp-port", + "--chat-session", + "--chat-session-id", + "--compare-to", + "--content", + "--context-file", + "--cwd", + "--data", + "--cpu", + "--cpu-cores", "--debug-port", - "--depth", "--desc", "--device", "--disk", "--disk-size", "--display", "--duration", "--duration-ms", - "--description", "--domain", "--droid-autonomy", "--droid-permission-mode", - "--duration-sec", "--enabled", "--event", - "--end-x", "--end-y", "--file", "--fps", "--from", "--from-file", "--group", "--group-id", "--head", "--icon", "--id", - "--image", "--index", "--initial-input", "--input", "--input-json", "--input-text", "--instructions", - "--ipsw", "--kind", - "--json-input", "--lane", "--lane-id", "--limit", "--max-bytes", + "--depth", + "--desc", + "--device", + "--disk", + "--disk-size", + "--display", + "--duration", + "--duration-ms", + "--description", + "--domain", + "--droid-autonomy", + "--droid-permission-mode", + "--duration-sec", + "--enabled", + "--event", + "--end-x", + "--end-y", + "--file", + "--fps", + "--from", + "--from-file", + "--group", + "--group-id", + "--head", + "--icon", + "--id", + "--image", + "--index", + "--initial-input", + "--input", + "--input-json", + "--input-text", + "--instructions", + "--ipsw", + "--kind", + "--json-input", + "--lane", + "--lane-id", + "--limit", + "--max-bytes", "--line", - "--max-log-bytes", "--max-prompt-chars", "--max-rounds", "--memory", - "--memory-id", "--merge-method", "--message", "--method", "--mode", "--model", - "--model-id", "--name", "--new", "--new-path", "--number", "--old", - "--old-path", "--owner", "--owner-id", "--owner-kind", + "--max-log-bytes", + "--max-prompt-chars", + "--max-rounds", + "--memory", + "--memory-id", + "--merge-method", + "--message", + "--method", + "--mode", + "--model", + "--model-id", + "--name", + "--new", + "--new-path", + "--number", + "--old", + "--old-path", + "--owner", + "--owner-id", + "--owner-kind", "--output", - "--params-json", "--parent", "--parent-lane", "--parent-lane-id", - "--path", "--permission-mode", "--permissions", "--port", "--pr", "--pr-id", - "--pr-number", "--pr-url", "--process", "--process-id", "--project-root", - "--prompt", "--provider", "--pty", "--pty-id", "--query", "--question", - "--reason", "--reasoning", "--recent-limit", "--ref", "--resume-session", "--resume-session-id", - "--resume-target", "--resume-target-id", "--role", "--root", - "--root-lane", "--round", "--rounds", "--rows", "--rule", "--run", "--run-id", "--scalar", - "--scalar-json", "--scope", "--seconds", "--session", "--session-id", "--set", - "--set-json", "--sha", "--signal", "--since", "--source", "--source-lane", "--stack", "--stack-id", - "--scheme", "--start-point", "--start-x", "--start-y", "--stash-ref", "--step", "--step-id", "--suite", "--suite-id", "--surface", - "--tab", "--tab-identifier", "--target", "--target-id", "--terminal", "--terminal-id", "--thread", "--thread-id", "--timeout", "--timeout-ms", "--title", "--tool-type", + "--params-json", + "--parent", + "--parent-lane", + "--parent-lane-id", + "--path", + "--permission-mode", + "--permissions", + "--port", + "--pr", + "--pr-id", + "--pr-number", + "--pr-url", + "--process", + "--process-id", + "--project-root", + "--prompt", + "--provider", + "--pty", + "--pty-id", + "--query", + "--question", + "--reason", + "--reasoning", + "--recent-limit", + "--ref", + "--resume-session", + "--resume-session-id", + "--resume-target", + "--resume-target-id", + "--role", + "--root", + "--root-lane", + "--round", + "--rounds", + "--rows", + "--rule", + "--run", + "--run-id", + "--scalar", + "--scalar-json", + "--scope", + "--seconds", + "--session", + "--session-id", + "--set", + "--set-json", + "--sha", + "--signal", + "--since", + "--source", + "--source-lane", + "--stack", + "--stack-id", + "--scheme", + "--start-point", + "--start-x", + "--start-y", + "--stash-ref", + "--step", + "--step-id", + "--suite", + "--suite-id", + "--surface", + "--tab", + "--tab-identifier", + "--target", + "--target-id", + "--terminal", + "--terminal-id", + "--thread", + "--thread-id", + "--timeout", + "--timeout-ms", + "--title", + "--tool-type", "--title-query", - "--udid", "--unattended", "--unattended-preset", "--url", "--value", "--vm-name", "--window-title", "--workspace", "--workspace-id", "--workspace-root", - "--coordinate-space", "--coords", - "--x", "--xcodeproj", "--y", + "--udid", + "--unattended", + "--unattended-preset", + "--url", + "--value", + "--vm-name", + "--window-title", + "--workspace", + "--workspace-id", + "--workspace-root", + "--coordinate-space", + "--coords", + "--x", + "--xcodeproj", + "--y", ]); function hasHelpFlag(args: string[]): boolean { const terminatorIndex = args.indexOf("--"); - const searchable = terminatorIndex >= 0 ? args.slice(0, terminatorIndex) : args; + const searchable = + terminatorIndex >= 0 ? args.slice(0, terminatorIndex) : args; const valueCarrierFlags = VALUE_CARRIER_FLAGS; for (let i = 0; i < searchable.length; i++) { const token = searchable[i]!; @@ -4155,8 +8441,14 @@ function hasHelpFlag(args: string[]): boolean { function buildCliPlan(command: string[]): CliPlan { const args = [...command]; + if (args[0] === "--version" || args[0] === "-v") { + return { kind: "help", text: `ade ${VERSION}\n` }; + } const primary = firstPositional(args); - if (!primary || primary === "-h" || primary === "--help") { + if (!primary) { + return { kind: "help", text: TOP_LEVEL_HELP }; + } + if (primary === "-h" || primary === "--help") { return { kind: "help", text: TOP_LEVEL_HELP }; } const aliases: Record = { @@ -4196,6 +8488,8 @@ function buildCliPlan(command: string[]): CliPlan { automation: "automations", "auto-update": "update", updates: "update", + operation: "operations", + project: "projects", }; const primaryHelpKey = aliases[primary] ?? primary; if (hasHelpFlag(args)) { @@ -4208,7 +8502,10 @@ function buildCliPlan(command: string[]): CliPlan { if (primaryHelpKey === "app-control") { return { kind: "help", text: buildAppControlHelp(args) }; } - return { kind: "help", text: HELP_BY_COMMAND[primaryHelpKey] ?? TOP_LEVEL_HELP }; + return { + kind: "help", + text: HELP_BY_COMMAND[primaryHelpKey] ?? TOP_LEVEL_HELP, + }; } if (primary === "help") { const topic = (firstPositional(args) ?? "").toLowerCase(); @@ -4222,7 +8519,10 @@ function buildCliPlan(command: string[]): CliPlan { if (key === "app-control") { return { kind: "help", text: buildAppControlHelp(args) }; } - return { kind: "help", text: key && HELP_BY_COMMAND[key] ? HELP_BY_COMMAND[key] : TOP_LEVEL_HELP }; + return { + kind: "help", + text: key && HELP_BY_COMMAND[key] ? HELP_BY_COMMAND[key] : TOP_LEVEL_HELP, + }; } if (primary === "version" || primary === "--version" || primary === "-v") { return { kind: "help", text: `ade ${VERSION}\n` }; @@ -4231,8 +8531,38 @@ function buildCliPlan(command: string[]): CliPlan { const rest = args; return { kind: "ade-code", rest }; } + if (primary === "desktop") { + return { kind: "desktop", rest: args }; + } + if (primary === "runtime") { + return { kind: "runtime", rest: args }; + } + if (primary === "serve") { + return { kind: "serve", rest: args }; + } + if (primary === "rpc") { + const sub = firstPositional(args); + if (sub === "stdio" || readFlag(args, ["--stdio"])) { + return { kind: "rpc-stdio", rest: args }; + } + throw new CliUsageError("rpc currently supports only --stdio."); + } + if (primary === "init") { + return { kind: "init", targetPath: firstPositional(args) }; + } + if (primary === "projects" || primary === "project") { + return buildProjectsPlan(args); + } + if (primary === "sync") { + return buildSyncPlan(args); + } if (primary === "status") { - return { kind: "execute", label: "status", summary: "status", steps: [{ key: "ping", method: "ping" }] }; + return { + kind: "execute", + label: "status", + summary: "status", + steps: [{ key: "ping", method: "ping" }], + }; } if (primary === "doctor") { return { @@ -4243,20 +8573,27 @@ function buildCliPlan(command: string[]): CliPlan { { key: "ping", method: "ping" }, { key: "rpcActions", method: "ade/actions/list" }, listActionsStep("actions"), - { ...actionStep("projectConfig", "project_config", "get"), optional: true }, + { + ...actionStep("projectConfig", "project_config", "get"), + optional: true, + }, ], }; } if (primary === "auth") { const sub = firstPositional(args) ?? "status"; - if (sub !== "status") throw new CliUsageError("auth currently supports status."); + if (sub !== "status") + throw new CliUsageError("auth currently supports status."); return { kind: "execute", label: "auth status", summary: "auth", steps: [ { key: "actions", method: "ade/actions/list" }, - { ...actionStep("projectConfig", "project_config", "get"), optional: true }, + { + ...actionStep("projectConfig", "project_config", "get"), + optional: true, + }, ], }; } @@ -4267,31 +8604,85 @@ function buildCliPlan(command: string[]): CliPlan { if (primary === "git") return buildGitPlan(args); if (primary === "diff" || primary === "diffs") return buildDiffPlan(args); if (primary === "files" || primary === "file") return buildFilesPlan(args); - if (primary === "missions" || primary === "mission") return buildMissionsPlan(args); + if (primary === "missions" || primary === "mission") + return buildMissionsPlan(args); if (primary === "prs" || primary === "pr") return buildPrPlan(args); - if (primary === "run" || primary === "process" || primary === "processes") return buildRunPlan(args); + if (primary === "run" || primary === "process" || primary === "processes") + return buildRunPlan(args); if (primary === "shell" || primary === "pty") return buildShellPlan(args); - if (primary === "terminal" || primary === "term") return buildTerminalPlan(args); - if (primary === "chat" || primary === "chats" || primary === "work") return buildChatPlan(args); + if (primary === "terminal" || primary === "term") + return buildTerminalPlan(args); + if (primary === "chat" || primary === "chats" || primary === "work") + return buildChatPlan(args); if (primary === "agent" || primary === "agents") return buildAgentPlan(args); if (primary === "cto") return buildCtoPlan(args); if (primary === "linear") return buildLinearPlan(args); - if (primary === "automations" || primary === "automation") return buildAutomationsPlan(args); + if (primary === "automations" || primary === "automation") + return buildAutomationsPlan(args); if (primary === "flow") return buildFlowPlan(args); - if (primary === "coordinator" || primary === "coord") return buildCoordinatorPlan(args); - if (primary === "ask") return { kind: "execute", label: "ask user", steps: [actionCallStep("result", "ask_user", collectGenericObjectArgs(args, { title: readValue(args, ["--title"]) ?? "ADE question", body: readValue(args, ["--body", "--question"]) ?? args.join(" ") }))] }; + if (primary === "coordinator" || primary === "coord") + return buildCoordinatorPlan(args); + if (primary === "ask") + return { + kind: "execute", + label: "ask user", + steps: [ + actionCallStep( + "result", + "ask_user", + collectGenericObjectArgs(args, { + title: readValue(args, ["--title"]) ?? "ADE question", + body: readValue(args, ["--body", "--question"]) ?? args.join(" "), + }), + ), + ], + }; if (primary === "tests" || primary === "test") return buildTestsPlan(args); - if (primary === "proof" || primary === "computer-use" || primary === "artifacts" || primary === "computer" || primary === "artifact") { + if ( + primary === "proof" || + primary === "computer-use" || + primary === "artifacts" || + primary === "computer" || + primary === "artifact" + ) { return buildProofPlan(args); } - if (primary === "ios-sim" || primary === "ios" || primary === "simulator") return buildIosSimulatorPlan(args); - if (primary === "app-control" || primary === "app" || primary === "apps" || primary === "electron") return buildAppControlPlan(args); - if (primary === "macos-vm" || primary === "macos" || primary === "mac-vm" || primary === "macvm") return buildMacosVmPlan(args); - if (primary === "browser" || primary === "ade-browser" || primary === "built-in-browser" || primary === "builtin-browser") return buildBrowserPlan(args); + if (primary === "ios-sim" || primary === "ios" || primary === "simulator") + return buildIosSimulatorPlan(args); + if ( + primary === "app-control" || + primary === "app" || + primary === "apps" || + primary === "electron" + ) + return buildAppControlPlan(args); + if ( + primary === "macos-vm" || + primary === "macos" || + primary === "mac-vm" || + primary === "macvm" + ) + return buildMacosVmPlan(args); + if ( + primary === "browser" || + primary === "ade-browser" || + primary === "built-in-browser" || + primary === "builtin-browser" + ) + return buildBrowserPlan(args); if (primary === "memory") return buildMemoryPlan(args); - if (primary === "settings" || primary === "config" || primary === "setting") return buildSettingsPlan(args); - if (primary === "actions" || primary === "action") return buildActionsPlan(args); - if (primary === "update" || primary === "auto-update" || primary === "updates") return buildUpdatePlan(args); + if (primary === "settings" || primary === "config" || primary === "setting") + return buildSettingsPlan(args); + if (primary === "operation" || primary === "operations") + return buildOperationsPlan(args); + if (primary === "actions" || primary === "action") + return buildActionsPlan(args); + if ( + primary === "update" || + primary === "auto-update" || + primary === "updates" + ) + return buildUpdatePlan(args); if (primary === "mcp" || primary === "mcp-server") return { kind: "mcp" }; if (primary === "cursor") return buildCursorPlan(args); throw new CliUsageError(`Unknown command '${primary}'. Run 'ade help'.`); @@ -4304,7 +8695,9 @@ function buildCursorPlan(args: string[]): CliPlan { return { kind: "help", text: HELP_BY_COMMAND.cursor ?? TOP_LEVEL_HELP }; } if (surface !== "cloud") { - throw new CliUsageError(`Unknown 'ade cursor' surface '${surface}'. The only supported surface is 'cloud'.`); + throw new CliUsageError( + `Unknown 'ade cursor' surface '${surface}'. The only supported surface is 'cloud'.`, + ); } if (hasHelpFlag(args)) { const group = peekFirstPositional(args)?.toLowerCase(); @@ -4316,7 +8709,9 @@ function buildCursorPlan(args: string[]): CliPlan { return { kind: "cursor-cloud", rest: args }; } -function findAdeManagedWorktreeRoot(startDir: string): { projectRoot: string; workspaceRoot: string } | null { +function findAdeManagedWorktreeRoot( + startDir: string, +): { projectRoot: string; workspaceRoot: string } | null { let resolved = path.resolve(startDir); try { resolved = fs.realpathSync.native(resolved); @@ -4325,18 +8720,26 @@ function findAdeManagedWorktreeRoot(startDir: string): { projectRoot: string; wo } const segments = resolved.split(path.sep); for (let index = segments.length - 2; index >= 0; index -= 1) { - if (segments[index] !== ".ade" || segments[index + 1] !== "worktrees") continue; + if (segments[index] !== ".ade" || segments[index + 1] !== "worktrees") + continue; const projectRoot = segments.slice(0, index).join(path.sep) || path.sep; const worktreeName = segments[index + 2]; if (!worktreeName) continue; - const workspaceRoot = segments.slice(0, index + 3).join(path.sep) || path.sep; + const workspaceRoot = + segments.slice(0, index + 3).join(path.sep) || path.sep; if (!fs.existsSync(path.join(projectRoot, ".ade"))) continue; - return { projectRoot: path.resolve(projectRoot), workspaceRoot: path.resolve(workspaceRoot) }; + return { + projectRoot: path.resolve(projectRoot), + workspaceRoot: path.resolve(workspaceRoot), + }; } return null; } -function findProjectRoots(startDir: string): { projectRoot: string; workspaceRoot: string } { +function findProjectRoots(startDir: string): { + projectRoot: string; + workspaceRoot: string; +} { let canonicalStart = path.resolve(startDir); try { canonicalStart = fs.realpathSync.native(canonicalStart); @@ -4366,7 +8769,10 @@ function findProjectRoots(startDir: string): { projectRoot: string; workspaceRoo return { projectRoot: fallback, workspaceRoot: fallback }; } -function resolveRoots(options: GlobalOptions): { projectRoot: string; workspaceRoot: string } { +function resolveRoots(options: GlobalOptions): { + projectRoot: string; + workspaceRoot: string; +} { const discovered = findProjectRoots(process.cwd()); const projectFromEnv = process.env.ADE_PROJECT_ROOT?.trim() ? path.resolve(process.env.ADE_PROJECT_ROOT.trim()) @@ -4375,13 +8781,15 @@ function resolveRoots(options: GlobalOptions): { projectRoot: string; workspaceR ? path.resolve(process.env.ADE_WORKSPACE_ROOT.trim()) : null; - const projectRoot = options.projectRoot ?? projectFromEnv ?? discovered.projectRoot; - const projectExplicitlyOverridden = options.projectRoot != null || projectFromEnv != null; + const projectRoot = + options.projectRoot ?? projectFromEnv ?? discovered.projectRoot; + const projectExplicitlyOverridden = + options.projectRoot != null || projectFromEnv != null; const workspaceRoot = - options.workspaceRoot - ?? workspaceFromEnv - ?? (projectExplicitlyOverridden ? projectRoot : discovered.workspaceRoot); + options.workspaceRoot ?? + workspaceFromEnv ?? + (projectExplicitlyOverridden ? projectRoot : discovered.workspaceRoot); return { projectRoot, workspaceRoot }; } @@ -4395,26 +8803,14 @@ function commandExists(command: string): boolean { return result.status === 0 && result.stdout.trim().length > 0; } -function resolveAdeCodeLaunch(): { command: string; args: string[] } { - const explicit = process.env.ADE_CODE_EXECUTABLE?.trim(); - if (explicit) return { command: explicit, args: [] }; - - const siblingDist = path.resolve(CLI_PACKAGE_ROOT, "..", "ade-code", "dist", "cli.js"); - if (fs.existsSync(siblingDist)) { - return { command: process.execPath, args: [siblingDist] }; - } - - if (commandExists("ade-code")) { - return { command: "ade-code", args: [] }; - } - - throw new CliUsageError("ade code could not find ade-code. Build apps/ade-code or install the ade-code binary."); -} - function resolveAdeCodeSocketPath(projectRoot: string): string { - return process.env.ADE_RPC_URL?.trim() - || process.env.ADE_RPC_SOCKET_PATH?.trim() - || path.join(projectRoot, ".ade", "ade.sock"); + return ( + process.env.ADE_RPC_URL?.trim() || + process.env.ADE_RPC_SOCKET_PATH?.trim() || + process.env.ADE_RUNTIME_SOCKET_PATH?.trim() || + resolveMachineAdeLayout().socketPath || + path.join(projectRoot, ".ade", "ade.sock") + ); } function buildAdeCodeArgs(rest: string[], options: GlobalOptions): string[] { @@ -4425,27 +8821,41 @@ function buildAdeCodeArgs(rest: string[], options: GlobalOptions): string[] { "--workspace-root", roots.workspaceRoot, ...(options.headless ? ["--embedded"] : []), - ...(options.requireSocket ? ["--socket", resolveAdeCodeSocketPath(roots.projectRoot), "--require-socket"] : []), + ...(options.requireSocket + ? [ + "--socket", + resolveAdeCodeSocketPath(roots.projectRoot), + "--require-socket", + ] + : []), ...rest, ]; } -function runAdeCode(rest: string[], options: GlobalOptions): { output: string; exitCode: number } { - const launch = resolveAdeCodeLaunch(); - const args = [ - ...launch.args, - ...buildAdeCodeArgs(rest, options), - ]; - const result = spawnSync(launch.command, args, { - cwd: process.cwd(), - env: process.env, - stdio: "inherit", - }); - if (result.error) throw result.error; - return { output: "", exitCode: typeof result.status === "number" ? result.status : 1 }; +async function runAdeCode( + rest: string[], + options: GlobalOptions, +): Promise<{ output: string; exitCode: number }> { + const sourceModule = path.join( + CLI_PACKAGE_ROOT, + "src", + "tuiClient", + "cli.tsx", + ); + const builtModule = CLI_ENTRY_PATH + ? path.join(path.dirname(CLI_ENTRY_PATH), "tuiClient", "cli.mjs") + : path.join(CLI_PACKAGE_ROOT, "dist", "tuiClient", "cli.mjs"); + const modulePath = fs.existsSync(builtModule) ? builtModule : sourceModule; + const { runAdeCodeCli } = await import(pathToFileURL(modulePath).href); + const exitCode = await runAdeCodeCli(buildAdeCodeArgs(rest, options)); + return { output: "", exitCode }; } -function runLocalCommand(command: string, args: string[], cwd: string): { ok: boolean; stdout: string; stderr: string } { +function runLocalCommand( + command: string, + args: string[], + cwd: string, +): { ok: boolean; stdout: string; stderr: string } { const result = spawnSync(command, args, { cwd, encoding: "utf8", @@ -4468,7 +8878,11 @@ function checkGitReadiness(projectRoot: string): ReadinessCheck { nextAction: "Install git and rerun ade doctor.", }; } - const inside = runLocalCommand("git", ["rev-parse", "--is-inside-work-tree"], projectRoot); + const inside = runLocalCommand( + "git", + ["rev-parse", "--is-inside-work-tree"], + projectRoot, + ); if (!inside.ok || inside.stdout !== "true") { return { ready: false, @@ -4477,8 +8891,16 @@ function checkGitReadiness(projectRoot: string): ReadinessCheck { nextAction: "Run ade with --project-root pointing at a git repository.", }; } - const root = runLocalCommand("git", ["rev-parse", "--show-toplevel"], projectRoot); - const branch = runLocalCommand("git", ["branch", "--show-current"], projectRoot); + const root = runLocalCommand( + "git", + ["rev-parse", "--show-toplevel"], + projectRoot, + ); + const branch = runLocalCommand( + "git", + ["branch", "--show-current"], + projectRoot, + ); return { ready: true, status: "ready", @@ -4491,7 +8913,11 @@ function checkGitReadiness(projectRoot: string): ReadinessCheck { } function getGitRemote(projectRoot: string): string | null { - const remote = runLocalCommand("git", ["config", "--get", "remote.origin.url"], projectRoot); + const remote = runLocalCommand( + "git", + ["config", "--get", "remote.origin.url"], + projectRoot, + ); return remote.ok && remote.stdout ? remote.stdout : null; } @@ -4499,7 +8925,9 @@ function checkGitHubReadiness(projectRoot: string): ReadinessCheck { const remote = getGitRemote(projectRoot); const hasGitHubRemote = Boolean(remote && /github\.com[:/]/i.test(remote)); const ghInstalled = commandExists("gh"); - const envTokenPresent = Boolean(process.env.ADE_GITHUB_TOKEN?.trim() || process.env.GITHUB_TOKEN?.trim()); + const envTokenPresent = Boolean( + process.env.ADE_GITHUB_TOKEN?.trim() || process.env.GITHUB_TOKEN?.trim(), + ); const ready = hasGitHubRemote && (ghInstalled || envTokenPresent); return { ready, @@ -4525,12 +8953,14 @@ function checkGitHubReadiness(projectRoot: string): ReadinessCheck { function checkLinearReadiness(projectRoot: string): ReadinessCheck { const { resolveAdeLayout } = requireAdeLayout(); const layout = resolveAdeLayout(projectRoot); - const encryptedTokenPresent = fs.existsSync(path.join(layout.secretsDir, "linear-token.v1.bin")); + const encryptedTokenPresent = fs.existsSync( + path.join(layout.secretsDir, "linear-token.v1.bin"), + ); const envTokenPresent = Boolean( - process.env.ADE_LINEAR_API?.trim() - || process.env.LINEAR_API_KEY?.trim() - || process.env.ADE_LINEAR_TOKEN?.trim() - || process.env.LINEAR_TOKEN?.trim() + process.env.ADE_LINEAR_API?.trim() || + process.env.LINEAR_API_KEY?.trim() || + process.env.ADE_LINEAR_TOKEN?.trim() || + process.env.LINEAR_TOKEN?.trim(), ); const ready = encryptedTokenPresent || envTokenPresent; return { @@ -4550,8 +8980,12 @@ function checkLinearReadiness(projectRoot: string): ReadinessCheck { } function checkProviderReadiness(value: unknown): ReadinessCheck { - const configResult = isRecord(value) && isRecord(value.result) ? value.result : value; - const effective = isRecord(configResult) && isRecord(configResult.effective) ? configResult.effective : {}; + const configResult = + isRecord(value) && isRecord(value.result) ? value.result : value; + const effective = + isRecord(configResult) && isRecord(configResult.effective) + ? configResult.effective + : {}; const ai = isRecord(effective.ai) ? effective.ai : {}; const defaultProvider = asString(ai.defaultProvider) ?? asString(ai.mode); const defaultModel = asString(ai.defaultModel); @@ -4563,8 +8997,15 @@ function checkProviderReadiness(value: unknown): ReadinessCheck { cursor: commandExists("agent") || commandExists("cursor-agent"), droid: commandExists("droid"), }; - const apiKeyProviders = Object.keys(apiKeys).filter((key) => Boolean(asString(apiKeys[key]))); - const ready = Boolean(defaultProvider || defaultModel || apiKeyProviders.length || Object.values(cliProviders).some(Boolean)); + const apiKeyProviders = Object.keys(apiKeys).filter((key) => + Boolean(asString(apiKeys[key])), + ); + const ready = Boolean( + defaultProvider || + defaultModel || + apiKeyProviders.length || + Object.values(cliProviders).some(Boolean), + ); return { ready, status: ready ? "ready" : "warning", @@ -4587,7 +9028,8 @@ function checkComputerUseReadiness(): ReadinessCheck { const isDarwin = process.platform === "darwin"; const screenshotReady = isDarwin && commandExists("screencapture"); const appLaunchReady = isDarwin && commandExists("open"); - const guiReady = isDarwin && (commandExists("swift") || commandExists("osascript")); + const guiReady = + isDarwin && (commandExists("swift") || commandExists("osascript")); const ready = isDarwin && screenshotReady && appLaunchReady && guiReady; return { ready, @@ -4612,16 +9054,22 @@ function checkComputerUseReadiness(): ReadinessCheck { } function checkPathReadiness(): ReadinessCheck { - const lookup = process.platform === "win32" - ? runLocalCommand("where", ["ade"], process.cwd()) - : runLocalCommand("which", ["ade"], process.cwd()); + const lookup = + process.platform === "win32" + ? runLocalCommand("where", ["ade"], process.cwd()) + : runLocalCommand("which", ["ade"], process.cwd()); const current = path.resolve(process.argv[1] ?? ""); - const whichPath = lookup.ok && lookup.stdout ? path.resolve(lookup.stdout.split(/\r?\n/)[0]!) : null; + const whichPath = + lookup.ok && lookup.stdout + ? path.resolve(lookup.stdout.split(/\r?\n/)[0]!) + : null; const onPath = Boolean(whichPath); return { ready: onPath, status: onPath ? "ready" : "warning", - message: onPath ? "ade is available on PATH." : "ade is not available on PATH.", + message: onPath + ? "ade is available on PATH." + : "ade is not available on PATH.", nextAction: onPath ? undefined : process.platform === "win32" @@ -4638,14 +9086,23 @@ function checkPathReadiness(): ReadinessCheck { }; } -function requireAdeLayout(): { resolveAdeLayout: (projectRoot: string) => { secretsDir: string } } { +function requireAdeLayout(): { + resolveAdeLayout: (projectRoot: string) => { secretsDir: string }; +} { // The CLI loads the shared layout dynamically elsewhere; this CommonJS fallback // keeps readiness checks synchronous and local-only. - return { resolveAdeLayout: (projectRoot: string) => ({ secretsDir: path.join(projectRoot, ".ade", "secrets") }) }; + return { + resolveAdeLayout: (projectRoot: string) => ({ + secretsDir: path.join(projectRoot, ".ade", "secrets"), + }), + }; } function actionDomainCounts(value: unknown): Record { - const actions = isRecord(value) && Array.isArray(value.actions) ? value.actions.filter(isRecord) : []; + const actions = + isRecord(value) && Array.isArray(value.actions) + ? value.actions.filter(isRecord) + : []; return actions.reduce>((acc, action) => { const domain = asString(action.domain) ?? "core"; acc[domain] = (acc[domain] ?? 0) + 1; @@ -4659,15 +9116,24 @@ function buildReadinessSnapshot(args: { summary: "doctor" | "auth"; }): JsonObject { const { connection, values, summary } = args; - const rpcActions = isRecord(values.rpcActions) && Array.isArray(values.rpcActions.actions) ? values.rpcActions.actions : []; - const actions = isRecord(values.actions) && Array.isArray(values.actions.actions) ? values.actions.actions : []; + const rpcActions = + isRecord(values.rpcActions) && Array.isArray(values.rpcActions.actions) + ? values.rpcActions.actions + : []; + const actions = + isRecord(values.actions) && Array.isArray(values.actions.actions) + ? values.actions.actions + : []; const projectConfig = values.projectConfig; const adeDir = path.join(connection.projectRoot, ".ade"); const sharedConfigPath = path.join(adeDir, "ade.yaml"); const localConfigPath = path.join(adeDir, "local.yaml"); + const attachedSocketAvailable = + connection.mode === "runtime-socket" || + connection.mode === "desktop-socket"; const desktopSocketAvailable = connection.mode === "desktop-socket"; const socketExists = isAdeMcpNamedPipePath(connection.socketPath) - ? desktopSocketAvailable + ? attachedSocketAvailable : fs.existsSync(connection.socketPath); const checks = { git: checkGitReadiness(connection.projectRoot), @@ -4680,12 +9146,16 @@ function buildReadinessSnapshot(args: { const recommendations = Object.entries(checks) .filter(([, check]) => check.nextAction) .map(([key, check]) => `${key}: ${check.nextAction}`); - if (!desktopSocketAvailable) { - recommendations.unshift("desktop: Start ADE desktop or pass --socket when Work chat, Path to Merge, Run tab state, or UI-owned proof state is required."); + if (!attachedSocketAvailable) { + recommendations.unshift( + "runtime: Start ADE runtime or remove --headless when Work chat, Path to Merge, Run tab state, or shared proof state is required.", + ); } const projectInitialized = fs.existsSync(adeDir); if (!projectInitialized) { - recommendations.unshift("project: Run ade doctor from an ADE project or pass --project-root ."); + recommendations.unshift( + "project: Run ade doctor from an ADE project or pass --project-root .", + ); } const actionCountsByDomain = actionDomainCounts(values.actions); const ready = projectInitialized && checks.git.ready && actions.length > 0; @@ -4696,7 +9166,12 @@ function buildReadinessSnapshot(args: { protocolVersion: PROTOCOL_VERSION, mode: connection.mode, selectedMode: connection.mode, - requestedMode: desktopSocketAvailable ? "desktop-socket" : "headless", + requestedMode: + connection.mode === "runtime-socket" + ? "runtime-socket" + : desktopSocketAvailable + ? "desktop-socket" + : "headless", runtime: { node: process.version, execPath: process.execPath, @@ -4719,12 +9194,16 @@ function buildReadinessSnapshot(args: { desktop: { socketPath: connection.socketPath, socketExists, - socketAvailable: desktopSocketAvailable, - message: desktopSocketAvailable - ? "Connected to live ADE desktop socket." - : socketExists - ? "Socket path exists but CLI is running in headless mode; the socket may be stale or unavailable." - : "No live ADE desktop socket was detected.", + socketAvailable: attachedSocketAvailable, + socketMode: connection.mode, + message: + connection.mode === "runtime-socket" + ? "Connected to ADE runtime daemon socket." + : desktopSocketAvailable + ? "Connected to legacy ADE desktop socket." + : socketExists + ? "Socket path exists but CLI is running in headless mode; the socket may be stale or unavailable." + : "No live ADE socket was detected.", }, actions: { rpcActionCount: rpcActions.length, @@ -4744,81 +9223,133 @@ function buildReadinessSnapshot(args: { }, networkChecks: { performed: false, - message: "Default doctor/auth checks do not call provider, GitHub, or Linear networks.", + message: + "Default doctor/auth checks do not call provider, GitHub, or Linear networks.", }, recommendations, - recommendation: recommendations[0] ?? (connection.mode === "desktop-socket" - ? "Using live ADE desktop state." - : "Headless mode is ready for local ADE actions; start ADE desktop for UI-owned runtime state."), + recommendation: + recommendations[0] ?? + (attachedSocketAvailable + ? "Using live ADE runtime state." + : "Headless mode is ready for local ADE actions; start ADE runtime for shared runtime state."), summary, }; } -class SocketJsonRpcClient { - private buffer: Buffer = Buffer.alloc(0); - private nextId = 1; - private pending = new Map void; - reject: (error: Error) => void; - timer: ReturnType; - }>(); - - private constructor(private readonly socket: net.Socket, private readonly timeoutMs: number) { - socket.on("data", (chunk) => this.onData(Buffer.from(chunk))); - socket.on("error", (error) => this.rejectAll(error instanceof Error ? error : new Error(String(error)))); - socket.on("close", () => this.rejectAll(new Error("ADE desktop socket closed."))); +function createSocketConnection(socketPath: string): net.Socket { + if (socketPath.startsWith("tcp://")) { + const parsed = new URL(socketPath); + return net.createConnection({ + host: parsed.hostname, + port: Number(parsed.port), + }); } + return net.createConnection(socketPath); +} - static connect(socketPath: string, timeoutMs: number): Promise { - return new Promise((resolve, reject) => { - const connectTimeoutMs = Math.min(timeoutMs, 5000); - const deadline = Date.now() + connectTimeoutMs; - const retryable = (error: NodeJS.ErrnoException) => - error.code === "ENOENT" || error.code === "ECONNREFUSED" || error.code === "EACCES" || error.code === "EPERM"; - const attempt = () => { - const socket = (() => { - if (socketPath.startsWith("tcp://")) { - const parsed = new URL(socketPath); - return net.createConnection({ - host: parsed.hostname, - port: Number(parsed.port), - }); - } - return net.createConnection(socketPath); - })(); - let settled = false; - let connectTimer: ReturnType | null = null; - const finish = (fn: () => void) => { - if (settled) return; - settled = true; - if (connectTimer) clearTimeout(connectTimer); - fn(); - }; - connectTimer = setTimeout(() => { - finish(() => { - socket.destroy(); - reject(new Error(`Timed out connecting to ADE desktop socket after ${connectTimeoutMs}ms.`)); - }); - }, Math.max(1, deadline - Date.now())); - socket.once("connect", () => { - finish(() => resolve(new SocketJsonRpcClient(socket, timeoutMs))); - }); - socket.once("error", (error: NodeJS.ErrnoException) => { +function isRetryableSocketConnectError(error: NodeJS.ErrnoException): boolean { + return ( + error.code === "ENOENT" || + error.code === "ECONNREFUSED" || + error.code === "EACCES" || + error.code === "EPERM" + ); +} + +function connectSocket( + socketPath: string, + timeoutMs: number, + label: string, +): Promise { + return new Promise((resolve, reject) => { + const connectTimeoutMs = Math.min(timeoutMs, 5000); + const deadline = Date.now() + connectTimeoutMs; + const attempt = () => { + const socket = createSocketConnection(socketPath); + let settled = false; + let connectTimer: ReturnType | null = null; + const finish = (fn: () => void) => { + if (settled) return; + settled = true; + if (connectTimer) clearTimeout(connectTimer); + fn(); + }; + connectTimer = setTimeout( + () => { finish(() => { socket.destroy(); - if (retryable(error) && Date.now() < deadline) { - setTimeout(attempt, 100); - return; - } - reject(error); + reject( + new Error( + `Timed out connecting to ${label} after ${connectTimeoutMs}ms.`, + ), + ); }); + }, + Math.max(1, deadline - Date.now()), + ); + socket.once("connect", () => { + finish(() => resolve(socket)); + }); + socket.once("error", (error: NodeJS.ErrnoException) => { + finish(() => { + socket.destroy(); + if (isRetryableSocketConnectError(error) && Date.now() < deadline) { + setTimeout(attempt, 100); + return; + } + reject(error); }); - }; - attempt(); - }); + }); + }; + attempt(); + }); +} + +class SocketJsonRpcClient { + private buffer: Buffer = Buffer.alloc(0); + private nextId = 1; + private closedError: Error | null = null; + private pending = new Map< + number, + { + resolve: (value: unknown) => void; + reject: (error: Error) => void; + timer: ReturnType; + } + >(); + private notificationHandlers = new Map< + string, + Set<(params: unknown) => void> + >(); + private anyNotificationHandlers = new Set< + (method: string, params: unknown) => void + >(); + private closeHandlers = new Set<(error: Error) => void>(); + + private constructor( + private readonly socket: net.Socket, + private readonly timeoutMs: number, + ) { + socket.on("data", (chunk) => this.onData(Buffer.from(chunk))); + socket.on("error", (error) => + this.rejectAll(error instanceof Error ? error : new Error(String(error))), + ); + socket.on("close", () => + this.failConnection(new Error("ADE socket closed.")), + ); + } + + static async connect( + socketPath: string, + timeoutMs: number, + label = "ADE socket", + ): Promise { + const socket = await connectSocket(socketPath, timeoutMs, label); + return new SocketJsonRpcClient(socket, timeoutMs); } - request(method: string, params?: JsonObject): Promise { + request(method: string, params?: unknown): Promise { + if (this.closedError) return Promise.reject(this.closedError); const id = this.nextId; this.nextId += 1; const payload: JsonRpcRequest = { @@ -4843,12 +9374,60 @@ class SocketJsonRpcClient { }); } + notify(method: string, params?: unknown): void { + if (this.closedError) return; + const payload: JsonRpcRequest = { + jsonrpc: "2.0", + method, + ...(params !== undefined ? { params } : {}), + }; + this.socket.write(`${JSON.stringify(payload)}\n`, "utf8"); + } + + onClose(handler: (error: Error) => void): () => void { + if (this.closedError) { + const error = this.closedError; + queueMicrotask(() => handler(error)); + return () => {}; + } + this.closeHandlers.add(handler); + return () => { + this.closeHandlers.delete(handler); + }; + } + + onNotification( + method: string, + handler: (params: unknown) => void, + ): () => void { + const handlers = + this.notificationHandlers.get(method) ?? + new Set<(params: unknown) => void>(); + handlers.add(handler); + this.notificationHandlers.set(method, handlers); + return () => { + handlers.delete(handler); + if (handlers.size === 0) this.notificationHandlers.delete(method); + }; + } + + onAnyNotification( + handler: (method: string, params: unknown) => void, + ): () => void { + this.anyNotificationHandlers.add(handler); + return () => { + this.anyNotificationHandlers.delete(handler); + }; + } + close(): void { this.socket.end(); } private onData(chunk: Buffer): void { - this.buffer = this.buffer.length ? Buffer.concat([this.buffer, chunk]) : chunk; + this.buffer = this.buffer.length + ? Buffer.concat([this.buffer, chunk]) + : chunk; while (true) { const newline = this.buffer.indexOf(0x0a); if (newline < 0) break; @@ -4864,18 +9443,36 @@ class SocketJsonRpcClient { try { parsed = JSON.parse(line); } catch (error) { - this.rejectAll(new Error(`Failed to parse ADE socket response: ${error instanceof Error ? error.message : String(error)}`)); + this.rejectAll( + new Error( + `Failed to parse ADE socket response: ${error instanceof Error ? error.message : String(error)}`, + ), + ); return; } if (!isRecord(parsed)) return; const id = typeof parsed.id === "number" ? parsed.id : null; - if (id == null) return; + if (id == null) { + const method = asString(parsed.method); + if (!method) return; + for (const handler of this.notificationHandlers.get(method) ?? []) { + handler(parsed.params); + } + for (const handler of this.anyNotificationHandlers) { + handler(method, parsed.params); + } + return; + } const pending = this.pending.get(id); if (!pending) return; this.pending.delete(id); clearTimeout(pending.timer); if (isRecord(parsed.error)) { - pending.reject(new Error(asString(parsed.error.message) ?? "ADE JSON-RPC request failed.")); + pending.reject( + new Error( + asString(parsed.error.message) ?? "ADE JSON-RPC request failed.", + ), + ); return; } pending.resolve(parsed.result); @@ -4888,6 +9485,16 @@ class SocketJsonRpcClient { pending.reject(error); } } + + private failConnection(error: Error): void { + if (this.closedError) return; + this.closedError = error; + this.rejectAll(error); + for (const handler of this.closeHandlers) { + handler(error); + } + this.closeHandlers.clear(); + } } class InProcessJsonRpcClient { @@ -4911,8 +9518,12 @@ class InProcessJsonRpcClient { } close(): void { - try { this.handler.dispose?.(); } catch {} - try { this.runtime.dispose(); } catch {} + try { + this.handler.dispose?.(); + } catch {} + try { + this.runtime.dispose(); + } catch {} if (this.previousRole == null) delete process.env.ADE_DEFAULT_ROLE; else process.env.ADE_DEFAULT_ROLE = this.previousRole; } @@ -4922,7 +9533,10 @@ async function startHeadlessRpcSocketServer(args: { socketPath: string; createHandler: () => JsonRpcHandler & { dispose?: () => void }; }): Promise<(() => void) | null> { - if (isAdeMcpNamedPipePath(args.socketPath) || fs.existsSync(args.socketPath)) { + if ( + isAdeMcpNamedPipePath(args.socketPath) || + fs.existsSync(args.socketPath) + ) { return null; } fs.mkdirSync(path.dirname(args.socketPath), { recursive: true }); @@ -4945,7 +9559,9 @@ async function startHeadlessRpcSocketServer(args: { return () => { stopHeadlessRpcServer(serverState); - try { fs.unlinkSync(args.socketPath); } catch {} + try { + fs.unlinkSync(args.socketPath); + } catch {} }; } @@ -4959,7 +9575,11 @@ async function startHeadlessRpcTcpServer(args: { const handleListening = () => { server.off("error", handleError); const address = server.address(); - if (typeof address === "object" && address && typeof address.port === "number") { + if ( + typeof address === "object" && + address && + typeof address.port === "number" + ) { resolve(address.port); } else { reject(new Error("Headless RPC TCP server did not expose a port.")); @@ -4986,7 +9606,15 @@ type HeadlessRpcServerState = { server: net.Server; }; -function createHeadlessRpcServer(createHandler: () => JsonRpcHandler & { dispose?: () => void }): HeadlessRpcServerState { +type NotifiableJsonRpcHandler = JsonRpcHandler & { + setNotifier?: ( + notify: ((method: string, params?: unknown) => void) | null, + ) => void; +}; + +function createHeadlessRpcServer( + createHandler: () => JsonRpcHandler & { dispose?: () => void }, +): HeadlessRpcServerState { const activeConnections = new Set(); const activeStops = new Set>(); const server = net.createServer((conn) => { @@ -4994,7 +9622,9 @@ function createHeadlessRpcServer(createHandler: () => JsonRpcHandler & { dispose const handler = createHandler(); const transport: JsonRpcTransport = { onData(callback) { - conn.on("data", (chunk) => callback(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk))); + conn.on("data", (chunk) => + callback(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)), + ); }, write(data) { conn.write(data); @@ -5004,6 +9634,9 @@ function createHeadlessRpcServer(createHandler: () => JsonRpcHandler & { dispose }, }; const stop = startJsonRpcServer(handler, transport, { nonFatal: true }); + (handler as NotifiableJsonRpcHandler).setNotifier?.((method, params) => + stop.notify(method, params), + ); activeStops.add(stop); let cleanedUp = false; const cleanup = () => { @@ -5011,8 +9644,12 @@ function createHeadlessRpcServer(createHandler: () => JsonRpcHandler & { dispose cleanedUp = true; activeConnections.delete(conn); activeStops.delete(stop); - try { stop(); } catch {} - try { handler.dispose?.(); } catch {} + try { + stop(); + } catch {} + try { + handler.dispose?.(); + } catch {} }; conn.once("close", cleanup); conn.once("end", cleanup); @@ -5024,12 +9661,18 @@ function createHeadlessRpcServer(createHandler: () => JsonRpcHandler & { dispose function stopHeadlessRpcServer(state: HeadlessRpcServerState): void { for (const conn of state.activeConnections) { - try { conn.destroy(); } catch {} + try { + conn.destroy(); + } catch {} } for (const stop of state.activeStops) { - try { stop(); } catch {} + try { + stop(); + } catch {} } - try { state.server.close(); } catch {} + try { + state.server.close(); + } catch {} } function discoverHeadlessWorktreeSocketPaths(projectRoot: string): string[] { @@ -5089,7 +9732,11 @@ async function startHeadlessRpcSocketServers(args: { const scan = async () => { await ensure(args.socketPath); - await Promise.all(discoverHeadlessWorktreeSocketPaths(args.projectRoot).map((socketPath) => ensure(socketPath))); + await Promise.all( + discoverHeadlessWorktreeSocketPaths(args.projectRoot).map((socketPath) => + ensure(socketPath), + ), + ); }; await scan(); @@ -5102,34 +9749,281 @@ async function startHeadlessRpcSocketServers(args: { stopped = true; clearInterval(interval); for (const stop of stops.values()) { - try { stop(); } catch {} + try { + stop(); + } catch {} } stops.clear(); }; } -export function shouldAttemptDesktopSocketConnection(socketPath: string): boolean { +export function shouldAttemptDesktopSocketConnection( + socketPath: string, +): boolean { return isAdeMcpNamedPipePath(socketPath) || fs.existsSync(socketPath); } -async function initializeConnection(connection: CliConnection, options: GlobalOptions): Promise { - await connection.request("ade/initialize", buildInitializeParams(options, "ade-cli")); +async function initializeConnection( + connection: CliConnection, + options: GlobalOptions, +): Promise { + await connection.request( + "ade/initialize", + buildInitializeParams(options, "ade-cli"), + ); +} + +function isMachineRuntimeScopedMethod(method: string): boolean { + return ( + method === "ade/initialize" || + method === "ade/initialized" || + method === "ping" || + method === "shutdown" || + method === "exit" || + method === "runtime/info" || + method === "machineInfo.get" || + method.startsWith("sync.") || + method.startsWith("projects.") + ); +} + +export function shouldAutoRegisterProjectForPlan( + plan: CliPlan & { kind: "execute" }, +): boolean { + return plan.steps.some((step) => !isMachineRuntimeScopedMethod(step.method)); +} + +function buildSyncPlan(args: string[]): CliPlan { + const sub = firstPositional(args) ?? "status"; + if (sub === "help") { + return { + kind: "help", + text: `${ADE_BANNER} +Usage: + ade sync status [--include-transfer-readiness] + ade sync refresh + ade sync devices + ade sync pin get + ade sync pin generate + ade sync pin set <6-digit-pin> + ade sync pin clear +`, + }; + } + if (sub === "status") { + return { + kind: "execute", + label: "sync status", + steps: [ + { + key: "result", + method: "sync.getStatus", + params: { + includeTransferReadiness: readFlag(args, [ + "--include-transfer-readiness", + ]), + forceTransferReadiness: readFlag(args, [ + "--force-transfer-readiness", + ]), + }, + }, + ], + }; + } + if (sub === "refresh" || sub === "refresh-discovery") { + return { + kind: "execute", + label: "sync refresh", + steps: [{ key: "result", method: "sync.refreshDiscovery" }], + }; + } + if (sub === "devices" || sub === "list-devices") { + return { + kind: "execute", + label: "sync devices", + steps: [{ key: "result", method: "sync.listDevices" }], + }; + } + if (sub === "pin") { + const action = firstPositional(args) ?? "get"; + if (action === "get" || action === "show") { + return { + kind: "execute", + label: "sync pin get", + steps: [{ key: "result", method: "sync.getPin" }], + }; + } + if (action === "set") { + const pin = requireValue( + readValue(args, ["--pin"]) ?? firstPositional(args), + "pin", + ); + return { + kind: "execute", + label: "sync pin set", + steps: [{ key: "result", method: "sync.setPin", params: { pin } }], + }; + } + if (action === "generate" || action === "new") { + return { + kind: "execute", + label: "sync pin generate", + steps: [{ key: "result", method: "sync.generatePin" }], + }; + } + if (action === "clear" || action === "remove") { + return { + kind: "execute", + label: "sync pin clear", + steps: [{ key: "result", method: "sync.clearPin" }], + }; + } + throw new CliUsageError(`Unsupported sync pin action: ${action}`); + } + throw new CliUsageError(`Unsupported sync command: ${sub}`); +} + +function buildProjectsPlan(args: string[]): CliPlan { + const sub = firstPositional(args) ?? "list"; + if (sub === "list" || sub === "ls") { + return { + kind: "execute", + label: "projects list", + formatter: "projects-list", + steps: [{ key: "result", method: "projects.list" }], + }; + } + if (sub === "add" || sub === "register") { + const rootPath = requireValue( + readValue(args, ["--path", "--root"]) ?? firstPositional(args), + "project path", + ); + return { + kind: "execute", + label: "projects add", + formatter: "projects-list", + steps: [{ key: "result", method: "projects.add", params: { rootPath } }], + }; + } + if (sub === "remove" || sub === "rm" || sub === "delete") { + const projectId = requireValue( + readValue(args, ["--project-id", "--id"]) ?? firstPositional(args), + "project id", + ); + return { + kind: "execute", + label: "projects remove", + steps: [ + { key: "result", method: "projects.remove", params: { projectId } }, + ], + }; + } + if (sub === "touch") { + const projectId = requireValue( + readValue(args, ["--project-id", "--id"]) ?? firstPositional(args), + "project id", + ); + return { + kind: "execute", + label: "projects touch", + formatter: "projects-list", + steps: [ + { key: "result", method: "projects.touch", params: { projectId } }, + ], + }; + } + throw new CliUsageError( + `projects supports list, add, remove, or touch; got '${sub}'.`, + ); +} + +function withProjectId( + params: JsonObject | undefined, + projectId: string, +): JsonObject { + return { + ...(params ?? {}), + projectId, + }; } -async function createConnection(options: GlobalOptions): Promise { +async function createConnection( + options: GlobalOptions, + args: { autoRegisterProject?: boolean } = {}, +): Promise { const roots = resolveRoots(options); - const { resolveAdeLayout } = await import("../../desktop/src/shared/adeLayout"); + const { resolveAdeLayout } = + await import("../../desktop/src/shared/adeLayout"); const layout = resolveAdeLayout(roots.projectRoot); - const socketPath = process.env.ADE_RPC_URL?.trim() || process.env.ADE_RPC_SOCKET_PATH?.trim() || layout.socketPath; + const legacySocketPath = + process.env.ADE_RPC_URL?.trim() || + process.env.ADE_RPC_SOCKET_PATH?.trim() || + layout.socketPath; + const autoRegisterProject = args.autoRegisterProject ?? true; + + if (!options.headless) { + let socketClient: SocketJsonRpcClient | null = null; + try { + const machineSocketPath = await resolveMachineRuntimeSocketPath(); + socketClient = await connectMachineRuntimeDaemon(options); + let activeProjectId: string | null = null; + const connection: CliConnection = { + mode: "runtime-socket", + projectRoot: roots.projectRoot, + workspaceRoot: roots.workspaceRoot, + socketPath: machineSocketPath, + request: (method, params) => + socketClient!.request( + method, + activeProjectId && !isMachineRuntimeScopedMethod(method) + ? withProjectId(params, activeProjectId) + : params, + ), + close: () => socketClient?.close(), + }; + if (autoRegisterProject) { + const registered = await connection.request("projects.add", { + rootPath: roots.projectRoot, + }); + const registeredProjectId = isRecord(registered) + ? asString(registered.projectId) + : null; + if (!registeredProjectId) { + throw new Error( + "Machine runtime did not return a projectId from projects.add.", + ); + } + activeProjectId = registeredProjectId; + } + return connection; + } catch (error) { + try { + socketClient?.close(); + } catch {} + if ( + options.requireSocket && + !shouldAttemptDesktopSocketConnection(legacySocketPath) + ) { + throw error; + } + } + } - if (!options.headless && (shouldAttemptDesktopSocketConnection(socketPath) || options.requireSocket)) { + if ( + !options.headless && + (shouldAttemptDesktopSocketConnection(legacySocketPath) || + options.requireSocket) + ) { try { - const socketClient = await SocketJsonRpcClient.connect(socketPath, options.timeoutMs); + const socketClient = await SocketJsonRpcClient.connect( + legacySocketPath, + options.timeoutMs, + ); const connection: CliConnection = { mode: "desktop-socket", projectRoot: roots.projectRoot, workspaceRoot: roots.workspaceRoot, - socketPath, + socketPath: legacySocketPath, request: (method, params) => socketClient.request(method, params), close: () => socketClient.close(), }; @@ -5141,21 +10035,23 @@ async function createConnection(options: GlobalOptions): Promise } if (options.requireSocket) { - throw new Error(`ADE desktop socket is not available at ${socketPath}.`); + throw new Error(`ADE socket is not available at ${legacySocketPath}.`); } const previousRole = process.env.ADE_DEFAULT_ROLE; process.env.ADE_DEFAULT_ROLE = options.role; - const [{ createAdeRuntime }, { createAdeRpcRequestHandler }] = await Promise.all([ - import("./bootstrap"), - import("./adeRpcServer"), - ]); - const runtime = await createAdeRuntime({ projectRoot: roots.projectRoot, workspaceRoot: roots.workspaceRoot }); - const createHandler = () => createAdeRpcRequestHandler({ - runtime, - serverVersion: VERSION, - onActionsListChanged: () => {}, + const [{ createAdeRuntime }, { createAdeRpcRequestHandler }] = + await Promise.all([import("./bootstrap"), import("./adeRpcServer")]); + const runtime = await createAdeRuntime({ + projectRoot: roots.projectRoot, + workspaceRoot: roots.workspaceRoot, }); + const createHandler = () => + createAdeRpcRequestHandler({ + runtime, + serverVersion: VERSION, + onActionsListChanged: () => {}, + }); const handler = createHandler(); const previousRpcUrl = process.env.ADE_RPC_URL; let stopHeadlessSocket: (() => void) | null = null; @@ -5170,7 +10066,7 @@ async function createConnection(options: GlobalOptions): Promise try { stopHeadlessSocket = await startHeadlessRpcSocketServers({ projectRoot: roots.projectRoot, - socketPath, + socketPath: legacySocketPath, createHandler, }); } catch { @@ -5182,11 +10078,15 @@ async function createConnection(options: GlobalOptions): Promise mode: "headless", projectRoot: roots.projectRoot, workspaceRoot: roots.workspaceRoot, - socketPath, + socketPath: legacySocketPath, request: (method, params) => inProcess.request(method, params), close: () => { - try { stopHeadlessSocket?.(); } catch {} - try { stopHeadlessTcp?.(); } catch {} + try { + stopHeadlessSocket?.(); + } catch {} + try { + stopHeadlessTcp?.(); + } catch {} if (previousRpcUrl == null) delete process.env.ADE_RPC_URL; else process.env.ADE_RPC_URL = previousRpcUrl; inProcess.close(); @@ -5196,7 +10096,10 @@ async function createConnection(options: GlobalOptions): Promise return connection; } -function buildInitializeParams(options: GlobalOptions, clientName: string): JsonObject { +function buildInitializeParams( + options: GlobalOptions, + clientName: string, +): JsonObject { const envChatSessionId = asString(process.env.ADE_CHAT_SESSION_ID); const envMissionId = asString(process.env.ADE_MISSION_ID); const envRunId = asString(process.env.ADE_RUN_ID); @@ -5207,7 +10110,8 @@ function buildInitializeParams(options: GlobalOptions, clientName: string): Json protocolVersion: PROTOCOL_VERSION, clientInfo: { name: clientName, version: VERSION }, identity: { - callerId: envChatSessionId ?? envAttemptId ?? `${clientName}:${process.pid}`, + callerId: + envChatSessionId ?? envAttemptId ?? `${clientName}:${process.pid}`, role: options.role, ...(envChatSessionId ? { chatSessionId: envChatSessionId } : {}), ...(envMissionId ? { missionId: envMissionId } : {}), @@ -5239,7 +10143,9 @@ function normalizeMcpAdeToolName(name: string): string { } function mcpToolScope(): "all" | "coordinator" { - return process.env.ADE_MCP_TOOL_SCOPE === "coordinator" ? "coordinator" : "all"; + return process.env.ADE_MCP_TOOL_SCOPE === "coordinator" + ? "coordinator" + : "all"; } function isMcpToolVisible(name: string): boolean { @@ -5257,14 +10163,19 @@ function formatMcpToolText(value: unknown): string { } async function runMcpServer(options: GlobalOptions): Promise { - const roots = resolveRoots({ ...options, headless: true, requireSocket: false }); + const roots = resolveRoots({ + ...options, + headless: true, + requireSocket: false, + }); const previousRole = process.env.ADE_DEFAULT_ROLE; process.env.ADE_DEFAULT_ROLE = options.role; - const [{ createAdeRuntime }, { createAdeRpcRequestHandler }] = await Promise.all([ - import("./bootstrap"), - import("./adeRpcServer"), - ]); - const runtime = await createAdeRuntime({ projectRoot: roots.projectRoot, workspaceRoot: roots.workspaceRoot }); + const [{ createAdeRuntime }, { createAdeRpcRequestHandler }] = + await Promise.all([import("./bootstrap"), import("./adeRpcServer")]); + const runtime = await createAdeRuntime({ + projectRoot: roots.projectRoot, + workspaceRoot: roots.workspaceRoot, + }); const adeHandler = createAdeRpcRequestHandler({ runtime, serverVersion: VERSION, @@ -5272,129 +10183,851 @@ async function runMcpServer(options: GlobalOptions): Promise { }); let initialized = false; let nextAdeRequestId = 1; - const callAde = async (method: string, params?: JsonObject): Promise => { + const callAde = async ( + method: string, + params?: JsonObject, + ): Promise => { return await adeHandler({ jsonrpc: "2.0", id: nextAdeRequestId++, method, ...(params !== undefined ? { params } : {}), }); - }; - const ensureInitialized = async (): Promise => { - if (initialized) return; - await callAde("ade/initialize", buildInitializeParams(options, "ade-mcp")); - initialized = true; - }; + }; + const ensureInitialized = async (): Promise => { + if (initialized) return; + await callAde("ade/initialize", buildInitializeParams(options, "ade-mcp")); + initialized = true; + }; + + const mcpHandler: JsonRpcHandler = async (request) => { + const method = typeof request.method === "string" ? request.method : ""; + const params = isRecord(request.params) ? request.params : {}; + if (method === "initialize") { + await ensureInitialized(); + const requestedVersion = + asString(params.protocolVersion) ?? PROTOCOL_VERSION; + return { + protocolVersion: requestedVersion, + capabilities: { + tools: { + listChanged: false, + }, + }, + serverInfo: { + name: "ade", + version: VERSION, + }, + }; + } + if (method === "notifications/initialized" || method === "initialized") { + await ensureInitialized(); + return null; + } + await ensureInitialized(); + if (method === "tools/list") { + const listed = await callAde("ade/actions/list"); + const actions = + isRecord(listed) && Array.isArray(listed.actions) + ? listed.actions.filter(isRecord) + : []; + return { + tools: actions + .map((action) => ({ + name: asString(action.name) ?? "", + description: asString(action.description) ?? "", + inputSchema: isRecord(action.inputSchema) + ? action.inputSchema + : { type: "object", properties: {} }, + })) + .filter( + (tool) => tool.name.length > 0 && isMcpToolVisible(tool.name), + ), + }; + } + if (method === "tools/call") { + const rawName = asString(params.name); + if (!rawName) { + throw new JsonRpcError( + JsonRpcErrorCode.invalidParams, + "tools/call requires a tool name.", + ); + } + if (!isMcpToolVisible(rawName)) { + throw new JsonRpcError( + JsonRpcErrorCode.methodNotFound, + `Tool not available in this MCP scope: ${rawName}`, + ); + } + const result = await callAde("ade/actions/call", { + name: normalizeMcpAdeToolName(rawName), + arguments: isRecord(params.arguments) ? params.arguments : {}, + }); + const isError = isRecord(result) && result.ok === false; + return { + content: [ + { + type: "text", + text: formatMcpToolText(result), + }, + ], + structuredContent: result ?? null, + isError, + }; + } + if (method === "shutdown") { + return {}; + } + if (method === "exit") { + process.nextTick(() => process.exit(0)); + return {}; + } + throw new JsonRpcError( + JsonRpcErrorCode.methodNotFound, + `Method not found: ${method}`, + ); + }; + + const transport: JsonRpcTransport = { + onData(callback) { + process.stdin.on("data", (chunk) => + callback(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)), + ); + }, + write(data) { + process.stdout.write(data); + }, + close() { + process.stdin.pause(); + }, + }; + const stop = startJsonRpcServer(mcpHandler, transport, { nonFatal: true }); + await new Promise((resolve) => { + let done = false; + const finish = () => { + if (done) return; + done = true; + resolve(); + }; + process.stdin.once("end", finish); + process.stdin.once("close", finish); + }); + stop(); + try { + adeHandler.dispose?.(); + } catch {} + try { + runtime.dispose(); + } catch {} + if (previousRole == null) delete process.env.ADE_DEFAULT_ROLE; + else process.env.ADE_DEFAULT_ROLE = previousRole; +} + +function parseOptionalPort(value: string | null, label: string): number | null { + if (value == null) return null; + const parsed = Number.parseInt(value, 10); + if (!Number.isFinite(parsed) || parsed <= 0 || parsed > 65_535) { + throw new CliUsageError(`${label} must be a TCP port between 1 and 65535.`); + } + return parsed; +} + +function normalizeRuntimeSocketPath(rawSocketPath: string): string { + return rawSocketPath.startsWith("tcp://") || + isAdeMcpNamedPipePath(rawSocketPath) + ? rawSocketPath + : path.resolve(rawSocketPath); +} + +async function resolveMachineRuntimeSocketPath( + rawOverride?: string | null, +): Promise { + const { resolveMachineAdeLayout } = + await import("./services/projects/machineLayout"); + const rawSocketPath = + rawOverride?.trim() || + process.env.ADE_RUNTIME_SOCKET_PATH?.trim() || + resolveMachineAdeLayout().socketPath; + return normalizeRuntimeSocketPath(rawSocketPath); +} + +function readRuntimeInfoVersion(value: unknown): string | null { + if (!isRecord(value) || !isRecord(value.runtimeInfo)) return null; + return asString(value.runtimeInfo.version); +} + +async function initializeMachineRuntimeDaemon( + client: SocketJsonRpcClient, + options: GlobalOptions, +): Promise { + const result = await client.request( + "ade/initialize", + buildInitializeParams(options, "ade-rpc-stdio-proxy"), + ); + return readRuntimeInfoVersion(result); +} + +async function shutdownMachineRuntimeDaemon( + client: SocketJsonRpcClient, +): Promise { + try { + await client.request("shutdown"); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + if (!message.includes("socket closed")) throw error; + } finally { + try { + client.close(); + } catch {} + } +} + +async function spawnMachineRuntimeDaemon( + socketPath: string, + options: GlobalOptions, +): Promise { + if (socketPath.startsWith("tcp://")) return false; + + const { resolveAdeServeCommand } = await import("./serviceManager/common"); + const serviceCommand = resolveAdeServeCommand(); + const args = [...serviceCommand.args]; + if ( + serviceCommand.command === process.execPath && + args.length === 1 && + args[0] === "serve" && + fs.existsSync(CLI_DIST_PATH) + ) { + args.splice(0, 1, CLI_DIST_PATH, "serve"); + } + args.push("--socket", socketPath); + + const child = spawn(serviceCommand.command, args, { + detached: true, + stdio: "ignore", + env: { + ...process.env, + ...(serviceCommand.env ?? {}), + ADE_DEFAULT_ROLE: options.role, + ADE_RPC_SOCKET_PATH: socketPath, + ADE_RUNTIME_SOCKET_PATH: socketPath, + }, + }); + child.once("error", () => {}); + child.unref(); + return true; +} + +async function connectMachineRuntimeDaemon( + options: GlobalOptions, + socketPathOverride?: string | null, +): Promise { + const socketPath = await resolveMachineRuntimeSocketPath(socketPathOverride); + const label = "ADE runtime daemon socket"; + try { + const client = await SocketJsonRpcClient.connect( + socketPath, + options.timeoutMs, + label, + ); + const runtimeVersion = await initializeMachineRuntimeDaemon( + client, + options, + ); + if (runtimeVersion && runtimeVersion !== VERSION) { + await shutdownMachineRuntimeDaemon(client); + const spawned = await spawnMachineRuntimeDaemon(socketPath, options); + if (!spawned) { + throw new Error( + `ADE runtime daemon version ${runtimeVersion} does not match CLI version ${VERSION}.`, + ); + } + const restarted = await SocketJsonRpcClient.connect( + socketPath, + options.timeoutMs, + label, + ); + const restartedVersion = await initializeMachineRuntimeDaemon( + restarted, + options, + ); + if (restartedVersion && restartedVersion !== VERSION) { + await shutdownMachineRuntimeDaemon(restarted); + throw new Error( + `ADE runtime daemon version ${restartedVersion} does not match CLI version ${VERSION}.`, + ); + } + return restarted; + } + return client; + } catch (firstError) { + const spawned = await spawnMachineRuntimeDaemon(socketPath, options); + if (!spawned) throw firstError; + try { + const client = await SocketJsonRpcClient.connect( + socketPath, + options.timeoutMs, + label, + ); + const runtimeVersion = await initializeMachineRuntimeDaemon( + client, + options, + ); + if (runtimeVersion && runtimeVersion !== VERSION) { + await shutdownMachineRuntimeDaemon(client); + throw new Error( + `ADE runtime daemon version ${runtimeVersion} does not match CLI version ${VERSION}.`, + ); + } + return client; + } catch (secondError) { + const firstMessage = + firstError instanceof Error ? firstError.message : String(firstError); + const secondMessage = + secondError instanceof Error + ? secondError.message + : String(secondError); + throw new Error( + `Unable to attach to ADE runtime daemon at ${socketPath}: ${secondMessage} (initial attempt: ${firstMessage})`, + ); + } + } +} + +async function runRuntimeCommand( + rest: string[], + options: GlobalOptions, +): Promise { + const args = [...rest]; + const sub = firstPositional(args) ?? "status"; + const socketOverride = readValue(args, ["--socket"]); + const socketPath = await resolveMachineRuntimeSocketPath(socketOverride); + + if (sub === "status") { + try { + const client = await SocketJsonRpcClient.connect( + socketPath, + Math.min(options.timeoutMs, 3_000), + "ADE runtime daemon socket", + ); + try { + const runtimeVersion = await initializeMachineRuntimeDaemon( + client, + options, + ); + return { + ok: true, + running: true, + socketPath, + version: runtimeVersion, + message: "ADE runtime daemon is running.", + }; + } finally { + client.close(); + } + } catch (error) { + return { + ok: false, + running: false, + socketPath, + message: error instanceof Error ? error.message : String(error), + }; + } + } + + if (sub === "start") { + const client = await connectMachineRuntimeDaemon(options, socketOverride); + try { + const runtimeVersion = await initializeMachineRuntimeDaemon( + client, + options, + ).catch(() => null); + return { + ok: true, + running: true, + socketPath, + version: runtimeVersion, + message: "ADE runtime daemon is running.", + }; + } finally { + client.close(); + } + } + + if (sub === "stop" || sub === "shutdown") { + try { + const client = await SocketJsonRpcClient.connect( + socketPath, + Math.min(options.timeoutMs, 3_000), + "ADE runtime daemon socket", + ); + try { + await initializeMachineRuntimeDaemon(client, options).catch(() => null); + await shutdownMachineRuntimeDaemon(client); + } finally { + client.close(); + } + return { + ok: true, + running: false, + socketPath, + message: "ADE runtime daemon stopped.", + }; + } catch (error) { + return { + ok: false, + running: false, + socketPath, + message: error instanceof Error ? error.message : String(error), + }; + } + } + + if (sub === "install-service") { + const { installRuntimeService } = await import("./serviceManager"); + return installRuntimeService(); + } + if (sub === "uninstall-service") { + const { uninstallRuntimeService } = await import("./serviceManager"); + return uninstallRuntimeService(); + } + if (sub === "service-status") { + const { getRuntimeServiceStatus } = await import("./serviceManager"); + return getRuntimeServiceStatus(); + } + + throw new CliUsageError( + "runtime supports status, start, stop, install-service, uninstall-service, or service-status.", + ); +} + +async function runDesktopCommand(rest: string[]): Promise { + const args = [...rest]; + const sub = firstPositional(args) ?? "open"; + const appName = + readValue(args, ["--app-name"]) ?? resolveDefaultDesktopAppName(); + if (sub !== "open" && sub !== "launch" && sub !== "start") { + throw new CliUsageError("desktop supports open."); + } + + if (process.platform === "darwin") { + const result = spawnSync("open", ["-a", appName], { encoding: "utf8" }); + const detail = + typeof result.stderr === "string" && result.stderr.trim() + ? result.stderr.trim() + : typeof result.stdout === "string" && result.stdout.trim() + ? result.stdout.trim() + : `Unable to open ${appName}.`; + return { + ok: result.status === 0, + platform: process.platform, + appName, + message: result.status === 0 ? `Opened ${appName}.` : detail, + }; + } + + return { + ok: false, + platform: process.platform, + appName, + message: + "Launching ADE desktop from the CLI is currently supported on macOS.", + }; +} + +function resolveDefaultDesktopAppName(): string { + const explicit = process.env.ADE_DESKTOP_APP_NAME?.trim(); + if (explicit) return explicit; + const channel = process.env.ADE_PACKAGE_CHANNEL?.trim().toLowerCase(); + if (channel === "alpha") return "ADE Alpha"; + if (channel === "beta") return "ADE Beta"; + return "ADE"; +} + +async function runNativeRpcStdio(options: GlobalOptions): Promise { + const previousRole = process.env.ADE_DEFAULT_ROLE; + process.env.ADE_DEFAULT_ROLE = options.role; + const [{ createStdioTransport }] = await Promise.all([ + import("./transports/stdioTransport"), + ]); + let client: SocketJsonRpcClient | null = null; + let stop: ReturnType | null = null; + let unsubscribeNotifications: (() => void) | null = null; + try { + client = await connectMachineRuntimeDaemon(options); + const handler: JsonRpcHandler = async (request) => { + const method = typeof request.method === "string" ? request.method : ""; + if (!method) return null; + if (request.id === undefined) { + client?.notify(method, request.params); + return null; + } + if (!client) { + throw new Error("ADE runtime daemon is not connected."); + } + try { + return await client.request(method, request.params); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + if ( + (method === "shutdown" || method === "exit") && + message.includes("socket closed") + ) { + return {}; + } + throw error; + } + }; + stop = startJsonRpcServer(handler, createStdioTransport(), { + nonFatal: true, + }); + unsubscribeNotifications = client.onAnyNotification((method, params) => + stop?.notify(method, params), + ); + await new Promise((resolve) => { + let done = false; + const finish = () => { + if (done) return; + done = true; + resolve(); + }; + client?.onClose(finish); + process.stdin.once("end", finish); + process.stdin.once("close", finish); + }); + } finally { + unsubscribeNotifications?.(); + try { + stop?.(); + } catch {} + try { + client?.close(); + } catch {} + if (previousRole == null) delete process.env.ADE_DEFAULT_ROLE; + else process.env.ADE_DEFAULT_ROLE = previousRole; + } +} + +async function runServe( + rest: string[], + options: GlobalOptions, +): Promise { + const args = [...rest]; + if (readFlag(args, ["--install-service"])) { + const { installRuntimeService } = await import("./serviceManager"); + return installRuntimeService(); + } + if (readFlag(args, ["--uninstall-service"])) { + const { uninstallRuntimeService } = await import("./serviceManager"); + return uninstallRuntimeService(); + } + if (readFlag(args, ["--service-status"])) { + const { getRuntimeServiceStatus } = await import("./serviceManager"); + return getRuntimeServiceStatus(); + } + const [ + { resolveMachineAdeLayout }, + { ProjectRegistry }, + { ProjectScopeRegistry }, + { createMultiProjectRpcRequestHandler }, + ] = await Promise.all([ + import("./services/projects/machineLayout"), + import("./services/projects/projectRegistry"), + import("./services/projects/projectScope"), + import("./multiProjectRpcServer"), + ]); - const mcpHandler: JsonRpcHandler = async (request) => { - const method = typeof request.method === "string" ? request.method : ""; - const params = isRecord(request.params) ? request.params : {}; - if (method === "initialize") { - await ensureInitialized(); - const requestedVersion = asString(params.protocolVersion) ?? PROTOCOL_VERSION; - return { - protocolVersion: requestedVersion, - capabilities: { - tools: { - listChanged: false, - }, + const layout = resolveMachineAdeLayout(); + const rawSocketPath = + readValue(args, ["--socket"]) ?? + process.env.ADE_RPC_SOCKET_PATH?.trim() ?? + layout.socketPath; + const socketPath = isAdeMcpNamedPipePath(rawSocketPath) + ? rawSocketPath + : path.resolve(rawSocketPath); + const port = parseOptionalPort(readValue(args, ["--port"]), "--port"); + const syncEnabled = !readFlag(args, ["--no-sync"]); + const projectRegistry = new ProjectRegistry(layout); + type ProjectRecord = ReturnType< + InstanceType["list"] + >[number]; + const toMobileProjectSummary = ( + record: ProjectRecord, + overrides: Partial = {}, + ): SyncMobileProjectSummary => ({ + id: record.projectId, + displayName: record.displayName, + rootPath: record.rootPath, + defaultBaseRef: null, + lastOpenedAt: + record.lastOpenedAt > 0 + ? new Date(record.lastOpenedAt).toISOString() + : null, + laneCount: 0, + isAvailable: true, + isCached: true, + isOpen: false, + ...overrides, + }); + let scopeRegistry: InstanceType; + scopeRegistry = new ProjectScopeRegistry(projectRegistry, { + syncRuntime: { + enabled: syncEnabled, + hostStartupEnabled: true, + hostDiscoveryEnabled: true, + forceHostRole: true, + runtimeKind: "headless", + appVersion: VERSION, + localDeviceIdPath: path.join(layout.secretsDir, "sync-device-id"), + phonePairingStateDir: layout.secretsDir, + projectCatalogProvider: { + listProjects: async () => ({ + projects: projectRegistry + .list() + .map((record) => toMobileProjectSummary(record)), + }), + prepareProjectConnection: async ( + request: SyncProjectSwitchRequestPayload, + ): Promise => { + const requestedId = + typeof request.projectId === "string" + ? request.projectId.trim() + : ""; + const requestedRootPath = + typeof request.rootPath === "string" + ? path.resolve(request.rootPath) + : ""; + const record = + projectRegistry + .list() + .find( + (candidate) => + (requestedId.length > 0 && + candidate.projectId === requestedId) || + (requestedRootPath.length > 0 && + path.resolve(candidate.rootPath) === requestedRootPath), + ) ?? null; + const project = record + ? toMobileProjectSummary(record, { isOpen: true }) + : null; + if (!record) { + return { + ok: false, + message: "That project is not registered on this ADE machine.", + project, + }; + } + try { + const scope = await scopeRegistry.ensureSyncHost(record.projectId); + const syncService = scope?.runtime.syncService ?? null; + if (!scope || !syncService) { + return { + ok: false, + message: "Phone sync is not available for that project.", + project, + }; + } + syncService.setHostDiscoveryEnabled?.(true); + await syncService.setHostStartupEnabled?.(true); + await syncService.initialize(); + const lanes = await scope.runtime.laneService + .list({ includeArchived: false, includeStatus: false }) + .catch(() => []); + const laneCount = lanes.length; + const readyProject = toMobileProjectSummary(record, { + isOpen: true, + laneCount, + }); + const status = await syncService.getStatus(); + const connectInfo = status.pairingConnectInfo; + if (!connectInfo) { + return { + ok: false, + message: "Phone sync is not ready for that project yet.", + project: readyProject, + }; + } + return { + ok: true, + project: readyProject, + connection: { + authKind: "paired", + token: null, + pairedDeviceId: null, + hostIdentity: connectInfo.hostIdentity, + port: connectInfo.port, + addressCandidates: connectInfo.addressCandidates, + }, + }; + } catch (error) { + return { + ok: false, + message: + error instanceof Error + ? error.message + : "Unable to prepare phone sync for that project.", + project, + }; + } }, - serverInfo: { - name: "ade", - version: VERSION, + completeProjectConnection: async ( + request: SyncProjectSwitchRequestPayload, + result: SyncProjectSwitchResultPayload, + ): Promise => { + if (!result.ok) return; + const projectId = + typeof result.project?.id === "string" && result.project.id.trim() + ? result.project.id.trim() + : typeof request.projectId === "string" && + request.projectId.trim() + ? request.projectId.trim() + : null; + if (!projectId) return; + try { + projectRegistry.touch(projectId); + } catch { + // The mobile handoff already succeeded; a stale registry touch should + // not fail the sync protocol completion. + } }, + }, + }, + }); + const previousRole = process.env.ADE_DEFAULT_ROLE; + process.env.ADE_DEFAULT_ROLE = options.role; + + const states: HeadlessRpcServerState[] = []; + let done = false; + let resolveDone: (() => void) | null = null; + + const finish = () => { + if (done) return; + done = true; + resolveDone?.(); + }; + + const createHandler = () => + createMultiProjectRpcRequestHandler({ + serverVersion: VERSION, + projectRegistry, + scopeRegistry, + disposeScopesOnDispose: false, + onShutdown: finish, + }); + + const listen = async ( + server: net.Server, + target: string | { port: number; host: string }, + ): Promise => { + await new Promise((resolve, reject) => { + const handleListening = () => { + server.off("error", handleError); + resolve(); }; - } - if (method === "notifications/initialized" || method === "initialized") { - await ensureInitialized(); - return null; - } - await ensureInitialized(); - if (method === "tools/list") { - const listed = await callAde("ade/actions/list"); - const actions = isRecord(listed) && Array.isArray(listed.actions) - ? listed.actions.filter(isRecord) - : []; - return { - tools: actions - .map((action) => ({ - name: asString(action.name) ?? "", - description: asString(action.description) ?? "", - inputSchema: isRecord(action.inputSchema) ? action.inputSchema : { type: "object", properties: {} }, - })) - .filter((tool) => tool.name.length > 0 && isMcpToolVisible(tool.name)), + const handleError = (error: Error) => { + server.off("listening", handleListening); + reject(error); }; - } - if (method === "tools/call") { - const rawName = asString(params.name); - if (!rawName) { - throw new JsonRpcError(JsonRpcErrorCode.invalidParams, "tools/call requires a tool name."); - } - if (!isMcpToolVisible(rawName)) { - throw new JsonRpcError(JsonRpcErrorCode.methodNotFound, `Tool not available in this MCP scope: ${rawName}`); + server.once("listening", handleListening); + server.once("error", handleError); + if (typeof target === "string") { + server.listen(target); + } else { + server.listen(target.port, target.host); } - const result = await callAde("ade/actions/call", { - name: normalizeMcpAdeToolName(rawName), - arguments: isRecord(params.arguments) ? params.arguments : {}, - }); - const isError = isRecord(result) && result.ok === false; - return { - content: [ - { - type: "text", - text: formatMcpToolText(result), - }, - ], - structuredContent: result ?? null, - isError, - }; - } - if (method === "shutdown") { - return {}; - } - if (method === "exit") { - process.nextTick(() => process.exit(0)); - return {}; - } - throw new JsonRpcError(JsonRpcErrorCode.methodNotFound, `Method not found: ${method}`); + }); }; - const transport: JsonRpcTransport = { - onData(callback) { - process.stdin.on("data", (chunk) => callback(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk))); - }, - write(data) { - process.stdout.write(data); - }, - close() { - process.stdin.pause(); - }, - }; - const stop = startJsonRpcServer(mcpHandler, transport, { nonFatal: true }); + fs.mkdirSync(layout.adeDir, { recursive: true, mode: 0o700 }); + if (!isAdeMcpNamedPipePath(socketPath)) { + fs.mkdirSync(path.dirname(socketPath), { recursive: true, mode: 0o700 }); + try { + fs.unlinkSync(socketPath); + } catch {} + } + + const socketState = createHeadlessRpcServer(createHandler); + states.push(socketState); + await listen(socketState.server, socketPath); + if (!isAdeMcpNamedPipePath(socketPath)) { + try { + fs.chmodSync(socketPath, 0o600); + } catch {} + } + + let tcpUrl: string | null = null; + if (port != null) { + const tcpState = createHeadlessRpcServer(createHandler); + states.push(tcpState); + await listen(tcpState.server, { port, host: "127.0.0.1" }); + tcpUrl = `tcp://127.0.0.1:${port}`; + } + + if (syncEnabled) { + void scopeRegistry.ensureSyncHost().catch((error: unknown) => { + process.stderr.write( + `ade serve sync host failed: ${error instanceof Error ? error.message : String(error)}\n`, + ); + }); + } + + process.stderr.write( + `ade serve listening on ${socketPath}${tcpUrl ? ` and ${tcpUrl}` : ""}\n`, + ); + await new Promise((resolve) => { - let done = false; - const finish = () => { - if (done) return; - done = true; - resolve(); - }; - process.stdin.once("end", finish); - process.stdin.once("close", finish); + resolveDone = resolve; + process.once("SIGINT", finish); + process.once("SIGTERM", finish); }); - stop(); - try { adeHandler.dispose?.(); } catch {} - try { runtime.dispose(); } catch {} + + for (const state of states) { + stopHeadlessRpcServer(state); + } + await scopeRegistry.disposeAll(); + if (!isAdeMcpNamedPipePath(socketPath)) { + try { + fs.unlinkSync(socketPath); + } catch {} + } if (previousRole == null) delete process.env.ADE_DEFAULT_ROLE; else process.env.ADE_DEFAULT_ROLE = previousRole; + return null; +} + +function isFailedServiceManagerResult(value: unknown): boolean { + if (!value || typeof value !== "object" || Array.isArray(value)) return false; + const record = value as Record; + return ( + record.ok === false && + (record.action === "install" || record.action === "uninstall") && + typeof record.serviceName === "string" + ); +} + +async function runInit( + targetPath: string | null, +): Promise<{ project: unknown; registryPath: string }> { + const [{ resolveMachineAdeLayout }, { ProjectRegistry }] = await Promise.all([ + import("./services/projects/machineLayout"), + import("./services/projects/projectRegistry"), + ]); + const layout = resolveMachineAdeLayout(); + const registry = new ProjectRegistry(layout); + const project = registry.add(path.resolve(targetPath ?? process.cwd())); + return { + project, + registryPath: registry.path, + }; } function unwrapToolResult(result: unknown): unknown { if (!isRecord(result)) return result; if (result.isError === true) { const structured = result.structuredContent; - const message = isRecord(structured) && isRecord(structured.error) - ? asString(structured.error.message) ?? "ADE tool call failed." - : "ADE tool call failed."; + const message = + isRecord(structured) && isRecord(structured.error) + ? (asString(structured.error.message) ?? "ADE tool call failed.") + : "ADE tool call failed."; throw new CliToolError(message, structured ?? result); } if (result.ok === false && isRecord(result.error)) { @@ -5410,8 +11043,10 @@ function unwrapToolResult(result: unknown): unknown { function unwrapActionEnvelope(value: unknown): unknown { if (!isRecord(value)) return value; if ( - Object.prototype.hasOwnProperty.call(value, "result") - && (asString(value.domain) || asString(value.action) || Object.prototype.hasOwnProperty.call(value, "statusHint")) + Object.prototype.hasOwnProperty.call(value, "result") && + (asString(value.domain) || + asString(value.action) || + Object.prototype.hasOwnProperty.call(value, "statusHint")) ) { return value.result; } @@ -5421,7 +11056,8 @@ function unwrapActionEnvelope(value: unknown): unknown { function missionIdFromCreateResult(value: unknown): string { const result = unwrapActionEnvelope(value); const mission = firstRecord(result, ["mission"]); - const id = asString(mission?.id) ?? (isRecord(result) ? asString(result.id) : null); + const id = + asString(mission?.id) ?? (isRecord(result) ? asString(result.id) : null); return requireValue(id ?? null, "created mission id"); } @@ -5429,11 +11065,14 @@ function newestRunFromListResult(value: unknown): JsonObject | null { const result = unwrapActionEnvelope(value); const runs = firstArray(result, ["runs", "items", "results"]); if (runs.length === 0) return null; - return [...runs].sort((left, right) => { - const leftAt = asString(left.startedAt) ?? asString(left.createdAt) ?? ""; - const rightAt = asString(right.startedAt) ?? asString(right.createdAt) ?? ""; - return rightAt.localeCompare(leftAt); - })[0] ?? null; + return ( + [...runs].sort((left, right) => { + const leftAt = asString(left.startedAt) ?? asString(left.createdAt) ?? ""; + const rightAt = + asString(right.startedAt) ?? asString(right.createdAt) ?? ""; + return rightAt.localeCompare(leftAt); + })[0] ?? null + ); } function runFromStartResult(value: unknown): JsonObject | null { @@ -5460,7 +11099,10 @@ function graphFromResult(value: unknown): JsonObject | null { if (!isRecord(result)) return null; if (hasRunGraphShape(result)) return result; const nestedGraph = isRecord(result.graph) ? result.graph : null; - const graph = nestedGraph && hasRunGraphShape(nestedGraph) ? nestedGraph : nestedGraph ?? result; + const graph = + nestedGraph && hasRunGraphShape(nestedGraph) + ? nestedGraph + : (nestedGraph ?? result); return isRecord(graph) ? graph : null; } @@ -5470,11 +11112,12 @@ function runFromGraphResult(value: unknown): JsonObject | null { } function hasRunGraphShape(value: unknown): boolean { - return isRecord(value) && ( - isRecord(value.run) - || Array.isArray(value.steps) - || Array.isArray(value.attempts) - || Array.isArray(value.timeline) + return ( + isRecord(value) && + (isRecord(value.run) || + Array.isArray(value.steps) || + Array.isArray(value.attempts) || + Array.isArray(value.timeline)) ); } @@ -5490,7 +11133,8 @@ function runIdFromWatchValues(values: JsonObject): string { } function renderLaneGraph(result: unknown): string { - const lanesRaw = isRecord(result) && Array.isArray(result.lanes) ? result.lanes : []; + const lanesRaw = + isRecord(result) && Array.isArray(result.lanes) ? result.lanes : []; const lanes = lanesRaw.filter(isRecord); if (lanes.length === 0) return "ADE lanes\n(no lanes)"; @@ -5510,10 +11154,14 @@ function renderLaneGraph(result: unknown): string { } for (const children of byParent.values()) { children.sort((left, right) => { - const leftDepth = typeof left.stackDepth === "number" ? left.stackDepth : 0; - const rightDepth = typeof right.stackDepth === "number" ? right.stackDepth : 0; + const leftDepth = + typeof left.stackDepth === "number" ? left.stackDepth : 0; + const rightDepth = + typeof right.stackDepth === "number" ? right.stackDepth : 0; if (leftDepth !== rightDepth) return leftDepth - rightDepth; - return String(left.name ?? left.id ?? "").localeCompare(String(right.name ?? right.id ?? "")); + return String(left.name ?? left.id ?? "").localeCompare( + String(right.name ?? right.id ?? ""), + ); }); } @@ -5525,9 +11173,17 @@ function renderLaneGraph(result: unknown): string { const archived = asString(lane.archivedAt) ? " archived" : ""; const id = asString(lane.id); const idSuffix = id ? ` (id: ${id})` : ""; - lines.push(`${prefix}${isLast ? "\\- " : "|- "}${name}${idSuffix}${branch ? ` [${branch}]` : ""}${status ? ` ${status}` : ""}${archived}`); - const children = id ? byParent.get(id) ?? [] : []; - children.forEach((child, index) => visit(child, `${prefix}${isLast ? " " : "| "}`, index === children.length - 1)); + lines.push( + `${prefix}${isLast ? "\\- " : "|- "}${name}${idSuffix}${branch ? ` [${branch}]` : ""}${status ? ` ${status}` : ""}${archived}`, + ); + const children = id ? (byParent.get(id) ?? []) : []; + children.forEach((child, index) => + visit( + child, + `${prefix}${isLast ? " " : "| "}`, + index === children.length - 1, + ), + ); }; const roots = byParent.get("") ?? []; roots.forEach((lane, index) => visit(lane, "", index === roots.length - 1)); @@ -5544,12 +11200,23 @@ function truncateCell(value: string, width = 42): string { function cell(value: unknown, width = 42): string { if (value == null) return ""; if (typeof value === "boolean") return value ? "yes" : "no"; - if (typeof value === "number") return Number.isFinite(value) ? String(value) : ""; + if (typeof value === "number") + return Number.isFinite(value) ? String(value) : ""; if (typeof value === "string") return truncateCell(value, width); - if (Array.isArray(value)) return truncateCell(value.map((entry) => cell(entry, 18)).filter(Boolean).join(", "), width); + if (Array.isArray(value)) + return truncateCell( + value + .map((entry) => cell(entry, 18)) + .filter(Boolean) + .join(", "), + width, + ); if (isRecord(value)) { - const id = asString(value.id) ?? asString(value.name) ?? asString(value.title); - return id ? truncateCell(id, width) : truncateCell(JSON.stringify(value), width); + const id = + asString(value.id) ?? asString(value.name) ?? asString(value.title); + return id + ? truncateCell(id, width) + : truncateCell(JSON.stringify(value), width); } return truncateCell(String(value), width); } @@ -5559,7 +11226,9 @@ function formatAutomationRunDetail(value: unknown): string { const run = isRecord(value.run) ? value.run : value; const actions = Array.isArray(value.actions) ? value.actions - : Array.isArray(run.actions) ? run.actions : []; + : Array.isArray(run.actions) + ? run.actions + : []; const header = renderKeyValues("ADE automation run", [ ["id", run.id], ["rule", run.automationId ?? run.ruleId], @@ -5572,15 +11241,21 @@ function formatAutomationRunDetail(value: unknown): string { const rows = actions .filter((action): action is JsonObject => isRecord(action)) .map((action) => { - const kind = typeof action.kind === "string" ? action.kind - : typeof action.type === "string" ? action.type - : "action"; + const kind = + typeof action.kind === "string" + ? action.kind + : typeof action.type === "string" + ? action.type + : "action"; const status = typeof action.status === "string" ? action.status : "?"; - const error = typeof action.errorMessage === "string" ? action.errorMessage : ""; + const error = + typeof action.errorMessage === "string" ? action.errorMessage : ""; const output = typeof action.output === "string" ? action.output : ""; const isLaneSetup = kind === "lane-setup"; const note = error - ? (isLaneSetup ? `FAILED: ${error}` : error) + ? isLaneSetup + ? `FAILED: ${error}` + : error : isLaneSetup && output ? `created lane: ${output}` : output; @@ -5591,22 +11266,46 @@ function formatAutomationRunDetail(value: unknown): string { return [header, "", "Actions", table].join("\n"); } -function renderKeyValues(title: string, entries: Array<[string, unknown]>): string { - const rows = entries.filter(([, value]) => value !== undefined && value !== null && value !== ""); +function renderKeyValues( + title: string, + entries: Array<[string, unknown]>, +): string { + const rows = entries.filter( + ([, value]) => value !== undefined && value !== null && value !== "", + ); const labelWidth = Math.max(0, ...rows.map(([label]) => label.length)); return [ title, - ...rows.map(([label, value]) => `${label.padEnd(labelWidth)} ${cell(value, 96)}`), + ...rows.map( + ([label, value]) => `${label.padEnd(labelWidth)} ${cell(value, 96)}`, + ), ].join("\n"); } -function renderTable(headers: string[], rows: unknown[][], emptyMessage: string): string { +function renderTable( + headers: string[], + rows: unknown[][], + emptyMessage: string, +): string { if (rows.length === 0) return emptyMessage; - const widths = headers.map((header, index) => Math.max( - header.length, - ...rows.map((row) => cell(row[index], index === headers.length - 1 ? 64 : 28).length), - )); - const renderRow = (row: unknown[]) => row.map((entry, index) => cell(entry, index === headers.length - 1 ? 64 : 28).padEnd(widths[index] ?? 0)).join(" ").trimEnd(); + const widths = headers.map((header, index) => + Math.max( + header.length, + ...rows.map( + (row) => + cell(row[index], index === headers.length - 1 ? 64 : 28).length, + ), + ), + ); + const renderRow = (row: unknown[]) => + row + .map((entry, index) => + cell(entry, index === headers.length - 1 ? 64 : 28).padEnd( + widths[index] ?? 0, + ), + ) + .join(" ") + .trimEnd(); return [ renderRow(headers), widths.map((width) => "-".repeat(width)).join(" "), @@ -5636,35 +11335,63 @@ function firstRecord(value: unknown, keys: string[]): JsonObject | null { function statusWord(value: unknown): string { const raw = cell(value, 24).toLowerCase(); if (!raw) return ""; - if (["success", "passing", "passed", "completed", "ready", "clean", "ok"].includes(raw)) return "OK"; - if (["failure", "failed", "failing", "error", "blocked", "dirty"].includes(raw)) return "FAIL"; - if (["pending", "running", "in_progress", "queued", "active"].includes(raw)) return "WAIT"; + if ( + [ + "success", + "passing", + "passed", + "completed", + "ready", + "clean", + "ok", + ].includes(raw) + ) + return "OK"; + if ( + ["failure", "failed", "failing", "error", "blocked", "dirty"].includes(raw) + ) + return "FAIL"; + if (["pending", "running", "in_progress", "queued", "active"].includes(raw)) + return "WAIT"; return raw.toUpperCase(); } function formatActionsList(value: unknown): string { - const actionResult = isRecord(value) && isRecord(value.actions) ? value.actions : value; + const actionResult = + isRecord(value) && isRecord(value.actions) ? value.actions : value; const actions = firstArray(actionResult, ["actions"]); if (actions.length === 0) return "ADE actions\n(no actions)"; const byDomain = new Map(); for (const action of actions) { const name = asString(action.name); - const domain = asString(action.domain) ?? (name?.includes(".") ? name.split(".")[0] : null) ?? "core"; + const domain = + asString(action.domain) ?? + (name?.includes(".") ? name.split(".")[0] : null) ?? + "core"; const list = byDomain.get(domain) ?? []; list.push(action); byDomain.set(domain, list); } const lines = [ "ADE actions", - "Use: ade actions run --input-json '{\"key\":\"value\"}'", - "For multi-parameter methods: --args-list-json '[\"first\",{\"second\":true}]'", + 'Use: ade actions run --input-json \'{"key":"value"}\'', + 'For multi-parameter methods: --args-list-json \'["first",{"second":true}]\'', ]; - for (const [domain, list] of [...byDomain.entries()].sort(([left], [right]) => left.localeCompare(right))) { + for (const [domain, list] of [...byDomain.entries()].sort(([left], [right]) => + left.localeCompare(right), + )) { lines.push("", `${domain}:`); - for (const action of list.sort((left, right) => cell(left.action ?? left.name).localeCompare(cell(right.action ?? right.name)))) { - const name = asString(action.action) ?? asString(action.name) ?? "(unknown)"; + for (const action of list.sort((left, right) => + cell(left.action ?? left.name).localeCompare( + cell(right.action ?? right.name), + ), + )) { + const name = + asString(action.action) ?? asString(action.name) ?? "(unknown)"; const description = asString(action.description) ?? ""; - lines.push(` ${name}${description ? ` - ${truncateCell(description, 86)}` : ""}`); + lines.push( + ` ${name}${description ? ` - ${truncateCell(description, 86)}` : ""}`, + ); } } return lines.join("\n"); @@ -5701,7 +11428,9 @@ function formatPrList(value: unknown): string { function formatPrChecks(value: unknown): string { const checks = firstArray(value, ["checks", "items"]); const summary = isRecord(value) ? value.summary : null; - const header = summary ? `ADE PR checks - ${cell(summary, 80)}` : "ADE PR checks"; + const header = summary + ? `ADE PR checks - ${cell(summary, 80)}` + : "ADE PR checks"; return `${header}\n${renderTable( ["status", "name", "details"], checks.map((check) => [ @@ -5718,46 +11447,67 @@ function formatPrComments(value: unknown): string { const comments = firstArray(value, ["comments", "issueComments"]); const lines = ["ADE PR comments"]; if (threads.length > 0) { - lines.push("", renderTable( - ["thread", "state", "file", "comment"], - threads.map((thread) => { - const threadComments = Array.isArray(thread.comments) ? thread.comments.filter(isRecord) : []; - const first = threadComments[0] ?? {}; - return [ - thread.id, - thread.isResolved ? "resolved" : "open", - `${cell(thread.path, 34)}${thread.line ? `:${thread.line}` : ""}`, - first.body ?? thread.body, - ]; - }), - "(no review threads)", - )); + lines.push( + "", + renderTable( + ["thread", "state", "file", "comment"], + threads.map((thread) => { + const threadComments = Array.isArray(thread.comments) + ? thread.comments.filter(isRecord) + : []; + const first = threadComments[0] ?? {}; + return [ + thread.id, + thread.isResolved ? "resolved" : "open", + `${cell(thread.path, 34)}${thread.line ? `:${thread.line}` : ""}`, + first.body ?? thread.body, + ]; + }), + "(no review threads)", + ), + ); } if (comments.length > 0) { - lines.push("", renderTable( - ["id", "author", "comment"], - comments.map((comment) => [comment.id, comment.author ?? comment.user, comment.body]), - "(no issue comments)", - )); + lines.push( + "", + renderTable( + ["id", "author", "comment"], + comments.map((comment) => [ + comment.id, + comment.author ?? comment.user, + comment.body, + ]), + "(no issue comments)", + ), + ); } - if (threads.length === 0 && comments.length === 0) lines.push("(no comments)"); + if (threads.length === 0 && comments.length === 0) + lines.push("(no comments)"); return lines.join("\n"); } function phaseKeysFromMission(mission: JsonObject): string { const metadata = isRecord(mission.metadata) ? mission.metadata : {}; - const phaseConfiguration = isRecord(metadata.phaseConfiguration) ? metadata.phaseConfiguration : {}; + const phaseConfiguration = isRecord(metadata.phaseConfiguration) + ? metadata.phaseConfiguration + : {}; const phaseKeys = Array.isArray(phaseConfiguration.phaseKeys) ? phaseConfiguration.phaseKeys : Array.isArray(phaseConfiguration.phases) - ? phaseConfiguration.phases.filter(isRecord).map((phase) => phase.phaseKey) + ? phaseConfiguration.phases + .filter(isRecord) + .map((phase) => phase.phaseKey) : []; - return phaseKeys.map((key) => cell(key, 24)).filter(Boolean).join(" -> "); + return phaseKeys + .map((key) => cell(key, 24)) + .filter(Boolean) + .join(" -> "); } function formatMissionDetail(value: unknown): string { const result = unwrapActionEnvelope(value); - const mission = firstRecord(result, ["mission"]) ?? (isRecord(result) ? result : {}); + const mission = + firstRecord(result, ["mission"]) ?? (isRecord(result) ? result : {}); const steps = firstArray(mission, ["steps"]); const phaseKeys = phaseKeysFromMission(mission); return [ @@ -5779,13 +11529,16 @@ function formatMissionDetail(value: unknown): string { steps.map((step) => [ step.index ?? step.stepIndex, step.status, - step.phaseKey ?? (isRecord(step.metadata) ? step.metadata.phaseKey : null), + step.phaseKey ?? + (isRecord(step.metadata) ? step.metadata.phaseKey : null), step.title, ]), "(no steps)", )}` : "", - ].filter(Boolean).join("\n"); + ] + .filter(Boolean) + .join("\n"); } function formatMissionList(value: unknown): string { @@ -5825,7 +11578,8 @@ function formatMissionRuns(value: unknown): string { function formatMissionGraph(value: unknown): string { const result = unwrapActionEnvelope(value); - const graph = isRecord(result) && isRecord(result.graph) ? result.graph : result; + const graph = + isRecord(result) && isRecord(result.graph) ? result.graph : result; const run = firstRecord(graph, ["run"]) ?? {}; const steps = firstArray(graph, ["steps"]); const attempts = firstArray(graph, ["attempts"]); @@ -5847,25 +11601,30 @@ function formatMissionGraph(value: unknown): string { steps.map((step) => [ step.id ?? step.stepKey, step.status, - step.phaseKey ?? (isRecord(step.metadata) ? step.metadata.phaseKey : null), + step.phaseKey ?? + (isRecord(step.metadata) ? step.metadata.phaseKey : null), step.title, ]), "(no steps)", )}` : "", - ].filter(Boolean).join("\n"); + ] + .filter(Boolean) + .join("\n"); } function formatMissionWatch(value: unknown): string { const result = isRecord(value) ? value : {}; const created = unwrapActionEnvelope(result.created); const started = unwrapActionEnvelope(result.started ?? result.result); - const mission = missionFromResult(result.mission) - ?? missionFromResult(created) - ?? missionFromResult(started) - ?? {}; + const mission = + missionFromResult(result.mission) ?? + missionFromResult(created) ?? + missionFromResult(started) ?? + {}; const runsResult = unwrapActionEnvelope(result.runs); - const newestRun = newestRunFromListResult(runsResult) ?? runFromStartResult(started); + const newestRun = + newestRunFromListResult(runsResult) ?? runFromStartResult(started); const graphResult = unwrapActionEnvelope(result.graph); const graph = graphFromResult(graphResult) ?? {}; const wait = firstRecord(graphResult, ["wait"]); @@ -5887,16 +11646,20 @@ function formatMissionWatch(value: unknown): string { ]), ]; if (graphSteps.length > 0) { - parts.push("", renderTable( - ["step", "status", "phase", "title"], - graphSteps.map((step) => [ - step.id ?? step.stepKey, - step.status, - step.phaseKey ?? (isRecord(step.metadata) ? step.metadata.phaseKey : null), - step.title, - ]), - "(no steps)", - )); + parts.push( + "", + renderTable( + ["step", "status", "phase", "title"], + graphSteps.map((step) => [ + step.id ?? step.stepKey, + step.status, + step.phaseKey ?? + (isRecord(step.metadata) ? step.metadata.phaseKey : null), + step.title, + ]), + "(no steps)", + ), + ); } return parts.join("\n"); } @@ -5905,7 +11668,11 @@ function formatFileTree(value: unknown): string { const entries = firstArray(value, ["entries", "nodes", "items", "children"]); return renderTable( ["type", "path", "size"], - entries.map((entry) => [entry.type ?? (entry.isDirectory ? "dir" : "file"), entry.path ?? entry.name, entry.sizeBytes ?? entry.size]), + entries.map((entry) => [ + entry.type ?? (entry.isDirectory ? "dir" : "file"), + entry.path ?? entry.name, + entry.sizeBytes ?? entry.size, + ]), "ADE files\n(no entries)", ); } @@ -5913,7 +11680,12 @@ function formatFileTree(value: unknown): string { function formatFileRead(value: unknown): string { if (typeof value === "string") return value; if (!isRecord(value)) return JSON.stringify(value, null, 2); - const text = typeof value.text === "string" ? value.text : typeof value.content === "string" ? value.content : null; + const text = + typeof value.text === "string" + ? value.text + : typeof value.content === "string" + ? value.content + : null; return text ?? JSON.stringify(value, null, 2); } @@ -5921,7 +11693,11 @@ function formatFilesSearch(value: unknown): string { const matches = firstArray(value, ["matches", "results", "items"]); return renderTable( ["file", "line", "match"], - matches.map((match) => [match.path ?? match.filePath, match.line ?? match.lineNumber, match.preview ?? match.text ?? match.match]), + matches.map((match) => [ + match.path ?? match.filePath, + match.line ?? match.lineNumber, + match.preview ?? match.text ?? match.match, + ]), "ADE file search\n(no matches)", ); } @@ -5941,7 +11717,13 @@ function formatDiffSummary(value: unknown): string { } function formatRunTable(value: unknown, title: string): string { - const rows = firstArray(value, ["processes", "definitions", "runtime", "runs", "items"]); + const rows = firstArray(value, [ + "processes", + "definitions", + "runtime", + "runs", + "items", + ]); return `${title}\n${renderTable( ["id", "status", "lane", "command"], rows.map((row) => [ @@ -5958,7 +11740,12 @@ function formatChatList(value: unknown): string { const sessions = firstArray(value, ["sessions", "chats", "items"]); return renderTable( ["session", "provider", "lane", "title"], - sessions.map((session) => [session.id ?? session.sessionId, session.provider ?? session.modelId, session.laneId, session.title]), + sessions.map((session) => [ + session.id ?? session.sessionId, + session.provider ?? session.modelId, + session.laneId, + session.title, + ]), "ADE chats\n(no sessions)", ); } @@ -5967,7 +11754,12 @@ function formatTestsRuns(value: unknown): string { const runs = firstArray(value, ["runs", "items"]); return renderTable( ["run", "status", "suite", "duration"], - runs.map((run) => [run.id ?? run.runId, statusWord(run.status), run.suiteId ?? run.suiteName, run.durationMs]), + runs.map((run) => [ + run.id ?? run.runId, + statusWord(run.status), + run.suiteId ?? run.suiteName, + run.durationMs, + ]), "ADE test runs\n(no runs)", ); } @@ -5976,21 +11768,35 @@ function formatProofList(value: unknown): string { const artifacts = firstArray(value, ["artifacts", "items"]); return renderTable( ["kind", "created", "title", "path"], - artifacts.map((artifact) => [artifact.kind ?? artifact.type, artifact.createdAt, artifact.title ?? artifact.name, artifact.path ?? artifact.uri]), + artifacts.map((artifact) => [ + artifact.kind ?? artifact.type, + artifact.createdAt, + artifact.title ?? artifact.name, + artifact.path ?? artifact.uri, + ]), "ADE proof artifacts\n(no artifacts)", ); } function formatIosSimStatus(value: unknown): string { const status = isRecord(value) ? value : {}; - const tools = Array.isArray(status.tools) ? status.tools.filter(isRecord) : []; + const tools = Array.isArray(status.tools) + ? status.tools.filter(isRecord) + : []; const activeDevice = isRecord(status.activeDevice) ? status.activeDevice : {}; - const activeSession = isRecord(status.activeSession) ? status.activeSession : {}; + const activeSession = isRecord(status.activeSession) + ? status.activeSession + : {}; return [ renderKeyValues("ADE iOS simulator", [ ["supported", status.supported], ["platform", status.platform], - ["active device", activeDevice.name ? `${activeDevice.name} (${activeDevice.state})` : null], + [ + "active device", + activeDevice.name + ? `${activeDevice.name} (${activeDevice.state})` + : null, + ], ["active app", activeSession.bundleId], ["mode", activeSession.mode], ["chat session", activeSession.chatSessionId], @@ -5998,26 +11804,44 @@ function formatIosSimStatus(value: unknown): string { "", renderTable( ["tool", "ready", "detail"], - tools.map((tool) => [tool.name, tool.available ? "yes" : "no", tool.detail]), + tools.map((tool) => [ + tool.name, + tool.available ? "yes" : "no", + tool.detail, + ]), "Tools\n(none)", ), ].join("\n"); } function formatIosSimDevices(value: unknown): string { - const devices = Array.isArray(value) ? value.filter(isRecord) : firstArray(value, ["devices", "items"]); + const devices = Array.isArray(value) + ? value.filter(isRecord) + : firstArray(value, ["devices", "items"]); return renderTable( ["udid", "device", "runtime", "state"], - devices.map((device) => [device.udid, device.name, device.runtime, device.state]), + devices.map((device) => [ + device.udid, + device.name, + device.runtime, + device.state, + ]), "ADE iOS simulators\n(no installed simulators)", ); } function formatIosSimApps(value: unknown): string { - const targets = Array.isArray(value) ? value.filter(isRecord) : firstArray(value, ["targets", "apps", "items"]); + const targets = Array.isArray(value) + ? value.filter(isRecord) + : firstArray(value, ["targets", "apps", "items"]); return renderTable( ["target", "kind", "name", "bundle"], - targets.map((target) => [target.id, target.kind, target.name, target.bundleId ?? target.detail]), + targets.map((target) => [ + target.id, + target.kind, + target.name, + target.bundleId ?? target.detail, + ]), "ADE iOS launchable apps\n(no apps)", ); } @@ -6048,17 +11872,38 @@ function formatIosSimStream(value: unknown): string { function formatIosSimSnapshot(value: unknown): string { const snapshot = isRecord(value) ? value : {}; - const screenshot = isRecord(snapshot.screenshot) ? snapshot.screenshot : snapshot; + const screenshot = isRecord(snapshot.screenshot) + ? snapshot.screenshot + : snapshot; const screen = isRecord(snapshot.screen) ? snapshot.screen : {}; - const providers = Array.isArray(snapshot.providers) ? snapshot.providers.filter(isRecord) : []; - const elements = Array.isArray(snapshot.elements) ? snapshot.elements.filter(isRecord) : []; - const providerSummary = providers.map((provider) => `${provider.source}:${provider.available ? provider.elementCount ?? "ok" : "unavailable"}`).join(", "); + const providers = Array.isArray(snapshot.providers) + ? snapshot.providers.filter(isRecord) + : []; + const elements = Array.isArray(snapshot.elements) + ? snapshot.elements.filter(isRecord) + : []; + const providerSummary = providers + .map( + (provider) => + `${provider.source}:${provider.available ? (provider.elementCount ?? "ok") : "unavailable"}`, + ) + .join(", "); return [ renderKeyValues("ADE iOS simulator snapshot", [ ["device", snapshot.deviceUdid], ["captured", snapshot.capturedAt], - ["screenshot", screenshot.width && screenshot.height ? `${screenshot.width}x${screenshot.height}` : null], - ["screen", screen.width && screen.height ? `${screen.width}x${screen.height} @${screen.scale ?? 1}x` : null], + [ + "screenshot", + screenshot.width && screenshot.height + ? `${screenshot.width}x${screenshot.height}` + : null, + ], + [ + "screen", + screen.width && screen.height + ? `${screen.width}x${screen.height} @${screen.scale ?? 1}x` + : null, + ], ["elements", elements.length], ["providers", providerSummary], ]), @@ -6066,25 +11911,42 @@ function formatIosSimSnapshot(value: unknown): string { elements.length ? renderTable( ["id", "source", "label", "source file"], - elements.slice(0, 20).map((element) => [ - element.id, - element.source, - element.label ?? element.identifier ?? element.componentId, - element.sourceFile ? `${element.sourceFile}${element.sourceLine ? `:${element.sourceLine}` : ""}` : "", - ]), + elements + .slice(0, 20) + .map((element) => [ + element.id, + element.source, + element.label ?? element.identifier ?? element.componentId, + element.sourceFile + ? `${element.sourceFile}${element.sourceLine ? `:${element.sourceLine}` : ""}` + : "", + ]), "", ) : "", - ].filter(Boolean).join("\n"); + ] + .filter(Boolean) + .join("\n"); } function formatIosSimSelection(value: unknown): string { - const item = firstRecord(value, ["item", "selection"]) ?? (isRecord(value) ? value : {}); + const item = + firstRecord(value, ["item", "selection"]) ?? (isRecord(value) ? value : {}); const metadata = isRecord(item.metadata) ? item.metadata : {}; return renderKeyValues("ADE iOS simulator selection", [ ["component", item.componentId], - ["source", isRecord(value) ? value.source ?? metadata.screenElementSource : metadata.screenElementSource], - ["file", item.sourceFile ? `${item.sourceFile}${item.sourceLine ? `:${item.sourceLine}` : ""}` : null], + [ + "source", + isRecord(value) + ? (value.source ?? metadata.screenElementSource) + : metadata.screenElementSource, + ], + [ + "file", + item.sourceFile + ? `${item.sourceFile}${item.sourceLine ? `:${item.sourceLine}` : ""}` + : null, + ], ["identifier", item.accessibilityIdentifier], ["chat session", metadata.chatSessionId], ["selected", item.selectedAt], @@ -6096,14 +11958,23 @@ function formatIosSimPreview(value: unknown): string { const targets = value.filter(isRecord); return renderTable( ["index", "title", "file", "kind"], - targets.map((target) => [target.previewDefinitionIndexInFile, target.title, target.sourceFilePath ?? target.sourceFile, target.kind]), + targets.map((target) => [ + target.previewDefinitionIndexInFile, + target.title, + target.sourceFilePath ?? target.sourceFile, + target.kind, + ]), "ADE iOS previews\n(no #Preview definitions found)", ); } const record = isRecord(value) ? value : {}; const capability = isRecord(record.capability) ? record.capability : record; - const steps = Array.isArray(capability.setupSteps) ? capability.setupSteps.join("; ") : null; - const selectedWindow = isRecord(capability.selectedWindow) ? capability.selectedWindow : {}; + const steps = Array.isArray(capability.setupSteps) + ? capability.setupSteps.join("; ") + : null; + const selectedWindow = isRecord(capability.selectedWindow) + ? capability.selectedWindow + : {}; return renderKeyValues("ADE iOS Preview Lab", [ ["supported", capability.supported ?? record.ok], ["xcode", capability.xcodeVersion], @@ -6136,7 +12007,9 @@ function formatMacosVmStatus(value: unknown): string { ]); } const provider = isRecord(status.activeProvider) ? status.activeProvider : {}; - const tools = Array.isArray(status.tools) ? status.tools.filter(isRecord) : []; + const tools = Array.isArray(status.tools) + ? status.tools.filter(isRecord) + : []; const laneVm = isRecord(status.laneVm) ? status.laneVm : null; const vms = Array.isArray(status.vms) ? status.vms.filter(isRecord) : []; const lines = [ @@ -6147,7 +12020,12 @@ function formatMacosVmStatus(value: unknown): string { ["provider", provider.kind], ["provider ready", provider.available], ["provider detail", provider.detail], - ["lane VM", laneVm ? `${laneVm.name ?? laneVm.id} (${laneVm.state ?? "unknown"})` : null], + [ + "lane VM", + laneVm + ? `${laneVm.name ?? laneVm.id} (${laneVm.state ?? "unknown"})` + : null, + ], ["guest path", laneVm?.guestSharedPath], ["host path", laneVm?.sharedDirectory ?? laneVm?.laneRoot], ["ssh", laneVm?.sshCommand], @@ -6156,13 +12034,22 @@ function formatMacosVmStatus(value: unknown): string { "", renderTable( ["lane", "vm", "state", "host path"], - vms.map((vm) => [vm.laneName ?? vm.laneId, vm.name, vm.state, vm.sharedDirectory ?? vm.laneRoot]), + vms.map((vm) => [ + vm.laneName ?? vm.laneId, + vm.name, + vm.state, + vm.sharedDirectory ?? vm.laneRoot, + ]), "Lane VMs\n(none)", ), "", renderTable( ["tool", "ready", "detail"], - tools.map((tool) => [tool.name, tool.available ? "yes" : "no", tool.detail]), + tools.map((tool) => [ + tool.name, + tool.available ? "yes" : "no", + tool.detail, + ]), "Tools\n(none)", ), ]; @@ -6171,7 +12058,9 @@ function formatMacosVmStatus(value: unknown): string { function formatMacosVmSharePolicy(value: unknown): string { const policy = isRecord(value) ? value : {}; - const excludedPaths = Array.isArray(policy.excludedPaths) ? policy.excludedPaths.filter((entry) => typeof entry === "string") : []; + const excludedPaths = Array.isArray(policy.excludedPaths) + ? policy.excludedPaths.filter((entry) => typeof entry === "string") + : []; return renderKeyValues("ADE macOS VM share policy", [ ["allowed", policy.allowed], ["mode", policy.syncMode], @@ -6188,7 +12077,10 @@ function formatMacosVmSharePolicy(value: unknown): string { function formatMacosVmGuide(value: unknown): string { if (isRecord(value) && typeof value.text === "string") return value.text; - return renderKeyValues("ADE macOS VM guide", Object.entries(isRecord(value) ? value : {}).slice(0, 24)); + return renderKeyValues( + "ADE macOS VM guide", + Object.entries(isRecord(value) ? value : {}).slice(0, 24), + ); } function formatMacosVmCapture(value: unknown): string { @@ -6203,7 +12095,10 @@ function formatMacosVmCapture(value: unknown): string { ["mode", capture.captureMode], ["window", window.windowTitle], ["process", window.processName], - ["frame", frame ? `${frame.x},${frame.y} ${frame.width}x${frame.height}` : null], + [ + "frame", + frame ? `${frame.x},${frame.y} ${frame.width}x${frame.height}` : null, + ], ["captured", capture.capturedAt], ["image data", capture.dataUrl ? "included" : null], ]); @@ -6213,13 +12108,20 @@ function formatMacosVmSelection(value: unknown): string { const result = isRecord(value) ? value : {}; const item = isRecord(result.item) ? result.item : {}; const metadata = isRecord(item.metadata) ? item.metadata : {}; - const selectedPoint = isRecord(metadata.selectedPoint) ? metadata.selectedPoint : {}; + const selectedPoint = isRecord(metadata.selectedPoint) + ? metadata.selectedPoint + : {}; const screenshot = isRecord(result.screenshot) ? result.screenshot : {}; return renderKeyValues("ADE macOS VM selection", [ ["source", result.source], ["lane", item.laneId], ["vm", item.vmName], - ["point", selectedPoint.x != null && selectedPoint.y != null ? `${selectedPoint.x},${selectedPoint.y}` : null], + [ + "point", + selectedPoint.x != null && selectedPoint.y != null + ? `${selectedPoint.x},${selectedPoint.y}` + : null, + ], ["coordinate space", selectedPoint.coordinateSpace], ["guest path", item.guestLanePath], ["host path", item.hostLanePath], @@ -6230,10 +12132,14 @@ function formatMacosVmSelection(value: unknown): string { function formatAppControlStatus(value: unknown): string { const status = isRecord(value) ? value : {}; - const providers = Array.isArray(status.providers) ? status.providers.filter(isRecord) : []; + const providers = Array.isArray(status.providers) + ? status.providers.filter(isRecord) + : []; const session = isRecord(status.activeSession) ? status.activeSession - : typeof status.status === "string" && status.label ? status : {}; + : typeof status.status === "string" && status.label + ? status + : {}; return [ renderKeyValues("ADE App Control", [ ["supported", status.supported], @@ -6252,7 +12158,11 @@ function formatAppControlStatus(value: unknown): string { "", renderTable( ["provider", "ready", "detail"], - providers.map((provider) => [provider.provider, provider.available ? "yes" : "no", provider.detail]), + providers.map((provider) => [ + provider.provider, + provider.available ? "yes" : "no", + provider.detail, + ]), "Providers\n(none)", ), ].join("\n"); @@ -6291,18 +12201,39 @@ function formatBrowserStatus(value: unknown): string { function formatAppControlSnapshot(value: unknown): string { const snapshot = isRecord(value) ? value : {}; - const screenshot = isRecord(snapshot.screenshot) ? snapshot.screenshot : snapshot; + const screenshot = isRecord(snapshot.screenshot) + ? snapshot.screenshot + : snapshot; const screen = isRecord(snapshot.screen) ? snapshot.screen : {}; - const providers = Array.isArray(snapshot.providers) ? snapshot.providers.filter(isRecord) : []; - const elements = Array.isArray(snapshot.elements) ? snapshot.elements.filter(isRecord) : []; - const providerSummary = providers.map((provider) => `${provider.provider}:${provider.available ? provider.elementCount ?? "ok" : "unavailable"}`).join(", "); + const providers = Array.isArray(snapshot.providers) + ? snapshot.providers.filter(isRecord) + : []; + const elements = Array.isArray(snapshot.elements) + ? snapshot.elements.filter(isRecord) + : []; + const providerSummary = providers + .map( + (provider) => + `${provider.provider}:${provider.available ? (provider.elementCount ?? "ok") : "unavailable"}`, + ) + .join(", "); return [ renderKeyValues("ADE App Control snapshot", [ ["title", snapshot.title], ["url", snapshot.url], ["captured", snapshot.capturedAt], - ["screenshot", screenshot.width && screenshot.height ? `${screenshot.width}x${screenshot.height}` : null], - ["screen", screen.width && screen.height ? `${screen.width}x${screen.height} @${screen.scale ?? 1}x` : null], + [ + "screenshot", + screenshot.width && screenshot.height + ? `${screenshot.width}x${screenshot.height}` + : null, + ], + [ + "screen", + screen.width && screen.height + ? `${screen.width}x${screen.height} @${screen.scale ?? 1}x` + : null, + ], ["elements", elements.length], ["providers", providerSummary], ]), @@ -6310,16 +12241,20 @@ function formatAppControlSnapshot(value: unknown): string { elements.length ? renderTable( ["ref", "role", "label", "selector"], - elements.slice(0, 24).map((element) => [ - element.ref ?? element.id, - element.role ?? element.tagName, - element.label ?? element.value ?? element.testId, - element.selector, - ]), + elements + .slice(0, 24) + .map((element) => [ + element.ref ?? element.id, + element.role ?? element.tagName, + element.label ?? element.value ?? element.testId, + element.selector, + ]), "", ) : "", - ].filter(Boolean).join("\n"); + ] + .filter(Boolean) + .join("\n"); } function formatTerminalList(value: unknown): string { @@ -6353,6 +12288,27 @@ function formatTerminalRead(value: unknown): string { return data.length ? `${header}\n\n${data}` : `${header}\n\n(no output)`; } +function formatProjectsList(value: unknown): string { + const projects = Array.isArray(value) + ? value.filter(isRecord) + : isRecord(value) && value.projectId + ? [value] + : firstArray(value, ["projects", "items"]); + return renderTable( + ["project", "name", "path", "git origin", "last opened"], + projects.map((project) => [ + project.projectId, + project.displayName, + project.rootPath, + project.gitOriginUrl, + typeof project.lastOpenedAt === "number" && project.lastOpenedAt > 0 + ? new Date(project.lastOpenedAt).toISOString() + : "", + ]), + "ADE projects\n(no projects registered)", + ); +} + function formatLinearQuickView(value: unknown): string { if (!isRecord(value)) return JSON.stringify(value, null, 2); const connection = isRecord(value.connection) ? value.connection : {}; @@ -6377,11 +12333,16 @@ function formatLinearQuickView(value: unknown): string { const projectRows = projects.map((project) => [ project.name, project.statusName ?? project.statusType, - typeof project.progress === "number" ? `${Math.round(project.progress * 100)}%` : "", + typeof project.progress === "number" + ? `${Math.round(project.progress * 100)}%` + : "", project.issueCount, ]); const issueRows = [...assignedIssues, ...recentIssues] - .filter((issue, index, all) => all.findIndex((candidate) => candidate.id === issue.id) === index) + .filter( + (issue, index, all) => + all.findIndex((candidate) => candidate.id === issue.id) === index, + ) .slice(0, 12) .map((issue) => [ issue.identifier, @@ -6393,7 +12354,11 @@ function formatLinearQuickView(value: unknown): string { header, "", "Projects", - renderTable(["project", "status", "progress", "issues"], projectRows, "(no projects)"), + renderTable( + ["project", "status", "progress", "issues"], + projectRows, + "(no projects)", + ), "", "Issues", renderTable(["id", "title", "state", "area"], issueRows, "(no issues)"), @@ -6401,22 +12366,41 @@ function formatLinearQuickView(value: unknown): string { } function formatAppControlSelection(value: unknown): string { - const item = firstRecord(value, ["item", "selection"]) ?? (isRecord(value) ? value : {}); + const item = + firstRecord(value, ["item", "selection"]) ?? (isRecord(value) ? value : {}); const metadata = isRecord(item.metadata) ? item.metadata : {}; - const selected = isRecord(metadata.selectedElement) ? metadata.selectedElement : {}; + const selected = isRecord(metadata.selectedElement) + ? metadata.selectedElement + : {}; return renderKeyValues("ADE App Control selection", [ ["component", item.componentId], - ["source", isRecord(value) ? value.source ?? item.provider : item.provider], - ["file", item.sourceFile ? `${item.sourceFile}${item.sourceLine ? `:${item.sourceLine}` : ""}` : null], + [ + "source", + isRecord(value) ? (value.source ?? item.provider) : item.provider, + ], + [ + "file", + item.sourceFile + ? `${item.sourceFile}${item.sourceLine ? `:${item.sourceLine}` : ""}` + : null, + ], ["selector", selected.selector], ["label", selected.label ?? metadata.label], ["selected", item.selectedAt], ]); } -function formatTextOutput(value: unknown, formatter: FormatterId | undefined): string { +function formatTextOutput( + value: unknown, + formatter: FormatterId | undefined, +): string { if (typeof value === "string") return value; - if (isRecord(value) && typeof value.visual === "string" && (!formatter || formatter === "lanes")) return value.visual; + if ( + isRecord(value) && + typeof value.visual === "string" && + (!formatter || formatter === "lanes") + ) + return value.visual; switch (formatter) { case "status": return renderKeyValues("ADE status", [ @@ -6426,61 +12410,79 @@ function formatTextOutput(value: unknown, formatter: FormatterId | undefined): s ["workspace", isRecord(value) ? value.workspaceRoot : null], ["socket", isRecord(value) ? value.socketPath : null], ]); - case "doctor": - { - const project = isRecord(value) && isRecord(value.project) ? value.project : {}; - const desktop = isRecord(value) && isRecord(value.desktop) ? value.desktop : {}; - const actions = isRecord(value) && isRecord(value.actions) ? value.actions : {}; - const git = isRecord(value) && isRecord(value.git) ? value.git : {}; - const github = isRecord(value) && isRecord(value.github) ? value.github : {}; - const linear = isRecord(value) && isRecord(value.linear) ? value.linear : {}; - const providers = isRecord(value) && isRecord(value.providers) ? value.providers : {}; - const computerUse = isRecord(value) && isRecord(value.computerUse) ? value.computerUse : {}; - const pathStatus = isRecord(value) && isRecord(value.path) ? value.path : {}; - const recommendations = isRecord(value) && Array.isArray(value.recommendations) ? value.recommendations : []; - return [ - renderKeyValues("ADE doctor", [ - ["ok", isRecord(value) ? value.ok : null], - ["cli version", isRecord(value) ? value.cliVersion : null], - ["mode", isRecord(value) ? value.mode : null], - ["project", isRecord(value) ? value.projectRoot : null], - ["workspace", isRecord(value) ? value.workspaceRoot : null], - ["project initialized", project.projectInitialized], - ["desktop socket", desktop.socketAvailable], - ["socket path", desktop.socketPath], - ["rpc actions", actions.rpcActionCount], - ["service actions", actions.actionCount], - ["git", git.message], - ["github", github.message], - ["linear", linear.message], - ["providers", providers.message], - ["computer use", computerUse.message], - ["path", pathStatus.message], - ["recommendation", isRecord(value) ? value.recommendation : null], - ]), - ...(recommendations.length ? ["", "Next actions", ...recommendations.map((entry) => `- ${cell(entry, 120)}`)] : []), - ].join("\n"); - } - case "auth": - { - const checks = isRecord(value) && isRecord(value.checks) ? value.checks : {}; - const git = isRecord(checks.git) ? checks.git : {}; - const github = isRecord(checks.github) ? checks.github : {}; - const linear = isRecord(checks.linear) ? checks.linear : {}; - const providers = isRecord(checks.providers) ? checks.providers : {}; - return renderKeyValues("ADE auth", [ - ["authenticated", isRecord(value) ? value.authenticated : null], - ["mode", isRecord(value) ? value.authMode : null], - ["role", isRecord(value) ? value.role : null], + case "doctor": { + const project = + isRecord(value) && isRecord(value.project) ? value.project : {}; + const desktop = + isRecord(value) && isRecord(value.desktop) ? value.desktop : {}; + const actions = + isRecord(value) && isRecord(value.actions) ? value.actions : {}; + const git = isRecord(value) && isRecord(value.git) ? value.git : {}; + const github = + isRecord(value) && isRecord(value.github) ? value.github : {}; + const linear = + isRecord(value) && isRecord(value.linear) ? value.linear : {}; + const providers = + isRecord(value) && isRecord(value.providers) ? value.providers : {}; + const computerUse = + isRecord(value) && isRecord(value.computerUse) ? value.computerUse : {}; + const pathStatus = + isRecord(value) && isRecord(value.path) ? value.path : {}; + const recommendations = + isRecord(value) && Array.isArray(value.recommendations) + ? value.recommendations + : []; + return [ + renderKeyValues("ADE doctor", [ + ["ok", isRecord(value) ? value.ok : null], + ["cli version", isRecord(value) ? value.cliVersion : null], + ["mode", isRecord(value) ? value.mode : null], ["project", isRecord(value) ? value.projectRoot : null], - ["actions", isRecord(value) ? value.availableActionCount : null], + ["workspace", isRecord(value) ? value.workspaceRoot : null], + ["project initialized", project.projectInitialized], + ["runtime socket", desktop.socketAvailable], + ["socket path", desktop.socketPath], + ["rpc actions", actions.rpcActionCount], + ["service actions", actions.actionCount], ["git", git.message], ["github", github.message], ["linear", linear.message], ["providers", providers.message], - ["note", isRecord(value) ? value.note : null], - ]); - } + ["computer use", computerUse.message], + ["path", pathStatus.message], + ["recommendation", isRecord(value) ? value.recommendation : null], + ]), + ...(recommendations.length + ? [ + "", + "Next actions", + ...recommendations.map((entry) => `- ${cell(entry, 120)}`), + ] + : []), + ].join("\n"); + } + case "auth": { + const checks = + isRecord(value) && isRecord(value.checks) ? value.checks : {}; + const git = isRecord(checks.git) ? checks.git : {}; + const github = isRecord(checks.github) ? checks.github : {}; + const linear = isRecord(checks.linear) ? checks.linear : {}; + const providers = isRecord(checks.providers) ? checks.providers : {}; + return renderKeyValues("ADE auth", [ + ["authenticated", isRecord(value) ? value.authenticated : null], + ["mode", isRecord(value) ? value.authMode : null], + ["role", isRecord(value) ? value.role : null], + ["project", isRecord(value) ? value.projectRoot : null], + ["actions", isRecord(value) ? value.availableActionCount : null], + ["git", git.message], + ["github", github.message], + ["linear", linear.message], + ["providers", providers.message], + ["note", isRecord(value) ? value.note : null], + ]); + } + case "projects-list": + return formatProjectsList(value); case "linear-quick-view": return formatLinearQuickView(value); case "lanes": @@ -6488,7 +12490,10 @@ function formatTextOutput(value: unknown, formatter: FormatterId | undefined): s case "lane-detail": return formatLaneDetail(value); case "git-status": - return renderKeyValues("ADE git status", Object.entries(isRecord(value) ? value : {})); + return renderKeyValues( + "ADE git status", + Object.entries(isRecord(value) ? value : {}), + ); case "diff-summary": return formatDiffSummary(value); case "file-read": @@ -6500,7 +12505,13 @@ function formatTextOutput(value: unknown, formatter: FormatterId | undefined): s case "prs-list": return formatPrList(value); case "pr-detail": - return renderKeyValues("ADE pull request", Object.entries(firstRecord(value, ["pr", "detail"]) ?? (isRecord(value) ? value : {})).slice(0, 16)); + return renderKeyValues( + "ADE pull request", + Object.entries( + firstRecord(value, ["pr", "detail"]) ?? + (isRecord(value) ? value : {}), + ).slice(0, 16), + ); case "pr-checks": return formatPrChecks(value); case "pr-comments": @@ -6567,22 +12578,35 @@ function formatTextOutput(value: unknown, formatter: FormatterId | undefined): s return formatAutomationRunDetail(value); case "action-result": default: - if (isRecord(value)) return renderKeyValues("ADE result", Object.entries(value).slice(0, 24)); + if (isRecord(value)) + return renderKeyValues( + "ADE result", + Object.entries(value).slice(0, 24), + ); return JSON.stringify(value, null, 2); } } -function inferFormatter(plan: CliPlan & { kind: "execute" }): FormatterId | undefined { +function inferFormatter( + plan: CliPlan & { kind: "execute" }, +): FormatterId | undefined { if (plan.formatter) return plan.formatter; if (plan.summary) return plan.summary; if (plan.visualizer === "lanes") return "lanes"; const label = plan.label.toLowerCase(); + if ( + label === "projects list" || + label === "projects add" || + label === "projects touch" + ) + return "projects-list"; if (label === "lane status") return "lane-detail"; if (label === "git status") return "git-status"; if (label === "diff changes") return "diff-summary"; if (label === "file read") return "file-read"; if (label === "file tree" || label === "file workspaces") return "files-tree"; - if (label === "file search" || label === "file quick-open") return "files-search"; + if (label === "file search" || label === "file quick-open") + return "files-search"; if (label === "pr list" || label === "pr list open") return "prs-list"; if (label === "pr detail" || label === "pr health") return "pr-detail"; if (label === "pr checks") return "pr-checks"; @@ -6595,20 +12619,64 @@ function inferFormatter(plan: CliPlan & { kind: "execute" }): FormatterId | unde if (label === "ios simulator status") return "ios-sim-status"; if (label === "ios simulator devices") return "ios-sim-devices"; if (label === "ios simulator launchable apps") return "ios-sim-apps"; - if (label === "ios simulator stream start" || label === "ios simulator stream status" || label === "ios simulator stream stop") return "ios-sim-stream"; - if (label === "ios simulator screen snapshot" || label === "ios simulator inspector snapshot" || label === "ios simulator screenshot") return "ios-sim-snapshot"; - if (label === "ios simulator select" || label === "ios simulator inspect point") return "ios-sim-selection"; - if (label === "ios simulator preview status" || label === "ios simulator previews" || label === "ios simulator preview render" || label === "ios simulator preview open") return "ios-sim-preview"; - if (label === "app control status" || label === "app control launch" || label === "app control connect" || label === "app control stop") return "app-control-status"; - if (label === "app control snapshot" || label === "app control screenshot") return "app-control-snapshot"; - if (label === "app control select" || label === "app control inspect point") return "app-control-selection"; - if (label === "browser status" || label === "browser panel" || label === "browser open" || label === "browser new tab" || label === "browser switch" || label === "browser close") return "browser-status"; - if (label === "macos vm status" || label === "macos vm start" || label === "macos vm stop" || label === "macos vm provision" || label === "macos vm delete") return "macos-vm-status"; + if ( + label === "ios simulator stream start" || + label === "ios simulator stream status" || + label === "ios simulator stream stop" + ) + return "ios-sim-stream"; + if ( + label === "ios simulator screen snapshot" || + label === "ios simulator inspector snapshot" || + label === "ios simulator screenshot" + ) + return "ios-sim-snapshot"; + if ( + label === "ios simulator select" || + label === "ios simulator inspect point" + ) + return "ios-sim-selection"; + if ( + label === "ios simulator preview status" || + label === "ios simulator previews" || + label === "ios simulator preview render" || + label === "ios simulator preview open" + ) + return "ios-sim-preview"; + if ( + label === "app control status" || + label === "app control launch" || + label === "app control connect" || + label === "app control stop" + ) + return "app-control-status"; + if (label === "app control snapshot" || label === "app control screenshot") + return "app-control-snapshot"; + if (label === "app control select" || label === "app control inspect point") + return "app-control-selection"; + if ( + label === "browser status" || + label === "browser panel" || + label === "browser open" || + label === "browser new tab" || + label === "browser switch" || + label === "browser close" + ) + return "browser-status"; + if ( + label === "macos vm status" || + label === "macos vm start" || + label === "macos vm stop" || + label === "macos vm provision" || + label === "macos vm delete" + ) + return "macos-vm-status"; if (label === "macos vm share policy") return "macos-vm-share-policy"; if (label === "macos vm guide") return "macos-vm-guide"; if (label === "macos vm screenshot") return "macos-vm-capture"; if (label === "macos vm select") return "macos-vm-selection"; - if (label === "terminal list" || label === "terminal active") return "terminal-list"; + if (label === "terminal list" || label === "terminal active") + return "terminal-list"; if (label === "terminal read") return "terminal-read"; if (label === "actions list") return "actions-list"; if (label.endsWith("actions")) return "actions-list"; @@ -6635,12 +12703,21 @@ function summarizeExecution(args: { return buildReadinessSnapshot({ connection, values, summary: "doctor" }); } if (plan.summary === "auth") { - const readiness = buildReadinessSnapshot({ connection, values, summary: "auth" }); + const readiness = buildReadinessSnapshot({ + connection, + values, + summary: "auth", + }); const actions = isRecord(readiness.actions) ? readiness.actions : {}; return { ok: readiness.ok, - authenticated: isRecord(readiness.auth) ? readiness.auth.localProjectAccess : false, - authMode: connection.mode === "desktop-socket" ? "local-desktop-socket" : "local-headless-project", + authenticated: isRecord(readiness.auth) + ? readiness.auth.localProjectAccess + : false, + authMode: + connection.mode === "desktop-socket" + ? "local-desktop-socket" + : "local-headless-project", role: process.env.ADE_DEFAULT_ROLE ?? "agent", projectRoot: connection.projectRoot, workspaceRoot: connection.workspaceRoot, @@ -6655,7 +12732,9 @@ function summarizeExecution(args: { path: readiness.path, }, recommendations: readiness.recommendations, - note: isRecord(readiness.auth) ? readiness.auth.note : "ADE CLI auth is local project access.", + note: isRecord(readiness.auth) + ? readiness.auth.note + : "ADE CLI auth is local project access.", }; } @@ -6667,7 +12746,11 @@ function summarizeExecution(args: { return { created, started, - mission: refreshedMission ?? missionFromResult(started) ?? missionFromResult(created) ?? created, + mission: + refreshedMission ?? + missionFromResult(started) ?? + missionFromResult(created) ?? + created, run: runFromGraphResult(graph) ?? runFromStartResult(started), graph, }; @@ -6700,12 +12783,12 @@ function summarizeExecution(args: { const result = values.result ?? values; if ( - isRecord(result) - && Object.prototype.hasOwnProperty.call(result, "result") - && asString(result.domain) - && asString(result.action) - && !plan.label.toLowerCase().startsWith("action ") - && !plan.label.toLowerCase().endsWith(" action") + isRecord(result) && + Object.prototype.hasOwnProperty.call(result, "result") && + asString(result.domain) && + asString(result.action) && + !plan.label.toLowerCase().startsWith("action ") && + !plan.label.toLowerCase().endsWith(" action") ) { return result.result; } @@ -6718,17 +12801,29 @@ function summarizeExecution(args: { return result; } -const TERMINAL_MISSION_RUN_STATUSES = new Set(["succeeded", "failed", "canceled", "cancelled"]); +const TERMINAL_MISSION_RUN_STATUSES = new Set([ + "succeeded", + "failed", + "canceled", + "cancelled", +]); const HEADLESS_ACTIVE_ATTEMPT_DRAIN_MS = 30 * 60 * 1000; -function graphWaitState(value: unknown): { status: string; activeCount: number } { +function graphWaitState(value: unknown): { + status: string; + activeCount: number; +} { const graph = graphFromResult(value) ?? {}; const run = firstRecord(graph, ["run"]) ?? {}; const status = (asString(run.status) ?? "").trim().toLowerCase(); const steps = firstArray(graph, ["steps"]); const attempts = firstArray(graph, ["attempts"]); - const activeStepCount = steps.filter((step) => asString(step.status)?.trim().toLowerCase() === "running").length; - const activeAttemptCount = attempts.filter((attempt) => asString(attempt.status)?.trim().toLowerCase() === "running").length; + const activeStepCount = steps.filter( + (step) => asString(step.status)?.trim().toLowerCase() === "running", + ).length; + const activeAttemptCount = attempts.filter( + (attempt) => asString(attempt.status)?.trim().toLowerCase() === "running", + ).length; return { status, activeCount: Math.max(activeStepCount, activeAttemptCount), @@ -6783,9 +12878,9 @@ async function waitForRunGraph(args: { if (pastDeadline) { timedOut = true; const shouldDrainActiveHeadlessWork = - args.connection.mode === "headless" - && waitState.activeCount > 0 - && now < headlessDrainDeadline; + args.connection.mode === "headless" && + waitState.activeCount > 0 && + now < headlessDrainDeadline; if (!shouldDrainActiveHeadlessWork) break; extendedForActiveHeadlessWork = true; } @@ -6811,52 +12906,81 @@ async function waitForRunGraph(args: { }; } -async function executePlan(plan: CliPlan & { kind: "execute" }, options: GlobalOptions): Promise { +async function executePlan( + plan: CliPlan & { kind: "execute" }, + options: GlobalOptions, +): Promise { let connection: CliConnection; const isWorkerMissionToolPlan = plan.label.startsWith("worker mission tool "); const workerRpcUrl = process.env.ADE_RPC_URL?.trim(); const workerSocketOverride = process.env.ADE_RPC_SOCKET_PATH?.trim(); - const connectionOptions = isWorkerMissionToolPlan && !options.requireSocket - ? { ...options, headless: false, requireSocket: Boolean(workerRpcUrl || workerSocketOverride) } - : plan.preferHeadless && !options.requireSocket - ? { ...options, headless: true } - : options; + const connectionOptions = + isWorkerMissionToolPlan && !options.requireSocket + ? { + ...options, + headless: false, + requireSocket: Boolean(workerRpcUrl || workerSocketOverride), + } + : plan.preferHeadless && !options.requireSocket + ? { ...options, headless: true } + : options; try { - connection = await createConnection(connectionOptions); + connection = await createConnection(connectionOptions, { + autoRegisterProject: shouldAutoRegisterProjectForPlan(plan), + }); } catch (error) { const roots = resolveRoots(options); let socketPath = path.join(roots.projectRoot, ".ade", "ade.sock"); try { - const { resolveAdeLayout } = await import("../../desktop/src/shared/adeLayout"); + const { resolveAdeLayout } = + await import("../../desktop/src/shared/adeLayout"); socketPath = resolveAdeLayout(roots.projectRoot).socketPath; } catch { // Keep the conventional Unix fallback if shared layout loading fails. } - const requestedMode = connectionOptions.requireSocket ? "desktop-socket" : connectionOptions.headless ? "headless" : "auto"; + const requestedMode = connectionOptions.requireSocket + ? "socket" + : connectionOptions.headless + ? "headless" + : "auto"; const cause = error instanceof Error ? error.message : String(error); const sourceRuntimeInterop = isSourceRuntimeInteropError(cause); - throw new CliExecutionError(`Failed to initialize ADE CLI connection for ${plan.label}.`, { - cause, - requestedMode, - projectRoot: roots.projectRoot, - workspaceRoot: roots.workspaceRoot, - socketPath, - nextAction: options.requireSocket - ? "Start ADE desktop for this project or remove --socket to allow headless mode." - : sourceRuntimeInterop - ? "Run `npm --prefix apps/ade-cli run build` and retry, or use `npm --prefix apps/ade-cli run cli:dev -- ...`." - : "Verify --project-root points at an ADE project and run ade doctor --json.", - }); + throw new CliExecutionError( + `Failed to initialize ADE CLI connection for ${plan.label}.`, + { + cause, + requestedMode, + projectRoot: roots.projectRoot, + workspaceRoot: roots.workspaceRoot, + socketPath, + nextAction: options.requireSocket + ? "Start the ADE runtime for this project or remove --socket to allow headless mode." + : sourceRuntimeInterop + ? "Run `npm --prefix apps/ade-cli run build` and retry, or use `npm --prefix apps/ade-cli run cli:dev -- ...`." + : "Verify --project-root points at an ADE project and run ade doctor --json.", + }, + ); } try { const values: JsonObject = {}; for (const step of plan.steps) { try { - const params = typeof step.params === "function" ? step.params(values) : step.params; + const params = + typeof step.params === "function" ? step.params(values) : step.params; if (step.method === "ade-cli/wait-run-graph") { const runId = requireValue(asString(params?.runId) ?? null, "run id"); - const waitMs = Math.max(0, Math.floor(typeof params?.waitMs === "number" ? params.waitMs : 0)); - const timelineLimit = Math.max(0, Math.floor(typeof params?.timelineLimit === "number" ? params.timelineLimit : 120)); + const waitMs = Math.max( + 0, + Math.floor(typeof params?.waitMs === "number" ? params.waitMs : 0), + ); + const timelineLimit = Math.max( + 0, + Math.floor( + typeof params?.timelineLimit === "number" + ? params.timelineLimit + : 120, + ), + ); values[step.key] = await waitForRunGraph({ connection, runId, @@ -6878,40 +13002,58 @@ async function executePlan(plan: CliPlan & { kind: "execute" }, options: GlobalO } return summarizeExecution({ plan, connection, values }); } catch (error) { - if (error instanceof CliToolError || error instanceof CliUsageError || error instanceof CliExecutionError) throw error; + if ( + error instanceof CliToolError || + error instanceof CliUsageError || + error instanceof CliExecutionError + ) + throw error; throw new CliExecutionError(`Failed while running ${plan.label}.`, { cause: error instanceof Error ? error.message : String(error), mode: connection.mode, projectRoot: connection.projectRoot, workspaceRoot: connection.workspaceRoot, socketPath: connection.socketPath, - nextAction: connection.mode === "desktop-socket" - ? "Check ADE desktop logs or retry with --headless if the workflow does not need UI-owned state." - : "Run ade doctor --json to inspect local project readiness, or start ADE desktop and retry with --socket.", + nextAction: + connection.mode === "desktop-socket" + ? "Check ADE desktop logs or retry with --headless if the workflow does not need UI-owned state." + : "Run ade doctor --json to inspect local project readiness, or start ADE desktop and retry with --socket.", }); } finally { await connection.close(); } } -function formatOutput(value: unknown, options: GlobalOptions, formatter?: FormatterId): string { +function formatOutput( + value: unknown, + options: GlobalOptions, + formatter?: FormatterId, +): string { if (options.text) { return `${formatTextOutput(value, formatter)}\n`; } return `${JSON.stringify(value, null, options.pretty ? 2 : 0)}\n`; } -async function runCli(argv: string[]): Promise<{ output: string; exitCode: number }> { +async function runCli( + argv: string[], +): Promise<{ output: string; exitCode: number }> { const parsed = parseCliArgs(argv); const plan = buildCliPlan(parsed.command); - if (plan.kind === "help") return { output: plan.text.endsWith("\n") ? plan.text : `${plan.text}\n`, exitCode: 0 }; + if (plan.kind === "help") + return { + output: plan.text.endsWith("\n") ? plan.text : `${plan.text}\n`, + exitCode: 0, + }; const originalConsole = { log: console.log, info: console.info, warn: console.warn, }; const writeDiagnostic = (...args: unknown[]) => { - process.stderr.write(`${args.map((arg) => typeof arg === "string" ? arg : JSON.stringify(arg)).join(" ")}\n`); + process.stderr.write( + `${args.map((arg) => (typeof arg === "string" ? arg : JSON.stringify(arg))).join(" ")}\n`, + ); }; console.log = writeDiagnostic; console.info = writeDiagnostic; @@ -6922,22 +13064,66 @@ async function runCli(argv: string[]): Promise<{ output: string; exitCode: numbe // RPC. The function handles its own --json/--text/--compact parsing on // the remaining tokens. try { - const result = await runCursorCloud(plan.rest, parsed.options.text ? "text" : "json"); + const result = await runCursorCloud( + plan.rest, + parsed.options.text ? "text" : "json", + ); return result; } catch (error) { - if (error instanceof CursorCloudUsageError) throw new CliUsageError(error.message); + if (error instanceof CursorCloudUsageError) + throw new CliUsageError(error.message); throw error; } } if (plan.kind === "mcp") { - await runMcpServer({ ...parsed.options, headless: true, requireSocket: false }); + await runMcpServer({ + ...parsed.options, + headless: true, + requireSocket: false, + }); + return { output: "", exitCode: 0 }; + } + if (plan.kind === "rpc-stdio") { + await runNativeRpcStdio(parsed.options); return { output: "", exitCode: 0 }; } + if (plan.kind === "desktop") { + const result = await runDesktopCommand(plan.rest); + return { + output: formatOutput(result, parsed.options, undefined), + exitCode: isRecord(result) && result.ok === false ? 1 : 0, + }; + } + if (plan.kind === "runtime") { + const result = await runRuntimeCommand(plan.rest, parsed.options); + return { + output: formatOutput(result, parsed.options, undefined), + exitCode: isRecord(result) && result.ok === false ? 1 : 0, + }; + } + if (plan.kind === "serve") { + const result = await runServe(plan.rest, parsed.options); + return { + output: + result == null ? "" : formatOutput(result, parsed.options, undefined), + exitCode: isFailedServiceManagerResult(result) ? 1 : 0, + }; + } + if (plan.kind === "init") { + const result = await runInit(plan.targetPath); + return { + output: formatOutput(result, parsed.options, undefined), + exitCode: 0, + }; + } if (plan.kind === "ade-code") { - return runAdeCode(plan.rest, parsed.options); + return await runAdeCode(plan.rest, parsed.options); } const result = await executePlan(plan, parsed.options); - return { output: formatOutput(result, parsed.options, inferFormatter(plan)), exitCode: 0 }; + return { + output: formatOutput(result, parsed.options, inferFormatter(plan)), + exitCode: 0, + }; } finally { console.log = originalConsole.log; console.info = originalConsole.info; @@ -6947,7 +13133,9 @@ async function runCli(argv: string[]): Promise<{ output: string; exitCode: numbe async function main(): Promise { const writeDiagnostic = (...args: unknown[]) => { - process.stderr.write(`${args.map((arg) => typeof arg === "string" ? arg : JSON.stringify(arg)).join(" ")}\n`); + process.stderr.write( + `${args.map((arg) => (typeof arg === "string" ? arg : JSON.stringify(arg))).join(" ")}\n`, + ); }; console.log = writeDiagnostic; console.info = writeDiagnostic; @@ -6983,7 +13171,9 @@ async function main(): Promise { process.exitCode = 1; return; } - process.stderr.write(`ade: ${error instanceof Error ? error.stack || error.message : String(error)}\n`); + process.stderr.write( + `ade: ${error instanceof Error ? error.stack || error.message : String(error)}\n`, + ); process.exitCode = 1; } } @@ -6998,6 +13188,7 @@ export { findProjectRoots, formatOutput, graphWaitState, + isFailedServiceManagerResult, parseCliArgs, renderLaneGraph, resolveRoots, diff --git a/apps/ade-cli/src/eventBuffer.ts b/apps/ade-cli/src/eventBuffer.ts index 07035ab77..be0475117 100644 --- a/apps/ade-cli/src/eventBuffer.ts +++ b/apps/ade-cli/src/eventBuffer.ts @@ -8,11 +8,13 @@ export type BufferedEvent = { export type EventBuffer = { push(event: Omit): void; drain(cursor: number, limit?: number): { events: BufferedEvent[]; nextCursor: number; hasMore: boolean }; + subscribe(listener: (event: BufferedEvent) => void): () => void; size(): number; }; export function createEventBuffer(capacity = 10_000): EventBuffer { const events: BufferedEvent[] = []; + const listeners = new Set<(event: BufferedEvent) => void>(); let nextId = 1; return { @@ -22,6 +24,13 @@ export function createEventBuffer(capacity = 10_000): EventBuffer { while (events.length > capacity) { events.shift(); } + for (const listener of [...listeners]) { + try { + listener(entry); + } catch { + // Event delivery is best-effort; one subscriber must not break producers. + } + } }, drain(cursor, limit = 100) { const clamped = Math.max(1, Math.min(1000, limit)); @@ -37,6 +46,12 @@ export function createEventBuffer(capacity = 10_000): EventBuffer { hasMore: startIdx + clamped < events.length, }; }, + subscribe(listener) { + listeners.add(listener); + return () => { + listeners.delete(listener); + }; + }, size() { return events.length; }, diff --git a/apps/ade-cli/src/headlessLinearServices.test.ts b/apps/ade-cli/src/headlessLinearServices.test.ts index c35a65b51..11a9c09da 100644 --- a/apps/ade-cli/src/headlessLinearServices.test.ts +++ b/apps/ade-cli/src/headlessLinearServices.test.ts @@ -1,3 +1,6 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; import { beforeEach, describe, expect, it, vi } from "vitest"; const mockState = vi.hoisted(() => ({ @@ -411,6 +414,26 @@ describe("headlessLinearServices", () => { services.dispose(); }); + it("exposes bundled Linear OAuth credentials in headless runtime", () => { + const previousAdeHome = process.env.ADE_HOME; + process.env.ADE_HOME = fs.mkdtempSync(path.join(os.tmpdir(), "ade-headless-linear-oauth-")); + const services = createHeadlessLinearServices(createDeps()); + try { + expect(services.linearCredentialService.getStatus().oauthConfigured).toBe(true); + expect(services.linearCredentialService.getOAuthClientCredentials()).toEqual({ + clientId: expect.any(String), + clientSecret: null, + }); + } finally { + services.dispose(); + if (previousAdeHome == null) { + delete process.env.ADE_HOME; + } else { + process.env.ADE_HOME = previousAdeHome; + } + } + }); + it("assigns CTO default title for cto identityKey", async () => { const services = createHeadlessLinearServices(createDeps()); diff --git a/apps/ade-cli/src/headlessLinearServices.ts b/apps/ade-cli/src/headlessLinearServices.ts index db732c7a6..94c3d49eb 100644 --- a/apps/ade-cli/src/headlessLinearServices.ts +++ b/apps/ade-cli/src/headlessLinearServices.ts @@ -30,7 +30,15 @@ import type { createWorkerTaskSessionService } from "../../desktop/src/main/serv import type { createWorkerHeartbeatService } from "../../desktop/src/main/services/cto/workerHeartbeatService"; import type { createAutomationSecretService } from "../../desktop/src/main/services/automations/automationSecretService"; import type { ComputerUseArtifactBrokerService } from "../../desktop/src/main/services/computerUse/computerUseArtifactBrokerService"; -import { getModelById, getRuntimeModelRefForDescriptor, resolveModelAlias } from "../../desktop/src/shared/modelRegistry"; +import { + getModelById, + getRuntimeModelRefForDescriptor, + resolveModelAlias, +} from "../../desktop/src/shared/modelRegistry"; +import { + getGitHubTokenAccessState, + parseGitHubScopeHeaders, +} from "../../desktop/src/shared/githubScopes"; import type { AdeRuntimePaths } from "./bootstrap"; import { createLinearClient as createLinearClientImpl } from "../../desktop/src/main/services/cto/linearClient"; import { createLinearIssueTracker as createLinearIssueTrackerImpl } from "../../desktop/src/main/services/cto/linearIssueTracker"; @@ -49,6 +57,12 @@ import { createFileService as createFileServiceImpl } from "../../desktop/src/ma import { createProcessService as createProcessServiceImpl } from "../../desktop/src/main/services/processes/processService"; import { createPrService as createPrServiceImpl } from "../../desktop/src/main/services/prs/prService"; import { createAutomationSecretService as createAutomationSecretServiceImpl } from "../../desktop/src/main/services/automations/automationSecretService"; +import { EncryptedFileCredentialStore } from "./services/credentials/credentialStore"; + +// Keep headless runtimes aligned with the desktop credential service so packaged +// alpha builds can offer the same PKCE-based Linear sign-in flow. +const BUNDLED_LINEAR_OAUTH_CLIENT_ID = + process.env.ADE_LINEAR_CLIENT_ID?.trim() || "432fb2ddb16f939ae5d5270e2c86571f"; type HeadlessLinearCredentialService = { getStatus: () => { @@ -60,10 +74,27 @@ type HeadlessLinearCredentialService = { scopes: string[]; checkedAt: string | null; authMode?: "manual" | "oauth" | null; + tokenExpiresAt?: string | null; + refreshTokenStored?: boolean; + oauthConfigured?: boolean; }; getTokenOrThrow: () => string; setToken: (token: string) => void; + setOAuthToken: (args: { + accessToken: string; + refreshToken?: string | null; + expiresAt?: string | null; + }) => void; clearToken: () => void; + setOAuthClientCredentials: (args: { + clientId: string; + clientSecret?: string | null; + }) => void; + clearOAuthClientCredentials: () => void; + getOAuthClientCredentials: () => { + clientId: string; + clientSecret: string | null; + } | null; }; type HeadlessGitHubStatus = { @@ -72,16 +103,23 @@ type HeadlessGitHubStatus = { storageScope: "app"; tokenType?: "classic" | "fine-grained" | "unknown"; repo: { owner: string; name: string } | null; + hasOrigin: boolean; userLogin: string | null; scopes: string[]; checkedAt: string | null; + repoAccessOk: boolean | null; + repoAccessError: string | null; + connected: boolean; }; -type HeadlessGitHubService = { - getStatus: () => Promise; +export type HeadlessGitHubService = { + getStatus: (opts?: { + forceRefresh?: boolean; + }) => Promise; detectRepo: () => Promise<{ owner: string; name: string } | null>; getRepoOrThrow: () => Promise<{ owner: string; name: string }>; getTokenOrThrow: () => string; + parseGitHubRepoFromRemoteUrl: typeof parseGitHubRepoFromRemoteUrl; apiRequest: (args: { method: "GET" | "POST" | "PATCH" | "PUT" | "DELETE"; path: string; @@ -89,12 +127,50 @@ type HeadlessGitHubService = { body?: unknown; token?: string; }) => Promise<{ data: T; response: Response | null }>; - addIssueComment: (owner: string, name: string, number: number, body: string) => Promise; - setIssueLabels: (owner: string, name: string, number: number, labels: string[]) => Promise; - closeIssue: (owner: string, name: string, number: number, reason?: "completed" | "not_planned") => Promise; - reopenIssue: (owner: string, name: string, number: number) => Promise; - assignIssue: (owner: string, name: string, number: number, assignees: string[]) => Promise; - setIssueTitle: (owner: string, name: string, number: number, title: string) => Promise; + setToken: (token: string) => void; + clearToken: () => void; + listRepoLabels: (owner: string, name: string) => Promise; + listRepoCollaborators: (owner: string, name: string) => Promise; + publishCurrentProject: (args: { + name: string; + description?: string; + isPrivate: boolean; + }) => Promise<{ state: "pushed" | "remote_added"; htmlUrl: string }>; + addIssueComment: ( + owner: string, + name: string, + number: number, + body: string, + ) => Promise; + setIssueLabels: ( + owner: string, + name: string, + number: number, + labels: string[], + ) => Promise; + closeIssue: ( + owner: string, + name: string, + number: number, + reason?: "completed" | "not_planned", + ) => Promise; + reopenIssue: ( + owner: string, + name: string, + number: number, + ) => Promise; + assignIssue: ( + owner: string, + name: string, + number: number, + assignees: string[], + ) => Promise; + setIssueTitle: ( + owner: string, + name: string, + number: number, + title: string, + ) => Promise; }; type HeadlessAgentChatSession = { @@ -163,19 +239,39 @@ type HeadlessLinearServices = { prService: ReturnType; agentChatService: { listSessions: () => Promise>>; - getSessionSummary: (sessionId: string) => Promise | null>; - getChatTranscript: (args: { sessionId: string; limit?: number; maxChars?: number }) => Promise<{ + getSessionSummary: ( + sessionId: string, + ) => Promise | null>; + getChatTranscript: (args: { sessionId: string; - entries: Array<{ role: "user" | "assistant"; text: string; timestamp: string }>; + limit?: number; + maxChars?: number; + }) => Promise<{ + sessionId: string; + entries: Array<{ + role: "user" | "assistant"; + text: string; + timestamp: string; + }>; truncated: boolean; totalEntries: number; }>; - previewSessionToolNames: (args?: { sessionId?: string | null }) => Promise; - createSession: (args: { laneId: string; title?: string }) => Promise; - updateSession: (args: { sessionId: string; title?: string | null }) => Promise; + previewSessionToolNames: (args?: { + sessionId?: string | null; + }) => Promise; + createSession: (args: { + laneId: string; + title?: string; + }) => Promise; + updateSession: (args: { + sessionId: string; + title?: string | null; + }) => Promise; sendMessage: (args: { sessionId: string; text: string }) => Promise; interrupt: (args: { sessionId: string }) => Promise; - resumeSession: (args: { sessionId: string }) => Promise; + resumeSession: (args: { + sessionId: string; + }) => Promise; dispose: (args: { sessionId: string }) => Promise; ensureIdentitySession: (args: { identityKey: string; @@ -185,7 +281,9 @@ type HeadlessLinearServices = { reuseExisting?: boolean; permissionMode?: string; }) => Promise; - setComputerUseArtifactBrokerService: (svc: ComputerUseArtifactBrokerService) => void; + setComputerUseArtifactBrokerService: ( + svc: ComputerUseArtifactBrokerService, + ) => void; }; workerTaskSessionService: ReturnType; workerHeartbeatService: ReturnType; @@ -200,9 +298,16 @@ function envToken(...names: string[]): string | null { return null; } +function asString(value: unknown): string { + return typeof value === "string" ? value : ""; +} + function ghAuthToken(): string | null { try { - const result = spawnSync("gh", ["auth", "token"], { encoding: "utf8", timeout: 5_000 }); + const result = spawnSync("gh", ["auth", "token"], { + encoding: "utf8", + timeout: 5_000, + }); if (result.status !== 0) return null; const token = result.stdout?.trim() ?? ""; return token.length > 0 ? token : null; @@ -211,12 +316,44 @@ function ghAuthToken(): string | null { } } -function detectGitHubRepo(projectRoot: string): { owner: string; name: string } | null { +function readGitOrigin(projectRoot: string): string | null { const result = spawnSync("git", ["remote", "get-url", "origin"], { cwd: projectRoot, encoding: "utf8", }); const remote = typeof result.stdout === "string" ? result.stdout.trim() : ""; + return remote.length > 0 ? remote : null; +} + +function runGitHeadless( + projectRoot: string, + args: string[], + timeoutMs: number, +): { exitCode: number; stdout: string; stderr: string } { + try { + const result = spawnSync("git", args, { + cwd: projectRoot, + encoding: "utf8", + timeout: timeoutMs, + }); + return { + exitCode: result.status ?? 1, + stdout: typeof result.stdout === "string" ? result.stdout : "", + stderr: typeof result.stderr === "string" ? result.stderr : "", + }; + } catch (error) { + return { + exitCode: 1, + stdout: "", + stderr: error instanceof Error ? error.message : String(error), + }; + } +} + +function parseGitHubRepoFromRemoteUrl( + remoteUrlRaw: string, +): { owner: string; name: string } | null { + const remote = remoteUrlRaw.trim(); if (!remote) return null; const ssh = remote.match(/^git@github\.com:(.+)$/i); if (ssh) { @@ -226,7 +363,10 @@ function detectGitHubRepo(projectRoot: string): { owner: string; name: string } try { const url = new URL(remote); if (!/github\.com$/i.test(url.hostname)) return null; - const parts = url.pathname.replace(/^\/+/, "").replace(/\.git$/i, "").split("/"); + const parts = url.pathname + .replace(/^\/+/, "") + .replace(/\.git$/i, "") + .split("/"); const owner = parts[0]?.trim() ?? ""; const name = parts[1]?.trim() ?? ""; return owner && name ? { owner, name } : null; @@ -235,28 +375,178 @@ function detectGitHubRepo(projectRoot: string): { owner: string; name: string } } } -function createHeadlessGitHubService(projectRoot: string, logger: Logger): HeadlessGitHubService { - let cachedStatus: Awaited> | null = null; +function detectGitHubRepo( + projectRoot: string, +): { owner: string; name: string } | null { + return parseGitHubRepoFromRemoteUrl(readGitOrigin(projectRoot) ?? ""); +} + +function parseNextGitHubLink(linkHeader: string | null): string | null { + if (!linkHeader) return null; + for (const part of linkHeader.split(",")) { + const match = part.match(/<([^>]+)>;\s*rel="([^"]+)"/); + if (match?.[2] === "next") return match[1] ?? null; + } + return null; +} + +const GITHUB_API_TIMEOUT_MS = 20_000; + +async function fetchGitHub(input: string | URL, init: RequestInit): Promise { + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), GITHUB_API_TIMEOUT_MS); + try { + return await fetch(input, { ...init, signal: controller.signal }); + } catch (error) { + if (error instanceof Error && error.name === "AbortError") { + throw new Error( + "GitHub API request timed out. Check network access on this machine.", + ); + } + throw error; + } finally { + clearTimeout(timer); + } +} + +export function createHeadlessGitHubService( + projectRoot: string, + logger: Logger, +): HeadlessGitHubService { + const credentialStore = new EncryptedFileCredentialStore(); + const tokenKey = "github.token.v1"; + let cachedStatus: Awaited< + ReturnType + > | null = null; let cachedAt = 0; + let tokenOverride: string | null = null; + let tokenDecryptionFailed = false; - const getToken = (): string => envToken("ADE_GITHUB_TOKEN", "GITHUB_TOKEN", "GH_TOKEN") ?? ghAuthToken() ?? ""; + const readStoredToken = (): string | null => { + if (tokenOverride != null) return tokenOverride; + try { + const stored = credentialStore.getSync(tokenKey); + tokenDecryptionFailed = false; + if (stored?.trim()) return stored.trim(); + } catch { + tokenDecryptionFailed = true; + } + return null; + }; + const getToken = (): string => + readStoredToken() ?? + envToken("ADE_GITHUB_TOKEN", "GITHUB_TOKEN", "GH_TOKEN") ?? + ghAuthToken() ?? + ""; const getTokenType = (token: string): HeadlessGitHubStatus["tokenType"] => { if (token.startsWith("github_pat_")) return "fine-grained"; if (token.startsWith("ghp_")) return "classic"; return "unknown"; }; + const readApiMessage = (payload: unknown, fallback: string): string => { + if ( + payload && + typeof payload === "object" && + "message" in payload && + typeof (payload as { message?: unknown }).message === "string" + ) { + return String((payload as { message: string }).message); + } + return fallback; + }; + const computeConnected = (args: { + tokenStored: boolean; + userLogin: string | null; + tokenType: HeadlessGitHubStatus["tokenType"]; + scopes: string[]; + repo: { owner: string; name: string } | null; + repoAccessOk: boolean | null; + }): boolean => { + if (!args.tokenStored || !args.userLogin) return false; + if (args.tokenType === "fine-grained") { + return args.repo ? args.repoAccessOk === true : true; + } + if (args.tokenType === "classic") { + return getGitHubTokenAccessState(args.scopes).hasRequiredAccess; + } + return true; + }; + const validateToken = async ( + token: string, + ): Promise<{ + userLogin: string | null; + scopes: string[]; + tokenType: HeadlessGitHubStatus["tokenType"]; + }> => { + const response = await fetchGitHub("https://api.github.com/user", { + method: "GET", + headers: { + accept: "application/vnd.github+json", + authorization: `Bearer ${token}`, + "user-agent": "ade-cli", + }, + }); + const scopes = parseGitHubScopeHeaders(response.headers); + const payload = await response.json().catch(() => ({})); + if (!response.ok) { + throw new Error( + readApiMessage( + payload, + `GitHub token validation failed (HTTP ${response.status})`, + ), + ); + } + const userLogin = + payload && + typeof payload === "object" && + typeof (payload as { login?: unknown }).login === "string" + ? (payload as { login: string }).login + : null; + return { userLogin, scopes, tokenType: getTokenType(token) }; + }; + const probeRepoAccess = async ( + token: string, + repo: { owner: string; name: string }, + ): Promise<{ ok: boolean; error: string | null }> => { + try { + const response = await fetchGitHub( + `https://api.github.com/repos/${encodeURIComponent(repo.owner)}/${encodeURIComponent(repo.name)}`, + { + method: "GET", + headers: { + accept: "application/vnd.github+json", + authorization: `Bearer ${token}`, + "user-agent": "ade-cli", + }, + }, + ); + if (response.ok) return { ok: true, error: null }; + const payload = await response.json().catch(() => ({})); + return { + ok: false, + error: `${response.status}: ${readApiMessage(payload, `HTTP ${response.status}`)}`, + }; + } catch (error) { + return { + ok: false, + error: error instanceof Error ? error.message : String(error), + }; + } + }; const apiRequest: HeadlessGitHubService["apiRequest"] = async (args) => { const token = (args.token ?? getToken()).trim(); if (!token) { - throw new Error("GitHub token missing. Set ADE_GITHUB_TOKEN or GITHUB_TOKEN, or run `gh auth login` so `gh auth token` returns a token."); + throw new Error( + "GitHub token missing. Set ADE_GITHUB_TOKEN or GITHUB_TOKEN, or run `gh auth login` so `gh auth token` returns a token.", + ); } const url = new URL(`https://api.github.com${args.path}`); for (const [key, value] of Object.entries(args.query ?? {})) { if (value == null) continue; url.searchParams.set(key, String(value)); } - const response = await fetch(url, { + const response = await fetchGitHub(url, { method: args.method, headers: { accept: "application/vnd.github+json", @@ -275,7 +565,10 @@ function createHeadlessGitHubService(projectRoot: string, logger: Logger): Headl } if (!response.ok) { const message = - typeof data === "object" && data && "message" in data && typeof (data as { message?: unknown }).message === "string" + typeof data === "object" && + data && + "message" in data && + typeof (data as { message?: unknown }).message === "string" ? String((data as { message?: unknown }).message) : `GitHub API request failed (HTTP ${response.status})`; throw new Error(message); @@ -283,116 +576,589 @@ function createHeadlessGitHubService(projectRoot: string, logger: Logger): Headl return { data: data as never, response }; }; + const apiRequestAllPages = async (args: { + path: string; + query?: Record; + token?: string; + }): Promise => { + const first = await apiRequest({ method: "GET", ...args }); + const out = Array.isArray(first.data) ? [...first.data] : []; + let nextUrl = parseNextGitHubLink( + first.response?.headers.get("link") ?? null, + ); + while (nextUrl) { + const url = new URL(nextUrl); + const next = await apiRequest({ + method: "GET", + path: `${url.pathname}${url.search}`, + token: args.token, + }); + if (Array.isArray(next.data)) out.push(...next.data); + nextUrl = parseNextGitHubLink(next.response?.headers.get("link") ?? null); + } + return out; + }; + + const createRepository = async (args: { + name: string; + description?: string; + isPrivate: boolean; + }): Promise<{ + cloneUrl: string; + sshUrl: string; + htmlUrl: string; + defaultBranch: string; + }> => { + const body: Record = { + name: args.name, + private: args.isPrivate, + auto_init: false, + }; + if (args.description != null && args.description.trim().length > 0) { + body.description = args.description.trim(); + } + const { data } = await apiRequest>({ + method: "POST", + path: "/user/repos", + body, + }); + return { + cloneUrl: asString(data.clone_url), + sshUrl: asString(data.ssh_url), + htmlUrl: asString(data.html_url), + defaultBranch: asString(data.default_branch) || "main", + }; + }; + + const getRepository = async ( + owner: string, + name: string, + ): Promise<{ + cloneUrl: string; + sshUrl: string; + htmlUrl: string; + defaultBranch: string; + size: number; + }> => { + const { data } = await apiRequest>({ + method: "GET", + path: `/repos/${encodeURIComponent(owner)}/${encodeURIComponent(name)}`, + }); + return { + cloneUrl: asString(data.clone_url), + sshUrl: asString(data.ssh_url), + htmlUrl: asString(data.html_url), + defaultBranch: asString(data.default_branch) || "main", + size: typeof data.size === "number" ? data.size : 0, + }; + }; + return { - async getStatus() { + async getStatus(opts: { forceRefresh?: boolean } = {}) { + if (opts.forceRefresh) { + cachedStatus = null; + cachedAt = 0; + } const now = Date.now(); - if (cachedStatus && now - cachedAt < 30_000) return { ...cachedStatus, repo: detectGitHubRepo(projectRoot) }; const repo = detectGitHubRepo(projectRoot); - const tokenStored = Boolean(getToken()); - const status: HeadlessGitHubStatus = { - tokenStored, - tokenDecryptionFailed: false, - storageScope: "app", - tokenType: tokenStored ? getTokenType(getToken()) : "unknown", - repo, - userLogin: null, - scopes: [], - checkedAt: tokenStored ? new Date(now).toISOString() : null, - }; - cachedStatus = status; - cachedAt = now; - return status; + const hasOrigin = Boolean(readGitOrigin(projectRoot)); + if (cachedStatus && now - cachedAt < 30_000) { + const repoChanged = + (cachedStatus.repo?.owner ?? null) !== (repo?.owner ?? null) || + (cachedStatus.repo?.name ?? null) !== (repo?.name ?? null); + const repoAccessOk = repoChanged ? null : cachedStatus.repoAccessOk; + const repoAccessError = repoChanged + ? null + : cachedStatus.repoAccessError; + return { + ...cachedStatus, + repo, + hasOrigin, + repoAccessOk, + repoAccessError, + connected: computeConnected({ + tokenStored: cachedStatus.tokenStored, + userLogin: cachedStatus.userLogin, + tokenType: cachedStatus.tokenType, + scopes: cachedStatus.scopes, + repo, + repoAccessOk, + }), + }; + } + const token = getToken(); + if (!token) { + const status: HeadlessGitHubStatus = { + tokenStored: false, + tokenDecryptionFailed, + storageScope: "app", + tokenType: "unknown", + repo, + hasOrigin, + userLogin: null, + scopes: [], + checkedAt: null, + repoAccessOk: null, + repoAccessError: null, + connected: false, + }; + cachedStatus = status; + cachedAt = now; + return status; + } + + try { + const validated = await validateToken(token); + let repoAccessOk: boolean | null = null; + let repoAccessError: string | null = null; + if (repo) { + const probe = await probeRepoAccess(token, repo); + repoAccessOk = probe.ok; + repoAccessError = probe.error; + if (!probe.ok) { + logger.warn("github.repo_probe_failed", { + repo: `${repo.owner}/${repo.name}`, + tokenType: validated.tokenType, + error: probe.error, + }); + } + } + const status: HeadlessGitHubStatus = { + tokenStored: true, + tokenDecryptionFailed: false, + storageScope: "app", + tokenType: validated.tokenType, + repo, + hasOrigin, + userLogin: validated.userLogin, + scopes: validated.scopes, + checkedAt: new Date(now).toISOString(), + repoAccessOk, + repoAccessError, + connected: computeConnected({ + tokenStored: true, + userLogin: validated.userLogin, + tokenType: validated.tokenType, + scopes: validated.scopes, + repo, + repoAccessOk, + }), + }; + cachedStatus = status; + cachedAt = now; + return status; + } catch (error) { + logger.warn("github.token_validation_failed", { + error: error instanceof Error ? error.message : String(error), + }); + const status: HeadlessGitHubStatus = { + tokenStored: true, + tokenDecryptionFailed: false, + storageScope: "app", + tokenType: getTokenType(token), + repo, + hasOrigin, + userLogin: null, + scopes: [], + checkedAt: new Date(now).toISOString(), + repoAccessOk: null, + repoAccessError: null, + connected: false, + }; + cachedStatus = status; + cachedAt = now; + return status; + } }, async detectRepo() { return detectGitHubRepo(projectRoot); }, async getRepoOrThrow() { const repo = detectGitHubRepo(projectRoot); - if (!repo) throw new Error("Unable to detect GitHub repo from git remote 'origin'."); + if (!repo) + throw new Error( + "Unable to detect GitHub repo from git remote 'origin'.", + ); return repo; }, getTokenOrThrow() { const token = getToken(); - if (!token) throw new Error("GitHub token missing. Set ADE_GITHUB_TOKEN or GITHUB_TOKEN, or run `gh auth login`."); + if (!token) + throw new Error( + "GitHub token missing. Set ADE_GITHUB_TOKEN or GITHUB_TOKEN, or run `gh auth login`.", + ); return token; }, + parseGitHubRepoFromRemoteUrl, + setToken(nextToken: string) { + tokenOverride = nextToken.trim(); + credentialStore.setSync(tokenKey, tokenOverride); + tokenDecryptionFailed = false; + cachedStatus = null; + cachedAt = 0; + }, + clearToken() { + tokenOverride = ""; + credentialStore.deleteSync(tokenKey); + tokenDecryptionFailed = false; + cachedStatus = null; + cachedAt = 0; + }, apiRequest, + async listRepoLabels(owner, name) { + return apiRequestAllPages({ + path: `/repos/${encodeURIComponent(owner)}/${encodeURIComponent(name)}/labels`, + query: { per_page: 100 }, + }); + }, + async listRepoCollaborators(owner, name) { + return apiRequestAllPages({ + path: `/repos/${encodeURIComponent(owner)}/${encodeURIComponent(name)}/collaborators`, + query: { per_page: 100 }, + }); + }, + async publishCurrentProject(args) { + const token = getToken(); + if (!token) { + const err = new Error( + "GitHub is not connected. Add a token in Settings.", + ) as Error & { code?: string }; + err.code = "github_not_connected"; + throw err; + } + + const existingRemote = runGitHeadless( + projectRoot, + ["remote", "get-url", "origin"], + 8_000, + ); + if ( + existingRemote.exitCode === 0 && + existingRemote.stdout.trim().length > 0 + ) { + const err = new Error( + "This project already has a GitHub remote named 'origin'.", + ) as Error & { code?: string }; + err.code = "remote_already_exists"; + throw err; + } + + let created: { + cloneUrl: string; + sshUrl: string; + htmlUrl: string; + defaultBranch: string; + }; + try { + created = await createRepository(args); + } catch (createErr) { + const message = + createErr instanceof Error ? createErr.message : String(createErr); + const isNameTaken = /already exists/i.test(message); + if (!isNameTaken) throw createErr; + + const validated = await validateToken(token).catch(() => ({ + userLogin: null as string | null, + })); + const owner = validated.userLogin; + if (!owner) throw createErr; + + const existing = await getRepository(owner, args.name); + if (existing.size > 0) { + const taken = new Error( + `A GitHub repo named '${args.name}' already exists on your account and contains commits. Pick a different name.`, + ) as Error & { code?: string }; + taken.code = "repo_name_taken"; + throw taken; + } + created = { + cloneUrl: existing.cloneUrl, + sshUrl: existing.sshUrl, + htmlUrl: existing.htmlUrl, + defaultBranch: existing.defaultBranch, + }; + } + + const cleanupLocalOrigin = (): void => { + runGitHeadless(projectRoot, ["remote", "remove", "origin"], 8_000); + }; + + const remoteAddRes = runGitHeadless( + projectRoot, + ["remote", "add", "origin", created.cloneUrl], + 8_000, + ); + if (remoteAddRes.exitCode !== 0) { + cleanupLocalOrigin(); + throw new Error( + `Failed to add origin remote: ${remoteAddRes.stderr.trim() || `exit ${remoteAddRes.exitCode}`}`, + ); + } + + const headRes = runGitHeadless( + projectRoot, + ["rev-parse", "--verify", "HEAD"], + 5_000, + ); + let resultState: "pushed" | "remote_added"; + if (headRes.exitCode === 0) { + const pushRes = runGitHeadless( + projectRoot, + ["push", "-u", "origin", "HEAD"], + 5 * 60_000, + ); + if (pushRes.exitCode !== 0) { + cleanupLocalOrigin(); + throw new Error( + `Failed to push to origin: ${pushRes.stderr.trim() || `exit ${pushRes.exitCode}`}`, + ); + } + resultState = "pushed"; + } else { + resultState = "remote_added"; + } + + cachedStatus = null; + cachedAt = 0; + + return { state: resultState, htmlUrl: created.htmlUrl }; + }, async addIssueComment(owner, name, number, body) { - return (await apiRequest({ - method: "POST", - path: `/repos/${encodeURIComponent(owner)}/${encodeURIComponent(name)}/issues/${number}/comments`, - body: { body }, - })).data; + return ( + await apiRequest({ + method: "POST", + path: `/repos/${encodeURIComponent(owner)}/${encodeURIComponent(name)}/issues/${number}/comments`, + body: { body }, + }) + ).data; }, async setIssueLabels(owner, name, number, labels) { - return (await apiRequest({ - method: "PUT", - path: `/repos/${encodeURIComponent(owner)}/${encodeURIComponent(name)}/issues/${number}/labels`, - body: { labels }, - })).data; + return ( + await apiRequest({ + method: "PUT", + path: `/repos/${encodeURIComponent(owner)}/${encodeURIComponent(name)}/issues/${number}/labels`, + body: { labels }, + }) + ).data; }, async closeIssue(owner, name, number, reason) { - return (await apiRequest({ - method: "PATCH", - path: `/repos/${encodeURIComponent(owner)}/${encodeURIComponent(name)}/issues/${number}`, - body: { state: "closed", ...(reason ? { state_reason: reason } : {}) }, - })).data; + return ( + await apiRequest({ + method: "PATCH", + path: `/repos/${encodeURIComponent(owner)}/${encodeURIComponent(name)}/issues/${number}`, + body: { + state: "closed", + ...(reason ? { state_reason: reason } : {}), + }, + }) + ).data; }, async reopenIssue(owner, name, number) { - return (await apiRequest({ - method: "PATCH", - path: `/repos/${encodeURIComponent(owner)}/${encodeURIComponent(name)}/issues/${number}`, - body: { state: "open" }, - })).data; + return ( + await apiRequest({ + method: "PATCH", + path: `/repos/${encodeURIComponent(owner)}/${encodeURIComponent(name)}/issues/${number}`, + body: { state: "open" }, + }) + ).data; }, async assignIssue(owner, name, number, assignees) { - return (await apiRequest({ - method: "POST", - path: `/repos/${encodeURIComponent(owner)}/${encodeURIComponent(name)}/issues/${number}/assignees`, - body: { assignees }, - })).data; + return ( + await apiRequest({ + method: "POST", + path: `/repos/${encodeURIComponent(owner)}/${encodeURIComponent(name)}/issues/${number}/assignees`, + body: { assignees }, + }) + ).data; }, async setIssueTitle(owner, name, number, title) { - return (await apiRequest({ - method: "PATCH", - path: `/repos/${encodeURIComponent(owner)}/${encodeURIComponent(name)}/issues/${number}`, - body: { title }, - })).data; + return ( + await apiRequest({ + method: "PATCH", + path: `/repos/${encodeURIComponent(owner)}/${encodeURIComponent(name)}/issues/${number}`, + body: { title }, + }) + ).data; }, }; } function createHeadlessLinearCredentialService(): HeadlessLinearCredentialService { - let token = envToken("ADE_LINEAR_API", "LINEAR_API_KEY", "ADE_LINEAR_TOKEN", "LINEAR_TOKEN") ?? ""; + const credentialStore = new EncryptedFileCredentialStore(); + const tokenKey = "linear.token.v1"; + const authModeKey = "linear.authMode.v1"; + const tokenExpiresAtKey = "linear.tokenExpiresAt.v1"; + const refreshTokenKey = "linear.refreshToken.v1"; + const oauthClientKey = "linear.oauthClient.v1"; + let tokenOverride: string | null = null; + let tokenDecryptionFailed = false; + + const readCredential = (key: string): string | null => { + try { + const stored = credentialStore.getSync(key); + tokenDecryptionFailed = false; + return stored?.trim() || null; + } catch { + tokenDecryptionFailed = true; + return null; + } + }; + + const writeCredential = ( + key: string, + value: string | null | undefined, + ): void => { + if (value?.trim()) { + credentialStore.setSync(key, value.trim()); + } else { + credentialStore.deleteSync(key); + } + tokenDecryptionFailed = false; + }; + + const readToken = (): { + token: string; + source: "stored" | "env" | "override" | null; + } => { + if (tokenOverride != null) { + return { + token: tokenOverride, + source: tokenOverride.trim().length > 0 ? "override" : null, + }; + } + const stored = readCredential(tokenKey); + if (stored) return { token: stored, source: "stored" }; + const envValue = + envToken( + "ADE_LINEAR_API", + "LINEAR_API_KEY", + "ADE_LINEAR_TOKEN", + "LINEAR_TOKEN", + ) ?? ""; + return { + token: envValue, + source: envValue.trim().length > 0 ? "env" : null, + }; + }; + + const readOAuthClientCredentials = (): { + clientId: string; + clientSecret: string | null; + } | null => { + const raw = readCredential(oauthClientKey); + if (!raw) { + return BUNDLED_LINEAR_OAUTH_CLIENT_ID + ? { clientId: BUNDLED_LINEAR_OAUTH_CLIENT_ID, clientSecret: null } + : null; + } + try { + const parsed = JSON.parse(raw) as unknown; + if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) + return null; + const record = parsed as Record; + const clientId = + typeof record.clientId === "string" ? record.clientId.trim() : ""; + if (!clientId) return null; + return { + clientId, + clientSecret: + typeof record.clientSecret === "string" && + record.clientSecret.trim().length > 0 + ? record.clientSecret.trim() + : null, + }; + } catch { + return null; + } + }; + return { getStatus() { + const { token, source } = readToken(); + const authMode = + source === "stored" || source === "override" + ? readCredential(authModeKey) === "oauth" + ? "oauth" + : "manual" + : token.trim().length > 0 + ? "manual" + : null; return { tokenStored: token.trim().length > 0, - tokenDecryptionFailed: false, + tokenDecryptionFailed, storageScope: "app", repo: null, userLogin: null, scopes: [], checkedAt: token.trim().length > 0 ? new Date().toISOString() : null, - authMode: token.trim().length > 0 ? "manual" : null, + authMode, + tokenExpiresAt: readCredential(tokenExpiresAtKey), + refreshTokenStored: Boolean(readCredential(refreshTokenKey)), + oauthConfigured: readOAuthClientCredentials() != null, }; }, getTokenOrThrow() { + const { token } = readToken(); if (!token.trim()) { - throw new Error("Linear token missing. Set ADE_LINEAR_API, LINEAR_API_KEY, ADE_LINEAR_TOKEN, or LINEAR_TOKEN for headless mode."); + throw new Error( + "Linear token missing. Set ADE_LINEAR_API, LINEAR_API_KEY, ADE_LINEAR_TOKEN, or LINEAR_TOKEN for headless mode.", + ); } return token.trim(); }, setToken(nextToken: string) { - token = nextToken.trim(); + tokenOverride = nextToken.trim(); + writeCredential(tokenKey, tokenOverride); + writeCredential(authModeKey, "manual"); + writeCredential(refreshTokenKey, null); + writeCredential(tokenExpiresAtKey, null); + }, + setOAuthToken(args: { + accessToken: string; + refreshToken?: string | null; + expiresAt?: string | null; + }) { + tokenOverride = args.accessToken.trim(); + writeCredential(tokenKey, tokenOverride); + writeCredential(authModeKey, "oauth"); + writeCredential(refreshTokenKey, args.refreshToken); + writeCredential(tokenExpiresAtKey, args.expiresAt); }, clearToken() { - token = ""; + tokenOverride = ""; + writeCredential(tokenKey, null); + writeCredential(authModeKey, null); + writeCredential(refreshTokenKey, null); + writeCredential(tokenExpiresAtKey, null); + }, + setOAuthClientCredentials(args: { + clientId: string; + clientSecret?: string | null; + }) { + const clientId = args.clientId.trim(); + if (!clientId.length) { + throw new Error("A Linear OAuth client ID is required."); + } + writeCredential( + oauthClientKey, + JSON.stringify({ + clientId, + clientSecret: args.clientSecret?.trim() || null, + }), + ); + }, + clearOAuthClientCredentials() { + writeCredential(oauthClientKey, null); + }, + getOAuthClientCredentials() { + return readOAuthClientCredentials(); }, }; } -function createHeadlessAgentChatService(projectRoot: string): HeadlessLinearServices["agentChatService"] { +function createHeadlessAgentChatService( + projectRoot: string, +): HeadlessLinearServices["agentChatService"] { const sessions = new Map(); const identitySessionIds = new Map(); const transcripts = new Map(); @@ -416,7 +1182,9 @@ function createHeadlessAgentChatService(projectRoot: string): HeadlessLinearServ ? `Headless ADE session for ${identityKey}. Automatic agent execution is not available in this runtime.` : "Headless ADE chat session. Automatic agent execution is not available in this runtime."; - const resolveHeadlessModel = (modelId?: string | null): { modelId: string; model: string } => { + const resolveHeadlessModel = ( + modelId?: string | null, + ): { modelId: string; model: string } => { const requested = modelId?.trim() || HEADLESS_MODEL_ID; const descriptor = getModelById(requested) ?? resolveModelAlias(requested); if (descriptor) { @@ -464,9 +1232,12 @@ function createHeadlessAgentChatService(projectRoot: string): HeadlessLinearServ status: args.status ?? existing.status, endedAt: args.endedAt === undefined ? existing.endedAt : args.endedAt, identityKey: args.identityKey ?? existing.identityKey, - reasoningEffort: args.reasoningEffort ?? existing.reasoningEffort ?? null, + reasoningEffort: + args.reasoningEffort ?? existing.reasoningEffort ?? null, permissionMode: args.permissionMode ?? existing.permissionMode, - summary: existing.summary ?? defaultSummary(args.identityKey ?? existing.identityKey), + summary: + existing.summary ?? + defaultSummary(args.identityKey ?? existing.identityKey), lastActivityAt: now, }; sessions.set(sessionId, updated); @@ -492,7 +1263,9 @@ function createHeadlessAgentChatService(projectRoot: string): HeadlessLinearServ lastOutputPreview: null, summary: defaultSummary(args.identityKey), ...(args.identityKey ? { identityKey: args.identityKey } : {}), - ...(args.reasoningEffort !== undefined ? { reasoningEffort: args.reasoningEffort } : {}), + ...(args.reasoningEffort !== undefined + ? { reasoningEffort: args.reasoningEffort } + : {}), ...(args.permissionMode ? { permissionMode: args.permissionMode } : {}), }; sessions.set(sessionId, created); @@ -505,14 +1278,28 @@ function createHeadlessAgentChatService(projectRoot: string): HeadlessLinearServ return { async listSessions() { - return Array.from(sessions.values()).sort((left, right) => Date.parse(right.lastActivityAt) - Date.parse(left.lastActivityAt)); + return Array.from(sessions.values()).sort( + (left, right) => + Date.parse(right.lastActivityAt) - Date.parse(left.lastActivityAt), + ); }, async getSessionSummary(sessionId: string) { return sessions.get(sessionId.trim()) ?? null; }, - async getChatTranscript({ sessionId, limit, maxChars }: { sessionId: string; limit?: number; maxChars?: number }) { + async getChatTranscript({ + sessionId, + limit, + maxChars, + }: { + sessionId: string; + limit?: number; + maxChars?: number; + }) { const safeLimit = Math.max(1, Math.min(500, Math.floor(limit ?? 100))); - const safeMaxChars = Math.max(32, Math.min(20_000, Math.floor(maxChars ?? 4_000))); + const safeMaxChars = Math.max( + 32, + Math.min(20_000, Math.floor(maxChars ?? 4_000)), + ); const source = ensureTranscript(sessionId.trim()); const entries = source.slice(-safeLimit).map((entry) => ({ ...entry, @@ -521,7 +1308,9 @@ function createHeadlessAgentChatService(projectRoot: string): HeadlessLinearServ return { sessionId, entries, - truncated: source.length > entries.length || entries.some((entry) => entry.text.length >= safeMaxChars), + truncated: + source.length > entries.length || + entries.some((entry) => entry.text.length >= safeMaxChars), totalEntries: source.length, }; }, @@ -532,8 +1321,14 @@ function createHeadlessAgentChatService(projectRoot: string): HeadlessLinearServ return ensureSession({ laneId: args.laneId, title: args.title }); }, async updateSession(args: { sessionId: string; title?: string | null }) { - const existing = sessions.get(args.sessionId) ?? ensureSession({ sessionId: args.sessionId, laneId: "lane-headless" }); - return ensureSession({ sessionId: existing.id, laneId: existing.laneId, title: args.title ?? existing.title }); + const existing = + sessions.get(args.sessionId) ?? + ensureSession({ sessionId: args.sessionId, laneId: "lane-headless" }); + return ensureSession({ + sessionId: existing.id, + laneId: existing.laneId, + title: args.title ?? existing.title, + }); }, async sendMessage(args: { sessionId: string; text: string }) { const sessionId = args.sessionId.trim(); @@ -544,12 +1339,19 @@ function createHeadlessAgentChatService(projectRoot: string): HeadlessLinearServ text: args.text, timestamp: new Date().toISOString(), }); - sessions.set(sessionId, { ...existing, lastActivityAt: new Date().toISOString() }); + sessions.set(sessionId, { + ...existing, + lastActivityAt: new Date().toISOString(), + }); } }, async interrupt(args: { sessionId: string }) { const existing = sessions.get(args.sessionId); - if (existing) sessions.set(args.sessionId, { ...existing, lastActivityAt: new Date().toISOString() }); + if (existing) + sessions.set(args.sessionId, { + ...existing, + lastActivityAt: new Date().toISOString(), + }); }, async resumeSession(args: { sessionId: string }) { return ensureSession({ @@ -563,7 +1365,10 @@ function createHeadlessAgentChatService(projectRoot: string): HeadlessLinearServ const existing = sessions.get(args.sessionId); sessions.delete(args.sessionId); transcripts.delete(args.sessionId); - if (existing?.identityKey && identitySessionIds.get(existing.identityKey) === args.sessionId) { + if ( + existing?.identityKey && + identitySessionIds.get(existing.identityKey) === args.sessionId + ) { identitySessionIds.delete(existing.identityKey); } }, @@ -607,7 +1412,9 @@ function createHeadlessAgentChatService(projectRoot: string): HeadlessLinearServ }; } -function createHeadlessWorkerHeartbeatService(): ReturnType { +function createHeadlessWorkerHeartbeatService(): ReturnType< + typeof createWorkerHeartbeatService +> { const runs: Array<{ id: string; agentId: string; @@ -633,7 +1440,13 @@ function createHeadlessWorkerHeartbeatService(): ReturnType }) { + async triggerWakeup(args: { + agentId: string; + reason?: string; + taskKey?: string | null; + issueKey?: string | null; + context?: Record; + }) { const runId = `wake-${randomUUID()}`; const now = new Date().toISOString(); runs.unshift({ @@ -644,7 +1457,8 @@ function createHeadlessWorkerHeartbeatService(): ReturnType; } -export function createHeadlessLinearServices(args: HeadlessLinearDeps): HeadlessLinearServices { +export function createHeadlessLinearServices( + args: HeadlessLinearDeps, +): HeadlessLinearServices { const automationSecretService = createAutomationSecretServiceImpl({ adeDir: args.adeDir, logger: args.logger, }); - const linearCredentialService = createHeadlessLinearCredentialService() as any; - const githubService = createHeadlessGitHubService(args.projectRoot, args.logger); + const linearCredentialService = + createHeadlessLinearCredentialService() as any; + const githubService = createHeadlessGitHubService( + args.projectRoot, + args.logger, + ); const linearClient = createLinearClientImpl({ credentials: linearCredentialService as any, logger: args.logger, }); const issueTracker = createLinearIssueTrackerImpl({ client: linearClient }); - const templateService = createLinearTemplateServiceImpl({ adeDir: args.adeDir }); - const workflowFileService = createLinearWorkflowFileServiceImpl({ projectRoot: args.projectRoot }); + const templateService = createLinearTemplateServiceImpl({ + adeDir: args.adeDir, + }); + const workflowFileService = createLinearWorkflowFileServiceImpl({ + projectRoot: args.projectRoot, + }); const flowPolicyService = createFlowPolicyServiceImpl({ db: args.db, projectId: args.projectId, @@ -709,7 +1533,9 @@ export function createHeadlessLinearServices(args: HeadlessLinearDeps): Headless } as any; const ptyService = { create: async () => { - throw new Error("PTY-backed run commands are unavailable in headless Linear services."); + throw new Error( + "PTY-backed run commands are unavailable in headless Linear services.", + ); }, dispose: () => {}, onData: () => () => {}, @@ -743,8 +1569,13 @@ export function createHeadlessLinearServices(args: HeadlessLinearDeps): Headless }); const workerHeartbeatService = createHeadlessWorkerHeartbeatService(); const agentChatService = createHeadlessAgentChatService(args.projectRoot); - if (typeof (prService as { setAgentChatService?: (svc: unknown) => void }).setAgentChatService === "function") { - (prService as { setAgentChatService: (svc: unknown) => void }).setAgentChatService(agentChatService as never); + if ( + typeof (prService as { setAgentChatService?: (svc: unknown) => void }) + .setAgentChatService === "function" + ) { + ( + prService as { setAgentChatService: (svc: unknown) => void } + ).setAgentChatService(agentChatService as never); } const closeoutService = createLinearCloseoutServiceImpl({ issueTracker, @@ -784,7 +1615,8 @@ export function createHeadlessLinearServices(args: HeadlessLinearDeps): Headless hasCredentials: () => linearCredentialService.getStatus().tokenStored, }); const handleIngressEvent = async (event: { issueId?: string | null }) => { - const issueId = typeof event.issueId === "string" ? event.issueId.trim() : ""; + const issueId = + typeof event.issueId === "string" ? event.issueId.trim() : ""; if (!issueId) return; await syncService.processIssueUpdate(issueId); }; @@ -793,7 +1625,9 @@ export function createHeadlessLinearServices(args: HeadlessLinearDeps): Headless logger: args.logger, projectId: args.projectId, linearClient, - secretService: automationSecretService as ReturnType, + secretService: automationSecretService as ReturnType< + typeof createAutomationSecretService + >, onEvent: handleIngressEvent, }); @@ -819,31 +1653,18 @@ export function createHeadlessLinearServices(args: HeadlessLinearDeps): Headless workerTaskSessionService, workerHeartbeatService, dispose: () => { - try { - syncService.dispose(); - } catch { - // ignore - } - try { - ingressService.dispose(); - } catch { - // ignore - } - try { - fileService.dispose(); - } catch { - // ignore - } - try { - processService.disposeAll(); - } catch { - // ignore - } - try { - workerHeartbeatService.dispose(); - } catch { - // ignore - } + const swallow = (fn: () => void) => { + try { + fn(); + } catch { + /* ignore */ + } + }; + swallow(() => syncService.dispose()); + swallow(() => ingressService.dispose()); + swallow(() => fileService.dispose()); + swallow(() => processService.disposeAll()); + swallow(() => workerHeartbeatService.dispose()); }, }; } diff --git a/apps/ade-cli/src/multiProjectRpcServer.test.ts b/apps/ade-cli/src/multiProjectRpcServer.test.ts new file mode 100644 index 000000000..965083e09 --- /dev/null +++ b/apps/ade-cli/src/multiProjectRpcServer.test.ts @@ -0,0 +1,427 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { describe, expect, it, vi } from "vitest"; +import { createEventBuffer } from "./eventBuffer"; +import { createMultiProjectRpcRequestHandler } from "./multiProjectRpcServer"; +import { ProjectRegistry } from "./services/projects/projectRegistry"; +import { ProjectScopeRegistry } from "./services/projects/projectScope"; + +function createRegistry() { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "ade-multi-project-rpc-")); + const projectRoot = path.join(root, "project"); + fs.mkdirSync(projectRoot, { recursive: true }); + const registry = new ProjectRegistry({ + adeDir: path.join(root, "home"), + projectsPath: path.join(root, "home", "projects.json"), + secretsDir: path.join(root, "home", "secrets"), + sockDir: path.join(root, "home", "sock"), + socketPath: path.join(root, "home", "sock", "ade.sock"), + binDir: path.join(root, "home", "bin"), + runtimeDir: path.join(root, "home", "runtime"), + }); + return { root, projectRoot, registry }; +} + +function makeRuntime(label: string) { + return { + operationService: { + start: vi.fn(() => ({ operationId: `${label}-operation`, startedAt: "2026-05-10T00:00:00.000Z" })), + finish: vi.fn(), + }, + laneService: { + list: vi.fn(async () => [{ id: `${label}-lane`, name: label }]), + }, + syncService: { + getStatus: vi.fn(async () => ({ role: "brain", label })), + }, + eventBuffer: createEventBuffer(), + dispose: vi.fn(), + }; +} + +describe("multi-project RPC server", () => { + it("exposes runtime-scoped project registry methods", async () => { + const { projectRoot, registry } = createRegistry(); + const handler = createMultiProjectRpcRequestHandler({ + serverVersion: "test", + projectRegistry: registry, + }); + + await handler({ + jsonrpc: "2.0", + id: 1, + method: "ade/initialize", + params: { protocolVersion: "test" }, + }); + + const added = await handler({ + jsonrpc: "2.0", + id: 2, + method: "projects.add", + params: { rootPath: projectRoot }, + }); + expect(added).toMatchObject({ + rootPath: projectRoot, + displayName: "project", + gitOriginUrl: null, + }); + + const listed = await handler({ + jsonrpc: "2.0", + id: 3, + method: "projects.list", + params: {}, + }); + expect(listed).toEqual([added]); + + const projectId = (added as { projectId: string }).projectId; + const touched = await handler({ + jsonrpc: "2.0", + id: 4, + method: "projects.touch", + params: { projectId }, + }); + expect((touched as { projectId: string }).projectId).toBe(projectId); + + await handler({ + jsonrpc: "2.0", + id: 5, + method: "projects.remove", + params: { projectId }, + }); + expect(await handler({ jsonrpc: "2.0", id: 6, method: "projects.list", params: {} })).toEqual([]); + + handler.dispose(); + }); + + it("requires projectId for project-scoped methods", async () => { + const { registry } = createRegistry(); + const handler = createMultiProjectRpcRequestHandler({ + serverVersion: "test", + projectRegistry: registry, + }); + + await handler({ + jsonrpc: "2.0", + id: 1, + method: "ade/initialize", + params: {}, + }); + + await expect(handler({ + jsonrpc: "2.0", + id: 2, + method: "ade/actions/list", + params: {}, + })).rejects.toThrow("requires params.projectId"); + + handler.dispose(); + }); + + it("passes runtime capability flags into project scopes", async () => { + const { projectRoot, registry } = createRegistry(); + const added = registry.add(projectRoot); + const runtime = { + capabilities: { memory: false }, + dispose: vi.fn(), + }; + const scopeRegistry = { + get: vi.fn(async () => ({ + registryProjectId: added.projectId, + record: added, + runtime, + dispose: vi.fn(), + })), + dispose: vi.fn(), + disposeAll: vi.fn(), + } as unknown as ProjectScopeRegistry; + const handler = createMultiProjectRpcRequestHandler({ + serverVersion: "test", + projectRegistry: registry, + scopeRegistry, + }); + + const init = await handler({ + jsonrpc: "2.0", + id: 1, + method: "ade/initialize", + params: {}, + }); + expect(init).toMatchObject({ + runtimeInfo: { multiProject: true }, + capabilities: { projects: true }, + }); + + const actions = await handler({ + jsonrpc: "2.0", + id: 2, + method: "ade/actions/list", + params: { projectId: added.projectId }, + }) as { actions: Array<{ name: string }> }; + expect(actions.actions.some((entry) => entry.name.startsWith("memory_"))).toBe(false); + expect(scopeRegistry.get).toHaveBeenCalledWith(added.projectId); + + handler.dispose(); + }); + + it("exposes runtime sync PIN methods through the selected sync host scope", async () => { + const { projectRoot, registry } = createRegistry(); + const added = registry.add(projectRoot); + const syncService = { + getPin: vi.fn(() => "123456"), + setPin: vi.fn(async (pin: string) => ({ role: "brain", pairingPin: pin })), + generatePin: vi.fn(async () => ({ role: "brain", pairingPin: "111222" })), + clearPin: vi.fn(async () => ({ role: "brain", pairingPin: null })), + getStatus: vi.fn(async () => ({ role: "brain" })), + refreshDiscovery: vi.fn(), + listDevices: vi.fn(), + updateLocalDevice: vi.fn(async (args: { name?: string }) => ({ deviceId: "machine-1", name: args.name })), + forgetDevice: vi.fn(async (deviceId: string) => ({ role: "brain", forgotten: deviceId })), + setActiveLanePresence: vi.fn(async (_laneIds: string[]) => {}), + }; + const scopeRegistry = { + get: vi.fn(), + ensureSyncHost: vi.fn(async () => ({ + registryProjectId: added.projectId, + record: added, + runtime: { syncService }, + dispose: vi.fn(), + })), + dispose: vi.fn(), + disposeAll: vi.fn(), + } as unknown as ProjectScopeRegistry; + const handler = createMultiProjectRpcRequestHandler({ + serverVersion: "test", + projectRegistry: registry, + scopeRegistry, + }); + + await handler({ + jsonrpc: "2.0", + id: 1, + method: "ade/initialize", + params: {}, + }); + + expect(await handler({ + jsonrpc: "2.0", + id: 2, + method: "sync.getPin", + params: { projectId: added.projectId }, + })).toEqual({ pin: "123456" }); + + expect(await handler({ + jsonrpc: "2.0", + id: 3, + method: "sync.setPin", + params: { projectId: added.projectId, pin: "654321" }, + })).toEqual({ role: "brain", pairingPin: "654321" }); + + expect(await handler({ + jsonrpc: "2.0", + id: 4, + method: "sync.generatePin", + params: { projectId: added.projectId }, + })).toEqual({ role: "brain", pairingPin: "111222" }); + + await handler({ + jsonrpc: "2.0", + id: 5, + method: "sync.clearPin", + params: { projectId: added.projectId }, + }); + + expect(await handler({ + jsonrpc: "2.0", + id: 6, + method: "sync.updateLocalDevice", + params: { projectId: added.projectId, name: "Mac Studio" }, + })).toEqual({ deviceId: "machine-1", name: "Mac Studio" }); + + expect(await handler({ + jsonrpc: "2.0", + id: 7, + method: "sync.forgetDevice", + params: { projectId: added.projectId, deviceId: "phone-1" }, + })).toEqual({ role: "brain", forgotten: "phone-1" }); + + expect(await handler({ + jsonrpc: "2.0", + id: 8, + method: "sync.setActiveLanePresence", + params: { projectId: added.projectId, laneIds: ["lane-1", 42, "lane-2"] }, + })).toBeNull(); + + expect(scopeRegistry.ensureSyncHost).toHaveBeenCalledWith(added.projectId); + expect(syncService.setPin).toHaveBeenCalledWith("654321"); + expect(syncService.generatePin).toHaveBeenCalledTimes(1); + expect(syncService.clearPin).toHaveBeenCalledTimes(1); + expect(syncService.updateLocalDevice).toHaveBeenCalledWith({ name: "Mac Studio" }); + expect(syncService.forgetDevice).toHaveBeenCalledWith("phone-1"); + expect(syncService.setActiveLanePresence).toHaveBeenCalledWith(["lane-1", "lane-2"]); + + handler.dispose(); + }); + + it("drops cached project handlers when the backing project scope is disposed", async () => { + const { projectRoot, registry } = createRegistry(); + const added = registry.add(projectRoot); + const firstRuntime = makeRuntime("first"); + const secondRuntime = makeRuntime("second"); + let disposeListener: ((projectId: string) => void) | null = null; + let getCount = 0; + const scopeRegistry = { + get: vi.fn(async () => ({ + registryProjectId: added.projectId, + record: added, + runtime: getCount++ === 0 ? firstRuntime : secondRuntime, + dispose: vi.fn(), + })), + ensureSyncHost: vi.fn(async () => { + disposeListener?.(added.projectId); + return { + registryProjectId: added.projectId, + record: added, + runtime: secondRuntime, + dispose: vi.fn(), + }; + }), + dispose: vi.fn(), + disposeAll: vi.fn(), + onDispose: vi.fn((listener: (projectId: string) => void) => { + disposeListener = listener; + return () => { + disposeListener = null; + }; + }), + } as unknown as ProjectScopeRegistry; + const handler = createMultiProjectRpcRequestHandler({ + serverVersion: "test", + projectRegistry: registry, + scopeRegistry, + }); + + await handler({ + jsonrpc: "2.0", + id: 1, + method: "ade/initialize", + params: {}, + }); + + const first = await handler({ + jsonrpc: "2.0", + id: 2, + method: "ade/actions/call", + params: { + projectId: added.projectId, + name: "run_ade_action", + arguments: { domain: "lane", action: "list" }, + }, + }) as { result: Array<{ id: string }> }; + expect(first.result[0]?.id).toBe("first-lane"); + + await handler({ + jsonrpc: "2.0", + id: 3, + method: "sync.getStatus", + params: { projectId: added.projectId }, + }); + + const second = await handler({ + jsonrpc: "2.0", + id: 4, + method: "ade/actions/call", + params: { + projectId: added.projectId, + name: "run_ade_action", + arguments: { domain: "lane", action: "list" }, + }, + }) as { result: Array<{ id: string }> }; + expect(second.result[0]?.id).toBe("second-lane"); + expect(scopeRegistry.get).toHaveBeenCalledTimes(2); + + handler.dispose(); + }); + + it("subscribes to project runtime events and emits JSON-RPC notifications", async () => { + const { projectRoot, registry } = createRegistry(); + const added = registry.add(projectRoot); + const eventBuffer = createEventBuffer(); + const scopeRegistry = { + get: vi.fn(async () => ({ + registryProjectId: added.projectId, + record: added, + runtime: { + eventBuffer, + dispose: vi.fn(), + }, + dispose: vi.fn(), + })), + ensureSyncHost: vi.fn(), + dispose: vi.fn(), + disposeAll: vi.fn(), + } as unknown as ProjectScopeRegistry; + const handler = createMultiProjectRpcRequestHandler({ + serverVersion: "test", + projectRegistry: registry, + scopeRegistry, + }); + const notify = vi.fn(); + handler.setNotifier(notify); + + await handler({ + jsonrpc: "2.0", + id: 1, + method: "ade/initialize", + params: {}, + }); + + const subscribed = await handler({ + jsonrpc: "2.0", + id: 2, + method: "runtimeEvents.subscribe", + params: { + projectId: added.projectId, + category: "runtime", + }, + }) as { subscriptionId: string }; + + eventBuffer.push({ + timestamp: "2026-05-10T00:00:00.000Z", + category: "runtime", + payload: { type: "file_change", event: { path: "README.md" } }, + }); + eventBuffer.push({ + timestamp: "2026-05-10T00:00:01.000Z", + category: "mission", + payload: { type: "ignored" }, + }); + + expect(notify).toHaveBeenCalledTimes(1); + expect(notify).toHaveBeenCalledWith("runtime/event", { + subscriptionId: subscribed.subscriptionId, + projectId: added.projectId, + event: expect.objectContaining({ + category: "runtime", + payload: { type: "file_change", event: { path: "README.md" } }, + }), + }); + + expect(await handler({ + jsonrpc: "2.0", + id: 3, + method: "runtimeEvents.unsubscribe", + params: { subscriptionId: subscribed.subscriptionId }, + })).toEqual({ removed: true }); + + eventBuffer.push({ + timestamp: "2026-05-10T00:00:02.000Z", + category: "runtime", + payload: { type: "file_change", event: { path: "package.json" } }, + }); + expect(notify).toHaveBeenCalledTimes(1); + + handler.dispose(); + }); +}); diff --git a/apps/ade-cli/src/multiProjectRpcServer.ts b/apps/ade-cli/src/multiProjectRpcServer.ts new file mode 100644 index 000000000..dff4ae43f --- /dev/null +++ b/apps/ade-cli/src/multiProjectRpcServer.ts @@ -0,0 +1,687 @@ +import { createAdeRpcRequestHandler } from "./adeRpcServer"; +import os from "node:os"; +import path from "node:path"; +import { browseProjectDirectories } from "../../desktop/src/main/services/projects/projectBrowserService"; +import { + getProjectDetail, + getProjectWorkSummary, +} from "../../desktop/src/main/services/projects/projectDetailService"; +import { createProjectScaffoldService } from "../../desktop/src/main/services/projects/projectScaffoldService"; +import type { Logger } from "../../desktop/src/main/services/logging/logger"; +import type { + CloneProjectInput, + CreateProjectInput, + ListMyGitHubReposInput, + ProjectBrowseInput, +} from "../../desktop/src/shared/types"; +import type { BufferedEvent } from "./eventBuffer"; +import { + JsonRpcError, + JsonRpcErrorCode, + type JsonRpcHandler, + type JsonRpcRequest, +} from "./jsonrpc"; +import { resolveMachineAdeLayout } from "./services/projects/machineLayout"; +import { + ProjectRegistry, + type ProjectId, +} from "./services/projects/projectRegistry"; +import { ProjectScopeRegistry } from "./services/projects/projectScope"; +import { createHeadlessGitHubService } from "./headlessLinearServices"; +import type { SyncPeerDeviceType } from "../../desktop/src/shared/types"; + +type HandlerEntry = { + handler: JsonRpcHandler & { dispose?: () => void }; +}; + +type RuntimeEventCategory = BufferedEvent["category"]; +type JsonRpcNotifier = (method: string, params?: unknown) => void; +type RuntimeEventSubscription = { + id: string; + projectId: ProjectId; + unsubscribe: () => void; +}; + +export type MultiProjectRpcHandlerOptions = { + serverVersion: string; + projectRegistry?: ProjectRegistry; + scopeRegistry?: ProjectScopeRegistry; + runtimeCapabilities?: { + memory?: boolean; + }; + disposeScopesOnDispose?: boolean; + onShutdown?: (() => void) | null; +}; + +const RUNTIME_METHODS = new Set([ + "ade/initialize", + "ade/initialized", + "ping", + "shutdown", + "exit", + "runtime/info", + "machineInfo.get", + "projects.list", + "projects.add", + "projects.remove", + "projects.touch", + "projects.browseDirectories", + "projects.getDetail", + "projects.getWorkSummary", + "projects.getDefaultParentDir", + "projects.create", + "projects.clone", + "projects.listMyGitHubRepos", + "runtimeEvents.subscribe", + "runtimeEvents.unsubscribe", + "sync.getStatus", + "sync.refreshDiscovery", + "sync.listDevices", + "sync.updateLocalDevice", + "sync.connectToBrain", + "sync.disconnectFromBrain", + "sync.forgetDevice", + "sync.getTransferReadiness", + "sync.transferBrainToLocal", + "sync.getPin", + "sync.setPin", + "sync.generatePin", + "sync.clearPin", + "sync.setActiveLanePresence", +]); + +function isRecord(value: unknown): value is Record { + return Boolean(value) && typeof value === "object" && !Array.isArray(value); +} + +function safeParams(value: unknown): Record { + return isRecord(value) ? value : {}; +} + +const machineProjectLogger: Logger = { + debug: () => {}, + info: () => {}, + warn: () => {}, + error: () => {}, +}; + +function readOptionalString(value: unknown): string | undefined { + return typeof value === "string" && value.trim().length > 0 + ? value.trim() + : undefined; +} + +function readProjectBrowseInput( + params: Record, +): ProjectBrowseInput { + const input: ProjectBrowseInput = {}; + const partialPath = readOptionalString(params.partialPath); + if (partialPath) input.partialPath = partialPath; + if (typeof params.cwd === "string") input.cwd = params.cwd.trim() || null; + if (typeof params.limit === "number" && Number.isFinite(params.limit)) + input.limit = params.limit; + return input; +} + +function readCreateProjectInput( + params: Record, +): CreateProjectInput { + const name = readOptionalString(params.name); + const parentDir = readOptionalString(params.parentDir); + if (!name) + throw new JsonRpcError( + JsonRpcErrorCode.invalidParams, + "projects.create requires name.", + ); + if (!parentDir) + throw new JsonRpcError( + JsonRpcErrorCode.invalidParams, + "projects.create requires parentDir.", + ); + return { name, parentDir }; +} + +function readCloneProjectInput( + params: Record, +): CloneProjectInput { + const url = readOptionalString(params.url); + const parentDir = readOptionalString(params.parentDir); + const name = readOptionalString(params.name); + const githubAuthHeader = readOptionalString(params.githubAuthHeader); + if (!url) + throw new JsonRpcError( + JsonRpcErrorCode.invalidParams, + "projects.clone requires url.", + ); + if (!parentDir) + throw new JsonRpcError( + JsonRpcErrorCode.invalidParams, + "projects.clone requires parentDir.", + ); + return { + url, + parentDir, + ...(name ? { name } : {}), + ...(githubAuthHeader ? { githubAuthHeader } : {}), + }; +} + +function readListMyReposInput( + params: Record, +): ListMyGitHubReposInput { + const search = readOptionalString(params.search); + return search ? { search } : {}; +} + +function createMachineProjectScaffoldService() { + const githubService = createHeadlessGitHubService( + process.cwd(), + machineProjectLogger, + ); + return createProjectScaffoldService({ + logger: machineProjectLogger, + githubService: githubService as never, + }); +} + +function defaultParentDir(projectRegistry: ProjectRegistry): string { + const first = projectRegistry.list()[0]?.rootPath; + if (first) return path.dirname(first); + return path.join(os.homedir(), "Projects"); +} + +function readProjectId(params: Record): ProjectId | null { + const value = params.projectId; + return typeof value === "string" && value.trim().length > 0 + ? value.trim() + : null; +} + +function omitProjectId( + params: Record, +): Record { + const { projectId: _projectId, ...rest } = params; + return rest; +} + +function readEventCategory(value: unknown): RuntimeEventCategory | null { + return value === "orchestrator" || + value === "dag_mutation" || + value === "runtime" || + value === "mission" + ? value + : null; +} + +function readCursor(value: unknown): number { + return typeof value === "number" && Number.isFinite(value) + ? Math.max(0, Math.floor(value)) + : 0; +} + +function readLimit(value: unknown): number { + return typeof value === "number" && Number.isFinite(value) + ? Math.max(1, Math.min(1000, Math.floor(value))) + : 100; +} + +export function createMultiProjectRpcRequestHandler( + options: MultiProjectRpcHandlerOptions, +): JsonRpcHandler & { + dispose: () => void; + setNotifier: (notify: JsonRpcNotifier | null) => void; +} { + const projectRegistry = options.projectRegistry ?? new ProjectRegistry(); + const handlers = new Map>(); + const eventSubscriptions = new Map(); + const disposeProjectRuntimeCaches = (projectId: ProjectId): void => { + const cached = handlers.get(projectId); + handlers.delete(projectId); + if (cached) { + void cached.then((entry) => entry.handler.dispose?.()).catch(() => {}); + } + for (const subscription of [...eventSubscriptions.values()]) { + if (subscription.projectId !== projectId) continue; + subscription.unsubscribe(); + eventSubscriptions.delete(subscription.id); + } + }; + const scopeRegistry = + options.scopeRegistry ?? + new ProjectScopeRegistry(projectRegistry, { + runtimeCapabilities: options.runtimeCapabilities, + }); + const removeScopeDisposeListener = + typeof (scopeRegistry as Partial).onDispose === + "function" + ? scopeRegistry.onDispose(disposeProjectRuntimeCaches) + : null; + let initializedParams: Record | null = null; + let notifier: JsonRpcNotifier | null = null; + let nextSubscriptionId = 1; + + const emitRuntimeEvent = ( + subscriptionId: string, + projectId: ProjectId, + event: BufferedEvent, + ): void => { + notifier?.("runtime/event", { + subscriptionId, + projectId, + event, + }); + }; + + const getProjectHandler = async ( + projectId: ProjectId, + ): Promise => { + const cached = handlers.get(projectId); + if (cached) return await cached; + + const pending = (async () => { + const scope = await scopeRegistry.get(projectId); + const handler = createAdeRpcRequestHandler({ + runtime: scope.runtime, + serverVersion: options.serverVersion, + onActionsListChanged: () => {}, + }); + if (initializedParams) { + await handler({ + jsonrpc: "2.0", + id: "initialize-project-scope", + method: "ade/initialize", + params: initializedParams, + }); + } + return { handler }; + })(); + handlers.set(projectId, pending); + + try { + return await pending; + } catch (error) { + handlers.delete(projectId); + throw error; + } + }; + + const subscribeRuntimeEvents = async (params: Record) => { + const projectId = readProjectId(params); + if (!projectId) { + throw new JsonRpcError( + JsonRpcErrorCode.invalidParams, + "runtimeEvents.subscribe requires projectId.", + ); + } + const category = + params.category == null ? null : readEventCategory(params.category); + if (params.category != null && !category) { + throw new JsonRpcError( + JsonRpcErrorCode.invalidParams, + "runtimeEvents.subscribe category is invalid.", + ); + } + const cursor = readCursor(params.cursor); + const limit = readLimit(params.limit); + const scope = await scopeRegistry.get(projectId); + const subscriptionId = `runtime-events-${nextSubscriptionId++}`; + const shouldForward = (event: BufferedEvent): boolean => + !category || event.category === category; + const unsubscribe = scope.runtime.eventBuffer.subscribe((event) => { + if (shouldForward(event)) + emitRuntimeEvent(subscriptionId, projectId, event); + }); + eventSubscriptions.set(subscriptionId, { + id: subscriptionId, + projectId, + unsubscribe, + }); + + const replay = scope.runtime.eventBuffer.drain(cursor, limit); + for (const event of replay.events) { + if (shouldForward(event)) + emitRuntimeEvent(subscriptionId, projectId, event); + } + return { + subscriptionId, + nextCursor: replay.nextCursor, + hasMore: replay.hasMore, + }; + }; + + const unsubscribeRuntimeEvents = (params: Record) => { + const subscriptionId = + typeof params.subscriptionId === "string" + ? params.subscriptionId.trim() + : ""; + if (!subscriptionId) { + throw new JsonRpcError( + JsonRpcErrorCode.invalidParams, + "runtimeEvents.unsubscribe requires subscriptionId.", + ); + } + const subscription = eventSubscriptions.get(subscriptionId); + if (!subscription) return { removed: false }; + subscription.unsubscribe(); + eventSubscriptions.delete(subscriptionId); + return { removed: true }; + }; + + const getSyncService = async (params: Record) => { + const projectId = readProjectId(params); + const scope = await scopeRegistry.ensureSyncHost(projectId ?? undefined); + const syncService = scope?.runtime.syncService ?? null; + if (!syncService) { + throw new JsonRpcError( + JsonRpcErrorCode.invalidRequest, + "Sync service is not available. Register a project first.", + ); + } + return syncService; + }; + + const handler = (async (request: JsonRpcRequest): Promise => { + const method = typeof request.method === "string" ? request.method : ""; + const params = safeParams(request.params); + + if (method === "ade/initialize") { + initializedParams = params; + return { + protocolVersion: + typeof params.protocolVersion === "string" + ? params.protocolVersion + : "2025-06-18", + runtimeInfo: { + name: "ade-rpc", + version: options.serverVersion, + buildHash: + typeof process.env.ADE_RUNTIME_BUILD_HASH === "string" && + process.env.ADE_RUNTIME_BUILD_HASH.trim() + ? process.env.ADE_RUNTIME_BUILD_HASH.trim() + : null, + multiProject: true, + }, + capabilities: { + actions: { + listChanged: true, + }, + projects: true, + machineProjects: { + browseDirectories: true, + getDetail: true, + getWorkSummary: true, + getDefaultParentDir: true, + create: true, + clone: true, + listMyGitHubRepos: true, + }, + }, + }; + } + + if (method === "ade/initialized") { + return null; + } + + if (!initializedParams) { + throw new JsonRpcError( + JsonRpcErrorCode.invalidRequest, + "Server must be initialized first.", + ); + } + + if (method === "ping") { + return { pong: true, at: new Date().toISOString() }; + } + + if (method === "runtime/info" || method === "machineInfo.get") { + const layout = resolveMachineAdeLayout(); + return { + version: options.serverVersion, + runtimeKind: "headless", + adeDir: layout.adeDir, + socketPath: layout.socketPath, + projectCount: projectRegistry.list().length, + }; + } + + if (method === "projects.list") { + return projectRegistry.list(); + } + + if (method === "projects.add") { + const rootPath = + typeof params.rootPath === "string" ? params.rootPath.trim() : ""; + if (!rootPath) { + throw new JsonRpcError( + JsonRpcErrorCode.invalidParams, + "projects.add requires rootPath.", + ); + } + return projectRegistry.add(rootPath); + } + + if (method === "projects.remove") { + const projectId = readProjectId(params); + if (!projectId) { + throw new JsonRpcError( + JsonRpcErrorCode.invalidParams, + "projects.remove requires projectId.", + ); + } + await scopeRegistry.dispose(projectId); + handlers.delete(projectId); + return { removed: projectRegistry.remove(projectId) }; + } + + if (method === "projects.touch") { + const projectId = readProjectId(params); + if (!projectId) { + throw new JsonRpcError( + JsonRpcErrorCode.invalidParams, + "projects.touch requires projectId.", + ); + } + return projectRegistry.touch(projectId); + } + + if (method === "projects.browseDirectories") { + return await browseProjectDirectories(readProjectBrowseInput(params)); + } + + if (method === "projects.getDetail") { + const rootPath = readOptionalString(params.rootPath); + if (!rootPath) { + throw new JsonRpcError( + JsonRpcErrorCode.invalidParams, + "projects.getDetail requires rootPath.", + ); + } + return await getProjectDetail(rootPath); + } + + if (method === "projects.getWorkSummary") { + const rootPath = readOptionalString(params.rootPath); + if (!rootPath) { + throw new JsonRpcError( + JsonRpcErrorCode.invalidParams, + "projects.getWorkSummary requires rootPath.", + ); + } + return await getProjectWorkSummary(rootPath); + } + + if (method === "projects.getDefaultParentDir") { + return defaultParentDir(projectRegistry); + } + + if (method === "projects.create") { + const result = + await createMachineProjectScaffoldService().createLocalProject( + readCreateProjectInput(params), + ); + return projectRegistry.add(result.rootPath); + } + + if (method === "projects.clone") { + const result = + await createMachineProjectScaffoldService().cloneRepository( + readCloneProjectInput(params), + ); + return projectRegistry.add(result.rootPath); + } + + if (method === "projects.listMyGitHubRepos") { + return await createMachineProjectScaffoldService().listMyGitHubRepos( + readListMyReposInput(params), + ); + } + + if (method === "runtimeEvents.subscribe") { + return await subscribeRuntimeEvents(params); + } + + if (method === "runtimeEvents.unsubscribe") { + return unsubscribeRuntimeEvents(params); + } + + if (method === "sync.getStatus") { + const syncService = await getSyncService(params); + return await syncService.getStatus({ + includeTransferReadiness: params.includeTransferReadiness === true, + forceTransferReadiness: params.forceTransferReadiness === true, + }); + } + + if (method === "sync.refreshDiscovery") { + return await (await getSyncService(params)).refreshDiscovery(); + } + + if (method === "sync.listDevices") { + return await (await getSyncService(params)).listDevices(); + } + + if (method === "sync.updateLocalDevice") { + const name = typeof params.name === "string" ? params.name : undefined; + const deviceType = + typeof params.deviceType === "string" + ? (params.deviceType as SyncPeerDeviceType) + : undefined; + return await ( + await getSyncService(params) + ).updateLocalDevice({ + ...(name !== undefined ? { name } : {}), + ...(deviceType !== undefined ? { deviceType } : {}), + }); + } + + if (method === "sync.connectToBrain") { + const syncService = await getSyncService(params); + return await syncService.connectToBrain( + omitProjectId(params) as Parameters< + typeof syncService.connectToBrain + >[0], + ); + } + + if (method === "sync.disconnectFromBrain") { + return await (await getSyncService(params)).disconnectFromBrain(); + } + + if (method === "sync.forgetDevice") { + const deviceId = + typeof params.deviceId === "string" ? params.deviceId : ""; + return await (await getSyncService(params)).forgetDevice(deviceId); + } + + if (method === "sync.getTransferReadiness") { + return await (await getSyncService(params)).getTransferReadiness(); + } + + if (method === "sync.transferBrainToLocal") { + return await (await getSyncService(params)).transferBrainToLocal(); + } + + if (method === "sync.getPin") { + return { pin: (await getSyncService(params)).getPin() }; + } + + if (method === "sync.setPin") { + const pin = typeof params.pin === "string" ? params.pin : ""; + return await (await getSyncService(params)).setPin(pin); + } + + if (method === "sync.generatePin") { + return await (await getSyncService(params)).generatePin(); + } + + if (method === "sync.clearPin") { + return await (await getSyncService(params)).clearPin(); + } + + if (method === "sync.setActiveLanePresence") { + const laneIds = Array.isArray(params.laneIds) + ? params.laneIds.filter( + (laneId): laneId is string => typeof laneId === "string", + ) + : []; + await (await getSyncService(params)).setActiveLanePresence(laneIds); + return null; + } + + if (method === "shutdown") { + process.nextTick(() => options.onShutdown?.()); + return {}; + } + + if (method === "exit") { + process.nextTick(() => process.exit(0)); + return {}; + } + + if (RUNTIME_METHODS.has(method)) { + throw new JsonRpcError( + JsonRpcErrorCode.methodNotFound, + `Method not found: ${method}`, + ); + } + + const projectId = readProjectId(params); + if (!projectId) { + throw new JsonRpcError( + JsonRpcErrorCode.invalidParams, + `Method ${method} requires params.projectId.`, + ); + } + + const entry = await getProjectHandler(projectId); + return await entry.handler({ + ...request, + params: omitProjectId(params), + }); + }) as JsonRpcHandler & { + dispose: () => void; + setNotifier: (notify: JsonRpcNotifier | null) => void; + }; + + handler.dispose = () => { + for (const subscription of eventSubscriptions.values()) { + subscription.unsubscribe(); + } + eventSubscriptions.clear(); + for (const cached of handlers.values()) { + void cached.then((entry) => entry.handler.dispose?.()).catch(() => {}); + } + handlers.clear(); + removeScopeDisposeListener?.(); + if (options.disposeScopesOnDispose ?? !options.scopeRegistry) { + void scopeRegistry.disposeAll(); + } + }; + + handler.setNotifier = (notify: JsonRpcNotifier | null) => { + notifier = notify; + }; + + return handler; +} diff --git a/apps/ade-cli/src/serviceManager/common.test.ts b/apps/ade-cli/src/serviceManager/common.test.ts new file mode 100644 index 000000000..6d42cd2a7 --- /dev/null +++ b/apps/ade-cli/src/serviceManager/common.test.ts @@ -0,0 +1,348 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; +import { + ADE_RUNTIME_SERVICE_NAME, + renderCommand, + resolveAdeServeCommand, + type AdeServiceCommand, + type ServiceManagerProcessResult, + type ServiceManagerSpawnSync, +} from "./common"; +import { installLaunchdService, isLaunchdPrintRunning, launchAgentPath, renderLaunchdPlist } from "./installLaunchd"; +import { installSystemdService, renderSystemdUnit, servicePath as systemdServicePath } from "./installSystemd"; +import { + buildWindowsCreateTaskArgs, + buildWindowsQueryTaskArgs, + buildWindowsRunTaskArgs, + installWindowsService, + isSchtasksOutputRunning, + parseSchtasksListStatus, + TASK_NAME, +} from "./installWindows"; + +const originalArgv = [...process.argv]; +const originalNodePath = process.env.NODE_PATH; +const tempDirs: string[] = []; + +afterEach(() => { + process.argv.splice(0, process.argv.length, ...originalArgv); + if (originalNodePath === undefined) delete process.env.NODE_PATH; + else process.env.NODE_PATH = originalNodePath; + while (tempDirs.length > 0) { + const dir = tempDirs.pop(); + if (dir) fs.rmSync(dir, { recursive: true, force: true }); + } +}); + +function makeTempHome(prefix: string): string { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), prefix)); + tempDirs.push(dir); + return dir; +} + +describe("resolveAdeServeCommand", () => { + it("uses node plus the CLI script when argv points at a real script", () => { + process.argv[1] = path.resolve("src/cli.ts"); + + expect(resolveAdeServeCommand()).toMatchObject({ + command: process.execPath, + args: [path.resolve("src/cli.ts"), "serve"], + }); + }); + + it("uses the executable directly when SEA argv contains the synthetic CLI script name", () => { + process.argv[1] = path.resolve("definitely-not-real-cli.cjs"); + + expect(resolveAdeServeCommand()).toMatchObject({ + command: process.execPath, + args: ["serve"], + }); + }); + + it("preserves NODE_PATH for standalone runtime sidecar dependencies", () => { + process.argv[1] = path.resolve("definitely-not-real-cli.cjs"); + process.env.NODE_PATH = "/opt/ade/runtime/node_modules"; + + expect(resolveAdeServeCommand()).toMatchObject({ + command: process.execPath, + args: ["serve"], + env: { + NODE_PATH: "/opt/ade/runtime/node_modules", + }, + }); + }); +}); + +describe("service manager status parsers", () => { + it("detects running launchd services from launchctl print output", () => { + expect(isLaunchdPrintRunning("state = running\npid = 123\n")).toBe(true); + expect(isLaunchdPrintRunning("state = waiting\n")).toBe(false); + }); + + it("detects running Windows scheduled tasks from schtasks output", () => { + expect(isSchtasksOutputRunning("TaskName: ADE Runtime\r\nStatus: Running\r\n")).toBe(true); + expect(isSchtasksOutputRunning("TaskName: ADE Runtime\r\nStatus: Ready\r\n")).toBe(false); + }); + + it("parses Windows scheduled task status from schtasks LIST output", () => { + expect(parseSchtasksListStatus("TaskName: ADE Runtime\r\nStatus: Ready\r\n")).toBe("Ready"); + expect(parseSchtasksListStatus("TaskName: ADE Runtime\r\n")).toBeNull(); + }); +}); + +describe("launchd service rendering", () => { + it("renders the launch agent path under the user home directory", () => { + expect(launchAgentPath("/Users/example")).toBe( + path.join("/Users/example", "Library", "LaunchAgents", `${ADE_RUNTIME_SERVICE_NAME}.plist`), + ); + }); + + it("renders plist content with escaped command, logs, and environment values", () => { + const plist = renderLaunchdPlist({ + command: "/Applications/ADE & Tools/ade", + args: ["serve", "--name", "A${ADE_RUNTIME_SERVICE_NAME}`); + expect(plist).toContain("ProgramArguments"); + expect(plist).toContain("/Applications/ADE & Tools/ade"); + expect(plist).toContain("A<B"); + expect(plist).toContain("EnvironmentVariables"); + expect(plist).toContain("NODE_PATH"); + expect(plist).toContain("/opt/ADE & deps"); + expect(plist).toContain("ADE_HOME"); + expect(plist).toContain("/Users/example/'ade'"); + expect(plist).toContain(`${path.join("/Users/example", ".ade", "runtime", "launchd.out.log")}`); + expect(plist).toContain(`${path.join("/Users/example", ".ade", "runtime", "launchd.err.log")}`); + }); +}); + +describe("launchd service install", () => { + const serviceCommand: AdeServiceCommand = { + command: "/Applications/ADE.app/Contents/MacOS/ade", + args: ["serve"], + env: { NODE_PATH: "/opt/ade/node_modules" }, + }; + + it("writes the plist and loads the launch agent", () => { + const homeDir = makeTempHome("ade-launchd-install-"); + const servicePath = launchAgentPath(homeDir); + const calls: Array<{ command: string; args: string[] }> = []; + const spawnSync = spawnSequence(calls, [ + { status: 0, stdout: "", stderr: "" }, + { status: 0, stdout: "", stderr: "" }, + ]); + + const result = installLaunchdService({ command: serviceCommand, spawnSync, homeDir }); + + expect(result).toMatchObject({ + ok: true, + serviceName: ADE_RUNTIME_SERVICE_NAME, + action: "install", + path: servicePath, + }); + expect(fs.readFileSync(servicePath, "utf8")).toBe(renderLaunchdPlist(serviceCommand, homeDir)); + expect(calls).toEqual([ + { command: "launchctl", args: ["unload", servicePath] }, + { command: "launchctl", args: ["load", servicePath] }, + ]); + }); + + it("surfaces launchctl load failures", () => { + const homeDir = makeTempHome("ade-launchd-fail-"); + const calls: Array<{ command: string; args: string[] }> = []; + const spawnSync = spawnSequence(calls, [ + { status: 0, stdout: "", stderr: "" }, + { status: 5, stdout: "", stderr: "Load failed" }, + ]); + + const result = installLaunchdService({ command: serviceCommand, spawnSync, homeDir }); + + expect(result.ok).toBe(false); + expect(result.message).toBe("Load failed"); + expect(calls.map((call) => call.args[0])).toEqual(["unload", "load"]); + }); +}); + +describe("systemd service rendering", () => { + it("renders the user service path under the home directory", () => { + expect(systemdServicePath("/home/example")).toBe( + path.join("/home/example", ".config", "systemd", "user", "ade-runtime.service"), + ); + }); + + it("renders unit content with quoted ExecStart and escaped percent environment values", () => { + const unit = renderSystemdUnit({ + command: "/opt/ADE CLI/node", + args: ["/opt/ade/cli.cjs", "serve"], + env: { + NODE_PATH: "/tmp/100%/node_modules", + }, + }); + + expect(unit).toContain("Description=ADE service daemon"); + expect(unit).toContain("Type=simple"); + expect(unit).toContain("ExecStart='/opt/ADE CLI/node' '/opt/ade/cli.cjs' 'serve'"); + expect(unit).toContain("Restart=always"); + expect(unit).toContain("Environment=NODE_PATH=/tmp/100%%/node_modules"); + expect(unit).toContain("WantedBy=default.target"); + }); +}); + +describe("systemd service install", () => { + const serviceCommand: AdeServiceCommand = { + command: "/opt/ade/bin/ade", + args: ["serve"], + env: { NODE_PATH: "/opt/ade/node_modules" }, + }; + + it("writes the user unit and enables it immediately", () => { + const homeDir = makeTempHome("ade-systemd-install-"); + const targetPath = systemdServicePath(homeDir); + const calls: Array<{ command: string; args: string[] }> = []; + const spawnSync = spawnSequence(calls, [ + { status: 0, stdout: "", stderr: "" }, + { status: 0, stdout: "", stderr: "" }, + ]); + + const result = installSystemdService({ command: serviceCommand, spawnSync, homeDir }); + + expect(result).toMatchObject({ + ok: true, + serviceName: ADE_RUNTIME_SERVICE_NAME, + action: "install", + path: targetPath, + }); + expect(fs.readFileSync(targetPath, "utf8")).toBe(renderSystemdUnit(serviceCommand)); + expect(calls).toEqual([ + { command: "systemctl", args: ["--user", "daemon-reload"] }, + { command: "systemctl", args: ["--user", "enable", "--now", "ade-runtime.service"] }, + ]); + }); + + it("does not enable when daemon-reload fails", () => { + const homeDir = makeTempHome("ade-systemd-reload-fail-"); + const calls: Array<{ command: string; args: string[] }> = []; + const spawnSync = spawnSequence(calls, [ + { status: 1, stdout: "", stderr: "reload failed" }, + ]); + + const result = installSystemdService({ command: serviceCommand, spawnSync, homeDir }); + + expect(result.ok).toBe(false); + expect(result.message).toBe("reload failed"); + expect(calls).toEqual([ + { command: "systemctl", args: ["--user", "daemon-reload"] }, + ]); + }); + + it("surfaces enable failures after a successful reload", () => { + const homeDir = makeTempHome("ade-systemd-enable-fail-"); + const calls: Array<{ command: string; args: string[] }> = []; + const spawnSync = spawnSequence(calls, [ + { status: 0, stdout: "", stderr: "" }, + { status: 1, stdout: "", stderr: "enable failed" }, + ]); + + const result = installSystemdService({ command: serviceCommand, spawnSync, homeDir }); + + expect(result.ok).toBe(false); + expect(result.message).toBe("enable failed"); + expect(calls.map((call) => call.args)).toEqual([ + ["--user", "daemon-reload"], + ["--user", "enable", "--now", "ade-runtime.service"], + ]); + }); +}); + +describe("Windows scheduled task helpers", () => { + const serviceCommand: AdeServiceCommand = { + command: "C:\\Program Files\\ADE\\ade.exe", + args: ["serve"], + }; + + it("builds schtasks create, run, and query arguments without invoking schtasks", () => { + const renderedCommand = renderCommand(serviceCommand); + + expect(buildWindowsCreateTaskArgs(renderedCommand)).toEqual([ + "/Create", + "/SC", + "ONLOGON", + "/TN", + TASK_NAME, + "/TR", + renderedCommand, + "/F", + ]); + expect(buildWindowsRunTaskArgs()).toEqual(["/Run", "/TN", TASK_NAME]); + expect(buildWindowsQueryTaskArgs()).toEqual(["/Query", "/TN", TASK_NAME, "/FO", "LIST", "/V"]); + }); + + it("starts the scheduled task immediately after a successful create", () => { + const calls: Array<{ command: string; args: string[] }> = []; + const spawnSync = spawnSequence(calls, [ + { status: 0, stdout: "SUCCESS: created", stderr: "" }, + { status: 0, stdout: "SUCCESS: attempted to run", stderr: "" }, + ]); + + const result = installWindowsService({ command: serviceCommand, spawnSync }); + + expect(result).toMatchObject({ + ok: true, + serviceName: ADE_RUNTIME_SERVICE_NAME, + action: "install", + path: TASK_NAME, + message: "ADE service scheduled task installed and started.", + }); + expect(calls).toEqual([ + { command: "schtasks.exe", args: buildWindowsCreateTaskArgs(renderCommand(serviceCommand)) }, + { command: "schtasks.exe", args: buildWindowsRunTaskArgs() }, + ]); + }); + + it("surfaces a clear install failure when create succeeds but immediate start fails", () => { + const calls: Array<{ command: string; args: string[] }> = []; + const spawnSync = spawnSequence(calls, [ + { status: 0, stdout: "SUCCESS: created", stderr: "" }, + { status: 1, stdout: "", stderr: "ERROR: access is denied" }, + ]); + + const result = installWindowsService({ command: serviceCommand, spawnSync }); + + expect(result.ok).toBe(false); + expect(result.message).toBe("ADE service scheduled task installed, but failed to start: ERROR: access is denied"); + expect(calls.map((call) => call.args)).toEqual([ + buildWindowsCreateTaskArgs(renderCommand(serviceCommand)), + buildWindowsRunTaskArgs(), + ]); + }); + + it("does not try to run the task when create fails", () => { + const calls: Array<{ command: string; args: string[] }> = []; + const spawnSync = spawnSequence(calls, [ + { status: 1, stdout: "", stderr: "ERROR: create failed" }, + ]); + + const result = installWindowsService({ command: serviceCommand, spawnSync }); + + expect(result.ok).toBe(false); + expect(result.message).toBe("ERROR: create failed"); + expect(calls).toHaveLength(1); + }); +}); + +function spawnSequence( + calls: Array<{ command: string; args: string[] }>, + results: ServiceManagerProcessResult[], +): ServiceManagerSpawnSync { + return (command, args) => { + calls.push({ command, args }); + return results.shift() ?? { status: 0, stdout: "", stderr: "" }; + }; +} diff --git a/apps/ade-cli/src/serviceManager/common.ts b/apps/ade-cli/src/serviceManager/common.ts new file mode 100644 index 000000000..5d70bc303 --- /dev/null +++ b/apps/ade-cli/src/serviceManager/common.ts @@ -0,0 +1,117 @@ +import fs from "node:fs"; +import path from "node:path"; +import type { SpawnSyncOptions } from "node:child_process"; + +export type ServiceManagerResult = { + ok: boolean; + serviceName: string; + action: "install" | "uninstall"; + path: string | null; + message: string; +}; + +export type ServiceManagerStatusResult = { + ok: boolean; + serviceName: string; + action: "status"; + installed: boolean | null; + running: boolean | null; + path: string | null; + message: string; +}; + +export type AdeServiceCommand = { + command: string; + args: string[]; + env?: Record; +}; + +function resolveRuntimeServiceName(env: NodeJS.ProcessEnv = process.env): string { + const explicit = env.ADE_RUNTIME_SERVICE_NAME?.trim(); + if (explicit) return explicit; + const channel = env.ADE_PACKAGE_CHANNEL?.trim().toLowerCase(); + if (channel === "alpha") return "com.ade.runtime.alpha"; + if (channel === "beta") return "com.ade.runtime.beta"; + return "com.ade.runtime"; +} + +export const ADE_RUNTIME_SERVICE_NAME = resolveRuntimeServiceName(); + +export type ServiceManagerProcessResult = { + status: number | null; + stdout?: string | Buffer | null; + stderr?: string | Buffer | null; +}; + +export type ServiceManagerSpawnSync = ( + command: string, + args: string[], + options?: SpawnSyncOptions, +) => ServiceManagerProcessResult; + +const RUNTIME_ENV_PASSTHROUGH = [ + "NODE_PATH", + "ADE_HOME", + "ADE_PACKAGE_CHANNEL", + "ADE_DESKTOP_APP_NAME", + "ADE_DISABLE_RUNTIME_SERVICE_INSTALL", + "ADE_RUNTIME_SERVICE_NAME", +] as const; + +function runtimeEnvironment(): Record | undefined { + const env: Record = {}; + if (process.versions.electron) { + env.ELECTRON_RUN_AS_NODE = "1"; + } + for (const key of RUNTIME_ENV_PASSTHROUGH) { + const value = process.env[key]; + if (value?.trim()) { + env[key] = value; + } + } + return Object.keys(env).length > 0 ? env : undefined; +} + +export function shellQuote(value: string): string { + return `'${value.replace(/'/g, "'\\''")}'`; +} + +export function resolveAdeServeCommand(): AdeServiceCommand { + const entry = typeof process.argv[1] === "string" && process.argv[1].trim() + ? path.resolve(process.argv[1]) + : ""; + const isNodeScript = /\.(?:cjs|mjs|js|ts)$/i.test(entry) && fs.existsSync(entry); + if (isNodeScript) { + return { + command: process.execPath, + args: [entry, "serve"], + env: runtimeEnvironment(), + }; + } + if (entry && fs.existsSync(entry)) { + return { + command: entry, + args: ["serve"], + env: runtimeEnvironment(), + }; + } + return { + command: process.execPath, + args: ["serve"], + env: runtimeEnvironment(), + }; +} + +export function renderCommand(command: AdeServiceCommand): string { + return [command.command, ...command.args].map(shellQuote).join(" "); +} + +function streamToText(value: string | Buffer | null | undefined): string { + if (typeof value === "string") return value.trim(); + if (Buffer.isBuffer(value)) return value.toString("utf8").trim(); + return ""; +} + +export function serviceManagerResultText(result: ServiceManagerProcessResult): string { + return streamToText(result.stderr) || streamToText(result.stdout); +} diff --git a/apps/ade-cli/src/serviceManager/index.ts b/apps/ade-cli/src/serviceManager/index.ts new file mode 100644 index 000000000..afcd0f754 --- /dev/null +++ b/apps/ade-cli/src/serviceManager/index.ts @@ -0,0 +1,66 @@ +import type { ServiceManagerResult, ServiceManagerStatusResult } from "./common"; +import { ADE_RUNTIME_SERVICE_NAME } from "./common"; +import { getLaunchdServiceStatus, installLaunchdService, uninstallLaunchdService } from "./installLaunchd"; +import { getSystemdServiceStatus, installSystemdService, uninstallSystemdService } from "./installSystemd"; +import { getWindowsServiceStatus, installWindowsService, uninstallWindowsService } from "./installWindows"; + +export type { ServiceManagerResult, ServiceManagerStatusResult } from "./common"; + +export function installRuntimeService(): ServiceManagerResult { + switch (process.platform) { + case "darwin": + return installLaunchdService(); + case "linux": + return installSystemdService(); + case "win32": + return installWindowsService(); + default: + return { + ok: false, + serviceName: ADE_RUNTIME_SERVICE_NAME, + action: "install", + path: null, + message: `ADE service installation is not supported on ${process.platform}.`, + }; + } +} + +export function uninstallRuntimeService(): ServiceManagerResult { + switch (process.platform) { + case "darwin": + return uninstallLaunchdService(); + case "linux": + return uninstallSystemdService(); + case "win32": + return uninstallWindowsService(); + default: + return { + ok: false, + serviceName: ADE_RUNTIME_SERVICE_NAME, + action: "uninstall", + path: null, + message: `ADE service removal is not supported on ${process.platform}.`, + }; + } +} + +export function getRuntimeServiceStatus(): ServiceManagerStatusResult { + switch (process.platform) { + case "darwin": + return getLaunchdServiceStatus(); + case "linux": + return getSystemdServiceStatus(); + case "win32": + return getWindowsServiceStatus(); + default: + return { + ok: false, + serviceName: ADE_RUNTIME_SERVICE_NAME, + action: "status", + installed: null, + running: null, + path: null, + message: `ADE service status is not supported on ${process.platform}.`, + }; + } +} diff --git a/apps/ade-cli/src/serviceManager/installLaunchd.ts b/apps/ade-cli/src/serviceManager/installLaunchd.ts new file mode 100644 index 000000000..6f842adf3 --- /dev/null +++ b/apps/ade-cli/src/serviceManager/installLaunchd.ts @@ -0,0 +1,172 @@ +import { spawnSync } from "node:child_process"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { + ADE_RUNTIME_SERVICE_NAME, + type AdeServiceCommand, + resolveAdeServeCommand, + serviceManagerResultText, + type ServiceManagerResult, + type ServiceManagerSpawnSync, + type ServiceManagerStatusResult, +} from "./common"; + +type LaunchdServiceManagerDeps = { + command?: AdeServiceCommand; + spawnSync?: ServiceManagerSpawnSync; + homeDir?: string; +}; + +function escapeXml(value: string): string { + return value + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """) + .replace(/'/g, "'"); +} + +function plistArray(values: string[]): string { + return [ + "", + ...values.map((value) => ` ${escapeXml(value)}`), + "", + ].join("\n"); +} + +export function launchAgentPath(homeDir = os.homedir()): string { + return path.join(homeDir, "Library", "LaunchAgents", `${ADE_RUNTIME_SERVICE_NAME}.plist`); +} + +export function isLaunchdPrintRunning(output: string): boolean { + return /\bstate\s*=\s*running\b/i.test(output); +} + +export function renderLaunchdPlist(command: AdeServiceCommand, homeDir = os.homedir()): string { + const envEntries = Object.entries(command.env ?? {}); + const envBlock = envEntries.length + ? [ + " EnvironmentVariables", + " ", + ...envEntries.flatMap(([key, value]) => [ + ` ${escapeXml(key)}`, + ` ${escapeXml(value)}`, + ]), + " ", + ].join("\n") + : ""; + const sections = [ + ` + + + + Label + ${ADE_RUNTIME_SERVICE_NAME} + ProgramArguments +${plistArray([command.command, ...command.args]).split("\n").map((line) => ` ${line}`).join("\n")} + RunAtLoad + + KeepAlive + + StandardOutPath + ${escapeXml(path.join(homeDir, ".ade", "runtime", "launchd.out.log"))} + StandardErrorPath + ${escapeXml(path.join(homeDir, ".ade", "runtime", "launchd.err.log"))}`, + envBlock, + ` + +`, + ].filter(Boolean); + return sections.join("\n"); +} + +export function installLaunchdService(deps: LaunchdServiceManagerDeps = {}): ServiceManagerResult { + const run = deps.spawnSync ?? spawnSync; + const homeDir = deps.homeDir ?? os.homedir(); + const servicePath = launchAgentPath(homeDir); + const command = deps.command ?? resolveAdeServeCommand(); + fs.mkdirSync(path.dirname(servicePath), { recursive: true }); + const plist = renderLaunchdPlist(command, homeDir); + fs.mkdirSync(path.join(homeDir, ".ade", "runtime"), { recursive: true }); + fs.writeFileSync(servicePath, plist, "utf8"); + run("launchctl", ["unload", servicePath], { stdio: "ignore" }); + const load = run("launchctl", ["load", servicePath], { encoding: "utf8" }); + if (load.status !== 0) { + return { + ok: false, + serviceName: ADE_RUNTIME_SERVICE_NAME, + action: "install", + path: servicePath, + message: serviceManagerResultText(load) || "launchctl load failed.", + }; + } + return { + ok: true, + serviceName: ADE_RUNTIME_SERVICE_NAME, + action: "install", + path: servicePath, + message: "ADE service launchd service installed.", + }; +} + +export function uninstallLaunchdService(): ServiceManagerResult { + const servicePath = launchAgentPath(); + spawnSync("launchctl", ["unload", servicePath], { stdio: "ignore" }); + try { fs.unlinkSync(servicePath); } catch {} + return { + ok: true, + serviceName: ADE_RUNTIME_SERVICE_NAME, + action: "uninstall", + path: servicePath, + message: "ADE service launchd service removed.", + }; +} + +export function getLaunchdServiceStatus(): ServiceManagerStatusResult { + const servicePath = launchAgentPath(); + if (!fs.existsSync(servicePath)) { + return { + ok: true, + serviceName: ADE_RUNTIME_SERVICE_NAME, + action: "status", + installed: false, + running: false, + path: servicePath, + message: "ADE service launchd service is not installed.", + }; + } + + const uid = typeof process.getuid === "function" ? process.getuid() : os.userInfo().uid; + let print = spawnSync("launchctl", ["print", `gui/${uid}/${ADE_RUNTIME_SERVICE_NAME}`], { encoding: "utf8" }); + if (print.status !== 0) { + const userPrint = spawnSync("launchctl", ["print", `user/${uid}/${ADE_RUNTIME_SERVICE_NAME}`], { encoding: "utf8" }); + if (userPrint.status === 0) { + print = userPrint; + } + } + if (print.status !== 0) { + return { + ok: true, + serviceName: ADE_RUNTIME_SERVICE_NAME, + action: "status", + installed: true, + running: false, + path: servicePath, + message: serviceManagerResultText(print) || "ADE service launchd service is installed but not loaded.", + }; + } + + const running = isLaunchdPrintRunning(print.stdout); + return { + ok: true, + serviceName: ADE_RUNTIME_SERVICE_NAME, + action: "status", + installed: true, + running, + path: servicePath, + message: running + ? "ADE service launchd service is running." + : "ADE service launchd service is loaded but not running.", + }; +} diff --git a/apps/ade-cli/src/serviceManager/installSystemd.ts b/apps/ade-cli/src/serviceManager/installSystemd.ts new file mode 100644 index 000000000..d5dad6c15 --- /dev/null +++ b/apps/ade-cli/src/serviceManager/installSystemd.ts @@ -0,0 +1,117 @@ +import { spawnSync } from "node:child_process"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { + ADE_RUNTIME_SERVICE_NAME, + type AdeServiceCommand, + renderCommand, + resolveAdeServeCommand, + serviceManagerResultText, + type ServiceManagerResult, + type ServiceManagerSpawnSync, + type ServiceManagerStatusResult, +} from "./common"; + +type SystemdServiceManagerDeps = { + command?: AdeServiceCommand; + spawnSync?: ServiceManagerSpawnSync; + homeDir?: string; +}; + +export function servicePath(homeDir = os.homedir()): string { + return path.join(homeDir, ".config", "systemd", "user", "ade-runtime.service"); +} + +export function renderSystemdUnit(command: AdeServiceCommand): string { + const envLines = Object.entries(command.env ?? {}) + .map(([key, value]) => `Environment=${key}=${value.replace(/%/g, "%%")}`) + .join("\n"); + return `[Unit] +Description=ADE service daemon + +[Service] +Type=simple +ExecStart=${renderCommand(command)} +Restart=always +RestartSec=2 +${envLines} + +[Install] +WantedBy=default.target +`; +} + +export function installSystemdService(deps: SystemdServiceManagerDeps = {}): ServiceManagerResult { + const run = deps.spawnSync ?? spawnSync; + const targetPath = servicePath(deps.homeDir); + const command = deps.command ?? resolveAdeServeCommand(); + fs.mkdirSync(path.dirname(targetPath), { recursive: true }); + const unit = renderSystemdUnit(command); + fs.writeFileSync(targetPath, unit, "utf8"); + const reload = run("systemctl", ["--user", "daemon-reload"], { encoding: "utf8" }); + if (reload.status !== 0) { + return { + ok: false, + serviceName: ADE_RUNTIME_SERVICE_NAME, + action: "install", + path: targetPath, + message: serviceManagerResultText(reload) || "systemctl daemon-reload failed.", + }; + } + const enable = run("systemctl", ["--user", "enable", "--now", "ade-runtime.service"], { encoding: "utf8" }); + return { + ok: enable.status === 0, + serviceName: ADE_RUNTIME_SERVICE_NAME, + action: "install", + path: targetPath, + message: enable.status === 0 + ? "ADE service systemd user service installed." + : serviceManagerResultText(enable) || "systemctl enable --now failed.", + }; +} + +export function uninstallSystemdService(): ServiceManagerResult { + const targetPath = servicePath(); + spawnSync("systemctl", ["--user", "disable", "--now", "ade-runtime.service"], { stdio: "ignore" }); + try { fs.unlinkSync(targetPath); } catch {} + spawnSync("systemctl", ["--user", "daemon-reload"], { stdio: "ignore" }); + return { + ok: true, + serviceName: ADE_RUNTIME_SERVICE_NAME, + action: "uninstall", + path: targetPath, + message: "ADE service systemd user service removed.", + }; +} + +export function getSystemdServiceStatus(): ServiceManagerStatusResult { + const targetPath = servicePath(); + const enabled = spawnSync("systemctl", ["--user", "is-enabled", "ade-runtime.service"], { encoding: "utf8" }); + const active = spawnSync("systemctl", ["--user", "is-active", "ade-runtime.service"], { encoding: "utf8" }); + const installed = fs.existsSync(targetPath) || enabled.status === 0; + if (!installed) { + return { + ok: true, + serviceName: ADE_RUNTIME_SERVICE_NAME, + action: "status", + installed: false, + running: false, + path: targetPath, + message: "ADE service systemd user service is not installed.", + }; + } + + const running = active.status === 0; + return { + ok: true, + serviceName: ADE_RUNTIME_SERVICE_NAME, + action: "status", + installed: true, + running, + path: targetPath, + message: running + ? "ADE service systemd user service is running." + : serviceManagerResultText(active) || "ADE service systemd user service is installed but not running.", + }; +} diff --git a/apps/ade-cli/src/serviceManager/installWindows.ts b/apps/ade-cli/src/serviceManager/installWindows.ts new file mode 100644 index 000000000..0ed9ab9c4 --- /dev/null +++ b/apps/ade-cli/src/serviceManager/installWindows.ts @@ -0,0 +1,118 @@ +import { spawnSync } from "node:child_process"; +import { + ADE_RUNTIME_SERVICE_NAME, + type AdeServiceCommand, + renderCommand, + resolveAdeServeCommand, + serviceManagerResultText, + type ServiceManagerResult, + type ServiceManagerSpawnSync, + type ServiceManagerStatusResult, +} from "./common"; + +export const TASK_NAME = "ADE Runtime"; + +type WindowsServiceManagerDeps = { + command?: AdeServiceCommand; + spawnSync?: ServiceManagerSpawnSync; +}; + +export function buildWindowsCreateTaskArgs(command: string): string[] { + return [ + "/Create", + "/SC", + "ONLOGON", + "/TN", + TASK_NAME, + "/TR", + command, + "/F", + ]; +} + +export function buildWindowsRunTaskArgs(): string[] { + return ["/Run", "/TN", TASK_NAME]; +} + +export function buildWindowsQueryTaskArgs(): string[] { + return ["/Query", "/TN", TASK_NAME, "/FO", "LIST", "/V"]; +} + +export function parseSchtasksListStatus(output: string): string | null { + const match = /^\s*Status:\s*(.*?)\s*$/im.exec(output); + return match?.[1] ?? null; +} + +export function isSchtasksOutputRunning(output: string): boolean { + return parseSchtasksListStatus(output)?.toLowerCase() === "running"; +} + +export function installWindowsService(deps: WindowsServiceManagerDeps = {}): ServiceManagerResult { + const run = deps.spawnSync ?? spawnSync; + const command = renderCommand(deps.command ?? resolveAdeServeCommand()); + const result = run("schtasks.exe", buildWindowsCreateTaskArgs(command), { encoding: "utf8" }); + if (result.status !== 0) { + return { + ok: false, + serviceName: ADE_RUNTIME_SERVICE_NAME, + action: "install", + path: TASK_NAME, + message: serviceManagerResultText(result) || "schtasks create failed.", + }; + } + const start = run("schtasks.exe", buildWindowsRunTaskArgs(), { encoding: "utf8" }); + if (start.status !== 0) { + return { + ok: false, + serviceName: ADE_RUNTIME_SERVICE_NAME, + action: "install", + path: TASK_NAME, + message: `ADE service scheduled task installed, but failed to start: ${serviceManagerResultText(start) || "schtasks run failed."}`, + }; + } + return { + ok: true, + serviceName: ADE_RUNTIME_SERVICE_NAME, + action: "install", + path: TASK_NAME, + message: "ADE service scheduled task installed and started.", + }; +} + +export function uninstallWindowsService(): ServiceManagerResult { + spawnSync("schtasks.exe", ["/Delete", "/TN", TASK_NAME, "/F"], { stdio: "ignore" }); + return { + ok: true, + serviceName: ADE_RUNTIME_SERVICE_NAME, + action: "uninstall", + path: TASK_NAME, + message: "ADE service scheduled task removed.", + }; +} + +export function getWindowsServiceStatus(): ServiceManagerStatusResult { + const result = spawnSync("schtasks.exe", buildWindowsQueryTaskArgs(), { encoding: "utf8" }); + if (result.status !== 0) { + return { + ok: true, + serviceName: ADE_RUNTIME_SERVICE_NAME, + action: "status", + installed: false, + running: false, + path: TASK_NAME, + message: serviceManagerResultText(result) || "ADE service scheduled task is not installed.", + }; + } + const running = isSchtasksOutputRunning(result.stdout); + return { + ok: true, + serviceName: ADE_RUNTIME_SERVICE_NAME, + action: "status", + installed: true, + running, + path: TASK_NAME, + message: running + ? "ADE service scheduled task is running." + : "ADE service scheduled task is installed.", + }; +} diff --git a/apps/ade-cli/src/services/agentRegistry.test.ts b/apps/ade-cli/src/services/agentRegistry.test.ts new file mode 100644 index 000000000..6b45ba884 --- /dev/null +++ b/apps/ade-cli/src/services/agentRegistry.test.ts @@ -0,0 +1,32 @@ +import { describe, expect, it } from "vitest"; +import { classifyAgentCliError } from "./agentRegistry"; + +describe("classifyAgentCliError", () => { + it("classifies missing agent CLIs with install/auth commands", () => { + expect(classifyAgentCliError("spawn codex ENOENT")).toMatchObject({ + agent: "codex", + displayName: "Codex CLI", + category: "missing", + installCommand: 'mkdir -p "$HOME/.npm-global" "$HOME/.local/bin" && NPM_CONFIG_PREFIX="$HOME/.npm-global" npm install -g @openai/codex', + authCommand: "codex login", + }); + }); + + it("classifies unauthenticated agent CLIs with auth commands", () => { + expect(classifyAgentCliError("codex failed: login required")).toMatchObject({ + agent: "codex", + displayName: "Codex CLI", + category: "unauthenticated", + authCommand: "codex login", + }); + }); + + it("uses the preferred provider for generic auth failures", () => { + expect(classifyAgentCliError("401 unauthorized", "claude")).toMatchObject({ + agent: "claude", + displayName: "Claude Code", + category: "unauthenticated", + authCommand: "claude /login", + }); + }); +}); diff --git a/apps/ade-cli/src/services/agentRegistry.ts b/apps/ade-cli/src/services/agentRegistry.ts new file mode 100644 index 000000000..3b865b4cc --- /dev/null +++ b/apps/ade-cli/src/services/agentRegistry.ts @@ -0,0 +1,141 @@ +export type AgentCliErrorCategory = "missing" | "unauthenticated"; + +export type AgentCliDescriptor = { + agent: string; + displayName: string; + binaryNames: string[]; + installCommand: string; + authCommand: string; + missingErrorPatterns: RegExp[]; + notAuthErrorPatterns: RegExp[]; +}; + +export type AgentCliErrorMatch = { + agent: string; + displayName: string; + category: AgentCliErrorCategory; + installCommand: string; + authCommand: string; +}; + +function npmGlobalInstallCommand(packageName: string): string { + if (typeof process !== "undefined" && process.platform === "win32") { + return `npm install -g ${packageName}`; + } + return `mkdir -p "$HOME/.npm-global" "$HOME/.local/bin" && NPM_CONFIG_PREFIX="$HOME/.npm-global" npm install -g ${packageName}`; +} + +export const AGENT_CLI_REGISTRY: AgentCliDescriptor[] = [ + { + agent: "claude", + displayName: "Claude Code", + binaryNames: ["claude"], + installCommand: npmGlobalInstallCommand("@anthropic-ai/claude-code"), + authCommand: "claude /login", + missingErrorPatterns: [ + /\bclaude\b.*\b(command not found|not recognized|not found|enoent)\b/i, + /\bspawn\s+claude\s+enoent\b/i, + ], + notAuthErrorPatterns: [ + /\bclaude\b.*\b(not logged in|not authenticated|unauthorized|authentication failed|login required)\b/i, + /\brun\s+[`'"]?claude\s+\/login[`'"]?/i, + ], + }, + { + agent: "codex", + displayName: "Codex CLI", + binaryNames: ["codex"], + installCommand: npmGlobalInstallCommand("@openai/codex"), + authCommand: "codex login", + missingErrorPatterns: [ + /\bcodex\b.*\b(command not found|not recognized|not found|enoent)\b/i, + /\bspawn\s+codex\s+enoent\b/i, + ], + notAuthErrorPatterns: [ + /\bcodex\b.*\b(not logged in|not authenticated|unauthorized|authentication failed|login required)\b/i, + /\brun\s+[`'"]?codex\s+login[`'"]?/i, + ], + }, + { + agent: "opencode", + displayName: "OpenCode", + binaryNames: ["opencode"], + installCommand: npmGlobalInstallCommand("opencode-ai"), + authCommand: "opencode auth login", + missingErrorPatterns: [ + /\bopencode\b.*\b(command not found|not recognized|not found|enoent)\b/i, + /\bspawn\s+opencode\s+enoent\b/i, + ], + notAuthErrorPatterns: [ + /\bopencode\b.*\b(not logged in|not authenticated|unauthorized|authentication failed|login required)\b/i, + ], + }, + { + agent: "cursor", + displayName: "Cursor Agent", + binaryNames: ["cursor-agent", "cursor"], + installCommand: 'mkdir -p "$HOME/.local/bin" && curl https://cursor.com/install -fsS | bash', + authCommand: "cursor-agent login", + missingErrorPatterns: [ + /\bcursor(?:-agent)?\b.*\b(command not found|not recognized|not found|enoent)\b/i, + /\bspawn\s+cursor(?:-agent)?\s+enoent\b/i, + ], + notAuthErrorPatterns: [ + /\bcursor(?:-agent)?\b.*\b(not logged in|not authenticated|unauthorized|authentication failed|login required)\b/i, + ], + }, +]; + +function descriptorMatchesPreferred(descriptor: AgentCliDescriptor, preferredAgent: string | null | undefined): boolean { + if (!preferredAgent) return false; + const normalized = preferredAgent.trim().toLowerCase(); + return descriptor.agent === normalized + || descriptor.displayName.toLowerCase().includes(normalized) + || descriptor.binaryNames.some((name) => name.toLowerCase() === normalized); +} + +function descriptorMentioned(descriptor: AgentCliDescriptor, text: string): boolean { + return descriptor.binaryNames.some((name) => new RegExp(`\\b${name.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}\\b`, "i").test(text)) + || new RegExp(`\\b${descriptor.agent.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}\\b`, "i").test(text); +} + +function toMatch(descriptor: AgentCliDescriptor, category: AgentCliErrorCategory): AgentCliErrorMatch { + return { + agent: descriptor.agent, + displayName: descriptor.displayName, + category, + installCommand: descriptor.installCommand, + authCommand: descriptor.authCommand, + }; +} + +export function classifyAgentCliError(message: string, preferredAgent?: string | null): AgentCliErrorMatch | null { + const text = message.trim(); + if (!text) return null; + const preferred = AGENT_CLI_REGISTRY.find((descriptor) => descriptorMatchesPreferred(descriptor, preferredAgent)); + const candidates = preferred + ? [preferred, ...AGENT_CLI_REGISTRY.filter((descriptor) => descriptor !== preferred)] + : AGENT_CLI_REGISTRY; + + for (const descriptor of candidates) { + const mentioned = descriptorMentioned(descriptor, text); + if (!mentioned && descriptor !== preferred) continue; + if (descriptor.missingErrorPatterns.some((pattern) => pattern.test(text))) { + return toMatch(descriptor, "missing"); + } + if (descriptor.notAuthErrorPatterns.some((pattern) => pattern.test(text))) { + return toMatch(descriptor, "unauthenticated"); + } + } + + if (preferred) { + if (/\b(command not found|not recognized|enoent|executable file not found|no such file or directory)\b/i.test(text)) { + return toMatch(preferred, "missing"); + } + if (/\b(not logged in|not authenticated|unauthorized|authentication failed|login required|invalid api key|401|403)\b/i.test(text)) { + return toMatch(preferred, "unauthenticated"); + } + } + + return null; +} diff --git a/apps/ade-cli/src/services/credentials/credentialStore.test.ts b/apps/ade-cli/src/services/credentials/credentialStore.test.ts new file mode 100644 index 000000000..73b1d7a53 --- /dev/null +++ b/apps/ade-cli/src/services/credentials/credentialStore.test.ts @@ -0,0 +1,99 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { + ElectronSafeStorageCredentialStore, + EncryptedFileCredentialStore, + KeytarCredentialStore, + createDefaultCredentialStore, +} from "./credentialStore"; + +let tempDir = ""; + +beforeEach(() => { + tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "ade-credentials-")); +}); + +afterEach(() => { + fs.rmSync(tempDir, { recursive: true, force: true }); +}); + +describe("EncryptedFileCredentialStore", () => { + it("persists credentials encrypted on disk", async () => { + const store = new EncryptedFileCredentialStore({ secretsDir: tempDir }); + + await store.set("linear.token.v1", "lin_secret"); + + expect(await store.get("linear.token.v1")).toBe("lin_secret"); + expect(fs.readFileSync(path.join(tempDir, "credentials.json.enc"), "utf8")).not.toContain("lin_secret"); + + const reloaded = new EncryptedFileCredentialStore({ secretsDir: tempDir }); + expect(reloaded.getSync("linear.token.v1")).toBe("lin_secret"); + }); + + it("deletes credentials without removing the machine key", async () => { + const store = new EncryptedFileCredentialStore({ secretsDir: tempDir }); + + await store.set("agent.token", "secret"); + await store.delete("agent.token"); + + expect(await store.get("agent.token")).toBeNull(); + expect(fs.existsSync(path.join(tempDir, ".machine-key"))).toBe(true); + }); +}); + +describe("ElectronSafeStorageCredentialStore", () => { + it("delegates encryption to the injected safeStorage implementation", async () => { + const safeStorage = { + isEncryptionAvailable: () => true, + encryptString: (value: string) => Buffer.from(`enc:${value}`, "utf8"), + decryptString: (value: Buffer) => value.toString("utf8").replace(/^enc:/, ""), + }; + const store = new ElectronSafeStorageCredentialStore({ secretsDir: tempDir, safeStorage }); + + await store.set("openai", "sk-test"); + + expect(await store.get("openai")).toBe("sk-test"); + expect(fs.readFileSync(path.join(tempDir, "credentials.json.enc"), "utf8")).toContain("enc:"); + }); +}); + +describe("KeytarCredentialStore", () => { + it("uses keytar account names without touching the filesystem", async () => { + const values = new Map(); + const store = new KeytarCredentialStore({ + keytar: { + async getPassword(service, account) { + return values.get(`${service}:${account}`) ?? null; + }, + async setPassword(service, account, password) { + values.set(`${service}:${account}`, password); + }, + async deletePassword(service, account) { + return values.delete(`${service}:${account}`); + }, + }, + service: "test.service", + }); + + await store.set("cursor", "cur_secret"); + expect(await store.get("cursor")).toBe("cur_secret"); + await store.delete("cursor"); + expect(await store.get("cursor")).toBeNull(); + }); +}); + +describe("createDefaultCredentialStore", () => { + it("falls back to encrypted-file storage when keytar is disabled", async () => { + const store = await createDefaultCredentialStore({ + env: { ADE_CREDENTIAL_STORE_DISABLE_KEYTAR: "1" } as NodeJS.ProcessEnv, + secretsDir: tempDir, + }); + + await store.set("codex", "token"); + + expect(await store.get("codex")).toBe("token"); + expect(fs.existsSync(path.join(tempDir, "credentials.json.enc"))).toBe(true); + }); +}); diff --git a/apps/ade-cli/src/services/credentials/credentialStore.ts b/apps/ade-cli/src/services/credentials/credentialStore.ts new file mode 100644 index 000000000..cbee0ab18 --- /dev/null +++ b/apps/ade-cli/src/services/credentials/credentialStore.ts @@ -0,0 +1,331 @@ +import crypto from "node:crypto"; +import fs from "node:fs"; +import path from "node:path"; +import { resolveMachineAdeLayout } from "../projects/machineLayout"; + +export interface CredentialStore { + get(key: string): Promise; + set(key: string, value: string): Promise; + delete(key: string): Promise; +} + +export type SyncCredentialStore = CredentialStore & { + getSync(key: string): string | null; + setSync(key: string, value: string): void; + deleteSync(key: string): void; +}; + +type StoredCredentialEnvelope = { + version: 1; + alg: "aes-256-gcm"; + iv: string; + tag: string; + ciphertext: string; +}; + +type SafeStorageLike = { + isEncryptionAvailable(): boolean; + encryptString(value: string): Buffer; + decryptString(value: Buffer): string; +}; + +const DEFAULT_CREDENTIALS_FILE = "credentials.json.enc"; +const DEFAULT_MACHINE_KEY_FILE = ".machine-key"; +const STORE_AAD = Buffer.from("ade.credentials.v1"); + +function normalizeKey(key: string): string { + const normalized = key.trim(); + if (!normalized.length) throw new Error("Credential key is required."); + if (normalized.includes("\0")) throw new Error("Credential key cannot contain null bytes."); + return normalized; +} + +function ensureMode600(filePath: string): void { + if (process.platform === "win32") return; + try { + fs.chmodSync(filePath, 0o600); + } catch { + // Best effort; some filesystems do not support chmod. + } +} + +function writeFileAtomic(filePath: string, contents: string | Buffer): void { + fs.mkdirSync(path.dirname(filePath), { recursive: true }); + const tmpPath = `${filePath}.${process.pid}.${Date.now()}.tmp`; + fs.writeFileSync(tmpPath, contents); + ensureMode600(tmpPath); + fs.renameSync(tmpPath, filePath); + ensureMode600(filePath); +} + +function isEnoent(error: unknown): boolean { + return typeof error === "object" + && error !== null + && "code" in error + && (error as { code?: unknown }).code === "ENOENT"; +} + +function readJsonObject(filePath: string): Record | null { + try { + const raw = fs.readFileSync(filePath, "utf8"); + const parsed = JSON.parse(raw) as unknown; + if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return null; + return parsed as Record; + } catch (error: unknown) { + if (isEnoent(error)) return {}; + throw error; + } +} + +function serializeStore(values: Record, machineKey: Buffer): StoredCredentialEnvelope { + const iv = crypto.randomBytes(12); + const cipher = crypto.createCipheriv("aes-256-gcm", machineKey, iv); + cipher.setAAD(STORE_AAD); + const ciphertext = Buffer.concat([ + cipher.update(JSON.stringify(values), "utf8"), + cipher.final(), + ]); + return { + version: 1, + alg: "aes-256-gcm", + iv: iv.toString("base64"), + tag: cipher.getAuthTag().toString("base64"), + ciphertext: ciphertext.toString("base64"), + }; +} + +function deserializeStore(raw: Record | null, machineKey: Buffer): Record { + if (!raw || Object.keys(raw).length === 0) return {}; + if (raw.version !== 1 || raw.alg !== "aes-256-gcm") { + throw new Error("Unsupported ADE credential store format."); + } + if (typeof raw.iv !== "string" || typeof raw.tag !== "string" || typeof raw.ciphertext !== "string") { + throw new Error("ADE credential store is malformed."); + } + const decipher = crypto.createDecipheriv("aes-256-gcm", machineKey, Buffer.from(raw.iv, "base64")); + decipher.setAAD(STORE_AAD); + decipher.setAuthTag(Buffer.from(raw.tag, "base64")); + const plaintext = Buffer.concat([ + decipher.update(Buffer.from(raw.ciphertext, "base64")), + decipher.final(), + ]).toString("utf8"); + const parsed = JSON.parse(plaintext) as unknown; + if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return {}; + const out: Record = {}; + for (const [key, value] of Object.entries(parsed as Record)) { + if (typeof value === "string") out[key] = value; + } + return out; +} + +function readOrCreateMachineKey(machineKeyPath: string): Buffer { + try { + const raw = fs.readFileSync(machineKeyPath, "utf8").trim(); + const key = Buffer.from(raw, "base64"); + if (key.length === 32) return key; + throw new Error("ADE credential machine key is invalid."); + } catch (error: unknown) { + if (!isEnoent(error)) throw error; + } + const key = crypto.randomBytes(32); + writeFileAtomic(machineKeyPath, `${key.toString("base64")}\n`); + return key; +} + +export class EncryptedFileCredentialStore implements SyncCredentialStore { + private readonly credentialsPath: string; + private readonly machineKeyPath: string; + + constructor(args: { secretsDir?: string; credentialsPath?: string; machineKeyPath?: string } = {}) { + const secretsDir = args.secretsDir ?? resolveMachineAdeLayout().secretsDir; + this.credentialsPath = args.credentialsPath ?? path.join(secretsDir, DEFAULT_CREDENTIALS_FILE); + this.machineKeyPath = args.machineKeyPath ?? path.join(secretsDir, DEFAULT_MACHINE_KEY_FILE); + } + + async get(key: string): Promise { + return this.getSync(key); + } + + async set(key: string, value: string): Promise { + this.setSync(key, value); + } + + async delete(key: string): Promise { + this.deleteSync(key); + } + + getSync(key: string): string | null { + const normalized = normalizeKey(key); + return this.readAll()[normalized] ?? null; + } + + setSync(key: string, value: string): void { + const normalized = normalizeKey(key); + const nextValue = value.trim(); + if (!nextValue.length) { + this.deleteSync(normalized); + return; + } + const values = this.readAll(); + values[normalized] = nextValue; + this.writeAll(values); + } + + deleteSync(key: string): void { + const normalized = normalizeKey(key); + const values = this.readAll(); + if (!(normalized in values)) return; + delete values[normalized]; + this.writeAll(values); + } + + private readAll(): Record { + const key = readOrCreateMachineKey(this.machineKeyPath); + return deserializeStore(readJsonObject(this.credentialsPath), key); + } + + private writeAll(values: Record): void { + const key = readOrCreateMachineKey(this.machineKeyPath); + writeFileAtomic(this.credentialsPath, `${JSON.stringify(serializeStore(values, key), null, 2)}\n`); + } +} + +export class ElectronSafeStorageCredentialStore implements SyncCredentialStore { + private readonly safeStorage: SafeStorageLike; + private readonly credentialsPath: string; + + constructor(args: { safeStorage: SafeStorageLike; credentialsPath?: string; secretsDir?: string }) { + this.safeStorage = args.safeStorage; + const secretsDir = args.secretsDir ?? resolveMachineAdeLayout().secretsDir; + this.credentialsPath = args.credentialsPath ?? path.join(secretsDir, DEFAULT_CREDENTIALS_FILE); + } + + async get(key: string): Promise { + return this.getSync(key); + } + + async set(key: string, value: string): Promise { + this.setSync(key, value); + } + + async delete(key: string): Promise { + this.deleteSync(key); + } + + getSync(key: string): string | null { + const normalized = normalizeKey(key); + return this.readAll()[normalized] ?? null; + } + + setSync(key: string, value: string): void { + const normalized = normalizeKey(key); + const nextValue = value.trim(); + if (!nextValue.length) { + this.deleteSync(normalized); + return; + } + const values = this.readAll(); + values[normalized] = nextValue; + this.writeAll(values); + } + + deleteSync(key: string): void { + const normalized = normalizeKey(key); + const values = this.readAll(); + if (!(normalized in values)) return; + delete values[normalized]; + this.writeAll(values); + } + + private readAll(): Record { + if (!this.safeStorage.isEncryptionAvailable()) { + throw new Error("Electron safeStorage is unavailable."); + } + try { + const encrypted = fs.readFileSync(this.credentialsPath); + const decrypted = this.safeStorage.decryptString(encrypted); + const parsed = JSON.parse(decrypted) as unknown; + if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return {}; + const out: Record = {}; + for (const [key, value] of Object.entries(parsed as Record)) { + if (typeof value === "string") out[key] = value; + } + return out; + } catch (error: unknown) { + if (isEnoent(error)) return {}; + throw error; + } + } + + private writeAll(values: Record): void { + if (!this.safeStorage.isEncryptionAvailable()) { + throw new Error("Electron safeStorage is unavailable."); + } + writeFileAtomic(this.credentialsPath, this.safeStorage.encryptString(JSON.stringify(values))); + } +} + +type KeytarModule = { + getPassword(service: string, account: string): Promise; + setPassword(service: string, account: string, password: string): Promise; + deletePassword(service: string, account: string): Promise; +}; + +async function loadOptionalKeytar(): Promise { + try { + const dynamicImport = new Function("specifier", "return import(specifier)") as (specifier: string) => Promise; + const mod = await dynamicImport("keytar"); + const candidate = (mod && typeof mod === "object" && "default" in mod ? (mod as { default: unknown }).default : mod) as Partial; + if ( + typeof candidate.getPassword === "function" + && typeof candidate.setPassword === "function" + && typeof candidate.deletePassword === "function" + ) { + return candidate as KeytarModule; + } + } catch { + return null; + } + return null; +} + +export class KeytarCredentialStore implements CredentialStore { + private readonly keytar: KeytarModule; + private readonly service: string; + + constructor(args: { keytar: KeytarModule; service?: string }) { + this.keytar = args.keytar; + this.service = args.service ?? "com.ade.runtime.credentials.v1"; + } + + async get(key: string): Promise { + return this.keytar.getPassword(this.service, normalizeKey(key)); + } + + async set(key: string, value: string): Promise { + const normalized = normalizeKey(key); + const nextValue = value.trim(); + if (!nextValue.length) { + await this.delete(normalized); + return; + } + await this.keytar.setPassword(this.service, normalized, nextValue); + } + + async delete(key: string): Promise { + await this.keytar.deletePassword(this.service, normalizeKey(key)); + } +} + +export async function createDefaultCredentialStore(args: { + env?: NodeJS.ProcessEnv; + secretsDir?: string; + preferKeytar?: boolean; +} = {}): Promise { + const env = args.env ?? process.env; + if (args.preferKeytar !== false && env.ADE_CREDENTIAL_STORE_DISABLE_KEYTAR !== "1") { + const keytar = await loadOptionalKeytar(); + if (keytar) return new KeytarCredentialStore({ keytar }); + } + return new EncryptedFileCredentialStore({ secretsDir: args.secretsDir }); +} diff --git a/apps/ade-cli/src/services/projects/machineLayout.ts b/apps/ade-cli/src/services/projects/machineLayout.ts new file mode 100644 index 000000000..6b9b14460 --- /dev/null +++ b/apps/ade-cli/src/services/projects/machineLayout.ts @@ -0,0 +1,36 @@ +import os from "node:os"; +import path from "node:path"; + +export type MachineAdeLayout = { + adeDir: string; + projectsPath: string; + secretsDir: string; + sockDir: string; + socketPath: string; + binDir: string; + runtimeDir: string; +}; + +export function resolveMachineAdeDir(env: NodeJS.ProcessEnv = process.env): string { + const explicit = env.ADE_HOME?.trim(); + if (explicit) return path.resolve(explicit); + return path.join(os.homedir(), ".ade"); +} + +export function resolveMachineAdeLayout(env: NodeJS.ProcessEnv = process.env): MachineAdeLayout { + const adeDir = resolveMachineAdeDir(env); + const secretsDir = path.join(adeDir, "secrets"); + const sockDir = path.join(adeDir, "sock"); + const socketPath = process.platform === "win32" + ? "\\\\.\\pipe\\ade-runtime" + : path.join(sockDir, "ade.sock"); + return { + adeDir, + projectsPath: path.join(adeDir, "projects.json"), + secretsDir, + sockDir, + socketPath, + binDir: path.join(adeDir, "bin"), + runtimeDir: path.join(adeDir, "runtime"), + }; +} diff --git a/apps/ade-cli/src/services/projects/projectRegistry.test.ts b/apps/ade-cli/src/services/projects/projectRegistry.test.ts new file mode 100644 index 000000000..6f044c2a3 --- /dev/null +++ b/apps/ade-cli/src/services/projects/projectRegistry.test.ts @@ -0,0 +1,86 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { + ProjectRegistry, + deriveProjectId, + isDisallowedProjectRoot, +} from "./projectRegistry"; + +const tempRoots = new Set(); + +function makeTempRoot(prefix = "ade-project-registry-"): string { + const root = fs.mkdtempSync(path.join(os.tmpdir(), prefix)); + tempRoots.add(root); + return root; +} + +afterEach(() => { + vi.restoreAllMocks(); + for (const root of tempRoots) { + fs.rmSync(root, { recursive: true, force: true }); + } + tempRoots.clear(); +}); + +describe("ProjectRegistry", () => { + it("rejects registering the user home directory", () => { + const homeDir = makeTempRoot("ade-project-registry-home-"); + vi.spyOn(os, "homedir").mockReturnValue(homeDir); + const registry = new ProjectRegistry({ + adeDir: path.join(homeDir, ".ade-runtime"), + projectsPath: path.join(homeDir, ".ade-runtime", "projects.json"), + secretsDir: path.join(homeDir, ".ade-runtime", "secrets"), + sockDir: path.join(homeDir, ".ade-runtime", "sock"), + socketPath: path.join(homeDir, ".ade-runtime", "sock", "ade.sock"), + binDir: path.join(homeDir, ".ade-runtime", "bin"), + runtimeDir: path.join(homeDir, ".ade-runtime", "runtime"), + }); + + expect(isDisallowedProjectRoot(homeDir)).toBe(true); + expect(() => registry.add(homeDir)).toThrow(/Refusing to register/); + }); + + it("filters already-persisted user home entries from project lists", () => { + const homeDir = makeTempRoot("ade-project-registry-home-"); + const projectRoot = path.join(homeDir, "Projects", "ADE"); + const registryDir = path.join(homeDir, ".ade-runtime"); + fs.mkdirSync(projectRoot, { recursive: true }); + fs.mkdirSync(registryDir, { recursive: true }); + vi.spyOn(os, "homedir").mockReturnValue(homeDir); + const projectsPath = path.join(registryDir, "projects.json"); + fs.writeFileSync( + projectsPath, + `${JSON.stringify({ + version: 1, + projects: [ + { + projectId: deriveProjectId(homeDir), + rootPath: homeDir, + displayName: "admin", + }, + { + projectId: deriveProjectId(projectRoot), + rootPath: projectRoot, + displayName: "ADE", + }, + ], + })}\n`, + "utf8", + ); + const registry = new ProjectRegistry({ + adeDir: registryDir, + projectsPath, + secretsDir: path.join(registryDir, "secrets"), + sockDir: path.join(registryDir, "sock"), + socketPath: path.join(registryDir, "sock", "ade.sock"), + binDir: path.join(registryDir, "bin"), + runtimeDir: path.join(registryDir, "runtime"), + }); + + expect(registry.list().map((project) => project.rootPath)).toEqual([ + projectRoot, + ]); + }); +}); diff --git a/apps/ade-cli/src/services/projects/projectRegistry.ts b/apps/ade-cli/src/services/projects/projectRegistry.ts new file mode 100644 index 000000000..9be2d69fb --- /dev/null +++ b/apps/ade-cli/src/services/projects/projectRegistry.ts @@ -0,0 +1,229 @@ +import { createHash } from "node:crypto"; +import { spawnSync } from "node:child_process"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { + resolveMachineAdeLayout, + type MachineAdeLayout, +} from "./machineLayout"; + +export type ProjectId = string; + +export type ProjectRecord = { + projectId: ProjectId; + rootPath: string; + displayName: string; + addedAt: number; + lastOpenedAt: number; + gitOriginUrl: string | null; +}; + +type ProjectRegistryFile = { + version: 1; + projects: ProjectRecord[]; +}; + +function normalizeRoot(rootPath: string): string { + return path.resolve(rootPath); +} + +function isSamePath(left: string, right: string): boolean { + return normalizeRoot(left) === normalizeRoot(right); +} + +export function isDisallowedProjectRoot( + rootPath: string, + homeDir = os.homedir(), +): boolean { + const normalized = normalizeRoot(rootPath); + if (homeDir && isSamePath(normalized, homeDir)) return true; + return normalized === path.parse(normalized).root; +} + +export function deriveProjectId(rootPath: string): ProjectId { + const normalized = normalizeRoot(rootPath); + const digest = createHash("sha256") + .update(normalized) + .digest("hex") + .slice(0, 24); + return `project_${digest}`; +} + +function readGitOriginUrl(rootPath: string): string | null { + const result = spawnSync("git", ["config", "--get", "remote.origin.url"], { + cwd: rootPath, + encoding: "utf8", + stdio: ["ignore", "pipe", "ignore"], + timeout: 5_000, + }); + if (result.status !== 0) return null; + const value = result.stdout.trim(); + return value.length ? value : null; +} + +function ensureProjectAdeDir(rootPath: string): void { + fs.mkdirSync(path.join(rootPath, ".ade"), { recursive: true }); +} + +function emptyFile(): ProjectRegistryFile { + return { version: 1, projects: [] }; +} + +function coerceRecord(value: unknown): ProjectRecord | null { + if (!value || typeof value !== "object" || Array.isArray(value)) return null; + const record = value as Record; + const rootPath = + typeof record.rootPath === "string" ? normalizeRoot(record.rootPath) : ""; + if (!rootPath) return null; + if (isDisallowedProjectRoot(rootPath)) return null; + const projectId = + typeof record.projectId === "string" && record.projectId.trim() + ? record.projectId.trim() + : deriveProjectId(rootPath); + const now = Date.now(); + return { + projectId, + rootPath, + displayName: + typeof record.displayName === "string" && record.displayName.trim() + ? record.displayName.trim() + : path.basename(rootPath), + addedAt: + typeof record.addedAt === "number" && Number.isFinite(record.addedAt) + ? record.addedAt + : now, + lastOpenedAt: + typeof record.lastOpenedAt === "number" && + Number.isFinite(record.lastOpenedAt) + ? record.lastOpenedAt + : now, + gitOriginUrl: + typeof record.gitOriginUrl === "string" && record.gitOriginUrl.trim() + ? record.gitOriginUrl.trim() + : null, + }; +} + +export class ProjectRegistry { + private readonly layout: MachineAdeLayout; + + constructor(layout: MachineAdeLayout = resolveMachineAdeLayout()) { + this.layout = layout; + } + + get path(): string { + return this.layout.projectsPath; + } + + list(): ProjectRecord[] { + return this.read().projects; + } + + get(projectId: ProjectId): ProjectRecord | null { + return this.list().find((record) => record.projectId === projectId) ?? null; + } + + findByRootPath(rootPath: string): ProjectRecord | null { + const normalized = normalizeRoot(rootPath); + return this.list().find((record) => record.rootPath === normalized) ?? null; + } + + add(rootPath: string): ProjectRecord { + const normalized = normalizeRoot(rootPath); + if (isDisallowedProjectRoot(normalized)) { + throw new Error( + "Refusing to register the user home directory or filesystem root as an ADE project. Choose a project folder.", + ); + } + const stat = fs.statSync(normalized); + if (!stat.isDirectory()) { + throw new Error(`Project root is not a directory: ${normalized}`); + } + + ensureProjectAdeDir(normalized); + + const file = this.read(); + const now = Date.now(); + const projectId = deriveProjectId(normalized); + const existingIndex = file.projects.findIndex( + (record) => + record.projectId === projectId || record.rootPath === normalized, + ); + const existing = existingIndex >= 0 ? file.projects[existingIndex] : null; + const next: ProjectRecord = { + projectId, + rootPath: normalized, + displayName: existing?.displayName ?? path.basename(normalized), + addedAt: existing?.addedAt ?? now, + lastOpenedAt: now, + gitOriginUrl: readGitOriginUrl(normalized), + }; + if (existingIndex >= 0) { + file.projects[existingIndex] = next; + } else { + file.projects.push(next); + } + this.write(file); + return next; + } + + remove(projectId: ProjectId): boolean { + const file = this.read(); + const nextProjects = file.projects.filter( + (record) => record.projectId !== projectId, + ); + if (nextProjects.length === file.projects.length) return false; + this.write({ ...file, projects: nextProjects }); + return true; + } + + touch(projectId: ProjectId): ProjectRecord { + const file = this.read(); + const index = file.projects.findIndex( + (record) => record.projectId === projectId, + ); + if (index < 0) throw new Error(`Unknown projectId: ${projectId}`); + const next: ProjectRecord = { + ...file.projects[index]!, + lastOpenedAt: Date.now(), + gitOriginUrl: readGitOriginUrl(file.projects[index]!.rootPath), + }; + file.projects[index] = next; + this.write(file); + return next; + } + + private read(): ProjectRegistryFile { + try { + const raw = fs.readFileSync(this.layout.projectsPath, "utf8"); + const parsed = JSON.parse(raw) as unknown; + if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) + return emptyFile(); + const projects = Array.isArray( + (parsed as { projects?: unknown }).projects, + ) + ? (parsed as { projects: unknown[] }).projects + .map(coerceRecord) + .filter((entry): entry is ProjectRecord => entry != null) + : []; + return { version: 1, projects }; + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") + return emptyFile(); + throw error; + } + } + + private write(file: ProjectRegistryFile): void { + fs.mkdirSync(this.layout.adeDir, { recursive: true, mode: 0o700 }); + fs.mkdirSync(path.dirname(this.layout.projectsPath), { + recursive: true, + mode: 0o700, + }); + const tempPath = `${this.layout.projectsPath}.${process.pid}.${Date.now()}.tmp`; + const payload = `${JSON.stringify({ version: 1, projects: file.projects }, null, 2)}\n`; + fs.writeFileSync(tempPath, payload, { encoding: "utf8", mode: 0o600 }); + fs.renameSync(tempPath, this.layout.projectsPath); + } +} diff --git a/apps/ade-cli/src/services/projects/projectScope.test.ts b/apps/ade-cli/src/services/projects/projectScope.test.ts new file mode 100644 index 000000000..46586af8c --- /dev/null +++ b/apps/ade-cli/src/services/projects/projectScope.test.ts @@ -0,0 +1,197 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { ProjectRegistry } from "./projectRegistry"; +import { ProjectScopeRegistry } from "./projectScope"; + +const createAdeRuntimeMock = vi.fn(); + +vi.mock("../../bootstrap", () => ({ + createAdeRuntime: createAdeRuntimeMock, +})); + +function createRegistry() { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "ade-project-scope-")); + const projectsRoot = path.join(root, "projects"); + const firstProjectRoot = path.join(projectsRoot, "first"); + const secondProjectRoot = path.join(projectsRoot, "second"); + fs.mkdirSync(firstProjectRoot, { recursive: true }); + fs.mkdirSync(secondProjectRoot, { recursive: true }); + + const registry = new ProjectRegistry({ + adeDir: path.join(root, "home"), + projectsPath: path.join(root, "home", "projects.json"), + secretsDir: path.join(root, "home", "secrets"), + sockDir: path.join(root, "home", "sock"), + socketPath: path.join(root, "home", "sock", "ade.sock"), + binDir: path.join(root, "home", "bin"), + runtimeDir: path.join(root, "home", "runtime"), + }); + + return { + registry, + first: registry.add(firstProjectRoot), + second: registry.add(secondProjectRoot), + }; +} + +describe("ProjectScopeRegistry", () => { + beforeEach(() => { + createAdeRuntimeMock.mockReset(); + createAdeRuntimeMock.mockImplementation(async () => ({ + dispose: vi.fn(), + })); + }); + + it("starts sync discovery only for the first opened daemon project scope", async () => { + const { registry, first, second } = createRegistry(); + const scopeRegistry = new ProjectScopeRegistry(registry, { + syncRuntime: { + enabled: true, + hostStartupEnabled: true, + hostDiscoveryEnabled: true, + forceHostRole: true, + runtimeKind: "daemon", + appVersion: "test", + localDeviceIdPath: "/tmp/ade-sync-device", + phonePairingStateDir: "/tmp/ade-phone-pairing", + }, + }); + + await scopeRegistry.get(first.projectId); + await scopeRegistry.get(second.projectId); + + expect(createAdeRuntimeMock).toHaveBeenCalledTimes(2); + expect(createAdeRuntimeMock.mock.calls[0]?.[0]).toMatchObject({ + projectRoot: first.rootPath, + syncRuntime: { + enabled: true, + hostStartupEnabled: true, + hostDiscoveryEnabled: true, + runtimeKind: "daemon", + }, + }); + expect(createAdeRuntimeMock.mock.calls[1]?.[0]).toMatchObject({ + projectRoot: second.rootPath, + syncRuntime: { + enabled: true, + hostStartupEnabled: false, + hostDiscoveryEnabled: false, + runtimeKind: "daemon", + }, + }); + + await scopeRegistry.disposeAll(); + }); + + it("does not pass sync runtime options when machine sync is disabled", async () => { + const { registry, first } = createRegistry(); + const scopeRegistry = new ProjectScopeRegistry(registry, { + syncRuntime: { enabled: false }, + }); + + await scopeRegistry.get(first.projectId); + + expect(createAdeRuntimeMock).toHaveBeenCalledTimes(1); + expect(createAdeRuntimeMock.mock.calls[0]?.[0]).not.toHaveProperty("syncRuntime"); + + await scopeRegistry.disposeAll(); + }); + + it("warms the most recently opened project as the sync host", async () => { + const { registry, first, second } = createRegistry(); + const file = JSON.parse(fs.readFileSync(registry.path, "utf8")) as { + projects: Array<{ projectId: string; lastOpenedAt: number; addedAt: number }>; + }; + file.projects = file.projects.map((project) => ({ + ...project, + lastOpenedAt: project.projectId === first.projectId ? 2_000 : 1_000, + addedAt: project.projectId === first.projectId ? 2_000 : 1_000, + })); + fs.writeFileSync(registry.path, JSON.stringify(file, null, 2)); + + const scopeRegistry = new ProjectScopeRegistry(registry, { + syncRuntime: { + enabled: true, + hostStartupEnabled: true, + hostDiscoveryEnabled: true, + forceHostRole: true, + runtimeKind: "daemon", + }, + }); + + const scope = await scopeRegistry.ensureSyncHost(); + + expect(scope?.registryProjectId).toBe(first.projectId); + expect(createAdeRuntimeMock).toHaveBeenCalledTimes(1); + expect(createAdeRuntimeMock.mock.calls[0]?.[0]).toMatchObject({ + projectRoot: first.rootPath, + syncRuntime: { + enabled: true, + hostStartupEnabled: true, + hostDiscoveryEnabled: true, + }, + }); + + await scopeRegistry.disposeAll(); + }); + + it("can switch the daemon sync host to a requested project", async () => { + const { registry, first, second } = createRegistry(); + const firstDispose = vi.fn(); + const secondDispose = vi.fn(); + const onDisposeProject = vi.fn(); + createAdeRuntimeMock + .mockResolvedValueOnce({ dispose: firstDispose }) + .mockResolvedValueOnce({ dispose: secondDispose }); + const scopeRegistry = new ProjectScopeRegistry(registry, { + onDisposeProject, + syncRuntime: { + enabled: true, + hostStartupEnabled: true, + hostDiscoveryEnabled: true, + forceHostRole: true, + runtimeKind: "daemon", + }, + }); + + await scopeRegistry.ensureSyncHost(first.projectId); + await scopeRegistry.ensureSyncHost(second.projectId); + + expect(firstDispose).toHaveBeenCalledTimes(1); + expect(onDisposeProject).toHaveBeenCalledWith(first.projectId); + expect(createAdeRuntimeMock).toHaveBeenCalledTimes(2); + expect(createAdeRuntimeMock.mock.calls[1]?.[0]).toMatchObject({ + projectRoot: second.rootPath, + syncRuntime: { + enabled: true, + hostStartupEnabled: true, + hostDiscoveryEnabled: true, + }, + }); + + await scopeRegistry.disposeAll(); + expect(secondDispose).toHaveBeenCalledTimes(1); + }); + + it("passes runtime capability options into project runtimes", async () => { + const { registry, first } = createRegistry(); + const scopeRegistry = new ProjectScopeRegistry(registry, { + runtimeCapabilities: { + memory: false, + }, + }); + + await scopeRegistry.get(first.projectId); + + expect(createAdeRuntimeMock).toHaveBeenCalledTimes(1); + expect(createAdeRuntimeMock.mock.calls[0]?.[0]).toMatchObject({ + capabilities: { + memory: false, + }, + }); + + await scopeRegistry.disposeAll(); + }); +}); diff --git a/apps/ade-cli/src/services/projects/projectScope.ts b/apps/ade-cli/src/services/projects/projectScope.ts new file mode 100644 index 000000000..96632df3f --- /dev/null +++ b/apps/ade-cli/src/services/projects/projectScope.ts @@ -0,0 +1,176 @@ +import type { AdeRuntime, AdeRuntimeSyncOptions } from "../../bootstrap"; +import type { SyncCommandPayload } from "../../../../desktop/src/shared/types"; +import { ProjectRegistry, type ProjectId, type ProjectRecord } from "./projectRegistry"; + +export class ProjectScope { + readonly registryProjectId: ProjectId; + readonly record: ProjectRecord; + readonly runtime: AdeRuntime; + + constructor(args: { + registryProjectId: ProjectId; + record: ProjectRecord; + runtime: AdeRuntime; + }) { + this.registryProjectId = args.registryProjectId; + this.record = args.record; + this.runtime = args.runtime; + } + + dispose(): void { + this.runtime.dispose(); + } +} + +export class ProjectScopeRegistry { + private readonly scopes = new Map>(); + private readonly disposeListeners = new Set<(projectId: ProjectId) => void>(); + private syncHostProjectId: ProjectId | null = null; + private readonly remoteCommandExecutor = { + execute: async (payload: SyncCommandPayload): Promise => { + return await this.executeRemoteCommand(payload); + }, + }; + + constructor( + private readonly projectRegistry: ProjectRegistry, + private readonly options: { + syncRuntime?: AdeRuntimeSyncOptions; + runtimeCapabilities?: { + memory?: boolean; + }; + onDisposeProject?: (projectId: ProjectId) => void; + } = {}, + ) {} + + onDispose(listener: (projectId: ProjectId) => void): () => void { + this.disposeListeners.add(listener); + return () => { + this.disposeListeners.delete(listener); + }; + } + + async get(projectId: ProjectId): Promise { + const cached = this.scopes.get(projectId); + if (cached) return await cached; + + const record = this.projectRegistry.get(projectId); + if (!record) { + throw new Error(`Unknown projectId: ${projectId}`); + } + + const pending = (async () => { + this.projectRegistry.touch(projectId); + const syncRuntime = this.buildSyncRuntimeOptions(projectId); + const { createAdeRuntime } = await import("../../bootstrap"); + const runtime = await createAdeRuntime({ + projectRoot: record.rootPath, + workspaceRoot: record.rootPath, + chatRuntime: "agent", + capabilities: this.options.runtimeCapabilities, + ...(syncRuntime ? { syncRuntime } : {}), + }); + return new ProjectScope({ + registryProjectId: projectId, + record, + runtime, + }); + })(); + this.scopes.set(projectId, pending); + + try { + return await pending; + } catch (error) { + this.scopes.delete(projectId); + if (this.syncHostProjectId === projectId) { + this.syncHostProjectId = null; + } + throw error; + } + } + + async dispose(projectId: ProjectId): Promise { + const cached = this.scopes.get(projectId); + if (!cached) return; + this.scopes.delete(projectId); + const scope = await cached.catch(() => null); + scope?.dispose(); + if (this.syncHostProjectId === projectId) { + this.syncHostProjectId = null; + } + this.options.onDisposeProject?.(projectId); + for (const listener of this.disposeListeners) { + listener(projectId); + } + } + + async disposeAll(): Promise { + const projectIds = [...this.scopes.keys()]; + await Promise.all(projectIds.map((projectId) => this.dispose(projectId))); + } + + async ensureSyncHost(projectId?: ProjectId): Promise { + if (!this.options.syncRuntime?.enabled) return null; + if (projectId) { + if (this.scopes.has(projectId) && this.syncHostProjectId !== projectId) { + await this.dispose(projectId); + } + const existingHostId = this.syncHostProjectId; + if (existingHostId && existingHostId !== projectId) { + await this.dispose(existingHostId); + } + this.syncHostProjectId = projectId; + return await this.get(projectId); + } + + const existingHostId = this.syncHostProjectId; + if (existingHostId) { + try { + return await this.get(existingHostId); + } catch { + this.syncHostProjectId = null; + } + } + + const record = this.projectRegistry + .list() + .slice() + .sort((left, right) => { + const openedDelta = right.lastOpenedAt - left.lastOpenedAt; + return openedDelta !== 0 ? openedDelta : right.addedAt - left.addedAt; + })[0]; + return record ? this.get(record.projectId) : null; + } + + private buildSyncRuntimeOptions(projectId: ProjectId): AdeRuntimeSyncOptions | null { + const base = this.options.syncRuntime; + if (!base?.enabled) return null; + const isHost = this.syncHostProjectId === null || this.syncHostProjectId === projectId; + if (isHost && this.syncHostProjectId === null) { + this.syncHostProjectId = projectId; + } + return { + ...base, + enabled: true, + registryProjectId: projectId, + hostStartupEnabled: isHost ? base.hostStartupEnabled ?? true : false, + hostDiscoveryEnabled: isHost ? base.hostDiscoveryEnabled ?? true : false, + remoteCommandExecutor: base.remoteCommandExecutor ?? this.remoteCommandExecutor, + }; + } + + private async executeRemoteCommand(payload: SyncCommandPayload): Promise { + const projectId = typeof payload.projectId === "string" && payload.projectId.trim() + ? payload.projectId.trim() + : null; + if (!projectId) { + throw new Error(`Remote command ${payload.action} requires projectId.`); + } + const scope = await this.get(projectId); + const syncService = scope.runtime.syncService; + if (!syncService) { + throw new Error(`Phone sync is not available for project ${projectId}.`); + } + return await syncService.executeRemoteCommand(payload); + } +} diff --git a/apps/ade-cli/src/services/sync/deviceRegistryService.ts b/apps/ade-cli/src/services/sync/deviceRegistryService.ts new file mode 100644 index 000000000..c4facdf0f --- /dev/null +++ b/apps/ade-cli/src/services/sync/deviceRegistryService.ts @@ -0,0 +1,673 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { randomUUID } from "node:crypto"; +import { execFileSync } from "node:child_process"; +import { resolveAdeLayout } from "../../../../desktop/src/shared/adeLayout"; +import type { + SyncBrainStatusPayload, + SyncClusterState, + SyncDeviceRecord, + SyncPeerConnectionState, + SyncPeerDeviceType, + SyncPeerMetadata, + SyncPeerPlatform, +} from "../../../../desktop/src/shared/types"; +import { normalizeNotificationPreferences, type NotificationPreferences } from "../../../../desktop/src/shared/types/sync"; +import type { Logger } from "../../../../desktop/src/main/services/logging/logger"; +import { mapPlatform } from "./syncProtocol"; +import { resolveTailscaleCliPath } from "./resolveTailscaleCliPath"; +import type { AdeDb } from "../../../../desktop/src/main/services/state/kvDb"; +import { nowIso, safeJsonParse, toOptionalString, uniqueStrings } from "../../../../desktop/src/main/services/shared/utils"; + +type DeviceRegistryServiceArgs = { + db: AdeDb; + logger: Logger; + projectRoot: string; + localDeviceIdPath?: string; +}; + +type DeviceRow = { + device_id: string; + site_id: string; + name: string; + platform: string; + device_type: string; + created_at: string; + updated_at: string; + last_seen_at: string | null; + last_host: string | null; + last_port: number | null; + tailscale_ip: string | null; + ip_addresses_json: string | null; + metadata_json: string | null; +}; + +type ClusterStateRow = { + cluster_id: string; + brain_device_id: string; + brain_epoch: number; + updated_at: string; + updated_by_device_id: string; +}; + +const DEVICE_ID_FILE = "sync-device-id"; +export const DEFAULT_SYNC_CLUSTER_ID = "default"; +const WORKSPACE_ACTIVITY_ID = "workspace"; +const TAILSCALE_STATUS_CACHE_MS = 30_000; + +let tailscaleStatusCache: + | { + expiresAt: number; + dnsName: string | null; + } + | null = null; + +function normalizeDeviceType(value: unknown): SyncPeerDeviceType { + const raw = typeof value === "string" ? value.trim() : ""; + if (raw === "desktop" || raw === "phone" || raw === "vps") return raw; + return "unknown"; +} + +function normalizePlatform(value: unknown): SyncPeerPlatform { + const raw = typeof value === "string" ? value.trim() : ""; + if (raw === "macOS" || raw === "linux" || raw === "windows" || raw === "iOS") return raw; + return "unknown"; +} + +function readJsonArray(raw: string | null | undefined): string[] { + return safeJsonParse(raw, []).filter((value) => typeof value === "string" && value.trim().length > 0); +} + +function mapDeviceRow(row: DeviceRow | null): SyncDeviceRecord | null { + if (!row) return null; + return { + deviceId: String(row.device_id), + siteId: String(row.site_id), + name: String(row.name), + platform: normalizePlatform(row.platform), + deviceType: normalizeDeviceType(row.device_type), + createdAt: String(row.created_at), + updatedAt: String(row.updated_at), + lastSeenAt: row.last_seen_at ? String(row.last_seen_at) : null, + lastHost: row.last_host ? String(row.last_host) : null, + lastPort: row.last_port == null ? null : Number(row.last_port), + tailscaleIp: row.tailscale_ip ? String(row.tailscale_ip) : null, + ipAddresses: readJsonArray(row.ip_addresses_json), + metadata: safeJsonParse>(row.metadata_json, {}), + }; +} + +function mapClusterStateRow(row: ClusterStateRow | null): SyncClusterState | null { + if (!row) return null; + return { + clusterId: String(row.cluster_id), + brainDeviceId: String(row.brain_device_id), + brainEpoch: Number(row.brain_epoch ?? 0), + updatedAt: String(row.updated_at), + updatedByDeviceId: String(row.updated_by_device_id), + }; +} + +type LocalNetworkMetadata = { + lanIpAddresses: string[]; + tailscaleIp: string | null; + tailscaleDnsName: string | null; +}; + +function isTailscaleAddress(ipAddress: string): boolean { + const parts = ipAddress.split("."); + if (parts.length !== 4) return false; + const octets = parts.map((part) => Number(part)); + if (octets.some((value) => !Number.isInteger(value) || value < 0 || value > 255)) return false; + return octets[0] === 100 && octets[1] >= 64 && octets[1] <= 127; +} + +function readLocalNetworkMetadata(): LocalNetworkMetadata { + const interfaces = os.networkInterfaces(); + const lan: string[] = []; + const tailscale: string[] = []; + for (const [interfaceName, entries] of Object.entries(interfaces)) { + const isLikelyTailscaleInterface = /tailscale|utun|tun/i.test(interfaceName); + for (const entry of entries ?? []) { + if (!entry || entry.internal || entry.family !== "IPv4") continue; + if (isLikelyTailscaleInterface || isTailscaleAddress(entry.address)) { + tailscale.push(entry.address); + } else { + lan.push(entry.address); + } + } + } + return { + lanIpAddresses: uniqueStrings(lan), + tailscaleIp: uniqueStrings(tailscale)[0] ?? null, + tailscaleDnsName: readLocalTailscaleDnsName(), + }; +} + +function normalizeTailscaleDnsName(value: unknown): string | null { + if (typeof value !== "string") return null; + const normalized = value.trim().replace(/\.$/, "").toLowerCase(); + return normalized.endsWith(".ts.net") ? normalized : null; +} + +function readLocalTailscaleDnsName(): string | null { + const now = Date.now(); + if (tailscaleStatusCache && tailscaleStatusCache.expiresAt > now) { + return tailscaleStatusCache.dnsName; + } + let dnsName: string | null = null; + try { + const raw = execFileSync(resolveTailscaleCliPath(), ["status", "--json"], { + encoding: "utf8", + stdio: ["ignore", "pipe", "ignore"], + timeout: 1_000, + }); + const parsed = safeJsonParse<{ Self?: { DNSName?: unknown } }>(raw, {}); + dnsName = normalizeTailscaleDnsName(parsed.Self?.DNSName); + } catch { + dnsName = null; + } + tailscaleStatusCache = { + expiresAt: now + TAILSCALE_STATUS_CACHE_MS, + dnsName, + }; + return dnsName; +} + +function firstPreferredHost(ipAddresses: string[]): string { + return ipAddresses[0] ?? os.hostname(); +} + +export function createDeviceRegistryService(args: DeviceRegistryServiceArgs) { + const layout = resolveAdeLayout(args.projectRoot); + const deviceIdPath = args.localDeviceIdPath ?? path.join(layout.secretsDir, DEVICE_ID_FILE); + const legacyProjectDeviceIdPath = path.join(layout.secretsDir, DEVICE_ID_FILE); + fs.mkdirSync(path.dirname(deviceIdPath), { recursive: true }); + + const readOrCreateLocalDeviceId = (): string => { + // One desktop, one device id: the shared file is authoritative across + // projects so each project's `sync_cluster_state.brain_device_id` agrees + // on the same local identity. If the shared file is empty, seed it from + // the first legacy per-project id we happen to see (one-time migration), + // otherwise mint a fresh id. `O_EXCL` on the seed write keeps two + // concurrent project contexts from racing to mint different ids. + const shared = fs.existsSync(deviceIdPath) ? fs.readFileSync(deviceIdPath, "utf8").trim() : ""; + if (shared.length > 0) return shared; + + const legacy = deviceIdPath !== legacyProjectDeviceIdPath && fs.existsSync(legacyProjectDeviceIdPath) + ? fs.readFileSync(legacyProjectDeviceIdPath, "utf8").trim() + : ""; + const candidate = legacy.length > 0 ? legacy : randomUUID(); + try { + fs.writeFileSync(deviceIdPath, `${candidate}\n`, { flag: "wx" }); + return candidate; + } catch (err) { + if ((err as NodeJS.ErrnoException).code !== "EEXIST") throw err; + // Another context won the race; use whatever they wrote. + return fs.readFileSync(deviceIdPath, "utf8").trim(); + } + }; + + const localDeviceId = readOrCreateLocalDeviceId(); + const localSiteId = args.db.sync.getSiteId(); + + const getLocalDefaults = () => { + const network = readLocalNetworkMetadata(); + const metadata: Record = { + hostname: os.hostname(), + }; + if (network.tailscaleDnsName) { + metadata.tailscaleDnsName = network.tailscaleDnsName; + } + return { + name: os.hostname(), + platform: mapPlatform(process.platform), + deviceType: "desktop" as SyncPeerDeviceType, + ipAddresses: network.lanIpAddresses, + tailscaleIp: network.tailscaleIp, + lastHost: firstPreferredHost(network.lanIpAddresses), + metadata, + }; + }; + + const upsertDeviceRecord = (record: { + deviceId: string; + siteId: string; + name: string; + platform: SyncPeerPlatform; + deviceType: SyncPeerDeviceType; + createdAt?: string; + updatedAt?: string; + lastSeenAt?: string | null; + lastHost?: string | null; + lastPort?: number | null; + tailscaleIp?: string | null; + ipAddresses?: string[]; + metadata?: Record; + }): SyncDeviceRecord => { + const now = nowIso(); + const existing = mapDeviceRow(args.db.get("select * from devices where device_id = ? limit 1", [record.deviceId])); + const nextCreatedAt = record.createdAt ?? existing?.createdAt ?? now; + const nextUpdatedAt = record.updatedAt ?? now; + const nextIpAddresses = uniqueStrings(record.ipAddresses ?? existing?.ipAddresses ?? []); + const nextMetadata = { + ...(existing?.metadata ?? {}), + ...(record.metadata ?? {}), + }; + args.db.run( + ` + insert into devices( + device_id, site_id, name, platform, device_type, + created_at, updated_at, last_seen_at, last_host, last_port, + tailscale_ip, ip_addresses_json, metadata_json + ) values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + on conflict(device_id) do update set + site_id = excluded.site_id, + name = excluded.name, + platform = excluded.platform, + device_type = excluded.device_type, + updated_at = excluded.updated_at, + last_seen_at = excluded.last_seen_at, + last_host = excluded.last_host, + last_port = excluded.last_port, + tailscale_ip = excluded.tailscale_ip, + ip_addresses_json = excluded.ip_addresses_json, + metadata_json = excluded.metadata_json + `, + [ + record.deviceId, + record.siteId, + record.name, + record.platform, + record.deviceType, + nextCreatedAt, + nextUpdatedAt, + record.lastSeenAt ?? existing?.lastSeenAt ?? null, + record.lastHost ?? existing?.lastHost ?? null, + record.lastPort ?? existing?.lastPort ?? null, + record.tailscaleIp ?? existing?.tailscaleIp ?? null, + JSON.stringify(nextIpAddresses), + JSON.stringify(nextMetadata), + ], + ); + return mapDeviceRow(args.db.get("select * from devices where device_id = ? limit 1", [record.deviceId]))!; + }; + + const ensureLocalDevice = (): SyncDeviceRecord => { + const existing = mapDeviceRow(args.db.get("select * from devices where device_id = ? limit 1", [localDeviceId])); + const defaults = getLocalDefaults(); + return upsertDeviceRecord({ + deviceId: localDeviceId, + siteId: localSiteId, + name: existing?.name ?? defaults.name, + platform: existing?.platform ?? defaults.platform, + deviceType: existing?.deviceType ?? defaults.deviceType, + lastSeenAt: nowIso(), + lastHost: defaults.lastHost ?? existing?.lastHost ?? null, + lastPort: existing?.lastPort ?? null, + tailscaleIp: defaults.tailscaleIp ?? existing?.tailscaleIp ?? null, + ipAddresses: defaults.ipAddresses.length > 0 ? defaults.ipAddresses : (existing?.ipAddresses ?? []), + metadata: { + ...(existing?.metadata ?? {}), + ...defaults.metadata, + }, + }); + }; + + const listDevices = (): SyncDeviceRecord[] => { + return args.db + .all("select * from devices order by case when device_id = ? then 0 else 1 end, name collate nocase asc", [localDeviceId]) + .map((row) => mapDeviceRow(row)) + .filter((row): row is SyncDeviceRecord => row != null); + }; + + const getDevice = (deviceId: string): SyncDeviceRecord | null => { + const normalized = deviceId.trim(); + if (!normalized) return null; + return mapDeviceRow(args.db.get("select * from devices where device_id = ? limit 1", [normalized])); + }; + + const getClusterState = (): SyncClusterState | null => { + return mapClusterStateRow( + args.db.get("select * from sync_cluster_state where cluster_id = ? limit 1", [DEFAULT_SYNC_CLUSTER_ID]), + ); + }; + + const setClusterState = (argsIn: { + brainDeviceId: string; + brainEpoch: number; + updatedByDeviceId?: string; + }): SyncClusterState => { + const now = nowIso(); + args.db.run( + ` + insert into sync_cluster_state(cluster_id, brain_device_id, brain_epoch, updated_at, updated_by_device_id) + values (?, ?, ?, ?, ?) + on conflict(cluster_id) do update set + brain_device_id = excluded.brain_device_id, + brain_epoch = excluded.brain_epoch, + updated_at = excluded.updated_at, + updated_by_device_id = excluded.updated_by_device_id + `, + [ + DEFAULT_SYNC_CLUSTER_ID, + argsIn.brainDeviceId, + argsIn.brainEpoch, + now, + argsIn.updatedByDeviceId ?? localDeviceId, + ], + ); + return getClusterState()!; + }; + + const bootstrapLocalBrainIfNeeded = (): SyncClusterState => { + const existing = getClusterState(); + if (existing) return existing; + ensureLocalDevice(); + return setClusterState({ + brainDeviceId: localDeviceId, + brainEpoch: 1, + updatedByDeviceId: localDeviceId, + }); + }; + + const updateLocalDevice = (updates: { + name?: string; + deviceType?: SyncPeerDeviceType; + }): SyncDeviceRecord => { + const current = ensureLocalDevice(); + return upsertDeviceRecord({ + deviceId: localDeviceId, + siteId: localSiteId, + name: toOptionalString(updates.name) ?? current.name, + platform: current.platform, + deviceType: updates.deviceType ?? current.deviceType, + lastSeenAt: nowIso(), + lastHost: current.lastHost, + lastPort: current.lastPort, + tailscaleIp: current.tailscaleIp, + ipAddresses: current.ipAddresses, + metadata: current.metadata, + }); + }; + + const touchLocalDevice = (argsIn: { + lastSeenAt?: string | null; + lastHost?: string | null; + lastPort?: number | null; + metadata?: Record; + } = {}): SyncDeviceRecord => { + const current = ensureLocalDevice(); + const network = readLocalNetworkMetadata(); + return upsertDeviceRecord({ + deviceId: current.deviceId, + siteId: current.siteId, + name: current.name, + platform: current.platform, + deviceType: current.deviceType, + lastSeenAt: argsIn.lastSeenAt ?? nowIso(), + lastHost: argsIn.lastHost ?? current.lastHost ?? firstPreferredHost(network.lanIpAddresses), + lastPort: argsIn.lastPort ?? current.lastPort, + tailscaleIp: network.tailscaleIp ?? current.tailscaleIp, + ipAddresses: network.lanIpAddresses.length > 0 ? network.lanIpAddresses : current.ipAddresses, + metadata: { + ...current.metadata, + ...(argsIn.metadata ?? {}), + }, + }); + }; + + const upsertPeerMetadata = ( + peer: SyncPeerMetadata | SyncPeerConnectionState, + extras: { + lastSeenAt?: string | null; + lastHost?: string | null; + lastPort?: number | null; + metadata?: Record; + } = {}, + ): SyncDeviceRecord => { + return upsertDeviceRecord({ + deviceId: peer.deviceId, + siteId: peer.siteId, + name: peer.deviceName, + platform: peer.platform, + deviceType: peer.deviceType, + lastSeenAt: extras.lastSeenAt ?? ("lastSeenAt" in peer ? peer.lastSeenAt : nowIso()), + lastHost: extras.lastHost ?? ("remoteAddress" in peer ? peer.remoteAddress : null), + lastPort: extras.lastPort ?? ("remotePort" in peer ? peer.remotePort : null), + metadata: { + dbVersion: peer.dbVersion, + ...(extras.metadata ?? {}), + }, + }); + }; + + type ApnsTokenKind = "alert" | "activity-start" | "activity-update"; + + const apnsMetaKey = (kind: ApnsTokenKind): string => { + if (kind === "alert") return "apnsAlertToken"; + if (kind === "activity-start") return "apnsActivityStartToken"; + return "apnsActivityUpdateTokens"; + }; + + const setApnsToken = ( + deviceId: string, + token: string, + kind: ApnsTokenKind, + env: "sandbox" | "production", + extras: { bundleId?: string; activityId?: string } = {}, + ): SyncDeviceRecord | null => { + const device = getDevice(deviceId); + if (!device) return null; + const nextMetadata: Record = { + ...device.metadata, + apnsEnv: env, + apnsTokenUpdatedAt: nowIso(), + }; + if (extras.bundleId) nextMetadata.apnsBundleId = extras.bundleId; + if (kind === "activity-update") { + const existing = (device.metadata.apnsActivityUpdateTokens as Record | undefined) ?? {}; + const activityId = extras.activityId?.trim() || WORKSPACE_ACTIVITY_ID; + nextMetadata.apnsActivityUpdateTokens = { ...existing, [activityId]: token }; + } else { + nextMetadata[apnsMetaKey(kind)] = token; + } + return upsertDeviceRecord({ + deviceId: device.deviceId, + siteId: device.siteId, + name: device.name, + platform: device.platform, + deviceType: device.deviceType, + lastSeenAt: device.lastSeenAt, + lastHost: device.lastHost, + lastPort: device.lastPort, + tailscaleIp: device.tailscaleIp, + ipAddresses: device.ipAddresses, + metadata: nextMetadata, + }); + }; + + const getApnsTokenForDevice = ( + deviceId: string, + kind: ApnsTokenKind, + activityId?: string, + ): string | null => { + const device = getDevice(deviceId); + if (!device) return null; + if (kind === "activity-update") { + const map = (device.metadata.apnsActivityUpdateTokens as Record | undefined) ?? {}; + return map[activityId?.trim() || WORKSPACE_ACTIVITY_ID] ?? null; + } + const raw = device.metadata[apnsMetaKey(kind)]; + return typeof raw === "string" && raw.trim().length > 0 ? raw : null; + }; + + const setNotificationPreferences = ( + deviceId: string, + prefs: NotificationPreferences, + ): SyncDeviceRecord | null => { + const device = getDevice(deviceId); + if (!device) return null; + const normalizedPrefs = normalizeNotificationPreferences(prefs); + return upsertDeviceRecord({ + deviceId: device.deviceId, + siteId: device.siteId, + name: device.name, + platform: device.platform, + deviceType: device.deviceType, + lastSeenAt: device.lastSeenAt, + lastHost: device.lastHost, + lastPort: device.lastPort, + tailscaleIp: device.tailscaleIp, + ipAddresses: device.ipAddresses, + metadata: { + ...device.metadata, + notificationPreferences: normalizedPrefs, + notificationPreferencesUpdatedAt: nowIso(), + }, + }); + }; + + const getNotificationPreferences = (deviceId: string): NotificationPreferences | null => { + const prefs = getDevice(deviceId)?.metadata.notificationPreferences; + if (!prefs || typeof prefs !== "object" || Array.isArray(prefs)) return null; + return normalizeNotificationPreferences(prefs); + }; + + const invalidateApnsToken = (deviceToken: string): void => { + const token = deviceToken.trim(); + if (!token) return; + const device = findDeviceByApnsToken(token); + if (!device) return; + const nextMetadata = { ...device.metadata }; + if (nextMetadata.apnsAlertToken === token) { + delete nextMetadata.apnsAlertToken; + } + if (nextMetadata.apnsActivityStartToken === token) { + delete nextMetadata.apnsActivityStartToken; + } + const updates = nextMetadata.apnsActivityUpdateTokens; + if (updates && typeof updates === "object" && !Array.isArray(updates)) { + const nextUpdates = { ...(updates as Record) }; + for (const [activityId, value] of Object.entries(nextUpdates)) { + if (value === token) delete nextUpdates[activityId]; + } + if (Object.keys(nextUpdates).length > 0) { + nextMetadata.apnsActivityUpdateTokens = nextUpdates; + } else { + delete nextMetadata.apnsActivityUpdateTokens; + } + } + upsertDeviceRecord({ + deviceId: device.deviceId, + siteId: device.siteId, + name: device.name, + platform: device.platform, + deviceType: device.deviceType, + lastSeenAt: device.lastSeenAt, + lastHost: device.lastHost, + lastPort: device.lastPort, + tailscaleIp: device.tailscaleIp, + ipAddresses: device.ipAddresses, + metadata: nextMetadata, + }); + }; + + const invalidateApnsTokensForDevice = (deviceId: string): void => { + const device = getDevice(deviceId); + if (!device) return; + const nextMetadata = { ...device.metadata }; + delete nextMetadata.apnsAlertToken; + delete nextMetadata.apnsActivityStartToken; + delete nextMetadata.apnsActivityUpdateTokens; + upsertDeviceRecord({ + deviceId: device.deviceId, + siteId: device.siteId, + name: device.name, + platform: device.platform, + deviceType: device.deviceType, + lastSeenAt: device.lastSeenAt, + lastHost: device.lastHost, + lastPort: device.lastPort, + tailscaleIp: device.tailscaleIp, + ipAddresses: device.ipAddresses, + metadata: nextMetadata, + }); + }; + + const findDeviceByApnsToken = (token: string): SyncDeviceRecord | null => { + for (const device of listDevices()) { + const alert = device.metadata.apnsAlertToken; + const activity = device.metadata.apnsActivityStartToken; + if (alert === token || activity === token) return device; + const updates = device.metadata.apnsActivityUpdateTokens; + if (updates && typeof updates === "object") { + for (const value of Object.values(updates as Record)) { + if (value === token) return device; + } + } + } + return null; + }; + + const applyBrainStatus = (payload: SyncBrainStatusPayload): void => { + upsertPeerMetadata(payload.brain, { lastSeenAt: nowIso() }); + for (const peer of payload.connectedPeers) { + upsertPeerMetadata(peer, { + lastSeenAt: peer.lastSeenAt, + lastHost: peer.remoteAddress, + lastPort: peer.remotePort, + }); + } + }; + + const clearClusterRegistryForViewerJoin = (): void => { + args.logger.info("sync.device_registry.clear_for_viewer_join", { + projectRoot: args.projectRoot, + localDeviceId, + }); + args.db.run("delete from sync_cluster_state"); + args.db.run("delete from devices"); + }; + + const forgetDevice = (deviceId: string): void => { + const normalized = deviceId.trim(); + if (!normalized || normalized === localDeviceId) return; + args.db.run("delete from devices where device_id = ?", [normalized]); + }; + + ensureLocalDevice(); + + return { + getLocalDeviceId(): string { + return localDeviceId; + }, + + getLocalSiteId(): string { + return localSiteId; + }, + + ensureLocalDevice, + touchLocalDevice, + updateLocalDevice, + listDevices, + getDevice, + getClusterState, + setClusterState, + bootstrapLocalBrainIfNeeded, + upsertPeerMetadata, + applyBrainStatus, + clearClusterRegistryForViewerJoin, + forgetDevice, + setApnsToken, + getApnsTokenForDevice, + setNotificationPreferences, + getNotificationPreferences, + invalidateApnsToken, + invalidateApnsTokensForDevice, + findDeviceByApnsToken, + }; +} + +export type DeviceRegistryService = ReturnType; diff --git a/apps/ade-cli/src/services/sync/resolveTailscaleCliPath.ts b/apps/ade-cli/src/services/sync/resolveTailscaleCliPath.ts new file mode 100644 index 000000000..d667af972 --- /dev/null +++ b/apps/ade-cli/src/services/sync/resolveTailscaleCliPath.ts @@ -0,0 +1,67 @@ +import fs from "node:fs"; +import path from "node:path"; +import type { PathLike } from "node:fs"; + +const TAILSCALE_CLI_MACOS_STANDALONE_PATHS = [ + "/opt/homebrew/bin/tailscale", + "/usr/local/bin/tailscale", +]; +const TAILSCALE_CLI_MACOS_APP_PATH = + "/Applications/Tailscale.app/Contents/MacOS/Tailscale"; + +function windowsTailscaleExeCandidates(env: NodeJS.ProcessEnv): string[] { + const programFiles = env.ProgramFiles?.trim(); + const programFilesX86 = env["ProgramFiles(x86)"]?.trim(); + const { join: winJoin } = path.win32; + const out: string[] = []; + if (programFiles) { + out.push(winJoin(programFiles, "Tailscale", "tailscale.exe")); + } + if (programFilesX86) { + out.push(winJoin(programFilesX86, "Tailscale", "tailscale.exe")); + } + if (out.length === 0) { + out.push( + "C:\\Program Files\\Tailscale\\tailscale.exe", + "C:\\Program Files (x86)\\Tailscale\\tailscale.exe", + ); + } + return out; +} + +export type ResolveTailscaleCliPathOptions = { + env?: NodeJS.ProcessEnv; + platform?: NodeJS.Platform; + /** Test seam; production uses `fs.existsSync`. */ + existsSync?: (path: PathLike) => boolean; +}; + +/** + * Resolves the Tailscale CLI for `status`, `serve`, etc. + * Precedence: `ADE_TAILSCALE_CLI`, known standalone macOS CLI paths, known + * macOS app bundle path, known Windows install paths, then `tailscale` (PATH + * lookup). + */ +export function resolveTailscaleCliPath( + options?: ResolveTailscaleCliPathOptions, +): string { + const env = options?.env ?? process.env; + const platform = options?.platform ?? process.platform; + const exists = options?.existsSync ?? ((p: PathLike) => fs.existsSync(p)); + const configured = env.ADE_TAILSCALE_CLI?.trim(); + if (configured) return configured; + if (platform === "darwin") { + for (const candidate of TAILSCALE_CLI_MACOS_STANDALONE_PATHS) { + if (exists(candidate)) return candidate; + } + if (exists(TAILSCALE_CLI_MACOS_APP_PATH)) { + return TAILSCALE_CLI_MACOS_APP_PATH; + } + } + if (platform === "win32") { + for (const candidate of windowsTailscaleExeCandidates(env)) { + if (exists(candidate)) return candidate; + } + } + return "tailscale"; +} diff --git a/apps/ade-cli/src/services/sync/syncHostService.test.ts b/apps/ade-cli/src/services/sync/syncHostService.test.ts new file mode 100644 index 000000000..bb8982a63 --- /dev/null +++ b/apps/ade-cli/src/services/sync/syncHostService.test.ts @@ -0,0 +1,343 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import type { + SyncMobileProjectSummary, + SyncPeerMetadata, + SyncRemoteCommandDescriptor, +} from "../../../../desktop/src/shared/types"; +import { + buildSyncHostHelloOkPayload, + createSyncHostService, + resolveSyncHostInboundProjectScope, +} from "./syncHostService"; + +const publishMock = vi.hoisted(() => vi.fn()); +const bonjourDestroyMock = vi.hoisted(() => vi.fn()); +const bonjourConstructorMock = vi.hoisted(() => vi.fn()); + +vi.mock("bonjour-service", () => ({ + Bonjour: bonjourConstructorMock, +})); + +type BonjourPublishArgs = { + name: string; + type: string; + protocol: string; + port: number; + txt: Record; + disableIPv6: boolean; +}; + +describe("resolveSyncHostInboundProjectScope", () => { + it("keeps runtime-scoped envelopes projectless", () => { + expect(resolveSyncHostInboundProjectScope("hello", "project-1", "project-1")).toEqual({ + ok: true, + projectId: null, + usedSingleProjectFallback: false, + }); + expect(resolveSyncHostInboundProjectScope("project_catalog_request", null, "project-1")).toEqual({ + ok: true, + projectId: null, + usedSingleProjectFallback: false, + }); + }); + + it("resolves missing project id through the single-active-project fallback", () => { + expect(resolveSyncHostInboundProjectScope("file_request", null, " project-1 ")).toEqual({ + ok: true, + projectId: "project-1", + usedSingleProjectFallback: true, + }); + expect(resolveSyncHostInboundProjectScope("terminal_input", " ", "project-1")).toEqual({ + ok: true, + projectId: "project-1", + usedSingleProjectFallback: true, + }); + }); + + it("accepts matching project-scoped envelopes", () => { + expect(resolveSyncHostInboundProjectScope("changeset_batch", " project-1 ", "project-1")).toEqual({ + ok: true, + projectId: "project-1", + usedSingleProjectFallback: false, + }); + expect(resolveSyncHostInboundProjectScope("chat_subscribe", "project-1", " project-1 ")).toEqual({ + ok: true, + projectId: "project-1", + usedSingleProjectFallback: false, + }); + }); + + it("rejects project-scoped envelopes for a different active project", () => { + expect(resolveSyncHostInboundProjectScope("changeset_ack", "project-2", "project-1")).toMatchObject({ + ok: false, + code: "project_mismatch", + expectedProjectId: "project-1", + receivedProjectId: "project-2", + }); + }); + + it("rejects project-scoped envelopes when no project is open", () => { + expect(resolveSyncHostInboundProjectScope("terminal_subscribe", "project-1", null)).toMatchObject({ + ok: false, + code: "project_not_open", + expectedProjectId: null, + receivedProjectId: "project-1", + }); + }); +}); + +describe("buildSyncHostHelloOkPayload", () => { + it("advertises daemon-hosted project catalog support in hello_ok without desktop", () => { + const peer = { + deviceId: "ios-phone-1", + deviceName: "Arul iPhone", + platform: "iOS", + deviceType: "phone", + siteId: "ios-site-1", + dbVersion: 0, + } satisfies SyncPeerMetadata; + const brain = { + deviceId: "daemon-host-1", + deviceName: "ADE daemon", + platform: "linux", + deviceType: "vps", + siteId: "daemon-site-1", + dbVersion: 7, + } satisfies SyncPeerMetadata; + const project = { + id: "project-1", + displayName: "ADE", + rootPath: "/Users/admin/Projects/ADE", + defaultBaseRef: "main", + lastOpenedAt: "2026-04-22T12:00:00.000Z", + laneCount: 3, + isAvailable: true, + isCached: true, + isOpen: false, + } satisfies SyncMobileProjectSummary; + const remoteCommand = { + action: "work.runQuickCommand", + scope: "project", + policy: { viewerAllowed: true }, + } satisfies SyncRemoteCommandDescriptor; + const localPresenceCommand = { + action: "lanes.presence.announce", + scope: "project", + policy: { viewerAllowed: true }, + } satisfies SyncRemoteCommandDescriptor; + + const payload = buildSyncHostHelloOkPayload({ + peer, + brain, + serverDbVersion: 7, + heartbeatIntervalMs: 30_000, + pollIntervalMs: 400, + projectCatalog: { projects: [project] }, + projectCatalogEnabled: true, + remoteCommandSupportedActions: [remoteCommand.action], + remoteCommandDescriptors: [remoteCommand], + localCommandDescriptors: [localPresenceCommand], + compressionThresholdBytes: 100_000, + }); + + expect(payload.peer).toBe(peer); + expect(payload.brain).toBe(brain); + expect(payload.serverDbVersion).toBe(7); + expect(payload.projects).toEqual([project]); + expect(payload.features.projectCatalog).toEqual({ enabled: true }); + expect(payload.features.fileAccess).toBe(true); + expect(payload.features.terminalStreaming).toBe(true); + expect(payload.features.chatStreaming).toEqual({ enabled: true }); + expect(payload.features.commandRouting).toEqual({ + mode: "allowlisted", + supportedActions: [remoteCommand.action, localPresenceCommand.action], + actions: [remoteCommand, localPresenceCommand], + }); + }); +}); + +function createDiscoveryLogger() { + return { + debug: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + }; +} + +function createTempProjectRoot(): { projectRoot: string; cleanup: () => void } { + const projectRoot = fs.mkdtempSync(path.join(os.tmpdir(), "ade-sync-discovery-")); + return { + projectRoot, + cleanup: () => fs.rmSync(projectRoot, { recursive: true, force: true }), + }; +} + +function createDiscoveryProject(overrides: Partial): SyncMobileProjectSummary { + return { + id: "project-1", + displayName: "Project", + rootPath: "/srv/project", + defaultBaseRef: "main", + lastOpenedAt: "2026-05-10T12:00:00.000Z", + laneCount: 0, + isAvailable: true, + isCached: true, + isOpen: false, + ...overrides, + }; +} + +function publishedAnnouncements(): BonjourPublishArgs[] { + return publishMock.mock.calls.map(([payload]) => payload as BonjourPublishArgs); +} + +function createHostArgs(projectRoot: string, projects: SyncMobileProjectSummary[]) { + return { + db: { + sync: { + getSiteId: () => "site-host-1", + getDbVersion: () => 7, + }, + }, + logger: createDiscoveryLogger(), + projectRoot, + port: 0, + discoveryEnabled: true, + runtimeKind: "headless" as const, + runtimeVersion: "2.0.0", + heartbeatIntervalMs: 60_000, + pollIntervalMs: 60_000, + brainStatusIntervalMs: 60_000, + pinStore: { + getPin: () => null, + hasPin: () => false, + verifyPin: () => false, + setPin: vi.fn(), + clearPin: vi.fn(), + }, + deviceRegistryService: { + ensureLocalDevice: () => ({ + deviceId: "host-device-1", + siteId: "host-site-1", + name: "ADE Build Host", + platform: "linux", + deviceType: "vps", + createdAt: "2026-05-10T12:00:00.000Z", + updatedAt: "2026-05-10T12:00:00.000Z", + lastSeenAt: "2026-05-10T12:00:00.000Z", + lastHost: "build-host.local", + lastPort: 8787, + tailscaleIp: "100.64.0.10", + ipAddresses: ["192.168.1.50"], + metadata: { tailscaleDnsName: "ade-build.tailnet.ts.net." }, + }), + }, + fileService: {}, + laneService: { + list: vi.fn().mockResolvedValue([]), + create: vi.fn(), + archive: vi.fn(), + }, + prService: { + listAll: vi.fn().mockResolvedValue([]), + getDetail: vi.fn(), + getStatus: vi.fn(), + getChecks: vi.fn(), + getReviews: vi.fn(), + getComments: vi.fn(), + getFiles: vi.fn(), + createFromLane: vi.fn(), + land: vi.fn(), + closePr: vi.fn(), + requestReviewers: vi.fn(), + }, + sessionService: { + list: () => [], + get: () => null, + readTranscriptTail: async () => "", + }, + ptyService: { + create: vi.fn(), + enrichSessions: (rows: unknown[]) => rows, + }, + computerUseArtifactBrokerService: { + listArtifacts: () => [], + }, + projectCatalogProvider: { + listProjects: vi.fn(async () => ({ projects })), + prepareProjectConnection: vi.fn(), + }, + }; +} + +describe("createSyncHostService LAN discovery", () => { + beforeEach(() => { + publishMock.mockReset(); + bonjourDestroyMock.mockReset(); + bonjourConstructorMock.mockReset(); + bonjourConstructorMock.mockImplementation(() => ({ + publish: publishMock, + destroy: bonjourDestroyMock, + })); + publishMock.mockImplementation(() => ({ + on: vi.fn(), + stop: vi.fn(), + })); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it("publishes headless runtime project metadata in Bonjour TXT records", async () => { + const { projectRoot, cleanup } = createTempProjectRoot(); + const projects = [ + createDiscoveryProject({ id: "project-1", displayName: "API, Server\nOne", rootPath: "/srv/api" }), + createDiscoveryProject({ id: "project-2", displayName: "Worker", rootPath: "/srv/worker" }), + ]; + const host = createSyncHostService( + createHostArgs(projectRoot, projects) as unknown as Parameters[0], + ); + + try { + const port = await host.waitUntilListening(); + await vi.waitFor(() => { + expect(publishedAnnouncements().some((announcement) => announcement.txt.projectCount === "2")).toBe(true); + }); + + const announcement = publishedAnnouncements() + .find((candidate) => candidate.txt.projectCount === "2"); + expect(announcement).toBeDefined(); + expect(announcement).toMatchObject({ + name: `ADE Sync ADE Build Host ${port}`, + type: "ade-sync", + protocol: "tcp", + port, + disableIPv6: true, + }); + expect(announcement?.txt).toEqual({ + version: "1", + runtimeKind: "headless", + runtimeVersion: "2.0.0", + projects: "project-1,project-2", + projectNames: "API Server One,Worker", + projectCount: "2", + deviceId: "host-device-1", + siteId: "host-site-1", + deviceName: "ADE Build Host", + port: String(port), + host: "192.168.1.50", + addresses: "192.168.1.50,100.64.0.10", + tailscaleIp: "100.64.0.10", + tailscaleDnsName: "ade-build.tailnet.ts.net", + }); + } finally { + await host.dispose(); + cleanup(); + } + }); +}); diff --git a/apps/ade-cli/src/services/sync/syncHostService.ts b/apps/ade-cli/src/services/sync/syncHostService.ts new file mode 100644 index 000000000..98abf8f2a --- /dev/null +++ b/apps/ade-cli/src/services/sync/syncHostService.ts @@ -0,0 +1,3255 @@ +import fs from "node:fs"; +import { execFile } from "node:child_process"; +import os from "node:os"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { promisify } from "node:util"; +import { createHash, randomBytes } from "node:crypto"; +import { Bonjour, type Service as BonjourService } from "bonjour-service"; +import { WebSocketServer, WebSocket, type RawData } from "ws"; +import { resolveAdeLayout } from "../../../../desktop/src/shared/adeLayout"; +import type { + AgentChatEventEnvelope, + CrsqlChangeRow, + DeviceMarker, + FileContent, + FileTreeNode, + FilesQuickOpenItem, + FilesSearchTextMatch, + FilesWorkspace, + LaneDetailPayload, + LaneListSnapshot, + LaneSummary, + PtyDataEvent, + PtyExitEvent, + SyncBrainStatusPayload, + SyncChangesetAckPayload, + SyncChangesetBatchPayload, + SyncCommandAckPayload, + SyncCommandPayload, + SyncCommandResultPayload, + SyncEnvelope, + SyncChatSubscribeSnapshotPayload, + SyncChatUnsubscribePayload, + SyncFileBlob, + SyncFileRequest, + SyncFileResponsePayload, + SyncHelloOkPayload, + SyncHelloPayload, + SyncMobileProjectSummary, + SyncPairingRequestPayload, + SyncPeerConnectionState, + SyncPeerMetadata, + SyncProjectCatalogChunkPayload, + SyncProjectCatalogPayload, + SyncProjectSwitchRequestPayload, + SyncProjectSwitchResultPayload, + SyncRemoteCommandDescriptor, + SyncTailnetDiscoveryStatus, + SyncTerminalSnapshotPayload, +} from "../../../../desktop/src/shared/types"; +import { parseAgentChatTranscript } from "../../../../desktop/src/shared/chatTranscript"; +import type { Logger } from "../../../../desktop/src/main/services/logging/logger"; +import type { createAgentChatService } from "../../../../desktop/src/main/services/chat/agentChatService"; +import type { createCtoStateService } from "../../../../desktop/src/main/services/cto/ctoStateService"; +import type { createFlowPolicyService } from "../../../../desktop/src/main/services/cto/flowPolicyService"; +import type { createLinearCredentialService } from "../../../../desktop/src/main/services/cto/linearCredentialService"; +import type { createLinearIngressService } from "../../../../desktop/src/main/services/cto/linearIngressService"; +import type { createLinearIssueTracker } from "../../../../desktop/src/main/services/cto/linearIssueTracker"; +import type { createLinearSyncService } from "../../../../desktop/src/main/services/cto/linearSyncService"; +import type { createWorkerAgentService } from "../../../../desktop/src/main/services/cto/workerAgentService"; +import type { createWorkerBudgetService } from "../../../../desktop/src/main/services/cto/workerBudgetService"; +import type { createWorkerHeartbeatService } from "../../../../desktop/src/main/services/cto/workerHeartbeatService"; +import type { createWorkerRevisionService } from "../../../../desktop/src/main/services/cto/workerRevisionService"; +import type { createProjectConfigService } from "../../../../desktop/src/main/services/config/projectConfigService"; +import type { createConflictService } from "../../../../desktop/src/main/services/conflicts/conflictService"; +import type { createFileService } from "../../../../desktop/src/main/services/files/fileService"; +import type { createDiffService } from "../../../../desktop/src/main/services/diffs/diffService"; +import type { createGitOperationsService } from "../../../../desktop/src/main/services/git/gitOperationsService"; +import type { createAutoRebaseService } from "../../../../desktop/src/main/services/lanes/autoRebaseService"; +import type { createLaneEnvironmentService } from "../../../../desktop/src/main/services/lanes/laneEnvironmentService"; +import type { createLaneService } from "../../../../desktop/src/main/services/lanes/laneService"; +import type { createLaneTemplateService } from "../../../../desktop/src/main/services/lanes/laneTemplateService"; +import type { createPortAllocationService } from "../../../../desktop/src/main/services/lanes/portAllocationService"; +import type { createRebaseSuggestionService } from "../../../../desktop/src/main/services/lanes/rebaseSuggestionService"; +import type { createProcessService } from "../../../../desktop/src/main/services/processes/processService"; +import type { createPtyService } from "../../../../desktop/src/main/services/pty/ptyService"; +import type { createIssueInventoryService } from "../../../../desktop/src/main/services/prs/issueInventoryService"; +import type { PathToMergeOrchestrator } from "../../../../desktop/src/main/services/prs/pathToMergeOrchestrator"; +import type { createPrService } from "../../../../desktop/src/main/services/prs/prService"; +import type { createQueueLandingService } from "../../../../desktop/src/main/services/prs/queueLandingService"; +import type { createSessionService } from "../../../../desktop/src/main/services/sessions/sessionService"; +import type { createComputerUseArtifactBrokerService } from "../../../../desktop/src/main/services/computerUse/computerUseArtifactBrokerService"; +import type { AdeDb } from "../../../../desktop/src/main/services/state/kvDb"; +import { hasNullByte, normalizeRelative, nowIso, resolvePathWithinRoot, safeJsonParse, toOptionalString, uniqueStrings, writeTextAtomic } from "../../../../desktop/src/main/services/shared/utils"; +import type { DeviceRegistryService } from "./deviceRegistryService"; +import { createSyncPairingStore } from "./syncPairingStore"; +import type { NotificationEventBus } from "../../../../desktop/src/main/services/notifications/notificationEventBus"; +import type { + ApnsEnvironment, + ApnsPushTokenKind, + NotificationPreferences, + SyncInAppNotificationPayload, + SyncNotificationPrefsPayload, + SyncRegisterPushTokenPayload, + SyncSendTestPushPayload, +} from "../../../../desktop/src/shared/types/sync"; +import { DEFAULT_NOTIFICATION_PREFERENCES, normalizeNotificationPreferences } from "../../../../desktop/src/shared/types/sync"; +import type { SyncPinStore } from "./syncPinStore"; +import { DEFAULT_SYNC_COMPRESSION_THRESHOLD_BYTES, DEFAULT_SYNC_HOST_PORT, encodeSyncEnvelope, mapPlatform, parseSyncEnvelope, wsDataToText } from "./syncProtocol"; +import { resolveTailscaleCliPath } from "./resolveTailscaleCliPath"; +import { createSyncRemoteCommandService, type SyncRemoteCommandService } from "./syncRemoteCommandService"; +const execFileAsync = promisify(execFile); +const DEFAULT_SYNC_HEARTBEAT_INTERVAL_MS = 30_000; +const DEFAULT_SYNC_HEARTBEAT_MISS_LIMIT = 2; +const MOBILE_SYNC_HEARTBEAT_MISS_LIMIT = 6; +const DEFAULT_SYNC_POLL_INTERVAL_MS = 400; +const DEFAULT_BRAIN_STATUS_INTERVAL_MS = 5_000; +const DEFAULT_TERMINAL_SNAPSHOT_BYTES = 220_000; +const PEER_BACKPRESSURE_BYTES = 4 * 1024 * 1024; +const MOBILE_COMMAND_RESULT_CACHE_TTL_MS = 30 * 60 * 1000; +const MOBILE_COMMAND_RESULT_CACHE_MAX_ENTRIES = 512; +const CHANGESET_ACK_TIMEOUT_MS = 10_000; +const MAX_CHANGESET_ACK_RETRIES = 6; +const LANE_PRESENCE_TTL_MS = 60_000; +const SYNC_MDNS_SERVICE_TYPE = "ade-sync"; +const MAX_PROJECT_CATALOG_ENVELOPE_BYTES = 768 * 1024; +const BONJOUR_PROJECT_TXT_ENTRY_LIMIT = 24; +const BONJOUR_PROJECT_NAME_MAX_LENGTH = 48; +export const SYNC_TAILNET_DISCOVERY_SERVICE_NAME = "svc:ade-sync"; +export const SYNC_TAILNET_DISCOVERY_SERVICE_PORT = DEFAULT_SYNC_HOST_PORT; +export type SyncRuntimeKind = "desktop-embedded" | "headless" | "remote-stdio" | "desktop" | "daemon" | "remote"; +const MOBILE_MUTATING_FILE_ACTIONS = new Set([ + "writeText", + "createFile", + "createDirectory", + "rename", + "deletePath", +]); + +type LanePresenceEntry = { + marker: DeviceMarker; + lastAnnouncedAtMs: number; + source: "local" | "remote"; +}; + +type PeerState = { + ws: WebSocket; + metadata: SyncPeerMetadata | null; + authenticated: boolean; + authKind: "bootstrap" | "paired" | null; + pairedDeviceId: string | null; + connectedAt: string; + lastSeenAt: string; + lastAppliedAt: string | null; + lastKnownServerDbVersion: number; + latencyMs: number | null; + awaitingHeartbeatAt: string | null; + missedHeartbeatCount: number; + remoteAddress: string | null; + remotePort: number | null; + subscribedSessionIds: Set; + subscribedChatSessionIds: Set; + chatTranscriptOffsets: Map; + chatEventIdsSent: Map>; + pendingChangesetBatch: PendingChangesetBatch | null; +}; + +type PendingChangesetBatch = { + batchId: string; + fromDbVersion: number; + toDbVersion: number; + changes: CrsqlChangeRow[]; + reason: SyncChangesetBatchPayload["reason"]; + sentAtMs: number; + retryCount: number; +}; + +type CachedMobileCommandWaiter = { + peer: PeerState; + requestId: string | null; +}; + +type CachedMobileCommand = { + commandId: string; + action: string; + argsKey: string; + argsFingerprint: string; + ack: SyncCommandAckPayload; + result: SyncCommandResultPayload | null; + waiters: CachedMobileCommandWaiter[]; + acceptedAtMs: number; + completedAtMs: number | null; +}; + +type PersistedMobileCommand = { + key: string; + projectRoot: string; + deviceId: string; + commandId: string; + action: string; + argsFingerprint: string; + ack: SyncCommandAckPayload; + result: SyncCommandResultPayload; + acceptedAtMs: number; + completedAtMs: number; +}; + +const PERSISTED_MOBILE_COMMAND_ACTIONS = new Set([ + "lanes.presence.announce", + "lanes.presence.release", + "notification_prefs", + "work.runQuickCommand", + "work.startCliSession", + "work.closeSession", + "processes.start", + "processes.stop", + "processes.kill", + "chat.interrupt", + "chat.approve", + "chat.respondToInput", + "chat.dispose", + "chat.archive", + "chat.unarchive", + "chat.delete", +]); + +function stableJsonValue(value: unknown): unknown { + if (value == null) return value; + if (Array.isArray(value)) return value.map(stableJsonValue); + if (typeof value !== "object") return value; + const input = value as Record; + const output: Record = {}; + for (const key of Object.keys(input).sort()) { + output[key] = stableJsonValue(input[key]); + } + return output; +} + +function stableJsonKey(value: unknown): string { + return JSON.stringify(stableJsonValue(value)) ?? "null"; +} + +function mobileCommandArgsFingerprint(argsKey: string): string { + return createHash("sha256").update(argsKey).digest("hex"); +} + +function safeObjectValue(value: unknown): Record | null { + return value && typeof value === "object" && !Array.isArray(value) + ? value as Record + : null; +} + +function persistedMobileCommandResult(action: string, result: SyncCommandResultPayload): SyncCommandResultPayload | null { + if (!PERSISTED_MOBILE_COMMAND_ACTIONS.has(action)) return null; + if (!result.ok) { + return { + commandId: result.commandId, + ok: false, + error: { + code: result.error?.code ?? "command_failed", + message: "Command failed before reconnect.", + }, + }; + } + if (action === "work.runQuickCommand" || action === "work.startCliSession") { + const raw = safeObjectValue(result.result); + const replayResult: Record = {}; + if (typeof raw?.sessionId === "string") replayResult.sessionId = raw.sessionId; + if (typeof raw?.ptyId === "string") replayResult.ptyId = raw.ptyId; + if (action === "work.startCliSession" && safeObjectValue(raw?.session)) replayResult.session = raw?.session; + return { + commandId: result.commandId, + ok: true, + result: Object.keys(replayResult).length > 0 ? replayResult : { ok: true }, + }; + } + return { + commandId: result.commandId, + ok: true, + result: { ok: true }, + }; +} + +function mobileCommandCacheKey(projectScopeKey: string, peer: PeerState, commandId: string): string | null { + const deviceId = peer.metadata?.deviceId ?? peer.pairedDeviceId; + if (!deviceId || !commandId) return null; + return `${projectScopeKey}:${deviceId}:${commandId}`; +} + +function addMobileCommandWaiter(record: CachedMobileCommand, peer: PeerState, requestId: string | null): void { + if (record.waiters.some((waiter) => waiter.peer === peer && waiter.requestId === requestId)) return; + record.waiters.push({ peer, requestId }); +} + +type SyncHostServiceArgs = { + db: AdeDb; + logger: Logger; + projectId?: string | null; + projectRoot: string; + fileService: ReturnType; + laneService: ReturnType; + gitService?: ReturnType; + diffService?: ReturnType; + conflictService?: ReturnType; + prService: ReturnType; + issueInventoryService?: ReturnType | null; + /** Optional Path-to-Merge orchestrator (forwarded to remote command service). */ + pathToMergeOrchestrator?: PathToMergeOrchestrator | null; + queueLandingService?: ReturnType | null; + sessionService: ReturnType; + ptyService: ReturnType; + processService?: ReturnType; + agentChatService?: ReturnType; + workerAgentService?: ReturnType | null; + workerBudgetService?: ReturnType | null; + workerHeartbeatService?: ReturnType | null; + workerRevisionService?: ReturnType | null; + ctoStateService?: ReturnType | null; + flowPolicyService?: ReturnType | null; + linearCredentialService?: ReturnType | null; + getLinearIngressService?: () => ReturnType | null; + getLinearIssueTracker?: () => ReturnType | null; + getLinearSyncService?: () => ReturnType | null; + projectConfigService?: ReturnType; + portAllocationService?: ReturnType; + laneEnvironmentService?: ReturnType; + laneTemplateService?: ReturnType; + rebaseSuggestionService?: ReturnType; + autoRebaseService?: ReturnType; + computerUseArtifactBrokerService: ReturnType; + pinStore: SyncPinStore; + bootstrapTokenPath?: string; + pairingSecretsPath?: string; + port?: number; + discoveryEnabled?: boolean; + runtimeKind?: SyncRuntimeKind; + runtimeVersion?: string; + heartbeatIntervalMs?: number; + pollIntervalMs?: number; + brainStatusIntervalMs?: number; + compressionThresholdBytes?: number; + deviceRegistryService?: DeviceRegistryService; + projectCatalogProvider?: { + listProjects: () => Promise; + prepareProjectConnection: (args: SyncProjectSwitchRequestPayload) => Promise; + completeProjectConnection?: ( + args: SyncProjectSwitchRequestPayload, + result: SyncProjectSwitchResultPayload, + ) => Promise; + }; + onStateChanged?: () => void; + notificationEventBus?: NotificationEventBus | null; + remoteCommandService?: SyncRemoteCommandService; + remoteCommandExecutor?: Pick; +}; + +function sanitizeRemoteAddress(remoteAddress: string | null | undefined): string | null { + const value = toOptionalString(remoteAddress); + if (!value) return null; + return value.startsWith("::ffff:") ? value.slice("::ffff:".length) : value; +} + +function ensureBootstrapToken(filePath: string): string { + fs.mkdirSync(path.dirname(filePath), { recursive: true }); + if (!fs.existsSync(filePath)) { + fs.writeFileSync(filePath, randomBytes(24).toString("hex"), { encoding: "utf8", mode: 0o600 }); + } + try { + fs.chmodSync(filePath, 0o600); + } catch { + // ignore chmod failures on platforms that don't support it + } + return fs.readFileSync(filePath, "utf8").trim(); +} + +function inferMimeType(filePath: string): string | null { + const ext = path.extname(filePath).toLowerCase(); + switch (ext) { + case ".png": + return "image/png"; + case ".jpg": + case ".jpeg": + return "image/jpeg"; + case ".gif": + return "image/gif"; + case ".webp": + return "image/webp"; + case ".mp4": + return "video/mp4"; + case ".mov": + return "video/quicktime"; + case ".zip": + return "application/zip"; + case ".json": + return "application/json"; + case ".md": + return "text/markdown"; + case ".txt": + case ".log": + return "text/plain"; + case ".yaml": + case ".yml": + return "application/yaml"; + default: + return null; + } +} + +function fileContentToBlob(filePath: string, content: FileContent): SyncFileBlob { + return { + path: filePath, + size: content.size, + mimeType: content.mimeType ?? inferMimeType(filePath), + encoding: content.encoding, + isBinary: content.isBinary, + content: content.content, + languageId: content.languageId, + }; +} + +function createBlobFromBuffer(filePath: string, buf: Buffer): SyncFileBlob { + const isBinary = hasNullByte(buf); + return { + path: filePath, + size: buf.length, + mimeType: inferMimeType(filePath), + encoding: isBinary ? "base64" : "utf-8", + isBinary, + content: isBinary ? buf.toString("base64") : buf.toString("utf8"), + languageId: null, + }; +} + +function toSyncPeerConnectionState(peer: PeerState, currentServerDbVersion: number): SyncPeerConnectionState | null { + if (!peer.metadata) return null; + return { + ...peer.metadata, + connectedAt: peer.connectedAt, + lastSeenAt: peer.lastSeenAt, + lastAppliedAt: peer.lastAppliedAt, + remoteAddress: peer.remoteAddress, + remotePort: peer.remotePort, + latencyMs: peer.latencyMs, + syncLag: Math.max(0, currentServerDbVersion - peer.lastKnownServerDbVersion), + isBrain: false, + isAuthenticated: peer.authenticated, + }; +} + +export function syncHeartbeatMissLimitForPeerMetadata(metadata: Pick | null | undefined): number { + return metadata?.platform === "iOS" || metadata?.deviceType === "phone" + ? MOBILE_SYNC_HEARTBEAT_MISS_LIMIT + : DEFAULT_SYNC_HEARTBEAT_MISS_LIMIT; +} + +const SYNC_HOST_PROJECT_SCOPED_INBOUND_ENVELOPE_TYPES = new Set([ + "changeset_batch", + "changeset_ack", + "file_request", + "terminal_subscribe", + "terminal_unsubscribe", + "terminal_input", + "terminal_resize", + "chat_subscribe", + "chat_unsubscribe", +]); + +type SyncHostProjectScopeResolution = + | { + ok: true; + projectId: string | null; + usedSingleProjectFallback: boolean; + } + | { + ok: false; + code: "project_not_open" | "project_mismatch"; + message: string; + expectedProjectId: string | null; + receivedProjectId: string | null; + }; + +export function resolveSyncHostInboundProjectScope( + type: SyncEnvelope["type"], + receivedProjectId: string | null | undefined, + hostProjectId: string | null | undefined, +): SyncHostProjectScopeResolution { + if (!SYNC_HOST_PROJECT_SCOPED_INBOUND_ENVELOPE_TYPES.has(type)) { + return { ok: true, projectId: null, usedSingleProjectFallback: false }; + } + + const received = toOptionalString(receivedProjectId); + const host = toOptionalString(hostProjectId); + if (!host) { + return { + ok: false, + code: "project_not_open", + message: "This ADE machine does not have a project open for phone sync.", + expectedProjectId: null, + receivedProjectId: received, + }; + } + if (!received) { + return { ok: true, projectId: host, usedSingleProjectFallback: true }; + } + if (received !== host) { + return { + ok: false, + code: "project_mismatch", + message: "This ADE machine is hosting a different project. Select the project again and retry.", + expectedProjectId: host, + receivedProjectId: received, + }; + } + return { ok: true, projectId: host, usedSingleProjectFallback: false }; +} + +export function buildSyncHostHelloOkPayload(args: { + peer: SyncPeerMetadata; + brain: SyncPeerMetadata; + serverDbVersion: number; + heartbeatIntervalMs: number; + pollIntervalMs: number; + projectCatalog: SyncProjectCatalogPayload; + projectCatalogEnabled: boolean; + remoteCommandSupportedActions: string[]; + remoteCommandDescriptors: SyncRemoteCommandDescriptor[]; + localCommandDescriptors: SyncRemoteCommandDescriptor[]; + compressionThresholdBytes?: number; + maxProjectCatalogEnvelopeBytes?: number; +}): SyncHelloOkPayload { + const actions = [ + ...args.remoteCommandDescriptors, + ...args.localCommandDescriptors, + ]; + const payload: SyncHelloOkPayload = { + peer: args.peer, + brain: args.brain, + serverDbVersion: args.serverDbVersion, + heartbeatIntervalMs: args.heartbeatIntervalMs, + pollIntervalMs: args.pollIntervalMs, + projects: args.projectCatalog.projects, + features: { + fileAccess: true, + terminalStreaming: true, + chatStreaming: { + enabled: true, + }, + projectCatalog: { + enabled: args.projectCatalogEnabled, + }, + changesetAck: { + enabled: true, + }, + bootstrapAuth: true, + pairingAuth: { + enabled: true, + pinDigits: 6, + }, + commandRouting: { + mode: "allowlisted", + supportedActions: [ + ...args.remoteCommandSupportedActions, + ...args.localCommandDescriptors.map((entry) => entry.action), + ], + actions, + }, + }, + }; + const envelopeBytes = Buffer.byteLength(encodeSyncEnvelope({ + type: "hello_ok", + payload, + compressionThresholdBytes: args.compressionThresholdBytes, + }), "utf8"); + return envelopeBytes <= (args.maxProjectCatalogEnvelopeBytes ?? MAX_PROJECT_CATALOG_ENVELOPE_BYTES) + ? payload + : { ...payload, projects: [] }; +} + +function parseHelloPayload(payload: unknown): SyncHelloPayload | null { + const value = payload as SyncHelloPayload | null; + const peer = value?.peer; + if (!peer || typeof peer !== "object") return null; + if (!toOptionalString(peer.deviceId) || !toOptionalString(peer.deviceName) || !toOptionalString(peer.siteId)) { + return null; + } + const auth = value?.auth; + let normalizedAuth = auth ?? null; + if (!normalizedAuth) { + const token = toOptionalString(value?.token); + if (!token) return null; + normalizedAuth = { + kind: "bootstrap", + token, + }; + } + if (normalizedAuth.kind === "bootstrap") { + if (!toOptionalString(normalizedAuth.token)) return null; + } else if (normalizedAuth.kind === "paired") { + if (!toOptionalString(normalizedAuth.deviceId) || !toOptionalString(normalizedAuth.secret)) return null; + } else { + return null; + } + return { + peer: { + deviceId: String(peer.deviceId).trim(), + deviceName: String(peer.deviceName).trim(), + platform: peer.platform ?? "unknown", + deviceType: peer.deviceType ?? "unknown", + siteId: String(peer.siteId).trim(), + dbVersion: Number(peer.dbVersion ?? 0), + capabilities: Array.isArray(peer.capabilities) + ? peer.capabilities + .filter((capability): capability is string => typeof capability === "string") + .map((capability) => capability.trim()) + .filter(Boolean) + : [], + }, + auth: normalizedAuth, + }; +} + +function parsePairingRequestPayload(payload: unknown): SyncPairingRequestPayload | null { + const value = payload as SyncPairingRequestPayload | null; + const code = toOptionalString(value?.code); + const peer = value?.peer; + if (!code || !peer || typeof peer !== "object") return null; + if (!toOptionalString(peer.deviceId) || !toOptionalString(peer.deviceName) || !toOptionalString(peer.siteId)) { + return null; + } + return { + code, + peer: { + deviceId: String(peer.deviceId).trim(), + deviceName: String(peer.deviceName).trim(), + platform: peer.platform ?? "unknown", + deviceType: peer.deviceType ?? "unknown", + siteId: String(peer.siteId).trim(), + dbVersion: Number(peer.dbVersion ?? 0), + }, + }; +} + +function shouldAttemptTailnetServiceAdvertise(): boolean { + if (process.env.ADE_TAILSCALE_SERVE === "0") return false; + if (process.env.NODE_ENV === "test" || process.env.VITEST) return false; + return process.platform === "darwin" || process.platform === "linux" || process.platform === "win32"; +} + +function looksLikePendingTailnetApproval(text: string): boolean { + return /\b(pending|approval|approve|review)\b/i.test(text); +} + +export function createSyncHostService(args: SyncHostServiceArgs) { + const layout = resolveAdeLayout(args.projectRoot); + const bootstrapTokenPath = args.bootstrapTokenPath ?? path.join(layout.secretsDir, "sync-bootstrap-token"); + const pairingSecretsPath = args.pairingSecretsPath ?? path.join(layout.secretsDir, "sync-paired-devices.json"); + const commandLedgerPath = path.join(layout.cacheDir, "sync-mobile-command-ledger.json"); + const bootstrapToken = ensureBootstrapToken(bootstrapTokenPath); + const pairingStore = createSyncPairingStore({ + filePath: pairingSecretsPath, + pinStore: args.pinStore, + }); + const remoteCommandService = args.remoteCommandService ?? createSyncRemoteCommandService({ + laneService: args.laneService, + prService: args.prService, + ptyService: args.ptyService, + sessionService: args.sessionService, + fileService: args.fileService, + gitService: args.gitService, + diffService: args.diffService, + conflictService: args.conflictService, + agentChatService: args.agentChatService, + workerAgentService: args.workerAgentService, + workerBudgetService: args.workerBudgetService, + workerHeartbeatService: args.workerHeartbeatService, + workerRevisionService: args.workerRevisionService, + ctoStateService: args.ctoStateService, + flowPolicyService: args.flowPolicyService, + linearCredentialService: args.linearCredentialService, + getLinearIngressService: args.getLinearIngressService, + getLinearIssueTracker: args.getLinearIssueTracker, + getLinearSyncService: args.getLinearSyncService, + issueInventoryService: args.issueInventoryService, + pathToMergeOrchestrator: args.pathToMergeOrchestrator, + queueLandingService: args.queueLandingService, + projectConfigService: args.projectConfigService, + processService: args.processService, + portAllocationService: args.portAllocationService, + laneEnvironmentService: args.laneEnvironmentService, + laneTemplateService: args.laneTemplateService, + rebaseSuggestionService: args.rebaseSuggestionService, + autoRebaseService: args.autoRebaseService, + logger: args.logger, + }); + const heartbeatIntervalMs = Math.max(5_000, Math.floor(args.heartbeatIntervalMs ?? DEFAULT_SYNC_HEARTBEAT_INTERVAL_MS)); + const pollIntervalMs = Math.max(100, Math.floor(args.pollIntervalMs ?? DEFAULT_SYNC_POLL_INTERVAL_MS)); + const brainStatusIntervalMs = Math.max(1_000, Math.floor(args.brainStatusIntervalMs ?? DEFAULT_BRAIN_STATUS_INTERVAL_MS)); + const compressionThresholdBytes = Math.max(256, Math.floor(args.compressionThresholdBytes ?? DEFAULT_SYNC_COMPRESSION_THRESHOLD_BYTES)); + const maxChangesetBatchBytes = 256 * 1024; + const maxChangesetBatchRows = 250; + const maxProjectCatalogEnvelopeBytes = MAX_PROJECT_CATALOG_ENVELOPE_BYTES; + const maxProjectCatalogChunkBytes = 192 * 1024; + const localPresenceCommandDescriptors: SyncRemoteCommandDescriptor[] = [ + { + action: "lanes.presence.announce", + scope: "project", + policy: { viewerAllowed: true }, + }, + { + action: "lanes.presence.release", + scope: "project", + policy: { viewerAllowed: true }, + }, + ]; + + const readBrainMetadata = (): SyncPeerMetadata => { + const localDevice = args.deviceRegistryService?.ensureLocalDevice(); + return { + deviceId: localDevice?.deviceId ?? args.db.sync.getSiteId(), + deviceName: localDevice?.name ?? os.hostname(), + platform: localDevice?.platform ?? mapPlatform(process.platform), + deviceType: localDevice?.deviceType ?? "desktop", + siteId: localDevice?.siteId ?? args.db.sync.getSiteId(), + dbVersion: args.db.sync.getDbVersion(), + }; + }; + + const peers = new Set(); + const mobileCommandResultCache = new Map(); + let commandReplayCount = 0; + let commandConflictCount = 0; + let lastCommandResultLatencyMs: number | null = null; + let lastChangesetAckLatencyMs: number | null = null; + + const pruneMobileCommandResultCache = (nowMs = Date.now()): void => { + for (const [key, record] of mobileCommandResultCache) { + if (record.completedAtMs == null) continue; + if (nowMs - record.completedAtMs > MOBILE_COMMAND_RESULT_CACHE_TTL_MS) { + mobileCommandResultCache.delete(key); + } + } + if (mobileCommandResultCache.size <= MOBILE_COMMAND_RESULT_CACHE_MAX_ENTRIES) return; + + const completed = [...mobileCommandResultCache.entries()] + .filter(([, record]) => record.completedAtMs != null) + .sort(([, left], [, right]) => (left.completedAtMs ?? left.acceptedAtMs) - (right.completedAtMs ?? right.acceptedAtMs)); + for (const [key] of completed) { + if (mobileCommandResultCache.size <= MOBILE_COMMAND_RESULT_CACHE_MAX_ENTRIES) break; + mobileCommandResultCache.delete(key); + } + }; + + const readPersistedCommandLedger = (): PersistedMobileCommand[] => { + try { + if (!fs.existsSync(commandLedgerPath)) return []; + const parsed = safeJsonParse<{ commands?: PersistedMobileCommand[] }>( + fs.readFileSync(commandLedgerPath, "utf8"), + { commands: [] }, + ); + return Array.isArray(parsed.commands) ? parsed.commands : []; + } catch (error) { + args.logger.warn("sync_host.command_ledger_read_failed", { + error: error instanceof Error ? error.message : String(error), + }); + return []; + } + }; + const writePersistedCommandLedger = (): void => { + const nowMs = Date.now(); + const commands: PersistedMobileCommand[] = []; + for (const [key, record] of mobileCommandResultCache) { + if (!record.result || record.completedAtMs == null) continue; + const persistedResult = persistedMobileCommandResult(record.action, record.result); + if (!persistedResult) continue; + if (!key.startsWith(`${args.projectRoot}:`)) continue; + if (nowMs - record.completedAtMs > MOBILE_COMMAND_RESULT_CACHE_TTL_MS) continue; + const deviceId = key.slice(`${args.projectRoot}:`.length).split(":")[0] ?? ""; + commands.push({ + key, + projectRoot: args.projectRoot, + deviceId, + commandId: record.commandId, + action: record.action, + argsFingerprint: record.argsFingerprint, + ack: record.ack, + result: persistedResult, + acceptedAtMs: record.acceptedAtMs, + completedAtMs: record.completedAtMs, + }); + } + commands.sort((left, right) => right.completedAtMs - left.completedAtMs); + writeTextAtomic(commandLedgerPath, `${JSON.stringify({ commands: commands.slice(0, MOBILE_COMMAND_RESULT_CACHE_MAX_ENTRIES) }, null, 2)}\n`); + }; + const loadPersistedCommandLedger = (): void => { + const nowMs = Date.now(); + for (const command of readPersistedCommandLedger()) { + if (command.projectRoot !== args.projectRoot) continue; + if (nowMs - command.completedAtMs > MOBILE_COMMAND_RESULT_CACHE_TTL_MS) continue; + const replayResult = persistedMobileCommandResult(command.action, command.result); + if (!replayResult) continue; + const legacyArgsKey = (command as { argsKey?: unknown }).argsKey; + const argsFingerprint = typeof command.argsFingerprint === "string" + ? command.argsFingerprint + : typeof legacyArgsKey === "string" + ? mobileCommandArgsFingerprint(legacyArgsKey) + : null; + if (!argsFingerprint) continue; + mobileCommandResultCache.set(command.key, { + commandId: command.commandId, + action: command.action, + argsKey: argsFingerprint, + argsFingerprint, + ack: command.ack, + result: replayResult, + waiters: [], + acceptedAtMs: command.acceptedAtMs, + completedAtMs: command.completedAtMs, + }); + } + }; + const commandLedgerSizeForProject = (): number => + [...mobileCommandResultCache.keys()].filter((key) => key.startsWith(`${args.projectRoot}:`)).length; + const dropInFlightCommandRecordsForProject = (): void => { + for (const [key, record] of mobileCommandResultCache) { + if (!key.startsWith(`${args.projectRoot}:`)) continue; + if (record.result == null) mobileCommandResultCache.delete(key); + } + }; + loadPersistedCommandLedger(); + /** Notification preferences keyed by deviceId. The map is a hot cache; + * device metadata is the restart-safe source for offline push fan-out. */ + const notificationPrefsByDeviceId = new Map(); + const storeNotificationPrefsForDevice = (deviceId: string, prefs: NotificationPreferences): void => { + const normalizedPrefs = normalizeNotificationPreferences(prefs); + notificationPrefsByDeviceId.set(deviceId, normalizedPrefs); + args.deviceRegistryService?.setNotificationPreferences?.(deviceId, normalizedPrefs); + }; + const readNotificationPrefsForDevice = (deviceId: string): NotificationPreferences => { + return notificationPrefsByDeviceId.get(deviceId) + ?? args.deviceRegistryService?.getNotificationPreferences?.(deviceId) + ?? DEFAULT_NOTIFICATION_PREFERENCES; + }; + const lanePresenceByLaneId = new Map>(); + let localActiveLaneIds = new Set(); + const PAIR_FAILURE_THRESHOLD = 5; + const PAIR_COOLDOWN_MS = 10 * 60_000; + const PAIR_FAILURE_WINDOW_MS = 10 * 60_000; + const pairFailures = new Map(); + const pruneExpiredPairFailures = (now = Date.now()): boolean => { + let changed = false; + for (const [ip, entry] of pairFailures) { + const cooldownExpired = entry.cooldownUntilMs > 0 && entry.cooldownUntilMs <= now; + const failureWindowExpired = entry.updatedAtMs + PAIR_FAILURE_WINDOW_MS <= now; + if (cooldownExpired || failureWindowExpired) { + pairFailures.delete(ip); + changed = true; + } + } + return changed; + }; + const registerPairFailure = (ip: string | null): void => { + if (!ip) return; + const now = Date.now(); + pruneExpiredPairFailures(now); + const entry = pairFailures.get(ip) ?? { count: 0, cooldownUntilMs: 0, updatedAtMs: now }; + entry.count += 1; + entry.updatedAtMs = now; + if (entry.count >= PAIR_FAILURE_THRESHOLD) { + entry.cooldownUntilMs = now + PAIR_COOLDOWN_MS; + entry.count = 0; + } + pairFailures.set(ip, entry); + }; + const pairingCooldownMsRemaining = (ip: string | null): number => { + if (!ip) return 0; + const entry = pairFailures.get(ip); + if (!entry) return 0; + const now = Date.now(); + const remaining = entry.cooldownUntilMs - now; + if (remaining > 0) return remaining; + if ( + (entry.cooldownUntilMs > 0 && remaining <= 0) + || entry.updatedAtMs + PAIR_FAILURE_WINDOW_MS <= now + ) { + pairFailures.delete(ip); + } + return 0; + }; + + const normalizeLaneId = (laneId: string | null | undefined): string | null => { + const normalized = toOptionalString(laneId); + return normalized && normalized.length > 0 ? normalized : null; + }; + + const listLanePresenceMarkers = (laneId: string): DeviceMarker[] => { + const entries = lanePresenceByLaneId.get(laneId); + if (!entries) return []; + return [...entries.values()] + .map((entry) => entry.marker) + .sort((left, right) => left.displayName.localeCompare(right.displayName)); + }; + + const upsertLanePresence = (argsIn: { + laneId: string; + marker: DeviceMarker; + source: "local" | "remote"; + }): boolean => { + const laneId = normalizeLaneId(argsIn.laneId); + if (!laneId) return false; + const byDevice = lanePresenceByLaneId.get(laneId) ?? new Map(); + const existing = byDevice.get(argsIn.marker.deviceId) ?? null; + const nextEntry: LanePresenceEntry = { + marker: argsIn.marker, + lastAnnouncedAtMs: Date.now(), + source: argsIn.source, + }; + byDevice.set(argsIn.marker.deviceId, nextEntry); + lanePresenceByLaneId.set(laneId, byDevice); + return ( + existing == null + || existing.source !== nextEntry.source + || existing.marker.displayName !== nextEntry.marker.displayName + || existing.marker.platform !== nextEntry.marker.platform + ); + }; + + const removeLanePresence = (laneId: string | null | undefined, deviceId: string | null | undefined): boolean => { + const normalizedLaneId = normalizeLaneId(laneId); + const normalizedDeviceId = toOptionalString(deviceId); + if (!normalizedLaneId || !normalizedDeviceId) return false; + const byDevice = lanePresenceByLaneId.get(normalizedLaneId); + if (!byDevice?.delete(normalizedDeviceId)) return false; + if (byDevice.size === 0) { + lanePresenceByLaneId.delete(normalizedLaneId); + } + return true; + }; + + const removeAllPresenceForDevice = ( + deviceId: string | null | undefined, + source?: LanePresenceEntry["source"], + ): boolean => { + const normalizedDeviceId = toOptionalString(deviceId); + if (!normalizedDeviceId) return false; + let changed = false; + for (const [laneId, byDevice] of lanePresenceByLaneId) { + const entry = byDevice.get(normalizedDeviceId); + if (!entry || (source && entry.source !== source)) continue; + byDevice.delete(normalizedDeviceId); + changed = true; + if (byDevice.size === 0) { + lanePresenceByLaneId.delete(laneId); + } + } + return changed; + }; + + const pruneExpiredLanePresence = (): boolean => { + const cutoff = Date.now() - LANE_PRESENCE_TTL_MS; + let changed = false; + for (const [laneId, byDevice] of lanePresenceByLaneId) { + for (const [deviceId, entry] of byDevice) { + if (entry.lastAnnouncedAtMs > cutoff) continue; + byDevice.delete(deviceId); + changed = true; + } + if (byDevice.size === 0) { + lanePresenceByLaneId.delete(laneId); + } + } + return changed; + }; + + const readLocalPresenceMarker = (): DeviceMarker | null => { + const localDevice = args.deviceRegistryService?.ensureLocalDevice() ?? null; + if (!localDevice) return null; + return { + deviceId: localDevice.deviceId, + displayName: localDevice.name, + platform: localDevice.platform, + }; + }; + + const refreshLocalLanePresence = (): boolean => { + if (localActiveLaneIds.size === 0) return false; + const marker = readLocalPresenceMarker(); + if (!marker) return false; + let changed = false; + for (const laneId of localActiveLaneIds) { + changed = upsertLanePresence({ + laneId, + marker, + source: "local", + }) || changed; + } + return changed; + }; + + const setLocalActiveLanePresence = (laneIds: string[]): void => { + const nextLaneIds = new Set( + laneIds + .map((laneId) => normalizeLaneId(laneId)) + .filter((laneId): laneId is string => laneId != null), + ); + const marker = readLocalPresenceMarker(); + let changed = false; + if (marker) { + for (const laneId of localActiveLaneIds) { + if (!nextLaneIds.has(laneId)) { + changed = removeLanePresence(laneId, marker.deviceId) || changed; + } + } + } + localActiveLaneIds = nextLaneIds; + if (marker) { + for (const laneId of localActiveLaneIds) { + changed = upsertLanePresence({ laneId, marker, source: "local" }) || changed; + } + } + if (changed) { + args.onStateChanged?.(); + broadcastBrainStatus(); + } + }; + + const buildRemotePresenceMarker = (peer: PeerState): DeviceMarker | null => { + if (!peer.metadata) return null; + return { + deviceId: peer.metadata.deviceId, + displayName: peer.metadata.deviceName, + platform: peer.metadata.platform, + }; + }; + + const decorateLaneSummary = (lane: LaneSummary): LaneSummary => { + const devicesOpen = listLanePresenceMarkers(lane.id); + return devicesOpen.length > 0 ? { ...lane, devicesOpen } : lane; + }; + + const decorateLaneSummaries = (lanes: LaneSummary[]): LaneSummary[] => + lanes.map((lane) => decorateLaneSummary(lane)); + + const decorateLaneListSnapshots = (snapshots: LaneListSnapshot[]): LaneListSnapshot[] => + snapshots.map((snapshot) => ({ + ...snapshot, + lane: decorateLaneSummary(snapshot.lane), + })); + + const decorateLaneDetailPayload = (detail: LaneDetailPayload): LaneDetailPayload => ({ + ...detail, + lane: decorateLaneSummary(detail.lane), + children: decorateLaneSummaries(detail.children), + }); + + const decorateCommandResult = ( + action: SyncCommandPayload["action"], + result: unknown, + ): unknown => { + pruneExpiredLanePresence(); + switch (action) { + case "lanes.list": + case "lanes.getChildren": + return Array.isArray(result) ? decorateLaneSummaries(result as LaneSummary[]) : result; + case "lanes.refreshSnapshots": { + const payload = result as + | { lanes?: LaneSummary[]; snapshots?: LaneListSnapshot[] } + | null + | undefined; + if (!payload || typeof payload !== "object") return result; + return { + ...payload, + ...(Array.isArray(payload.lanes) ? { lanes: decorateLaneSummaries(payload.lanes) } : {}), + ...(Array.isArray(payload.snapshots) + ? { snapshots: decorateLaneListSnapshots(payload.snapshots) } + : {}), + }; + } + case "lanes.getDetail": + return result && typeof result === "object" + ? decorateLaneDetailPayload(result as LaneDetailPayload) + : result; + case "lanes.create": + case "lanes.createChild": + case "lanes.createFromUnstaged": + case "lanes.importBranch": + case "lanes.attach": + case "lanes.adoptAttached": + return result && typeof result === "object" + ? decorateLaneSummary(result as LaneSummary) + : result; + default: + return result; + } + }; + const server = new WebSocketServer({ + host: "0.0.0.0", + port: args.port ?? DEFAULT_SYNC_HOST_PORT, + maxPayload: 25 * 1024 * 1024, + }); + + let disposed = false; + let startupError: Error | null = null; + let bonjourInstance: Bonjour | null = null; + let bonjourAnnouncement: BonjourService | null = null; + let bonjourPort: number | null = null; + let bonjourSignature: string | null = null; + let bonjourProjectTxt: { projects: string; projectNames: string; projectCount: string } = { + projects: typeof args.projectId === "string" && args.projectId.trim() ? args.projectId.trim() : "", + projectNames: typeof args.projectId === "string" && args.projectId.trim() ? "Current project" : "", + projectCount: typeof args.projectId === "string" && args.projectId.trim() ? "1" : "", + }; + let bonjourProjectRefreshInFlight = false; + let tailnetServeSignature: string | null = null; + let tailnetServeLastFailureSignature: string | null = null; + let tailnetServePublishSequence = 0; + let tailnetServeActivePublishToken = 0; + let discoveryEnabled = args.discoveryEnabled !== false; + let tailnetDiscoveryStatus: SyncTailnetDiscoveryStatus = { + state: !discoveryEnabled + ? "disabled" + : shouldAttemptTailnetServiceAdvertise() ? "disabled" : "unavailable", + serviceName: SYNC_TAILNET_DISCOVERY_SERVICE_NAME, + servicePort: SYNC_TAILNET_DISCOVERY_SERVICE_PORT, + target: null, + updatedAt: null, + error: !discoveryEnabled + ? "Tailnet discovery is disabled for this background project context." + : shouldAttemptTailnetServiceAdvertise() + ? "Tailnet discovery has not been published yet." + : "Tailscale Serve discovery is not available in this ADE process.", + stderr: null, + }; + let lastBroadcastAt: string | null = null; + const startedAtMs = Date.now(); + + server.on("error", (error: unknown) => { + const normalized = error instanceof Error ? error : new Error(String(error)); + if (!disposed && !server.address()) { + startupError = normalized; + } + args.logger.warn("sync_host.server_error", { + error: normalized.message, + code: (normalized as NodeJS.ErrnoException).code ?? null, + port: args.port ?? DEFAULT_SYNC_HOST_PORT, + }); + args.onStateChanged?.(); + }); + + const pollTimer = setInterval(() => { + void pumpChanges().catch((error) => { + args.logger.warn("sync_host.poll_failed", { error: error instanceof Error ? error.message : String(error) }); + }); + void pumpChatEvents().catch((error) => { + args.logger.warn("sync_host.chat_poll_failed", { error: error instanceof Error ? error.message : String(error) }); + }); + }, pollIntervalMs); + const heartbeatTimer = setInterval(() => { + pruneExpiredPairFailures(); + const refreshedLocalPresence = refreshLocalLanePresence(); + if (refreshedLocalPresence || pruneExpiredLanePresence()) { + args.onStateChanged?.(); + broadcastBrainStatus(); + } + const sentAt = nowIso(); + for (const peer of peers) { + if (!peer.authenticated || peer.ws.readyState !== WebSocket.OPEN) continue; + if (isPeerBackpressured(peer)) { + args.logger.debug("sync_host.heartbeat_deferred_backpressure", { + peerDeviceId: peer.metadata?.deviceId ?? null, + bufferedAmount: peer.ws.bufferedAmount, + }); + continue; + } + if (peer.awaitingHeartbeatAt) { + peer.missedHeartbeatCount += 1; + if (peer.missedHeartbeatCount >= syncHeartbeatMissLimitForPeerMetadata(peer.metadata)) { + try { + peer.ws.close(4001, "Heartbeat timed out"); + } catch { + // ignore + } + continue; + } + } else { + peer.missedHeartbeatCount = 0; + } + peer.awaitingHeartbeatAt = sentAt; + send(peer.ws, "heartbeat", { kind: "ping", sentAt, dbVersion: args.db.sync.getDbVersion() }); + } + }, heartbeatIntervalMs); + const brainStatusTimer = setInterval(() => { + broadcastBrainStatus(); + }, brainStatusIntervalMs); + const chatEventSubscription = args.agentChatService?.subscribeToEvents( + (event) => { + broadcastChatEvent(event); + // Let the notification bus (mobile push fan-out) observe chat events. + // Failures here must never break chat delivery to the UI. + try { + args.notificationEventBus?.publishChatEvent(event); + } catch (error) { + args.logger.warn("sync_host.notification_publish_failed", { + error: error instanceof Error ? error.message : String(error), + }); + } + }, + ) ?? null; + + server.on("connection", (ws, request) => { + const remoteAddress = sanitizeRemoteAddress(request.socket.remoteAddress); + const peer: PeerState = { + ws, + metadata: null, + authenticated: false, + authKind: null, + pairedDeviceId: null, + connectedAt: nowIso(), + lastSeenAt: nowIso(), + lastAppliedAt: null, + lastKnownServerDbVersion: 0, + latencyMs: null, + awaitingHeartbeatAt: null, + missedHeartbeatCount: 0, + remoteAddress, + remotePort: request.socket.remotePort ?? null, + subscribedSessionIds: new Set(), + subscribedChatSessionIds: new Set(), + chatTranscriptOffsets: new Map(), + chatEventIdsSent: new Map(), + pendingChangesetBatch: null, + }; + peers.add(peer); + ws.on("message", (raw) => { + void handleMessage(peer, raw).catch((error) => { + args.logger.warn("sync_host.message_failed", { + error: error instanceof Error ? error.message : String(error), + peerDeviceId: peer.metadata?.deviceId ?? null, + }); + }); + }); + ws.on("close", () => { + if (removeAllPresenceForDevice(peer.metadata?.deviceId, "remote")) { + broadcastBrainStatus(); + } + peers.delete(peer); + args.onStateChanged?.(); + broadcastBrainStatus(); + }); + ws.on("error", (error) => { + args.logger.warn("sync_host.socket_error", { + error: error instanceof Error ? error.message : String(error), + peerDeviceId: peer.metadata?.deviceId ?? null, + }); + }); + }); + + const publishLanDiscovery = (port: number): void => { + if (disposed) return; + if (!discoveryEnabled) { + unpublishLanDiscovery(); + return; + } + const localDevice = args.deviceRegistryService?.ensureLocalDevice() ?? null; + const hostName = localDevice?.name ?? os.hostname(); + const tailscaleDnsName = + typeof localDevice?.metadata?.tailscaleDnsName === "string" + ? localDevice.metadata.tailscaleDnsName.trim().replace(/\.$/, "").toLowerCase() + : ""; + const ipAddresses = uniqueStrings([ + ...(localDevice?.ipAddresses ?? []), + localDevice?.tailscaleIp ?? null, + ].filter((value): value is string => typeof value === "string" && value.trim().length > 0)); + const addressesCsv = ipAddresses.length > 0 ? ipAddresses.join(",") : "127.0.0.1"; + const preferredHost = ipAddresses[0] ?? localDevice?.lastHost ?? ""; + const txt = { + version: "1", + runtimeKind: args.runtimeKind ?? "desktop-embedded", + runtimeVersion: args.runtimeVersion ?? "", + projects: bonjourProjectTxt.projects, + projectNames: bonjourProjectTxt.projectNames, + projectCount: bonjourProjectTxt.projectCount, + deviceId: localDevice?.deviceId ?? "", + siteId: localDevice?.siteId ?? "", + deviceName: hostName, + port: String(port), + host: preferredHost, + addresses: addressesCsv, + tailscaleIp: localDevice?.tailscaleIp ?? "", + tailscaleDnsName: tailscaleDnsName.endsWith(".ts.net") ? tailscaleDnsName : "", + }; + const signature = JSON.stringify({ hostName, port, txt }); + if (bonjourAnnouncement && bonjourPort === port && bonjourSignature === signature) return; + if (!bonjourInstance) { + bonjourInstance = new Bonjour(undefined, (error: unknown) => { + args.logger.warn("sync_host.discovery_error", { + error: error instanceof Error ? error.message : String(error), + }); + }); + } + if (bonjourAnnouncement) { + try { + bonjourAnnouncement.stop?.(); + } catch { + // ignore cleanup failures + } + bonjourAnnouncement = null; + } + bonjourPort = port; + bonjourSignature = signature; + bonjourAnnouncement = bonjourInstance.publish({ + name: `ADE Sync ${hostName} ${port}`, + type: SYNC_MDNS_SERVICE_TYPE, + protocol: "tcp", + port, + txt, + disableIPv6: true, + }); + bonjourAnnouncement.on("error", (error: unknown) => { + args.logger.warn("sync_host.discovery_publish_failed", { + error: error instanceof Error ? error.message : String(error), + }); + }); + refreshLanDiscoveryProjects(port); + }; + + const refreshLanDiscoveryProjects = (port: number, projectCatalog?: SyncProjectCatalogPayload): void => { + if ((!args.projectCatalogProvider && !projectCatalog) || bonjourProjectRefreshInFlight) return; + bonjourProjectRefreshInFlight = true; + void Promise.resolve(projectCatalog ?? buildProjectCatalogPayload()) + .then((catalog) => { + const projectIds = uniqueStrings(catalog.projects + .map((project) => project.id) + .filter((value): value is string => typeof value === "string" && value.trim().length > 0)) + .slice(0, BONJOUR_PROJECT_TXT_ENTRY_LIMIT); + const projectNames = uniqueStrings(catalog.projects + .map((project) => typeof project.displayName === "string" ? project.displayName : "") + .map((value) => value.replace(/[,\r\n]/g, " ").replace(/\s+/g, " ").trim().slice(0, BONJOUR_PROJECT_NAME_MAX_LENGTH)) + .filter((value) => value.length > 0)) + .slice(0, BONJOUR_PROJECT_TXT_ENTRY_LIMIT); + const next = { + projects: projectIds.join(","), + projectNames: projectNames.join(","), + projectCount: String(catalog.projects.length), + }; + if ( + next.projects === bonjourProjectTxt.projects + && next.projectNames === bonjourProjectTxt.projectNames + && next.projectCount === bonjourProjectTxt.projectCount + ) { + return; + } + bonjourProjectTxt = next; + if (bonjourPort === port) { + publishLanDiscovery(port); + } + }) + .catch((error) => { + args.logger.warn("sync_host.discovery_project_catalog_failed", { + error: error instanceof Error ? error.message : String(error), + }); + }) + .finally(() => { + bonjourProjectRefreshInFlight = false; + }); + }; + + const unpublishLanDiscovery = (): void => { + if (!bonjourAnnouncement) return; + try { + bonjourAnnouncement.stop?.(); + } catch { + // ignore cleanup failures + } + bonjourAnnouncement = null; + bonjourPort = null; + bonjourSignature = null; + }; + + const updateTailnetDiscoveryStatus = ( + next: SyncTailnetDiscoveryStatus, + ): void => { + tailnetDiscoveryStatus = next; + setTimeout(() => { + if (!disposed) args.onStateChanged?.(); + }, 0); + }; + + const publishTailnetDiscovery = ( + port: number, + options?: { force?: boolean }, + ): void => { + if (disposed) return; + if (!discoveryEnabled) { + void unpublishTailnetDiscovery(); + updateTailnetDiscoveryStatus({ + state: "disabled", + serviceName: SYNC_TAILNET_DISCOVERY_SERVICE_NAME, + servicePort: SYNC_TAILNET_DISCOVERY_SERVICE_PORT, + target: null, + updatedAt: nowIso(), + error: "Tailnet discovery is disabled for this background project context.", + stderr: null, + }); + return; + } + if (!shouldAttemptTailnetServiceAdvertise()) { + updateTailnetDiscoveryStatus({ + state: "unavailable", + serviceName: SYNC_TAILNET_DISCOVERY_SERVICE_NAME, + servicePort: SYNC_TAILNET_DISCOVERY_SERVICE_PORT, + target: null, + updatedAt: nowIso(), + error: "Tailscale Serve discovery is not available in this ADE process.", + stderr: null, + }); + return; + } + const cli = resolveTailscaleCliPath(); + const signature = `${SYNC_TAILNET_DISCOVERY_SERVICE_NAME}:${SYNC_TAILNET_DISCOVERY_SERVICE_PORT}->${port}`; + if (tailnetServeSignature === signature && !options?.force) return; + if (tailnetServeLastFailureSignature === signature && !options?.force) return; + const publishToken = ++tailnetServePublishSequence; + tailnetServeActivePublishToken = publishToken; + tailnetServeSignature = signature; + const target = `tcp://127.0.0.1:${port}`; + updateTailnetDiscoveryStatus({ + state: "publishing", + serviceName: SYNC_TAILNET_DISCOVERY_SERVICE_NAME, + servicePort: SYNC_TAILNET_DISCOVERY_SERVICE_PORT, + target, + updatedAt: nowIso(), + error: null, + stderr: null, + }); + const cliArgs = [ + "serve", + "--yes", + `--service=${SYNC_TAILNET_DISCOVERY_SERVICE_NAME}`, + `--tcp=${SYNC_TAILNET_DISCOVERY_SERVICE_PORT}`, + target, + ]; + void execFileAsync(cli, cliArgs, { timeout: 10_000 }) + .then(({ stdout, stderr }) => { + if (tailnetServeActivePublishToken !== publishToken) return; + tailnetServeLastFailureSignature = null; + const stdoutText = stdout.trim(); + const stderrText = stderr.trim(); + const outputText = [stdoutText, stderrText].filter(Boolean).join("\n"); + updateTailnetDiscoveryStatus({ + state: looksLikePendingTailnetApproval(outputText) ? "pending_approval" : "published", + serviceName: SYNC_TAILNET_DISCOVERY_SERVICE_NAME, + servicePort: SYNC_TAILNET_DISCOVERY_SERVICE_PORT, + target, + updatedAt: nowIso(), + error: null, + stderr: stderrText || null, + }); + args.logger.info("sync_host.tailnet_discovery_published", { + service: SYNC_TAILNET_DISCOVERY_SERVICE_NAME, + servicePort: SYNC_TAILNET_DISCOVERY_SERVICE_PORT, + target, + stdout: stdoutText || null, + stderr: stderrText || null, + }); + }) + .catch((error: unknown) => { + if (tailnetServeActivePublishToken !== publishToken) return; + if (tailnetServeSignature === signature) { + tailnetServeSignature = null; + } + tailnetServeLastFailureSignature = signature; + const errorMessage = error instanceof Error ? error.message : String(error); + const code = (error as NodeJS.ErrnoException | null | undefined)?.code ?? null; + const stderr = typeof (error as { stderr?: unknown })?.stderr === "string" + ? String((error as { stderr?: string }).stderr).trim() + : null; + const errorText = [errorMessage, stderr].filter(Boolean).join("\n"); + updateTailnetDiscoveryStatus({ + state: code === "ENOENT" + ? "unavailable" + : looksLikePendingTailnetApproval(errorText) + ? "pending_approval" + : "failed", + serviceName: SYNC_TAILNET_DISCOVERY_SERVICE_NAME, + servicePort: SYNC_TAILNET_DISCOVERY_SERVICE_PORT, + target, + updatedAt: nowIso(), + error: code === "ENOENT" ? "Tailscale CLI was not found." : errorMessage, + stderr, + }); + const logPayload = { + service: SYNC_TAILNET_DISCOVERY_SERVICE_NAME, + servicePort: SYNC_TAILNET_DISCOVERY_SERVICE_PORT, + target, + error: errorMessage, + code, + stderr, + }; + if (code === "ENOENT") { + args.logger.info("sync_host.tailnet_discovery_unavailable", logPayload); + } else { + args.logger.warn("sync_host.tailnet_discovery_failed", logPayload); + } + }); + }; + + const unpublishTailnetDiscovery = async (): Promise => { + if (!tailnetServeSignature) return; + tailnetServeActivePublishToken = ++tailnetServePublishSequence; + tailnetServeSignature = null; + if (!shouldAttemptTailnetServiceAdvertise()) { + updateTailnetDiscoveryStatus({ + state: "unavailable", + serviceName: SYNC_TAILNET_DISCOVERY_SERVICE_NAME, + servicePort: SYNC_TAILNET_DISCOVERY_SERVICE_PORT, + target: null, + updatedAt: nowIso(), + error: null, + stderr: null, + }); + return; + } + const cli = resolveTailscaleCliPath(); + try { + await execFileAsync( + cli, + ["serve", "--yes", `--service=${SYNC_TAILNET_DISCOVERY_SERVICE_NAME}`, "off"], + { timeout: 10_000 }, + ); + updateTailnetDiscoveryStatus({ + state: "disabled", + serviceName: SYNC_TAILNET_DISCOVERY_SERVICE_NAME, + servicePort: SYNC_TAILNET_DISCOVERY_SERVICE_PORT, + target: null, + updatedAt: nowIso(), + error: null, + stderr: null, + }); + args.logger.info("sync_host.tailnet_discovery_unpublished", { + service: SYNC_TAILNET_DISCOVERY_SERVICE_NAME, + servicePort: SYNC_TAILNET_DISCOVERY_SERVICE_PORT, + }); + } catch (error: unknown) { + const errorMessage = error instanceof Error ? error.message : String(error); + const code = (error as NodeJS.ErrnoException | null | undefined)?.code ?? null; + updateTailnetDiscoveryStatus({ + state: code === "ENOENT" ? "unavailable" : "disabled", + serviceName: SYNC_TAILNET_DISCOVERY_SERVICE_NAME, + servicePort: SYNC_TAILNET_DISCOVERY_SERVICE_PORT, + target: null, + updatedAt: nowIso(), + error: code === "ENOENT" ? "Tailscale CLI was not found." : errorMessage, + stderr: null, + }); + args.logger.warn("sync_host.tailnet_discovery_unpublish_failed", { + service: SYNC_TAILNET_DISCOVERY_SERVICE_NAME, + servicePort: SYNC_TAILNET_DISCOVERY_SERVICE_PORT, + error: errorMessage, + code, + }); + } + }; + + function send(target: WebSocket | PeerState, type: SyncEnvelope["type"], payload: TPayload, requestId?: string | null): boolean { + const ws = target instanceof WebSocket ? target : target.ws; + if (ws.readyState !== WebSocket.OPEN) return false; + // Drop sends to backpressured peers as the default — most envelopes are + // either replayable (chat events / changesets re-derived from db state) or + // tolerable to lose (acks, status pings). Routes that *must* deliver under + // backpressure should call ws.send / sendAndWait directly. + if (target instanceof WebSocket ? ws.bufferedAmount >= PEER_BACKPRESSURE_BYTES : isPeerBackpressured(target)) { + return false; + } + ws.send(encodeSyncEnvelope({ type, payload, requestId, compressionThresholdBytes })); + return true; + } + + function sendRequired(peer: PeerState, type: SyncEnvelope["type"], payload: TPayload, requestId?: string | null): boolean { + const ws = peer.ws; + if (ws.readyState !== WebSocket.OPEN) return false; + ws.send(encodeSyncEnvelope({ type, payload, requestId, compressionThresholdBytes }), (error) => { + if (!error) return; + args.logger.warn("sync_host.required_send_failed", { + type, + requestId: requestId ?? null, + peerDeviceId: peer.metadata?.deviceId ?? peer.pairedDeviceId ?? null, + error: error.message, + }); + }); + return true; + } + + function isPeerBackpressured(peer: PeerState): boolean { + return peer.ws.bufferedAmount >= PEER_BACKPRESSURE_BYTES; + } + + function sendAndWait( + ws: WebSocket, + type: SyncEnvelope["type"], + payload: TPayload, + requestId?: string | null, + ): Promise { + if (ws.readyState === WebSocket.CLOSING || ws.readyState === WebSocket.CLOSED) { + return Promise.reject(new Error("Cannot send on closed WebSocket.")); + } + return new Promise((resolve, reject) => { + ws.send( + encodeSyncEnvelope({ type, payload, requestId, compressionThresholdBytes }), + (error) => { + if (error) reject(error); + else resolve(); + }, + ); + }); + } + + function encodedEnvelopeBytes( + type: SyncEnvelope["type"], + payload: TPayload, + requestId?: string | null, + ): number { + return Buffer.byteLength(encodeSyncEnvelope({ type, payload, requestId, compressionThresholdBytes }), "utf8"); + } + + function closeExistingPeersForDevice(deviceId: string, currentPeer: PeerState): void { + const normalized = toOptionalString(deviceId); + if (!normalized) return; + for (const peer of peers) { + if (peer === currentPeer) continue; + if (peer.metadata?.deviceId !== normalized && peer.pairedDeviceId !== normalized) continue; + peer.authenticated = false; + peer.metadata = null; + peer.authKind = null; + peer.pairedDeviceId = null; + try { + peer.ws.close(4000, "Superseded by a newer connection for this device"); + } catch { + // ignore close failures + } + } + } + + function makeChangesetBatchId(peer: PeerState, fromDbVersion: number, toDbVersion: number): string { + const deviceId = peer.metadata?.deviceId ?? peer.pairedDeviceId ?? "peer"; + return `changeset:${deviceId}:${fromDbVersion}:${toDbVersion}:${Date.now()}:${randomBytes(4).toString("hex")}`; + } + + function peerSupportsChangesetAck(peer: PeerState): boolean { + return Array.isArray(peer.metadata?.capabilities) && peer.metadata.capabilities.includes("changesetAck"); + } + + function sendNextChangesetBatch( + peer: PeerState, + reason: SyncChangesetBatchPayload["reason"], + fromDbVersion: number, + toDbVersion: number, + changes: CrsqlChangeRow[], + ): PendingChangesetBatch | null { + let chunk: CrsqlChangeRow[] = []; + let chunkBytes = 0; + + for (const change of changes) { + const changeBytes = Buffer.byteLength(JSON.stringify(change), "utf8"); + if ( + chunk.length > 0 + && (chunk.length >= maxChangesetBatchRows || chunkBytes + changeBytes > maxChangesetBatchBytes) + ) { + break; + } + chunk.push(change); + chunkBytes += changeBytes; + } + if (chunk.length === 0 && changes.length > 0) { + chunk = [changes[0]!]; + } + if (chunk.length === 0 && toDbVersion <= fromDbVersion) return null; + + const chunkToDbVersion = chunk.length > 0 + ? Math.max(...chunk.map((change) => Number(change.db_version ?? fromDbVersion))) + : toDbVersion; + const batch: PendingChangesetBatch = { + batchId: makeChangesetBatchId(peer, fromDbVersion, chunkToDbVersion), + reason, + fromDbVersion, + toDbVersion: chunkToDbVersion, + changes: chunk, + sentAtMs: Date.now(), + retryCount: 0, + }; + const sent = send(peer, "changeset_batch", { + batchId: batch.batchId, + reason, + fromDbVersion, + toDbVersion: chunkToDbVersion, + changes: chunk, + }); + return sent ? batch : null; + } + + function resendPendingChangesetBatch(peer: PeerState): boolean { + const batch = peer.pendingChangesetBatch; + if (!batch) return false; + batch.sentAtMs = Date.now(); + batch.retryCount += 1; + return send(peer, "changeset_batch", { + batchId: batch.batchId, + reason: batch.reason, + fromDbVersion: batch.fromDbVersion, + toDbVersion: batch.toDbVersion, + changes: batch.changes, + }); + } + + async function buildProjectCatalogPayload(): Promise { + if (!args.projectCatalogProvider) { + return { projects: [] }; + } + try { + return await args.projectCatalogProvider.listProjects(); + } catch (error) { + args.logger.warn("sync_host.project_catalog_failed", { + error: error instanceof Error ? error.message : String(error), + }); + return { projects: [] }; + } + } + + function splitProjectCatalog(projects: SyncMobileProjectSummary[]): SyncMobileProjectSummary[][] { + const chunks: SyncMobileProjectSummary[][] = []; + let chunk: SyncMobileProjectSummary[] = []; + let chunkBytes = 0; + + const flush = (): void => { + if (chunk.length === 0) return; + chunks.push(chunk); + chunk = []; + chunkBytes = 0; + }; + + for (const project of projects) { + const projectBytes = Buffer.byteLength(JSON.stringify(project), "utf8"); + if (chunk.length > 0 && chunkBytes + projectBytes > maxProjectCatalogChunkBytes) { + flush(); + } + chunk.push(project); + chunkBytes += projectBytes; + } + flush(); + return chunks; + } + + function sendProjectCatalog( + peer: PeerState, + projectCatalog: SyncProjectCatalogPayload, + requestId?: string | null, + ): void { + if (encodedEnvelopeBytes("project_catalog", projectCatalog, requestId) <= maxProjectCatalogEnvelopeBytes) { + send(peer.ws, "project_catalog", projectCatalog, requestId); + return; + } + + const chunks = splitProjectCatalog(projectCatalog.projects); + const total = Math.max(1, chunks.length); + const catalogId = randomBytes(8).toString("hex"); + if (chunks.length === 0) { + send(peer.ws, "project_catalog_chunk", { + catalogId, + index: 0, + total, + done: true, + projects: [], + } satisfies SyncProjectCatalogChunkPayload, requestId); + return; + } + + chunks.forEach((projects, index) => { + send(peer.ws, "project_catalog_chunk", { + catalogId, + index, + total, + done: index === total - 1, + projects, + } satisfies SyncProjectCatalogChunkPayload, requestId); + }); + } + + async function handleProjectSwitchRequest( + peer: PeerState, + requestId: string | null | undefined, + payload: SyncProjectSwitchRequestPayload | null, + ): Promise { + if (!args.projectCatalogProvider) { + sendRequired(peer, "project_switch_result", { + ok: false, + message: "Project switching is not available from this machine.", + }, requestId); + return; + } + try { + const result = await args.projectCatalogProvider.prepareProjectConnection(payload ?? {}); + await sendAndWait(peer.ws, "project_switch_result", result, requestId); + try { + await args.projectCatalogProvider.completeProjectConnection?.(payload ?? {}, result); + } catch (completionError) { + args.logger.warn("sync_host.project_switch_completion_failed", { + message: completionError instanceof Error ? completionError.message : String(completionError), + }); + } + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + args.logger.warn("sync_host.project_switch_failed", { message }); + sendRequired(peer, "project_switch_result", { + ok: false, + message, + }, requestId); + } + } + + function buildBrainStatus(): SyncBrainStatusPayload { + const brainMetadata = readBrainMetadata(); + if (disposed) { + return { + brain: brainMetadata, + connectedPeers: [], + metrics: { + connectedPeerCount: 0, + runningSessionCount: 0, + dbVersion: brainMetadata.dbVersion, + uptimeMs: Date.now() - startedAtMs, + lastBroadcastAt, + pendingChangesetPeerCount: 0, + commandLedgerSize: commandLedgerSizeForProject(), + commandReplayCount, + commandConflictCount, + lastCommandResultLatencyMs, + lastChangesetAckLatencyMs, + }, + }; + } + const dbVersion = args.db.sync.getDbVersion(); + const connectedPeers = [...peers] + .map((peer) => toSyncPeerConnectionState(peer, dbVersion)) + .filter((peer): peer is SyncPeerConnectionState => peer != null); + return { + brain: { + ...brainMetadata, + dbVersion, + }, + connectedPeers, + metrics: { + connectedPeerCount: connectedPeers.length, + runningSessionCount: args.sessionService.list({ status: "running", limit: 200 }).length, + dbVersion, + uptimeMs: Date.now() - startedAtMs, + lastBroadcastAt, + pendingChangesetPeerCount: [...peers].filter((peer) => peer.pendingChangesetBatch != null).length, + commandLedgerSize: commandLedgerSizeForProject(), + commandReplayCount, + commandConflictCount, + lastCommandResultLatencyMs, + lastChangesetAckLatencyMs, + }, + }; + } + + function broadcastBrainStatus(): void { + if (disposed) return; + const payload = buildBrainStatus(); + for (const peer of peers) { + if (!peer.authenticated || peer.ws.readyState !== WebSocket.OPEN) continue; + send(peer.ws, "brain_status", payload); + } + } + + async function readChatTranscriptEventsSince( + transcriptPath: string, + startOffset: number, + ): Promise<{ events: AgentChatEventEnvelope[]; nextOffset: number }> { + let fh: fs.promises.FileHandle | null = null; + try { + fh = await fs.promises.open(transcriptPath, "r"); + const stat = await fh.stat(); + const size = stat.size; + const normalizedStart = Math.max(0, Math.min(startOffset, size)); + if (size <= normalizedStart) { + return { events: [], nextOffset: size }; + } + + const out = Buffer.alloc(size - normalizedStart); + await fh.read(out, 0, out.length, normalizedStart); + const lastNewline = out.lastIndexOf(0x0a); + if (lastNewline < 0) { + return { events: [], nextOffset: normalizedStart }; + } + + const completeSlice = out.subarray(0, lastNewline + 1); + const raw = completeSlice.toString("utf8"); + return { + events: parseAgentChatTranscript(raw), + nextOffset: normalizedStart + completeSlice.length, + }; + } catch { + return { events: [], nextOffset: Math.max(0, startOffset) }; + } finally { + await fh?.close().catch(() => {}); + } + } + + function chatEventDeliveryKey(event: AgentChatEventEnvelope): string { + return `${event.sessionId}:${event.sequence ?? -1}:${event.timestamp}:${event.event.type}`; + } + + function rememberChatEventSent(peer: PeerState, event: AgentChatEventEnvelope): boolean { + const key = chatEventDeliveryKey(event); + let sent = peer.chatEventIdsSent.get(event.sessionId); + if (!sent) { + sent = new Set(); + peer.chatEventIdsSent.set(event.sessionId, sent); + } + if (sent.has(key)) return false; + sent.add(key); + if (sent.size > 800) { + const overflow = sent.size - 800; + let removed = 0; + for (const existingKey of sent) { + sent.delete(existingKey); + removed += 1; + if (removed >= overflow) break; + } + } + return true; + } + + async function pumpChatEvents(): Promise { + if (disposed) return; + + for (const peer of peers) { + if (!peer.authenticated || peer.ws.readyState !== WebSocket.OPEN) continue; + if (isPeerBackpressured(peer)) continue; + for (const sessionId of peer.subscribedChatSessionIds) { + const session = args.sessionService.get(sessionId); + if (!session?.transcriptPath) continue; + + const startOffset = peer.chatTranscriptOffsets.get(sessionId) ?? 0; + const { events, nextOffset } = await readChatTranscriptEventsSince(session.transcriptPath, startOffset); + if (nextOffset !== startOffset) { + peer.chatTranscriptOffsets.set(sessionId, nextOffset); + } + for (const event of events) { + if (!rememberChatEventSent(peer, event)) continue; + send(peer.ws, "chat_event", event); + } + } + } + } + + function broadcastChatEvent(event: AgentChatEventEnvelope): void { + for (const peer of peers) { + if (!peer.authenticated || peer.ws.readyState !== WebSocket.OPEN) continue; + if (isPeerBackpressured(peer)) continue; + if (!peer.subscribedChatSessionIds.has(event.sessionId)) continue; + if (!rememberChatEventSent(peer, event)) continue; + send(peer.ws, "chat_event", event); + } + } + + async function pumpChanges(): Promise { + if (disposed) return; + const currentDbVersion = args.db.sync.getDbVersion(); + const nowMs = Date.now(); + for (const peer of peers) { + if (!peer.authenticated || !peer.metadata || peer.ws.readyState !== WebSocket.OPEN) continue; + if (isPeerBackpressured(peer)) continue; + if (peer.pendingChangesetBatch) { + if (nowMs - peer.pendingChangesetBatch.sentAtMs >= CHANGESET_ACK_TIMEOUT_MS) { + const pending = peer.pendingChangesetBatch; + if (pending.retryCount >= MAX_CHANGESET_ACK_RETRIES) { + args.logger.warn("sync_host.changeset_ack_timeout", { + peerDeviceId: peer.metadata.deviceId, + batchId: pending.batchId, + fromDbVersion: pending.fromDbVersion, + toDbVersion: pending.toDbVersion, + retryCount: pending.retryCount, + }); + try { + peer.ws.close(4000, "Changeset acknowledgement timed out"); + } catch { + // ignore close failures + } + continue; + } + const resent = resendPendingChangesetBatch(peer); + args.logger.debug("sync_host.changeset_ack_retry", { + peerDeviceId: peer.metadata.deviceId, + batchId: pending.batchId, + fromDbVersion: pending.fromDbVersion, + toDbVersion: pending.toDbVersion, + retryCount: pending.retryCount, + resent, + }); + } + continue; + } + if (currentDbVersion <= peer.lastKnownServerDbVersion) continue; + const changes = args.db.sync + .exportChangesSince(peer.lastKnownServerDbVersion) + .filter((change: CrsqlChangeRow) => change.site_id !== peer.metadata?.siteId); + const pending = sendNextChangesetBatch(peer, "broadcast", peer.lastKnownServerDbVersion, currentDbVersion, changes); + if (pending) { + if (peerSupportsChangesetAck(peer)) { + peer.pendingChangesetBatch = pending; + } else { + peer.lastKnownServerDbVersion = Math.max(peer.lastKnownServerDbVersion, pending.toDbVersion); + } + lastBroadcastAt = nowIso(); + } else { + args.logger.debug("sync_host.changeset_deferred_backpressure", { + peerDeviceId: peer.metadata?.deviceId ?? null, + fromDbVersion: peer.lastKnownServerDbVersion, + toDbVersion: currentDbVersion, + bufferedAmount: peer.ws.bufferedAmount, + }); + } + } + } + + function handleChangesetAck(peer: PeerState, payload: SyncChangesetAckPayload | null | undefined): void { + const pending = peer.pendingChangesetBatch; + if (!pending || !payload) return; + if (payload.batchId !== pending.batchId) { + args.logger.debug("sync_host.changeset_ack_ignored", { + peerDeviceId: peer.metadata?.deviceId ?? null, + expectedBatchId: pending.batchId, + receivedBatchId: payload.batchId, + }); + return; + } + if (!payload.ok) { + pending.retryCount += 1; + pending.sentAtMs = Date.now(); + args.logger.warn("sync_host.changeset_ack_failed", { + peerDeviceId: peer.metadata?.deviceId ?? null, + batchId: pending.batchId, + fromDbVersion: pending.fromDbVersion, + toDbVersion: pending.toDbVersion, + retryCount: pending.retryCount, + error: payload.error?.message ?? "Changeset apply failed.", + }); + if (pending.retryCount >= MAX_CHANGESET_ACK_RETRIES) { + try { + peer.ws.close(4000, "Changeset apply failed repeatedly"); + } catch { + // ignore close failures + } + } + return; + } + if (payload.toDbVersion < pending.toDbVersion) return; + peer.lastKnownServerDbVersion = Math.max(peer.lastKnownServerDbVersion, pending.toDbVersion); + peer.pendingChangesetBatch = null; + peer.lastAppliedAt = nowIso(); + lastChangesetAckLatencyMs = Math.max(0, Date.now() - pending.sentAtMs); + args.logger.debug("sync_host.changeset_ack_applied", { + peerDeviceId: peer.metadata?.deviceId ?? null, + batchId: pending.batchId, + fromDbVersion: pending.fromDbVersion, + toDbVersion: pending.toDbVersion, + latencyMs: lastChangesetAckLatencyMs, + }); + broadcastBrainStatus(); + } + + function resolveArtifactPath(request: Extract["args"]): string { + const artifactId = toOptionalString(request.artifactId); + const explicitUri = toOptionalString(request.uri) ?? toOptionalString(request.path); + let candidate = explicitUri; + if (artifactId) { + const artifact = args.computerUseArtifactBrokerService.listArtifacts({ artifactId })[0] ?? null; + candidate = artifact?.uri ?? candidate; + } + if (!candidate) { + throw new Error("Artifact request requires artifactId, uri, or path."); + } + if (/^https?:\/\//i.test(candidate)) { + throw new Error("Remote artifact URLs are not supported by this sync host."); + } + if (/^file:\/\//i.test(candidate)) { + try { + candidate = fileURLToPath(candidate); + } catch { + throw new Error("Artifact file URL is invalid."); + } + } + const absolute = path.isAbsolute(candidate) + ? candidate + : path.resolve(args.projectRoot, candidate); + let resolvedArtifactPath: string; + try { + resolvedArtifactPath = resolvePathWithinRoot(layout.artifactsDir, absolute); + } catch { + throw new Error("Artifact path must resolve within .ade/artifacts."); + } + if (!fs.existsSync(resolvedArtifactPath) || !fs.statSync(resolvedArtifactPath).isFile()) { + throw new Error("Artifact file does not exist."); + } + return resolvedArtifactPath; + } + + function isMobilePeer(peer: PeerState): boolean { + return peer.metadata?.platform === "iOS" || peer.metadata?.deviceType === "phone"; + } + + function assertMobileFileMutationAllowed(peer: PeerState, payload: SyncFileRequest): void { + if (!MOBILE_MUTATING_FILE_ACTIONS.has(payload.action)) return; + if (!isMobilePeer(peer)) return; + + const workspaceId = toOptionalString((payload as { args?: { workspaceId?: unknown } }).args?.workspaceId); + if (!workspaceId) return; + const workspace = args.fileService.listWorkspaces({ includeArchived: true }) + .find((entry) => entry.id === workspaceId); + if (!workspace || workspace.mobileReadOnly === true || workspace.isReadOnlyByDefault) { + throw new Error("Mobile file access is read-only for this workspace."); + } + } + + function isMobileLaneFileMutationBlocked(payload: SyncCommandPayload): boolean { + const laneId = toOptionalString((payload.args as Record | null | undefined)?.laneId); + if (!laneId) return false; + const workspace = args.fileService.listWorkspaces({ includeArchived: true }) + .find((entry) => entry.laneId === laneId); + return workspace ? workspace.mobileReadOnly === true || workspace.isReadOnlyByDefault : true; + } + + async function handleFileRequest(peer: PeerState, requestId: string | null, payload: SyncFileRequest): Promise { + const respond = (response: SyncFileResponsePayload) => { + sendRequired(peer, "file_response", response, requestId); + }; + + try { + assertMobileFileMutationAllowed(peer, payload); + let result: + | FilesWorkspace[] + | FileTreeNode[] + | FileContent + | FilesQuickOpenItem[] + | FilesSearchTextMatch[] + | SyncFileBlob + | { ok: true } = { ok: true }; + + switch (payload.action) { + case "listWorkspaces": + result = args.fileService.listWorkspaces(payload.args ?? {}); + break; + case "listTree": + result = await args.fileService.listTree(payload.args); + break; + case "readFile": + result = fileContentToBlob(payload.args.path, args.fileService.readFile(payload.args)); + break; + case "writeText": + args.fileService.writeWorkspaceText(payload.args); + result = { ok: true }; + break; + case "createFile": + args.fileService.createFile(payload.args); + result = { ok: true }; + break; + case "createDirectory": + args.fileService.createDirectory(payload.args); + result = { ok: true }; + break; + case "rename": + args.fileService.rename(payload.args); + result = { ok: true }; + break; + case "deletePath": + args.fileService.deletePath(payload.args); + result = { ok: true }; + break; + case "quickOpen": + result = await args.fileService.quickOpen(payload.args); + break; + case "searchText": + result = await args.fileService.searchText(payload.args); + break; + case "readArtifact": { + const artifactPath = resolveArtifactPath(payload.args); + result = createBlobFromBuffer(normalizeRelative(path.relative(args.projectRoot, artifactPath)), fs.readFileSync(artifactPath)); + break; + } + default: + throw new Error(`Unsupported file action: ${(payload as { action?: string }).action ?? "unknown"}`); + } + + respond({ + ok: true, + action: payload.action, + result, + }); + } catch (error) { + respond({ + ok: false, + action: payload.action, + error: { + code: "file_request_failed", + message: error instanceof Error ? error.message : String(error), + }, + }); + } + } + + async function handleCommand(peer: PeerState, requestId: string | null, payload: SyncCommandPayload): Promise { + const commandId = toOptionalString(payload.commandId) ?? requestId ?? `cmd-${Date.now()}`; + const requestedProjectId = toOptionalString(payload.projectId); + const hostProjectId = toOptionalString(args.projectId); + const commandScopeKey = requestedProjectId ?? hostProjectId ?? args.projectRoot; + const commandCacheKey = mobileCommandCacheKey(commandScopeKey, peer, commandId); + const commandArgsKey = stableJsonKey(payload.args ?? {}); + const commandArgsFingerprint = mobileCommandArgsFingerprint(commandArgsKey); + pruneMobileCommandResultCache(); + + const sendResult = (record: CachedMobileCommand | null, result: SyncCommandResultPayload) => { + if (!record) { + sendRequired(peer, "command_result", result, requestId); + return; + } + record.result = result; + record.completedAtMs = Date.now(); + lastCommandResultLatencyMs = Math.max(0, record.completedAtMs - record.acceptedAtMs); + const waiters = record.waiters.splice(0); + for (const waiter of waiters) { + sendRequired(waiter.peer, "command_result", result, waiter.requestId); + } + pruneMobileCommandResultCache(); + try { + writePersistedCommandLedger(); + } catch (error) { + args.logger.warn("sync_host.command_ledger_write_failed", { + error: error instanceof Error ? error.message : String(error), + }); + } + }; + const startCommandRecord = (ack: SyncCommandAckPayload): CachedMobileCommand | null => { + sendRequired(peer, "command_ack", ack, requestId); + if (!commandCacheKey) return null; + const record: CachedMobileCommand = { + commandId, + action: payload.action, + argsKey: commandArgsKey, + argsFingerprint: commandArgsFingerprint, + ack, + result: null, + waiters: [{ peer, requestId }], + acceptedAtMs: Date.now(), + completedAtMs: null, + }; + mobileCommandResultCache.set(commandCacheKey, record); + return record; + }; + const existingCommand = commandCacheKey ? mobileCommandResultCache.get(commandCacheKey) : null; + if (existingCommand) { + if (existingCommand.action !== payload.action || existingCommand.argsFingerprint !== commandArgsFingerprint) { + commandConflictCount += 1; + const mismatchResult: SyncCommandResultPayload = { + commandId, + ok: false, + error: { + code: "duplicate_command_mismatch", + message: "A command with this id already exists for a different action or payload.", + }, + }; + sendRequired(peer, "command_ack", { + commandId, + accepted: false, + status: "rejected", + message: mismatchResult.error?.message ?? null, + }, requestId); + sendRequired(peer, "command_result", mismatchResult, requestId); + return; + } + commandReplayCount += 1; + sendRequired(peer, "command_ack", existingCommand.ack, requestId); + if (existingCommand.result) { + sendRequired(peer, "command_result", existingCommand.result, requestId); + } else { + addMobileCommandWaiter(existingCommand, peer, requestId); + } + return; + } + + const reject = (message: string, code = "unsupported_command") => { + const ack: SyncCommandAckPayload = { + commandId, + accepted: false, + status: "rejected", + message, + }; + const result: SyncCommandResultPayload = { + commandId, + ok: false, + error: { + code, + message, + }, + }; + sendResult(startCommandRecord(ack), result); + }; + + const descriptor = remoteCommandService.getDescriptor(payload.action); + const policy = descriptor?.policy ?? null; + const shouldRouteToProject = + Boolean(args.remoteCommandExecutor) + && Boolean(requestedProjectId) + && requestedProjectId !== hostProjectId; + if (requestedProjectId && hostProjectId && requestedProjectId !== hostProjectId && !shouldRouteToProject) { + reject("This ADE machine is hosting a different project. Select the project again and retry.", "project_not_open"); + return; + } + if (payload.action === "notification_prefs") { + // iOS bridges `SyncService.setMutePush` through the command envelope + // rather than a second `notification_prefs` envelope. We translate by + // merging `{ muteUntil }` into the device's existing prefs (or the + // default prefs if none have been uploaded yet) so the notification + // bus starts gating immediately — the same `isAllowedByPrefs` path the + // envelope-based update feeds. + const deviceId = peer.metadata?.deviceId; + if (!deviceId) { + reject("notification_prefs requires an authenticated device.", "invalid_command"); + return; + } + const rawArgs = (payload.args as Record | null | undefined) ?? {}; + const rawMute = rawArgs.muteUntil; + const muteUntil = typeof rawMute === "string" && rawMute.length > 0 ? rawMute : null; + const existing = readNotificationPrefsForDevice(deviceId); + storeNotificationPrefsForDevice(deviceId, { ...existing, muteUntil }); + const ack: SyncCommandAckPayload = { + commandId, + accepted: true, + status: "accepted", + message: muteUntil ? `Muted pushes until ${muteUntil}.` : "Cleared push mute.", + }; + sendResult(startCommandRecord(ack), { + commandId, + ok: true, + result: { ok: true, muteUntil }, + }); + return; + } + if (payload.action === "lanes.presence.announce" || payload.action === "lanes.presence.release") { + if (requestedProjectId && hostProjectId && requestedProjectId !== hostProjectId) { + reject("Lane presence is not available for a project that is not open in this phone sync host.", "project_not_open"); + return; + } + if (hostProjectId && !requestedProjectId) { + reject(`${payload.action} requires projectId. Select the project again and retry.`, "missing_project"); + return; + } + const laneId = normalizeLaneId((payload.args as Record | null | undefined)?.laneId as string | null); + if (!laneId) { + reject(`${payload.action} requires laneId.`, "invalid_command"); + return; + } + const marker = buildRemotePresenceMarker(peer); + if (!marker) { + reject("Lane presence requires authenticated peer metadata.", "invalid_command"); + return; + } + const changed = payload.action === "lanes.presence.announce" + ? upsertLanePresence({ laneId, marker, source: "remote" }) + : removeLanePresence(laneId, marker.deviceId); + if (changed) { + args.onStateChanged?.(); + broadcastBrainStatus(); + } + const ack: SyncCommandAckPayload = { + commandId, + accepted: true, + status: "accepted", + message: payload.action === "lanes.presence.announce" + ? `Marked ${laneId} as open on ${marker.displayName}.` + : `Released ${laneId} on ${marker.displayName}.`, + }; + sendResult(startCommandRecord(ack), { + commandId, + ok: true, + result: { ok: true }, + }); + return; + } + if (!policy) { + reject(`Unsupported remote command: ${payload.action}.`); + return; + } + if (descriptor?.scope === "project") { + if (hostProjectId && !requestedProjectId) { + reject(`Remote command ${payload.action} requires projectId. Select the project again and retry.`, "missing_project"); + return; + } + if (requestedProjectId && !hostProjectId) { + reject(`Remote command ${payload.action} requires an open project on this ADE machine.`, "project_not_open"); + return; + } + } + if (!policy.viewerAllowed) { + reject(`Remote command ${payload.action} is not available to paired controller devices.`, "forbidden_command"); + return; + } + if (payload.action === "files.writeTextAtomic" && isMobilePeer(peer) && isMobileLaneFileMutationBlocked(payload)) { + reject("Mobile file access is read-only for this workspace.", "mobile_read_only"); + return; + } + if (policy.localOnly || policy.requiresApproval) { + reject(`Remote command ${payload.action} requires approval on this machine.`, "approval_required"); + return; + } + + const acceptedRecord = startCommandRecord({ + commandId, + accepted: true, + status: "accepted", + message: `Executing ${payload.action}.`, + }); + + try { + const executor = shouldRouteToProject && args.remoteCommandExecutor + ? args.remoteCommandExecutor + : remoteCommandService; + const created = await executor.execute(payload); + sendResult(acceptedRecord, { + commandId, + ok: true, + result: decorateCommandResult(payload.action, created), + }); + } catch (error) { + sendResult(acceptedRecord, { + commandId, + ok: false, + error: { + code: "command_failed", + message: error instanceof Error ? error.message : String(error), + }, + }); + } + } + + function rejectProjectScopedEnvelope( + peer: PeerState, + type: SyncEnvelope["type"], + requestId: string | null, + payload: unknown, + resolution: Extract, + ): void { + args.logger.warn("sync_host.project_scope_rejected", { + type, + requestId, + code: resolution.code, + expectedProjectId: resolution.expectedProjectId, + receivedProjectId: resolution.receivedProjectId, + peerDeviceId: peer.metadata?.deviceId ?? peer.pairedDeviceId ?? null, + }); + + if (type === "changeset_batch") { + const batchPayload = (payload ?? {}) as Partial; + sendRequired(peer, "changeset_ack", { + batchId: toOptionalString(batchPayload.batchId) ?? requestId ?? "", + fromDbVersion: Number(batchPayload.fromDbVersion ?? 0), + toDbVersion: Number(batchPayload.toDbVersion ?? 0), + appliedDbVersion: args.db.sync.getDbVersion(), + appliedCount: 0, + ok: false, + error: { + code: resolution.code, + message: resolution.message, + }, + } satisfies SyncChangesetAckPayload, requestId); + return; + } + + if (type === "file_request") { + const action = toOptionalString((payload as Partial | null | undefined)?.action) ?? "unknown"; + sendRequired(peer, "file_response", { + ok: false, + action: action as SyncFileRequest["action"], + error: { + code: resolution.code, + message: resolution.message, + }, + } satisfies SyncFileResponsePayload, requestId); + } + } + + async function handleMessage(peer: PeerState, raw: RawData): Promise { + const rawText = wsDataToText(raw); + const envelope = parseSyncEnvelope(rawText); + const heartbeatAwaitedAt = peer.awaitingHeartbeatAt; + peer.lastSeenAt = nowIso(); + peer.awaitingHeartbeatAt = null; + peer.missedHeartbeatCount = 0; + + if (!peer.authenticated) { + if (envelope.type !== "hello" && envelope.type !== "pairing_request") { + send(peer.ws, "hello_error", { + code: "invalid_hello", + message: "Authenticate with hello or pairing_request before sending other messages.", + }, envelope.requestId); + try { + peer.ws.close(4003, "Authentication required"); + } catch { + // ignore + } + return; + } + if (envelope.type === "pairing_request") { + const pairing = parsePairingRequestPayload(envelope.payload); + if (!pairing) { + send(peer.ws, "pairing_result", { + ok: false, + error: { + code: "pairing_failed", + message: "Invalid pairing request payload.", + }, + }, envelope.requestId); + try { peer.ws.close(4003, "Pairing failed"); } catch { /* ignore */ } + return; + } + const cooldownMs = pairingCooldownMsRemaining(peer.remoteAddress); + if (cooldownMs > 0) { + const minutes = Math.ceil(cooldownMs / 60_000); + send(peer.ws, "pairing_result", { + ok: false, + error: { + code: "pairing_failed", + message: `Too many failed PIN attempts. Try again in ${minutes} minute${minutes === 1 ? "" : "s"}.`, + }, + }, envelope.requestId); + try { peer.ws.close(4004, "Pairing cooldown"); } catch { /* ignore */ } + return; + } + try { + const result = pairingStore.pairPeer(pairing.peer, pairing.code); + if (peer.remoteAddress) { + pairFailures.delete(peer.remoteAddress); + } + args.deviceRegistryService?.upsertPeerMetadata(pairing.peer, { + lastSeenAt: nowIso(), + lastHost: peer.remoteAddress, + lastPort: peer.remotePort, + }); + send(peer.ws, "pairing_result", { + ok: true, + deviceId: result.deviceId, + secret: result.secret, + }, envelope.requestId); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + const thrownCode = (error as { code?: string } | null)?.code ?? null; + const resultCode: "pin_not_set" | "invalid_pin" | "pairing_failed" = + thrownCode === "pin_not_set" || thrownCode === "invalid_pin" + ? thrownCode + : "pairing_failed"; + send(peer.ws, "pairing_result", { + ok: false, + error: { + code: resultCode, + message, + }, + }, envelope.requestId); + // Drop the socket after any failed pair so brute-forcing the 6-digit + // PIN requires a new TCP+WS handshake per attempt, and track per-IP + // failures so sustained guessers hit a cooldown. + if (resultCode === "invalid_pin" || resultCode === "pairing_failed") { + registerPairFailure(peer.remoteAddress); + } + try { peer.ws.close(4003, "Pairing failed"); } catch { /* ignore */ } + } + return; + } + const hello = parseHelloPayload(envelope.payload); + if (!hello) { + send(peer.ws, "hello_error", { + code: "invalid_hello", + message: "Invalid hello payload.", + }, envelope.requestId); + try { + peer.ws.close(4003, "Authentication failed"); + } catch { + // ignore + } + return; + } + const authFailed = (() => { + if (hello.auth?.kind === "bootstrap") { + return hello.auth.token !== bootstrapToken; + } + if (hello.auth?.kind === "paired") { + if (hello.auth.deviceId !== hello.peer.deviceId) return true; + return !pairingStore.authenticate(hello.auth.deviceId, hello.auth.secret); + } + return true; + })(); + if (authFailed) { + send(peer.ws, "hello_error", { + code: "auth_failed", + message: "Sync authentication failed.", + }, envelope.requestId); + try { + peer.ws.close(4003, "Authentication failed"); + } catch { + // ignore + } + return; + } + + closeExistingPeersForDevice(hello.peer.deviceId, peer); + peer.authenticated = true; + peer.metadata = hello.peer; + const auth = hello.auth ?? { kind: "bootstrap", token: "" }; + peer.authKind = auth.kind; + peer.pairedDeviceId = auth.kind === "paired" ? auth.deviceId : null; + peer.lastKnownServerDbVersion = Math.max(0, Math.floor(hello.peer.dbVersion)); + args.deviceRegistryService?.upsertPeerMetadata(hello.peer, { + lastSeenAt: nowIso(), + lastHost: peer.remoteAddress, + lastPort: peer.remotePort, + }); + const projectCatalog = await buildProjectCatalogPayload(); + send(peer.ws, "hello_ok", buildSyncHostHelloOkPayload({ + peer: hello.peer, + brain: readBrainMetadata(), + serverDbVersion: args.db.sync.getDbVersion(), + heartbeatIntervalMs, + pollIntervalMs, + projectCatalog, + projectCatalogEnabled: Boolean(args.projectCatalogProvider), + remoteCommandSupportedActions: remoteCommandService.getSupportedActions(), + remoteCommandDescriptors: remoteCommandService.getDescriptors(), + localCommandDescriptors: localPresenceCommandDescriptors, + compressionThresholdBytes, + maxProjectCatalogEnvelopeBytes, + }), envelope.requestId); + args.onStateChanged?.(); + await pumpChanges(); + broadcastBrainStatus(); + return; + } + + const projectScope = resolveSyncHostInboundProjectScope(envelope.type, envelope.projectId, args.projectId); + if (!projectScope.ok) { + rejectProjectScopedEnvelope(peer, envelope.type, envelope.requestId, envelope.payload, projectScope); + return; + } + if (projectScope.usedSingleProjectFallback) { + args.logger.warn("sync_host.project_scope_missing", { + type: envelope.type, + requestId: envelope.requestId, + resolvedProjectId: projectScope.projectId, + peerDeviceId: peer.metadata?.deviceId ?? peer.pairedDeviceId ?? null, + }); + } + + switch (envelope.type) { + case "project_catalog_request": { + sendProjectCatalog(peer, await buildProjectCatalogPayload(), envelope.requestId); + break; + } + case "project_switch_request": { + await handleProjectSwitchRequest(peer, envelope.requestId, envelope.payload as SyncProjectSwitchRequestPayload); + break; + } + case "heartbeat": { + const payload = envelope.payload as { kind?: string; sentAt?: string } | null; + if (payload?.kind === "ping") { + send(peer.ws, "heartbeat", { + kind: "pong", + sentAt: payload.sentAt ?? nowIso(), + dbVersion: args.db.sync.getDbVersion(), + }, envelope.requestId); + } else if (payload?.kind === "pong" && heartbeatAwaitedAt) { + const now = Date.now(); + const sentAtMs = Date.parse(heartbeatAwaitedAt); + peer.latencyMs = Number.isFinite(sentAtMs) ? Math.max(0, now - sentAtMs) : null; + peer.awaitingHeartbeatAt = null; + } + break; + } + case "changeset_batch": { + const payload = (envelope.payload ?? {}) as SyncChangesetBatchPayload; + const batchId = payload.batchId || envelope.requestId || ""; + const changes = Array.isArray(payload.changes) ? payload.changes as CrsqlChangeRow[] : []; + try { + let appliedCount = 0; + if (changes.length > 0) { + args.db.sync.applyChanges(changes); + appliedCount = changes.length; + peer.lastAppliedAt = nowIso(); + lastBroadcastAt = nowIso(); + args.onStateChanged?.(); + broadcastBrainStatus(); + } + sendRequired(peer, "changeset_ack", { + batchId, + fromDbVersion: Number(payload.fromDbVersion ?? 0), + toDbVersion: Number(payload.toDbVersion ?? 0), + appliedDbVersion: args.db.sync.getDbVersion(), + appliedCount, + ok: true, + } satisfies SyncChangesetAckPayload, envelope.requestId); + } catch (error) { + sendRequired(peer, "changeset_ack", { + batchId, + fromDbVersion: Number(payload.fromDbVersion ?? 0), + toDbVersion: Number(payload.toDbVersion ?? 0), + appliedDbVersion: args.db.sync.getDbVersion(), + appliedCount: 0, + ok: false, + error: { + code: "changeset_apply_failed", + message: error instanceof Error ? error.message : String(error), + }, + } satisfies SyncChangesetAckPayload, envelope.requestId); + throw error; + } + break; + } + case "changeset_ack": { + handleChangesetAck(peer, envelope.payload as SyncChangesetAckPayload); + break; + } + case "file_request": + await handleFileRequest(peer, envelope.requestId, envelope.payload as SyncFileRequest); + break; + case "terminal_subscribe": { + const payload = envelope.payload as { sessionId?: string; maxBytes?: number } | null; + const sessionId = toOptionalString(payload?.sessionId); + if (!sessionId) break; + peer.subscribedSessionIds.add(sessionId); + const session = args.sessionService.get(sessionId); + const transcript = session + ? await args.sessionService.readTranscriptTail( + session.transcriptPath, + Math.max(1_024, Math.min(2_000_000, Math.floor(payload?.maxBytes ?? DEFAULT_TERMINAL_SNAPSHOT_BYTES))), + { raw: true, alignToLineBoundary: true }, + ) + : ""; + const snapshot: SyncTerminalSnapshotPayload = { + sessionId, + transcript, + status: session?.status ?? null, + runtimeState: session?.runtimeState ?? null, + lastOutputPreview: session?.lastOutputPreview ?? null, + capturedAt: nowIso(), + }; + sendRequired(peer, "terminal_snapshot", snapshot, envelope.requestId); + break; + } + case "terminal_unsubscribe": { + const payload = envelope.payload as { sessionId?: string } | null; + const sessionId = toOptionalString(payload?.sessionId); + if (sessionId) { + peer.subscribedSessionIds.delete(sessionId); + } + break; + } + case "terminal_input": { + // Forward keystrokes / pasted text from a mobile client into the + // active PTY for the named session. We require a prior subscribe so + // only an attached peer can drive the shell — protects against an + // attacker who acquired a session id but is not actively viewing. + const payload = envelope.payload as { sessionId?: string; data?: string } | null; + const sessionId = toOptionalString(payload?.sessionId); + const data = typeof payload?.data === "string" ? payload.data : null; + if (!sessionId || data == null) break; + if (!peer.subscribedSessionIds.has(sessionId)) { + args.logger.warn("sync.terminal_input_unsubscribed_session", { sessionId }); + break; + } + const accepted = args.ptyService.writeBySessionId(sessionId, data); + if (!accepted) { + args.logger.info("sync.terminal_input_no_active_pty", { sessionId }); + } + break; + } + case "terminal_resize": { + // Mobile clients re-emit this whenever their visible viewport + // changes (rotation, split view, dynamic font). We forward to the + // active PTY so command-line apps re-flow correctly. Out-of-bound + // values are clamped inside ptyService. + const payload = envelope.payload as { sessionId?: string; cols?: number; rows?: number } | null; + const sessionId = toOptionalString(payload?.sessionId); + const cols = typeof payload?.cols === "number" ? Math.floor(payload.cols) : null; + const rows = typeof payload?.rows === "number" ? Math.floor(payload.rows) : null; + if (!sessionId || cols == null || rows == null) break; + if (!peer.subscribedSessionIds.has(sessionId)) break; + args.ptyService.resizeBySessionId(sessionId, cols, rows); + break; + } + case "chat_subscribe": { + const payload = envelope.payload as { sessionId?: string; maxBytes?: number } | null; + const sessionId = toOptionalString(payload?.sessionId); + if (!sessionId) break; + peer.subscribedChatSessionIds.add(sessionId); + + const session = args.sessionService.get(sessionId); + const maxBytes = Math.max( + 1_024, + Math.min(2_000_000, Math.floor(typeof payload?.maxBytes === "number" ? payload.maxBytes : DEFAULT_TERMINAL_SNAPSHOT_BYTES)), + ); + const raw = session?.transcriptPath + ? await args.sessionService.readTranscriptTail( + session.transcriptPath, + maxBytes, + { raw: true, alignToLineBoundary: true }, + ) + : ""; + const events = parseAgentChatTranscript(raw).filter((event) => event.sessionId === sessionId); + const transcriptSize = session?.transcriptPath && fs.existsSync(session.transcriptPath) + ? fs.statSync(session.transcriptPath).size + : 0; + peer.chatTranscriptOffsets.set(sessionId, transcriptSize); + const snapshot: SyncChatSubscribeSnapshotPayload = { + sessionId, + capturedAt: nowIso(), + truncated: transcriptSize > maxBytes, + events, + }; + sendRequired(peer, "chat_subscribe", snapshot, envelope.requestId); + break; + } + case "chat_unsubscribe": { + const payload = envelope.payload as SyncChatUnsubscribePayload | null; + const sessionId = toOptionalString(payload?.sessionId); + if (sessionId) { + peer.subscribedChatSessionIds.delete(sessionId); + peer.chatTranscriptOffsets.delete(sessionId); + peer.chatEventIdsSent.delete(sessionId); + } + break; + } + case "command": + await handleCommand(peer, envelope.requestId, { + ...(envelope.payload as SyncCommandPayload), + ...(!toOptionalString((envelope.payload as SyncCommandPayload | null)?.projectId) && envelope.projectId + ? { projectId: envelope.projectId } + : {}), + }); + break; + case "register_push_token": { + const payload = envelope.payload as SyncRegisterPushTokenPayload | null; + handleRegisterPushToken(peer, envelope.requestId, payload); + break; + } + case "notification_prefs": { + const payload = envelope.payload as SyncNotificationPrefsPayload | null; + handleNotificationPrefs(peer, payload); + break; + } + case "send_test_push": { + const payload = envelope.payload as SyncSendTestPushPayload | null; + await handleSendTestPush(peer, envelope.requestId, payload); + break; + } + default: + break; + } + } + + function handleRegisterPushToken( + peer: PeerState, + requestId: string | null | undefined, + payload: SyncRegisterPushTokenPayload | null, + ): void { + const deviceId = peer.metadata?.deviceId; + if (!deviceId) { + args.logger.warn("sync_host.push_token_missing_device", {}); + sendRequired(peer, "command_ack", { + commandId: "push-token:unknown", + accepted: false, + status: "missing_device_id", + message: "Cannot store push token before device registration completes.", + }, requestId ?? null); + return; + } + if (!payload || typeof payload.token !== "string" || payload.token.trim().length === 0) { + args.logger.warn("sync_host.push_token_missing", { deviceId }); + sendRequired(peer, "command_ack", { + commandId: `push-token:${deviceId}:unknown`, + accepted: false, + status: "invalid_payload", + message: "Push token registration did not include a token.", + }, requestId ?? null); + return; + } + const kind: ApnsPushTokenKind = + payload.kind === "alert" || payload.kind === "activity-start" || payload.kind === "activity-update" + ? payload.kind + : "alert"; + if (kind === "activity-update" && !payload.activityId?.trim()) { + args.logger.warn("sync_host.push_token_missing_activity_id", { deviceId }); + sendRequired(peer, "command_ack", { + commandId: `push-token:${deviceId}:${kind}`, + accepted: false, + status: "missing_activity_id", + message: "Live Activity update tokens require an activity id.", + }, requestId ?? null); + return; + } + const env: ApnsEnvironment = payload.env === "production" ? "production" : "sandbox"; + const stored = args.deviceRegistryService?.setApnsToken?.(deviceId, payload.token.trim(), kind, env, { + bundleId: payload.bundleId, + activityId: payload.activityId, + }); + if (!stored) { + sendRequired(peer, "command_ack", { + commandId: `push-token:${deviceId}:${kind}`, + accepted: false, + status: "device_not_found", + message: `Could not store ${kind} push token for ${deviceId}.`, + }, requestId ?? null); + return; + } + // Optional ack so the client can retry on failure. + sendRequired(peer, "command_ack", { + commandId: `push-token:${deviceId}:${kind}`, + accepted: true, + status: "accepted", + message: `Stored ${kind} push token for ${deviceId}.`, + }, requestId ?? null); + } + + function handleNotificationPrefs(peer: PeerState, payload: SyncNotificationPrefsPayload | null): void { + const deviceId = peer.metadata?.deviceId; + if (!deviceId || !payload || !payload.prefs) return; + storeNotificationPrefsForDevice(deviceId, normalizeNotificationPreferences(payload.prefs)); + } + + async function handleSendTestPush( + peer: PeerState, + requestId: string | null | undefined, + payload: SyncSendTestPushPayload | null, + ): Promise { + const deviceId = peer.metadata?.deviceId; + if (!deviceId) return; + const kind = payload?.kind === "activity" ? "activity" : "alert"; + const result = args.notificationEventBus + ? await args.notificationEventBus.sendTestPush(deviceId, kind) + : { ok: false, reason: "notification_bus_unavailable" as const }; + sendRequired(peer, "command_result", { + commandId: `push-test:${deviceId}:${kind}`, + ok: result.ok, + ...(result.ok ? {} : { error: { code: "test_push_failed", message: result.reason ?? "unknown" } }), + }, requestId ?? null); + } + + /** + * Deliver a foreground-only notification to a specific iOS peer over the + * existing WebSocket. Used by the notification bus when the device is + * currently connected, in place of (or alongside) an APNs alert. + */ + function sendInAppNotification( + deviceId: string, + payload: Omit, + ): void { + const fullPayload: SyncInAppNotificationPayload = { + ...payload, + generatedAt: nowIso(), + }; + for (const peer of peers) { + if (!peer.authenticated || peer.ws.readyState !== WebSocket.OPEN) continue; + if (peer.metadata?.deviceId !== deviceId) continue; + send(peer.ws, "in_app_notification", fullPayload); + } + } + + function getNotificationPrefsForDevice(deviceId: string): NotificationPreferences | null { + return readNotificationPrefsForDevice(deviceId); + } + + function isIosPeerConnected(deviceId: string): boolean { + for (const peer of peers) { + if (peer.metadata?.deviceId !== deviceId) continue; + if (!peer.authenticated || peer.ws.readyState !== WebSocket.OPEN) continue; + return true; + } + return false; + } + + const getLanePresenceSnapshot = (): Array<{ laneId: string; devicesOpen: DeviceMarker[] }> => { + return [...lanePresenceByLaneId.keys()] + .sort((left, right) => left.localeCompare(right)) + .map((laneId) => ({ + laneId, + devicesOpen: listLanePresenceMarkers(laneId), + })) + .filter((entry) => entry.devicesOpen.length > 0); + }; + + return { + async waitUntilListening(): Promise { + if (startupError) { + throw startupError; + } + if (server.address()) { + const address = server.address(); + const port = typeof address === "object" && address ? address.port : DEFAULT_SYNC_HOST_PORT; + publishLanDiscovery(port); + publishTailnetDiscovery(port); + return port; + } + await new Promise((resolve, reject) => { + const onListening = () => { + cleanup(); + resolve(); + }; + const onError = (error: unknown) => { + cleanup(); + const normalized = error instanceof Error ? error : new Error(String(error)); + startupError = normalized; + reject(normalized); + }; + const cleanup = () => { + server.off("listening", onListening); + server.off("error", onError); + }; + server.on("listening", onListening); + server.on("error", onError); + if (startupError) { + cleanup(); + reject(startupError); + return; + } + if (server.address()) { + cleanup(); + resolve(); + } + }); + const address = server.address(); + const port = typeof address === "object" && address ? address.port : DEFAULT_SYNC_HOST_PORT; + publishLanDiscovery(port); + publishTailnetDiscovery(port); + return port; + }, + + getPort(): number | null { + const address = server.address(); + return typeof address === "object" && address ? address.port : null; + }, + + getBootstrapToken(): string { + return bootstrapToken; + }, + + setLocalActiveLanePresence(laneIds: string[]): void { + setLocalActiveLanePresence(laneIds); + }, + + refreshLanDiscovery(options?: { forceTailnet?: boolean }): void { + const address = server.address(); + if (typeof address === "object" && address) { + publishLanDiscovery(address.port); + publishTailnetDiscovery(address.port, { force: options?.forceTailnet }); + } + }, + + setDiscoveryEnabled(enabled: boolean): void { + if (discoveryEnabled === enabled) return; + discoveryEnabled = enabled; + const address = server.address(); + if (!enabled) { + unpublishLanDiscovery(); + void unpublishTailnetDiscovery(); + updateTailnetDiscoveryStatus({ + state: "disabled", + serviceName: SYNC_TAILNET_DISCOVERY_SERVICE_NAME, + servicePort: SYNC_TAILNET_DISCOVERY_SERVICE_PORT, + target: null, + updatedAt: nowIso(), + error: "Tailnet discovery is disabled for this background project context.", + stderr: null, + }); + return; + } + if (typeof address === "object" && address) { + publishLanDiscovery(address.port); + publishTailnetDiscovery(address.port, { force: true }); + } + }, + + revokePairedDevice(deviceId: string): void { + pairingStore.revoke(deviceId); + let revokedConnectedPeer = false; + for (const peer of peers) { + if (!peer.authenticated || peer.authKind !== "paired" || peer.pairedDeviceId !== deviceId) continue; + revokedConnectedPeer = true; + peer.authenticated = false; + peer.metadata = null; + peer.authKind = null; + peer.pairedDeviceId = null; + try { + peer.ws.close(4003, "Pairing revoked"); + } catch { + // ignore close failures + } + } + if (revokedConnectedPeer) { + args.onStateChanged?.(); + broadcastBrainStatus(); + } + }, + + getPeerStates(): SyncPeerConnectionState[] { + const dbVersion = args.db.sync.getDbVersion(); + const latestByDevice = new Map(); + for (const peer of [...peers] + .map((peer) => toSyncPeerConnectionState(peer, dbVersion)) + .filter((peer): peer is SyncPeerConnectionState => peer != null)) { + const existing = latestByDevice.get(peer.deviceId); + if (!existing || peer.connectedAt > existing.connectedAt) { + latestByDevice.set(peer.deviceId, peer); + } + } + return [...latestByDevice.values()]; + }, + + getTailnetDiscoveryStatus(): SyncTailnetDiscoveryStatus { + return { ...tailnetDiscoveryStatus }; + }, + + getLanePresenceSnapshot(): Array<{ laneId: string; devicesOpen: DeviceMarker[] }> { + return getLanePresenceSnapshot(); + }, + + getChatSubscriptionSnapshot(): Array<{ deviceId: string; subscribedChatSessionIds: string[] }> { + return [...peers] + .map((peer) => { + if (!peer.metadata) return null; + return { + deviceId: peer.metadata.deviceId, + subscribedChatSessionIds: [...peer.subscribedChatSessionIds].sort(), + }; + }) + .filter((peer): peer is { deviceId: string; subscribedChatSessionIds: string[] } => peer != null); + }, + + getBrainStatusSnapshot(): SyncBrainStatusPayload { + return buildBrainStatus(); + }, + + async broadcastProjectCatalog(): Promise { + const payload = await buildProjectCatalogPayload(); + if (bonjourPort != null) { + refreshLanDiscoveryProjects(bonjourPort, payload); + } + for (const peer of peers) { + if (!peer.authenticated || peer.ws.readyState !== WebSocket.OPEN) continue; + sendProjectCatalog(peer, payload); + } + }, + + /** + * Push an in-app notification to a specific iOS peer over the WebSocket. + * Used by the notification event bus as the foreground-delivery path. + */ + sendInAppNotification( + deviceId: string, + payload: Omit, + ): void { + sendInAppNotification(deviceId, payload); + }, + + /** Returns the latest announced notification prefs for a device, or null. */ + getNotificationPrefsForDevice(deviceId: string): NotificationPreferences | null { + return getNotificationPrefsForDevice(deviceId); + }, + + /** Whether a given device is currently connected + authenticated. */ + isIosPeerConnected(deviceId: string): boolean { + return isIosPeerConnected(deviceId); + }, + + handlePtyData(event: PtyDataEvent): void { + const payload = { + sessionId: event.sessionId, + ptyId: event.ptyId, + data: event.data, + at: nowIso(), + }; + for (const peer of peers) { + if (!peer.authenticated || !peer.subscribedSessionIds.has(event.sessionId) || peer.ws.readyState !== WebSocket.OPEN) continue; + if (isPeerBackpressured(peer)) continue; + send(peer.ws, "terminal_data", payload); + } + }, + + handlePtyExit(event: PtyExitEvent): void { + const payload = { + sessionId: event.sessionId, + ptyId: event.ptyId, + exitCode: event.exitCode, + at: nowIso(), + }; + for (const peer of peers) { + if (!peer.authenticated || !peer.subscribedSessionIds.has(event.sessionId) || peer.ws.readyState !== WebSocket.OPEN) continue; + if (isPeerBackpressured(peer)) continue; + send(peer.ws, "terminal_exit", payload); + } + }, + + async dispose(): Promise { + if (disposed) return; + disposed = true; + localActiveLaneIds = new Set(); + lanePresenceByLaneId.clear(); + dropInFlightCommandRecordsForProject(); + chatEventSubscription?.(); + clearInterval(pollTimer); + clearInterval(heartbeatTimer); + clearInterval(brainStatusTimer); + unpublishLanDiscovery(); + try { + await unpublishTailnetDiscovery(); + } catch { + // Never throw from dispose. + } + await new Promise((resolve) => { + const finish = () => resolve(); + for (const peer of peers) { + try { + peer.ws.close(); + } catch { + // ignore + } + } + if (!server.address()) { + finish(); + return; + } + try { + server.close(() => finish()); + } catch { + finish(); + } + }); + if (bonjourAnnouncement) { + try { + bonjourAnnouncement.stop?.(); + } catch { + // ignore cleanup failures + } + bonjourAnnouncement = null; + } + bonjourPort = null; + bonjourSignature = null; + if (bonjourInstance) { + try { + bonjourInstance.destroy(); + } catch { + // ignore cleanup failures + } + bonjourInstance = null; + } + }, + }; +} + +export type SyncHostService = ReturnType; diff --git a/apps/ade-cli/src/services/sync/syncPairingStore.ts b/apps/ade-cli/src/services/sync/syncPairingStore.ts new file mode 100644 index 000000000..c7b487003 --- /dev/null +++ b/apps/ade-cli/src/services/sync/syncPairingStore.ts @@ -0,0 +1,110 @@ +import fs from "node:fs"; +import path from "node:path"; +import { createHash, randomBytes, timingSafeEqual } from "node:crypto"; +import type { SyncPeerMetadata } from "../../../../desktop/src/shared/types"; +import { nowIso, safeJsonParse, writeTextAtomic } from "../../../../desktop/src/main/services/shared/utils"; +import type { SyncPinStore } from "./syncPinStore"; + +type PairingRecord = { + secretHash: string; + createdAt: string; + lastUsedAt: string | null; + peerName: string; + peerPlatform: string; + peerDeviceType: string; +}; + +type PairingSecretsFile = Record; + +type SyncPairingStoreArgs = { + filePath: string; + pinStore: SyncPinStore; +}; + +function hashSecret(secret: string): string { + return createHash("sha256").update(secret).digest("hex"); +} + +function safeHashEquals(expectedHash: string, actualHash: string): boolean { + const expected = Buffer.from(expectedHash, "utf8"); + const actual = Buffer.from(actualHash, "utf8"); + if (expected.length !== actual.length) { + timingSafeEqual(expected, Buffer.alloc(expected.length)); + return false; + } + return timingSafeEqual(expected, actual); +} + +function pairingError(code: "pin_not_set" | "invalid_pin", message: string): Error { + const err = new Error(message) as Error & { code?: string }; + err.code = code; + return err; +} + +export function createSyncPairingStore(args: SyncPairingStoreArgs) { + fs.mkdirSync(path.dirname(args.filePath), { recursive: true }); + + const readRecords = (): PairingSecretsFile => { + if (!fs.existsSync(args.filePath)) return {}; + return safeJsonParse(fs.readFileSync(args.filePath, "utf8"), {}); + }; + + const writeRecords = (records: PairingSecretsFile): void => { + writeTextAtomic(args.filePath, `${JSON.stringify(records, null, 2)}\n`); + try { + fs.chmodSync(args.filePath, 0o600); + } catch { + // ignore chmod failures on platforms that don't support it + } + }; + + return { + pairPeer(peer: SyncPeerMetadata, pin: string): { deviceId: string; secret: string } { + if (!args.pinStore.hasPin()) { + throw pairingError("pin_not_set", "No pairing PIN is set on this computer."); + } + if (!args.pinStore.verifyPin(pin)) { + throw pairingError("invalid_pin", "Incorrect pairing PIN."); + } + const secret = randomBytes(24).toString("hex"); + const records = readRecords(); + const existing = records[peer.deviceId] ?? null; + records[peer.deviceId] = { + secretHash: hashSecret(secret), + createdAt: existing?.createdAt ?? nowIso(), + lastUsedAt: null, + peerName: peer.deviceName, + peerPlatform: peer.platform, + peerDeviceType: peer.deviceType, + }; + writeRecords(records); + return { + deviceId: peer.deviceId, + secret, + }; + }, + + authenticate(deviceId: string, secret: string): boolean { + const normalized = deviceId.trim(); + if (!normalized) return false; + const records = readRecords(); + const entry = records[normalized]; + if (!entry) return false; + if (!safeHashEquals(entry.secretHash, hashSecret(secret))) return false; + entry.lastUsedAt = nowIso(); + writeRecords(records); + return true; + }, + + revoke(deviceId: string): void { + const normalized = deviceId.trim(); + if (!normalized) return; + const records = readRecords(); + if (!(normalized in records)) return; + delete records[normalized]; + writeRecords(records); + }, + }; +} + +export type SyncPairingStore = ReturnType; diff --git a/apps/ade-cli/src/services/sync/syncPeerService.ts b/apps/ade-cli/src/services/sync/syncPeerService.ts new file mode 100644 index 000000000..3ec1a2d77 --- /dev/null +++ b/apps/ade-cli/src/services/sync/syncPeerService.ts @@ -0,0 +1,579 @@ +import { WebSocket, type RawData } from "ws"; +import type { + SyncBrainStatusPayload, + SyncChangesetAckPayload, + SyncChangesetBatchPayload, + SyncClientStatus, + SyncCommandAckPayload, + SyncCommandResultPayload, + SyncDesktopConnectionDraft, + SyncRemoteCommandAction, + SyncPeerMetadata, + SyncRunQuickCommandArgs, +} from "../../../../desktop/src/shared/types"; +import type { Logger } from "../../../../desktop/src/main/services/logging/logger"; +import type { AdeDb } from "../../../../desktop/src/main/services/state/kvDb"; +import { nowIso } from "../../../../desktop/src/main/services/shared/utils"; +import type { DeviceRegistryService } from "./deviceRegistryService"; +import { DEFAULT_SYNC_COMPRESSION_THRESHOLD_BYTES, encodeSyncEnvelope, parseSyncEnvelope, wsDataToText } from "./syncProtocol"; + +type SyncPeerServiceArgs = { + db: AdeDb; + logger: Logger; + deviceRegistryService: DeviceRegistryService; + onStatusChange?: (status: SyncClientStatus) => void; + onBrainStatus?: (payload: SyncBrainStatusPayload) => void; + onRemoteChangesApplied?: () => void; +}; + +type PendingRequest = { + resolve: (value: unknown) => void; + reject: (error: Error) => void; + timer: ReturnType; +}; + +type InternalStatus = SyncClientStatus; +type PendingChangesetBatch = { + batchId: string; + payload: SyncChangesetBatchPayload; + sentAtMs: number; + retryCount: number; +}; + +const CHANGESET_ACK_TIMEOUT_MS = 10_000; +const MAX_CHANGESET_ACK_RETRIES = 6; + +export function createSyncPeerService(args: SyncPeerServiceArgs) { + let ws: WebSocket | null = null; + let disposed = false; + let relayTimer: NodeJS.Timeout | null = null; + let heartbeatTimer: NodeJS.Timeout | null = null; + let connectionDraft: SyncDesktopConnectionDraft | null = null; + let latestBrainStatus: SyncBrainStatusPayload | null = null; + let outboundLocalDbVersion = args.db.sync.getDbVersion(); + let latestRemoteDbVersion = 0; + let pendingOutboundChangeset: PendingChangesetBatch | null = null; + const pendingRequests = new Map(); + let pendingConnect: { resolve: () => void; reject: (error: Error) => void } | null = null; + + const status: InternalStatus = { + state: "disconnected", + host: null, + port: null, + connectedAt: null, + lastSeenAt: null, + latencyMs: null, + syncLag: null, + lastRemoteDbVersion: 0, + brainDeviceId: null, + hostName: null, + error: null, + message: null, + savedDraft: null, + }; + + const emitStatus = () => { + status.lastRemoteDbVersion = latestRemoteDbVersion; + status.savedDraft = connectionDraft + ? { + host: connectionDraft.host, + port: connectionDraft.port, + authKind: connectionDraft.authKind ?? "bootstrap", + pairedDeviceId: connectionDraft.pairedDeviceId ?? null, + lastRemoteDbVersion: connectionDraft.lastRemoteDbVersion ?? latestRemoteDbVersion, + } + : null; + args.onStatusChange?.({ ...status }); + }; + + const stopTimers = () => { + if (relayTimer) { + clearInterval(relayTimer); + relayTimer = null; + } + if (heartbeatTimer) { + clearInterval(heartbeatTimer); + heartbeatTimer = null; + } + }; + + const clearPendingRequests = (message: string) => { + for (const [requestId, pending] of pendingRequests) { + clearTimeout(pending.timer); + pending.reject(new Error(message)); + pendingRequests.delete(requestId); + } + }; + + const applyDraft = (draft: SyncDesktopConnectionDraft | null) => { + connectionDraft = draft + ? { + host: draft.host.trim(), + port: Math.max(1, Math.floor(draft.port)), + token: draft.token, + authKind: draft.authKind ?? "bootstrap", + pairedDeviceId: draft.pairedDeviceId ?? null, + lastRemoteDbVersion: Math.max(0, Math.floor(draft.lastRemoteDbVersion ?? 0)), + } + : null; + emitStatus(); + }; + + const currentLocalPeerMetadata = (): SyncPeerMetadata => { + const localDevice = args.deviceRegistryService.ensureLocalDevice(); + return { + deviceId: localDevice.deviceId, + deviceName: localDevice.name, + platform: localDevice.platform, + deviceType: localDevice.deviceType, + siteId: localDevice.siteId, + dbVersion: latestRemoteDbVersion, + capabilities: ["changesetAck"], + }; + }; + + const sendChangesetAck = ( + batch: SyncChangesetBatchPayload, + ok: boolean, + appliedDbVersion: number, + appliedCount: number, + error?: unknown, + ) => { + if (!ws || ws.readyState !== WebSocket.OPEN) return; + const payload: SyncChangesetAckPayload = { + batchId: batch.batchId, + fromDbVersion: Number(batch.fromDbVersion ?? 0), + toDbVersion: Number(batch.toDbVersion ?? 0), + appliedDbVersion, + appliedCount, + ok, + ...(error + ? { error: { code: "changeset_apply_failed", message: error instanceof Error ? error.message : String(error) } } + : {}), + }; + ws.send( + encodeSyncEnvelope({ + type: "changeset_ack", + requestId: batch.batchId, + payload, + compressionThresholdBytes: DEFAULT_SYNC_COMPRESSION_THRESHOLD_BYTES, + }), + ); + }; + + const sendOutboundChangeset = (pending: PendingChangesetBatch) => { + if (!ws || ws.readyState !== WebSocket.OPEN) return false; + ws.send( + encodeSyncEnvelope({ + type: "changeset_batch", + requestId: pending.batchId, + payload: pending.payload, + compressionThresholdBytes: DEFAULT_SYNC_COMPRESSION_THRESHOLD_BYTES, + }), + ); + return true; + }; + + const sendLocalChanges = () => { + if (!ws || ws.readyState !== WebSocket.OPEN) return; + const nowMs = Date.now(); + if (pendingOutboundChangeset) { + if (nowMs - pendingOutboundChangeset.sentAtMs >= CHANGESET_ACK_TIMEOUT_MS) { + if (pendingOutboundChangeset.retryCount >= MAX_CHANGESET_ACK_RETRIES) { + args.logger.warn("sync_peer.changeset_ack_timeout_exhausted", { + batchId: pendingOutboundChangeset.batchId, + retryCount: pendingOutboundChangeset.retryCount, + }); + disconnectInternal("error", null, "Changeset acknowledgement timed out."); + return; + } + pendingOutboundChangeset.sentAtMs = nowMs; + pendingOutboundChangeset.retryCount += 1; + sendOutboundChangeset(pendingOutboundChangeset); + } + return; + } + const currentDbVersion = args.db.sync.getDbVersion(); + if (currentDbVersion <= outboundLocalDbVersion) return; + const localSiteId = args.deviceRegistryService.getLocalSiteId(); + const changes = args.db.sync + .exportChangesSince(outboundLocalDbVersion) + .filter((change) => change.site_id === localSiteId); + const previousDbVersion = outboundLocalDbVersion; + if (!changes.length) { + outboundLocalDbVersion = currentDbVersion; + return; + } + const batchId = `changeset:${currentLocalPeerMetadata().deviceId}:${previousDbVersion}:${currentDbVersion}:${Date.now()}:${Math.random().toString(16).slice(2)}`; + pendingOutboundChangeset = { + batchId, + payload: { + batchId, + reason: "relay", + fromDbVersion: previousDbVersion, + toDbVersion: currentDbVersion, + changes, + }, + sentAtMs: nowMs, + retryCount: 0, + }; + sendOutboundChangeset(pendingOutboundChangeset); + }; + + const startRelay = () => { + stopTimers(); + relayTimer = setInterval(() => { + try { + sendLocalChanges(); + } catch (error) { + args.logger.warn("sync_peer.relay_failed", { + error: error instanceof Error ? error.message : String(error), + }); + } + }, 400); + }; + + const startHeartbeatFallback = () => { + heartbeatTimer = setInterval(() => { + if (!ws || ws.readyState !== WebSocket.OPEN) return; + ws.send( + encodeSyncEnvelope({ + type: "heartbeat", + payload: { + kind: "ping", + sentAt: nowIso(), + dbVersion: latestRemoteDbVersion, + }, + }), + ); + }, 30_000); + }; + + const disconnectInternal = (state: SyncClientStatus["state"], message: string | null, error: string | null) => { + stopTimers(); + if (ws) { + try { + ws.removeAllListeners(); + ws.close(); + } catch { + // ignore + } + } + ws = null; + pendingOutboundChangeset = null; + latestBrainStatus = null; + status.state = state; + status.connectedAt = null; + status.lastSeenAt = null; + status.latencyMs = null; + status.syncLag = null; + status.brainDeviceId = null; + status.hostName = null; + status.message = message; + status.error = error; + clearPendingRequests(error ?? message ?? "Sync peer disconnected."); + emitStatus(); + }; + + const handleMessage = (raw: RawData) => { + const envelope = parseSyncEnvelope(wsDataToText(raw)); + status.lastSeenAt = nowIso(); + switch (envelope.type) { + case "hello_ok": { + const payload = envelope.payload as { + brain: SyncPeerMetadata; + serverDbVersion: number; + }; + latestRemoteDbVersion = Math.max(0, Math.floor(payload.serverDbVersion ?? 0)); + status.state = "connected"; + status.connectedAt = nowIso(); + status.message = `Connected to host ${payload.brain.deviceName}.`; + status.error = null; + status.brainDeviceId = payload.brain.deviceId; + status.hostName = payload.brain.deviceName; + if (connectionDraft) { + connectionDraft.lastRemoteDbVersion = latestRemoteDbVersion; + } + outboundLocalDbVersion = Math.max(outboundLocalDbVersion, args.db.sync.getDbVersion()); + emitStatus(); + startRelay(); + startHeartbeatFallback(); + pendingConnect?.resolve(); + pendingConnect = null; + break; + } + case "hello_error": { + const payload = envelope.payload as { message?: string }; + pendingConnect?.reject(new Error(payload?.message ?? "Sync peer authentication failed.")); + pendingConnect = null; + disconnectInternal("error", null, payload?.message ?? "Sync peer authentication failed."); + break; + } + case "changeset_batch": { + const payload = (envelope.payload ?? {}) as SyncChangesetBatchPayload; + const changes = Array.isArray(payload.changes) ? payload.changes : []; + try { + if (changes.length) { + args.db.sync.applyChanges(changes); + args.onRemoteChangesApplied?.(); + } + latestRemoteDbVersion = Math.max(latestRemoteDbVersion, Math.floor(payload.toDbVersion ?? latestRemoteDbVersion)); + if (connectionDraft) connectionDraft.lastRemoteDbVersion = latestRemoteDbVersion; + sendChangesetAck(payload, true, args.db.sync.getDbVersion(), changes.length); + emitStatus(); + } catch (error) { + sendChangesetAck(payload, false, args.db.sync.getDbVersion(), 0, error); + throw error; + } + break; + } + case "changeset_ack": { + const payload = envelope.payload as SyncChangesetAckPayload; + if (!pendingOutboundChangeset || payload.batchId !== pendingOutboundChangeset.batchId) break; + if (!payload.ok) { + if (pendingOutboundChangeset.retryCount >= MAX_CHANGESET_ACK_RETRIES) { + const message = payload.error?.message ?? "Changeset apply failed repeatedly."; + args.logger.warn("sync_peer.changeset_ack_failed_exhausted", { + batchId: pendingOutboundChangeset.batchId, + retryCount: pendingOutboundChangeset.retryCount, + error: message, + }); + disconnectInternal("error", null, message); + break; + } + pendingOutboundChangeset.sentAtMs = Date.now(); + pendingOutboundChangeset.retryCount += 1; + args.logger.warn("sync_peer.changeset_ack_failed", { + batchId: pendingOutboundChangeset.batchId, + error: payload.error?.message ?? "Changeset apply failed.", + }); + break; + } + if (payload.toDbVersion < pendingOutboundChangeset.payload.toDbVersion) break; + const acknowledgedRemoteVersion = Math.max( + latestRemoteDbVersion, + pendingOutboundChangeset.payload.toDbVersion, + Math.floor(payload.toDbVersion ?? 0), + ); + latestRemoteDbVersion = acknowledgedRemoteVersion; + if (connectionDraft) { + connectionDraft.lastRemoteDbVersion = acknowledgedRemoteVersion; + } + outboundLocalDbVersion = Math.max(outboundLocalDbVersion, pendingOutboundChangeset.payload.toDbVersion); + pendingOutboundChangeset = null; + emitStatus(); + break; + } + case "brain_status": { + const payload = envelope.payload as SyncBrainStatusPayload; + latestBrainStatus = payload; + status.brainDeviceId = payload.brain.deviceId; + status.hostName = payload.brain.deviceName; + const localDeviceId = args.deviceRegistryService.getLocalDeviceId(); + const localPeer = payload.connectedPeers.find((peer) => peer.deviceId === localDeviceId) ?? null; + status.latencyMs = localPeer?.latencyMs ?? null; + status.syncLag = localPeer?.syncLag ?? 0; + args.onBrainStatus?.(payload); + emitStatus(); + break; + } + case "heartbeat": { + const payload = envelope.payload as { kind?: string; sentAt?: string }; + if (payload?.kind === "ping" && ws && ws.readyState === WebSocket.OPEN) { + ws.send( + encodeSyncEnvelope({ + type: "heartbeat", + requestId: envelope.requestId ?? null, + payload: { + kind: "pong", + sentAt: payload.sentAt ?? nowIso(), + dbVersion: latestRemoteDbVersion, + }, + }), + ); + } + break; + } + case "command_ack": + case "command_result": { + const requestId = envelope.requestId ?? null; + if (!requestId) break; + const pending = pendingRequests.get(requestId); + if (!pending) break; + if (envelope.type === "command_result") { + clearTimeout(pending.timer); + pendingRequests.delete(requestId); + const payload = envelope.payload as SyncCommandResultPayload; + if (payload.ok) { + pending.resolve(payload.result ?? null); + } else { + pending.reject(new Error(payload.error?.message ?? "Remote command failed.")); + } + } else { + const payload = envelope.payload as SyncCommandAckPayload; + if (!payload.accepted) { + clearTimeout(pending.timer); + pendingRequests.delete(requestId); + pending.reject(new Error(payload.message ?? "Remote command rejected.")); + } + } + break; + } + default: + break; + } + }; + + return { + setSavedDraft(draft: SyncDesktopConnectionDraft | null): void { + applyDraft(draft); + }, + + async connect(draft: SyncDesktopConnectionDraft): Promise { + if (disposed) { + throw new Error("Sync peer service is disposed."); + } + this.disconnect({ preserveDraft: true }); + applyDraft(draft); + latestRemoteDbVersion = Math.max(0, Math.floor(draft.lastRemoteDbVersion ?? 0)); + status.state = "connecting"; + status.host = draft.host.trim(); + status.port = Math.max(1, Math.floor(draft.port)); + status.message = `Connecting to ${status.host}:${String(status.port)}...`; + status.error = null; + emitStatus(); + + await new Promise((resolve, reject) => { + const socket = new WebSocket(`ws://${status.host}:${String(status.port)}`); + ws = socket; + pendingConnect = { resolve, reject }; + + const cleanup = () => { + socket.removeListener("open", onOpen); + socket.removeListener("error", onError); + }; + + const onOpen = () => { + cleanup(); + const peer = currentLocalPeerMetadata(); + const auth = draft.authKind === "paired" && draft.pairedDeviceId + ? { + kind: "paired" as const, + deviceId: draft.pairedDeviceId, + secret: draft.token, + } + : { + kind: "bootstrap" as const, + token: draft.token, + }; + socket.send( + encodeSyncEnvelope({ + type: "hello", + requestId: "hello", + payload: { + peer, + auth, + }, + compressionThresholdBytes: DEFAULT_SYNC_COMPRESSION_THRESHOLD_BYTES, + }), + ); + }; + + const onError = (error: Error) => { + cleanup(); + pendingConnect?.reject(error); + pendingConnect = null; + disconnectInternal("error", null, error.message); + }; + + socket.once("open", onOpen); + socket.once("error", onError); + socket.on("message", (raw) => { + try { + handleMessage(raw); + } catch (error) { + args.logger.warn("sync_peer.message_failed", { + error: error instanceof Error ? error.message : String(error), + }); + } + }); + socket.on("close", () => { + if (disposed) return; + if (pendingConnect) { + pendingConnect.reject(new Error("Connection closed before authentication completed.")); + pendingConnect = null; + } + disconnectInternal("disconnected", "Disconnected from host.", null); + }); + }); + }, + + disconnect(options: { preserveDraft?: boolean } = {}): void { + const nextDraft = options.preserveDraft ? connectionDraft : null; + disconnectInternal("disconnected", connectionDraft ? "Disconnected from host." : null, null); + if (!options.preserveDraft) { + applyDraft(null); + } else { + applyDraft(nextDraft); + } + }, + + getStatus(): SyncClientStatus { + return { ...status }; + }, + + getLatestBrainStatus(): SyncBrainStatusPayload | null { + return latestBrainStatus ? { ...latestBrainStatus, connectedPeers: [...latestBrainStatus.connectedPeers] } : null; + }, + + getConnectionDraft(): SyncDesktopConnectionDraft | null { + return connectionDraft ? { ...connectionDraft } : null; + }, + + isConnected(): boolean { + return status.state === "connected" && Boolean(ws) && ws?.readyState === WebSocket.OPEN; + }, + + flushLocalChanges(): void { + sendLocalChanges(); + }, + + async executeRemoteCommand(action: SyncRemoteCommandAction | (string & {}), commandArgs: Record): Promise { + if (!ws || ws.readyState !== WebSocket.OPEN) { + throw new Error("Not connected to a host device."); + } + const requestId = `sync-command-${Date.now()}-${Math.random().toString(16).slice(2)}`; + const promise = new Promise((resolve, reject) => { + const timer = setTimeout(() => { + pendingRequests.delete(requestId); + reject(new Error("Timed out waiting for remote command result.")); + }, 20_000); + pendingRequests.set(requestId, { resolve, reject, timer }); + }); + ws.send( + encodeSyncEnvelope({ + type: "command", + requestId, + payload: { + commandId: requestId, + action, + args: commandArgs, + }, + compressionThresholdBytes: DEFAULT_SYNC_COMPRESSION_THRESHOLD_BYTES, + }), + ); + return await promise; + }, + + async runQuickCommand(argsIn: SyncRunQuickCommandArgs): Promise { + return await this.executeRemoteCommand("work.runQuickCommand", argsIn); + }, + + async dispose(): Promise { + disposed = true; + this.disconnect(); + }, + }; +} + +export type SyncPeerService = ReturnType; diff --git a/apps/ade-cli/src/services/sync/syncPinStore.ts b/apps/ade-cli/src/services/sync/syncPinStore.ts new file mode 100644 index 000000000..5fe1702a3 --- /dev/null +++ b/apps/ade-cli/src/services/sync/syncPinStore.ts @@ -0,0 +1,147 @@ +import fs from "node:fs"; +import path from "node:path"; +import { pbkdf2Sync, randomBytes, timingSafeEqual } from "node:crypto"; +import { safeJsonParse, writeTextAtomic } from "../../../../desktop/src/main/services/shared/utils"; + +type SyncPinStoreArgs = { + filePath: string; +}; + +type LegacySyncPinFile = { + pin: string; + updatedAt: string; +}; + +type HashedSyncPinFile = { + version: 2; + algorithm: "pbkdf2-sha256"; + iterations: number; + salt: string; + hash: string; + updatedAt: string; +}; + +type SyncPinFile = LegacySyncPinFile | HashedSyncPinFile; + +const PIN_PATTERN = /^\d{6}$/; +const PIN_HASH_ITERATIONS = 120_000; +const PIN_HASH_BYTES = 32; + +function derivePinHash(pin: string, salt: string, iterations: number): string { + return pbkdf2Sync(pin, salt, iterations, PIN_HASH_BYTES, "sha256").toString("hex"); +} + +function safeEqualHex(left: string, right: string): boolean { + const leftBuffer = Buffer.from(left, "hex"); + const rightBuffer = Buffer.from(right, "hex"); + return leftBuffer.length === rightBuffer.length && timingSafeEqual(leftBuffer, rightBuffer); +} + +function createHashedPinFile(pin: string, updatedAt = new Date().toISOString()): HashedSyncPinFile { + const salt = randomBytes(16).toString("hex"); + return { + version: 2, + algorithm: "pbkdf2-sha256", + iterations: PIN_HASH_ITERATIONS, + salt, + hash: derivePinHash(pin, salt, PIN_HASH_ITERATIONS), + updatedAt, + }; +} + +function isHashedPinFile(value: SyncPinFile | null): value is HashedSyncPinFile { + if (!value || !("version" in value)) return false; + return value.version === 2 + && value.algorithm === "pbkdf2-sha256" + && Number.isInteger(value.iterations) + && value.iterations > 0 + && typeof value.salt === "string" + && /^[0-9a-f]+$/i.test(value.salt) + && typeof value.hash === "string" + && /^[0-9a-f]+$/i.test(value.hash); +} + +export function createSyncPinStore(args: SyncPinStoreArgs) { + fs.mkdirSync(path.dirname(args.filePath), { recursive: true }); + + let cachedPlainPin: string | null = null; + let cachedRecord: HashedSyncPinFile | null | undefined; + + const writeRecord = (record: HashedSyncPinFile): void => { + writeTextAtomic(args.filePath, `${JSON.stringify(record, null, 2)}\n`); + try { + fs.chmodSync(args.filePath, 0o600); + } catch { + // ignore chmod failures on platforms that don't support it + } + }; + + const readFromDisk = (): HashedSyncPinFile | null => { + if (!fs.existsSync(args.filePath)) return null; + const parsed = safeJsonParse( + fs.readFileSync(args.filePath, "utf8"), + null, + ); + if (isHashedPinFile(parsed)) return parsed; + + const pin = typeof (parsed as LegacySyncPinFile | null)?.pin === "string" + ? (parsed as LegacySyncPinFile).pin.trim() + : ""; + if (!PIN_PATTERN.test(pin)) return null; + + const migrated = createHashedPinFile(pin, (parsed as LegacySyncPinFile).updatedAt); + writeRecord(migrated); + cachedPlainPin = pin; + return migrated; + }; + + const loadRecord = (): HashedSyncPinFile | null => { + if (cachedRecord !== undefined) return cachedRecord; + cachedRecord = readFromDisk(); + return cachedRecord; + }; + + return { + getPin(): string | null { + if (cachedPlainPin !== null) return cachedPlainPin; + loadRecord(); + return cachedPlainPin; + }, + + hasPin(): boolean { + return loadRecord() !== null; + }, + + verifyPin(pin: string): boolean { + const trimmed = pin.trim(); + if (!PIN_PATTERN.test(trimmed)) return false; + const record = loadRecord(); + if (!record) return false; + const hash = derivePinHash(trimmed, record.salt, record.iterations); + return safeEqualHex(hash, record.hash); + }, + + setPin(pin: string): void { + const trimmed = pin.trim(); + if (!PIN_PATTERN.test(trimmed)) { + throw new Error("PIN must be 6 digits."); + } + const payload = createHashedPinFile(trimmed); + writeRecord(payload); + cachedRecord = payload; + cachedPlainPin = trimmed; + }, + + clearPin(): void { + try { + fs.rmSync(args.filePath, { force: true }); + } catch { + // ignore cleanup failures + } + cachedRecord = null; + cachedPlainPin = null; + }, + }; +} + +export type SyncPinStore = ReturnType; diff --git a/apps/ade-cli/src/services/sync/syncProtocol.ts b/apps/ade-cli/src/services/sync/syncProtocol.ts new file mode 100644 index 000000000..895ea90f9 --- /dev/null +++ b/apps/ade-cli/src/services/sync/syncProtocol.ts @@ -0,0 +1,148 @@ +import { gunzipSync, gzipSync } from "node:zlib"; +import type { SyncCompressionCodec, SyncEnvelope, SyncPeerPlatform, SyncProtocolVersion } from "../../../../desktop/src/shared/types"; +import { safeJsonParse } from "../../../../desktop/src/main/services/shared/utils"; + +export const SYNC_PROTOCOL_VERSION: SyncProtocolVersion = 1; +export const DEFAULT_SYNC_HOST_PORT = 8787; +export const DEFAULT_SYNC_COMPRESSION_THRESHOLD_BYTES = 4 * 1024; +export const MAX_UNCOMPRESSED_SYNC_ENVELOPE_BYTES = 25 * 1024 * 1024; + +export function mapPlatform(platform: NodeJS.Platform): SyncPeerPlatform { + switch (platform) { + case "darwin": + return "macOS"; + case "linux": + return "linux"; + case "win32": + return "windows"; + default: + return "unknown"; + } +} + +export function wsDataToText(data: unknown): string { + if (typeof data === "string") return data; + if (Buffer.isBuffer(data)) return data.toString("utf8"); + if (Array.isArray(data)) return Buffer.concat(data).toString("utf8"); + return String(data); +} + +export type ParsedSyncEnvelope = { + version: SyncProtocolVersion; + type: SyncEnvelope["type"]; + projectId: string | null; + requestId: string | null; + compression: SyncCompressionCodec; + payload: unknown; + raw: SyncEnvelope; +}; + +type EncodeEnvelopeArgs = { + type: SyncEnvelope["type"]; + projectId?: string | null; + requestId?: string | null; + payload: unknown; + compressionThresholdBytes?: number; +}; + +function asSyncEnvelope(value: unknown): SyncEnvelope { + return value as SyncEnvelope; +} + +export function encodeSyncEnvelope(args: EncodeEnvelopeArgs): string { + const payloadJson = JSON.stringify(args.payload ?? null); + const payloadBytes = Buffer.byteLength(payloadJson, "utf8"); + const requestId = typeof args.requestId === "string" && args.requestId.trim().length > 0 + ? args.requestId.trim() + : null; + const projectId = typeof args.projectId === "string" && args.projectId.trim().length > 0 + ? args.projectId.trim() + : null; + const threshold = Math.max(0, Math.floor(args.compressionThresholdBytes ?? DEFAULT_SYNC_COMPRESSION_THRESHOLD_BYTES)); + + if (payloadBytes >= threshold) { + const compressed = gzipSync(Buffer.from(payloadJson, "utf8")); + return JSON.stringify(asSyncEnvelope({ + version: SYNC_PROTOCOL_VERSION, + type: args.type, + ...(projectId ? { projectId } : {}), + requestId, + compression: "gzip", + payloadEncoding: "base64", + payload: compressed.toString("base64"), + uncompressedBytes: payloadBytes, + })); + } + + return JSON.stringify(asSyncEnvelope({ + version: SYNC_PROTOCOL_VERSION, + type: args.type, + ...(projectId ? { projectId } : {}), + requestId, + compression: "none", + payloadEncoding: "json", + payload: args.payload ?? null, + })); +} + +export function parseSyncEnvelope(rawText: string): ParsedSyncEnvelope { + const decoded = safeJsonParse(rawText, null); + if (!decoded || typeof decoded !== "object") { + throw new Error("Invalid sync envelope JSON."); + } + if (decoded.version !== SYNC_PROTOCOL_VERSION) { + throw new Error(`Unsupported sync protocol version: ${String((decoded as { version?: unknown }).version ?? "unknown")}`); + } + + const requestId = typeof decoded.requestId === "string" && decoded.requestId.trim().length > 0 + ? decoded.requestId.trim() + : null; + const projectId = typeof decoded.projectId === "string" && decoded.projectId.trim().length > 0 + ? decoded.projectId.trim() + : null; + + if (decoded.compression === "gzip") { + if (decoded.payloadEncoding !== "base64" || typeof decoded.payload !== "string") { + throw new Error("Compressed sync envelopes must use base64 payload encoding."); + } + let uncompressedBuffer: Buffer; + try { + uncompressedBuffer = gunzipSync(Buffer.from(decoded.payload, "base64")); + } catch (error) { + throw new Error(`Failed to decode gzip sync envelope${requestId ? ` ${requestId}` : ""}${projectId ? ` for project ${projectId}` : ""}: ${error instanceof Error ? error.message : String(error)}`); + } + if (uncompressedBuffer.byteLength > MAX_UNCOMPRESSED_SYNC_ENVELOPE_BYTES) { + throw new Error(`Decoded sync envelope exceeds ${MAX_UNCOMPRESSED_SYNC_ENVELOPE_BYTES} bytes.`); + } + if ( + typeof decoded.uncompressedBytes === "number" + && decoded.uncompressedBytes !== uncompressedBuffer.byteLength + ) { + throw new Error("Decoded sync envelope size does not match declared uncompressedBytes."); + } + const uncompressed = uncompressedBuffer.toString("utf8"); + return { + version: decoded.version, + type: decoded.type, + projectId, + requestId, + compression: "gzip", + payload: safeJsonParse(uncompressed, null), + raw: decoded, + }; + } + + if (decoded.payloadEncoding !== "json") { + throw new Error("Uncompressed sync envelopes must use JSON payload encoding."); + } + + return { + version: decoded.version, + type: decoded.type, + projectId, + requestId, + compression: "none", + payload: decoded.payload, + raw: decoded, + }; +} diff --git a/apps/ade-cli/src/services/sync/syncRemoteCommandService.ts b/apps/ade-cli/src/services/sync/syncRemoteCommandService.ts new file mode 100644 index 000000000..e5aa946d8 --- /dev/null +++ b/apps/ade-cli/src/services/sync/syncRemoteCommandService.ts @@ -0,0 +1,2528 @@ +import { randomUUID } from "node:crypto"; +import type { + AgentChatCreateArgs, + AgentChatArchiveArgs, + AgentChatApproveArgs, + AgentChatDisposeArgs, + AgentChatFileRef, + AgentChatGetSummaryArgs, + AgentChatListArgs, + AgentChatProvider, + AgentChatRespondToInputArgs, + AgentChatResumeArgs, + AgentChatSendArgs, + AgentChatSession, + AgentChatSessionSummary, + AgentChatSteerArgs, + AgentChatCancelSteerArgs, + AgentChatEditSteerArgs, + AgentChatDispatchSteerArgs, + AgentChatCancelDispatchedSteerArgs, + AgentChatInterruptArgs, + AgentChatUpdateSessionArgs, + AgentStatus, + AddPrCommentArgs, + AiReviewSummaryArgs, + ApplyLaneTemplateArgs, + ArchiveLaneArgs, + AttachLaneArgs, + ClosePrArgs, + CancelQueueAutomationArgs, + CtoCoreMemory, + CtoIdentity, + CtoTriggerAgentWakeupArgs, + CreateChildLaneArgs, + CreateLaneArgs, + CreateLaneFromUnstagedArgs, + CreatePrFromLaneArgs, + CreateIntegrationLaneForProposalArgs, + ConvergenceRuntimeState, + CleanupIntegrationWorkflowArgs, + DeleteLaneArgs, + DeleteIntegrationProposalArgs, + DismissIntegrationCleanupArgs, + DraftPrDescriptionArgs, + GetDiffChangesArgs, + GetFileDiffArgs, + GitBatchFileActionArgs, + GitCherryPickArgs, + GitCommitArgs, + GitFileActionArgs, + GitGenerateCommitMessageArgs, + GitGetCommitMessageArgs, + GitGetFileHistoryArgs, + GitCheckoutBranchArgs, + GitListBranchesArgs, + GitListCommitFilesArgs, + GitPushArgs, + GitRevertArgs, + GitStashPushArgs, + GitStashRefArgs, + GitSyncArgs, + ImportBranchLaneArgs, + LandPrArgs, + LandQueueNextArgs, + PauseQueueAutomationArgs, + PipelineSettings, + PrConvergenceStatePatch, + LaneEnvInitConfig, + LaneEnvInitProgress, + LaneDetailPayload, + LaneListSnapshot, + LaneOverlayOverrides, + LaneStateSnapshotSummary, + ListLanesArgs, + ListIntegrationWorkflowsArgs, + ListSessionsArgs, + LinkPrToLaneArgs, + RebasePushArgs, + RebaseStartArgs, + RenameLaneArgs, + ReopenPrArgs, + RecheckIntegrationStepArgs, + ReactToPrCommentArgs, + ReplyToPrReviewThreadArgs, + ReparentLaneArgs, + RequestPrReviewersArgs, + ReorderQueuePrsArgs, + ResumeQueueAutomationArgs, + RerunPrChecksArgs, + SetPrLabelsArgs, + SetPrReviewThreadResolvedArgs, + StartIntegrationResolutionArgs, + SubmitPrReviewArgs, + SyncCommandPayload, + SyncRemoteCommandAction, + SyncRemoteCommandDescriptor, + SyncRemoteCommandPolicy, + SyncStartCliSessionArgs, + SyncStartCliSessionResult, + SyncRunQuickCommandArgs, + TerminalSessionSummary, + UpdateSessionMetaArgs, + UpdateIntegrationProposalArgs, + TerminalToolType, + UpdateLaneAppearanceArgs, + UpdatePrBodyArgs, + UpdatePrTitleArgs, + WriteTextAtomicArgs, +} from "../../../../desktop/src/shared/types"; +import { + buildTrackedCliLaunchCommand, + buildTrackedCliResumeCommand, + isLaunchProfile, + isTrackedCliPermissionMode, + LAUNCH_PROFILE_TITLE, + LAUNCH_PROFILE_TOOL_TYPE, + launchProfileForTerminalSession, + resolveTrackedCliResumeCommand, + validateLaunchProfilePermissionMode, +} from "../../../../desktop/src/shared/cliLaunch"; +import { normalizePrCreationStrategy } from "../../../../desktop/src/shared/prStrategy"; +import type { createAgentChatService } from "../../../../desktop/src/main/services/chat/agentChatService"; +import type { createCtoStateService } from "../../../../desktop/src/main/services/cto/ctoStateService"; +import type { createFlowPolicyService } from "../../../../desktop/src/main/services/cto/flowPolicyService"; +import type { createLinearCredentialService } from "../../../../desktop/src/main/services/cto/linearCredentialService"; +import type { createLinearIngressService } from "../../../../desktop/src/main/services/cto/linearIngressService"; +import type { createLinearIssueTracker } from "../../../../desktop/src/main/services/cto/linearIssueTracker"; +import type { createLinearSyncService } from "../../../../desktop/src/main/services/cto/linearSyncService"; +import type { createWorkerAgentService } from "../../../../desktop/src/main/services/cto/workerAgentService"; +import type { createWorkerBudgetService } from "../../../../desktop/src/main/services/cto/workerBudgetService"; +import type { createWorkerHeartbeatService } from "../../../../desktop/src/main/services/cto/workerHeartbeatService"; +import type { createWorkerRevisionService } from "../../../../desktop/src/main/services/cto/workerRevisionService"; +import { matchLaneOverlayPolicies } from "../../../../desktop/src/main/services/config/laneOverlayMatcher"; +import type { createProjectConfigService } from "../../../../desktop/src/main/services/config/projectConfigService"; +import type { createConflictService } from "../../../../desktop/src/main/services/conflicts/conflictService"; +import type { createDiffService } from "../../../../desktop/src/main/services/diffs/diffService"; +import type { createFileService } from "../../../../desktop/src/main/services/files/fileService"; +import type { createGitOperationsService } from "../../../../desktop/src/main/services/git/gitOperationsService"; +import type { createAutoRebaseService } from "../../../../desktop/src/main/services/lanes/autoRebaseService"; +import type { createLaneEnvironmentService } from "../../../../desktop/src/main/services/lanes/laneEnvironmentService"; +import type { createLaneService } from "../../../../desktop/src/main/services/lanes/laneService"; +import type { createLaneTemplateService } from "../../../../desktop/src/main/services/lanes/laneTemplateService"; +import type { createPortAllocationService } from "../../../../desktop/src/main/services/lanes/portAllocationService"; +import type { createRebaseSuggestionService } from "../../../../desktop/src/main/services/lanes/rebaseSuggestionService"; +import type { createProcessService } from "../../../../desktop/src/main/services/processes/processService"; +import type { Logger } from "../../../../desktop/src/main/services/logging/logger"; +import type { createPrService } from "../../../../desktop/src/main/services/prs/prService"; +import type { createIssueInventoryService } from "../../../../desktop/src/main/services/prs/issueInventoryService"; +import type { PathToMergeOrchestrator } from "../../../../desktop/src/main/services/prs/pathToMergeOrchestrator"; +import type { createQueueLandingService } from "../../../../desktop/src/main/services/prs/queueLandingService"; +import type { createPtyService } from "../../../../desktop/src/main/services/pty/ptyService"; +import type { createSessionService } from "../../../../desktop/src/main/services/sessions/sessionService"; + +type SyncRemoteCommandServiceArgs = { + laneService: ReturnType; + prService: ReturnType; + issueInventoryService?: ReturnType | null; + /** + * Optional Path-to-Merge orchestrator. When present, iOS callers can start + * and stop the convergence loop via the `prs.pathToMerge.start` / + * `prs.pathToMerge.stop` sync commands. Optional so older builds (without + * the orchestrator wired) keep compiling and degrade gracefully on iOS. + */ + pathToMergeOrchestrator?: PathToMergeOrchestrator | null; + queueLandingService?: ReturnType | null; + ptyService: ReturnType; + sessionService: ReturnType; + fileService: ReturnType; + gitService?: ReturnType; + diffService?: ReturnType; + conflictService?: ReturnType; + agentChatService?: ReturnType; + workerAgentService?: ReturnType | null; + workerBudgetService?: ReturnType | null; + workerHeartbeatService?: ReturnType | null; + workerRevisionService?: ReturnType | null; + ctoStateService?: ReturnType | null; + flowPolicyService?: ReturnType | null; + linearCredentialService?: ReturnType | null; + /** + * Resolvers for services created after createSyncService in main.ts. + * Router handlers read them lazily so init order is not load-bearing. + */ + getLinearIngressService?: () => ReturnType | null; + getLinearIssueTracker?: () => ReturnType | null; + getLinearSyncService?: () => ReturnType | null; + projectConfigService?: ReturnType; + processService?: ReturnType | null; + portAllocationService?: ReturnType | null; + laneEnvironmentService?: ReturnType | null; + laneTemplateService?: ReturnType | null; + rebaseSuggestionService?: ReturnType | null; + autoRebaseService?: ReturnType | null; + logger: Logger; +}; + +type RegisteredRemoteCommand = { + descriptor: SyncRemoteCommandDescriptor; + handler: (args: Record) => Promise; +}; + +function isRecord(value: unknown): value is Record { + return Boolean(value) && typeof value === "object" && !Array.isArray(value); +} + +function asTrimmedString(value: unknown): string | null { + return typeof value === "string" && value.trim().length > 0 ? value.trim() : null; +} + +function asOptionalBoolean(value: unknown): boolean | undefined { + return typeof value === "boolean" ? value : undefined; +} + +function asOptionalNumber(value: unknown): number | undefined { + return typeof value === "number" && Number.isFinite(value) ? value : undefined; +} + +function asStringArray(value: unknown): string[] { + if (!Array.isArray(value)) return []; + return value.map((entry) => asTrimmedString(entry)).filter((entry): entry is string => Boolean(entry)); +} + +function parseAgentChatFileRefs(value: unknown): AgentChatFileRef[] | undefined { + if (!Array.isArray(value)) return undefined; + const attachments: AgentChatFileRef[] = []; + for (const entry of value) { + if (!isRecord(entry)) continue; + const path = asTrimmedString(entry.path); + let type: "image" | "file" | null = null; + if (entry.type === "image") type = "image"; + else if (entry.type === "file") type = "file"; + if (!path || !type) continue; + attachments.push({ path, type }); + } + return attachments; +} + +function parseCursorConfigValues( + value: unknown, +): AgentChatUpdateSessionArgs["cursorConfigValues"] | AgentChatCreateArgs["cursorConfigValues"] { + if (value == null) return null; + if (!isRecord(value)) return {}; + return Object.fromEntries( + Object.entries(value) + .filter((entry): entry is [string, string | boolean | number] => ( + typeof entry[1] === "string" + || typeof entry[1] === "boolean" + || (typeof entry[1] === "number" && Number.isFinite(entry[1])) + )) + .map(([key, entryValue]): [string, string | boolean | number] => [key.trim(), entryValue]) + .filter(([key]) => key.length > 0), + ); +} + +function requireString(value: unknown, message: string): string { + const parsed = asTrimmedString(value); + if (!parsed) throw new Error(message); + return parsed; +} + +function requireStringArray(value: unknown, message: string): string[] { + const parsed = asStringArray(value); + if (parsed.length === 0) throw new Error(message); + return parsed; +} + +function requireService(value: T | null | undefined, message: string): T { + if (value == null) throw new Error(message); + return value; +} + +function parseProcessLaneArgs(payload: Record, action: string): { laneId: string } { + return { + laneId: requireString(payload.laneId, `${action} requires laneId.`), + }; +} + +function parseProcessActionArgs(payload: Record, action: string): { laneId: string; processId: string; runId?: string } { + const parsed = { + laneId: requireString(payload.laneId, `${action} requires laneId.`), + processId: requireString(payload.processId, `${action} requires processId.`), + }; + const runId = asTrimmedString(payload.runId); + return runId ? { ...parsed, runId } : parsed; +} + +async function summarizeChatSessionForRemote( + agentChatService: ReturnType, + session: AgentChatSession, +): Promise { + const summary = await agentChatService.getSessionSummary(session.id); + if (summary) return summary; + + return { + sessionId: session.id, + laneId: session.laneId, + provider: session.provider, + model: session.model, + ...(session.modelId ? { modelId: session.modelId } : {}), + ...(session.sessionProfile ? { sessionProfile: session.sessionProfile } : {}), + reasoningEffort: session.reasoningEffort ?? null, + codexFastMode: session.codexFastMode === true, + executionMode: session.executionMode ?? null, + ...(session.permissionMode ? { permissionMode: session.permissionMode } : {}), + ...(session.interactionMode !== undefined ? { interactionMode: session.interactionMode } : {}), + ...(session.claudePermissionMode ? { claudePermissionMode: session.claudePermissionMode } : {}), + ...(session.codexApprovalPolicy ? { codexApprovalPolicy: session.codexApprovalPolicy } : {}), + ...(session.codexSandbox ? { codexSandbox: session.codexSandbox } : {}), + ...(session.codexConfigSource ? { codexConfigSource: session.codexConfigSource } : {}), + ...(session.opencodePermissionMode ? { opencodePermissionMode: session.opencodePermissionMode } : {}), + ...(session.droidPermissionMode ? { droidPermissionMode: session.droidPermissionMode } : {}), + ...(session.cursorModeSnapshot ? { cursorModeSnapshot: session.cursorModeSnapshot } : {}), + ...(session.cursorModeId !== undefined ? { cursorModeId: session.cursorModeId } : {}), + ...(session.cursorConfigValues ? { cursorConfigValues: session.cursorConfigValues } : {}), + ...(session.identityKey ? { identityKey: session.identityKey } : {}), + ...(session.surface ? { surface: session.surface } : {}), + automationId: session.automationId ?? null, + automationRunId: session.automationRunId ?? null, + ...(session.capabilityMode ? { capabilityMode: session.capabilityMode } : {}), + completion: session.completion ?? null, + status: session.status, + idleSinceAt: session.idleSinceAt ?? null, + startedAt: session.createdAt, + endedAt: null, + lastActivityAt: session.lastActivityAt, + lastOutputPreview: null, + summary: null, + ...(session.threadId ? { threadId: session.threadId } : {}), + ...(session.requestedCwd !== undefined ? { requestedCwd: session.requestedCwd } : {}), + }; +} + +function parseListLanesArgs(value: Record): ListLanesArgs { + return { + includeArchived: asOptionalBoolean(value.includeArchived), + includeStatus: asOptionalBoolean(value.includeStatus), + }; +} + +function parseCreateLaneArgs(value: Record): CreateLaneArgs { + return { + name: requireString(value.name, "lanes.create requires name."), + ...(asTrimmedString(value.description) ? { description: asTrimmedString(value.description)! } : {}), + ...(asTrimmedString(value.parentLaneId) ? { parentLaneId: asTrimmedString(value.parentLaneId)! } : {}), + ...(asTrimmedString(value.baseBranch) ? { baseBranch: asTrimmedString(value.baseBranch)! } : {}), + }; +} + +function parseCreateChildLaneArgs(value: Record): CreateChildLaneArgs { + return { + name: requireString(value.name, "lanes.createChild requires name."), + parentLaneId: requireString(value.parentLaneId, "lanes.createChild requires parentLaneId."), + ...(asTrimmedString(value.description) ? { description: asTrimmedString(value.description)! } : {}), + ...(asTrimmedString(value.folder) ? { folder: asTrimmedString(value.folder)! } : {}), + }; +} + +function parseCreateLaneFromUnstagedArgs(value: Record): CreateLaneFromUnstagedArgs { + return { + name: requireString(value.name, "lanes.createFromUnstaged requires name."), + sourceLaneId: requireString(value.sourceLaneId, "lanes.createFromUnstaged requires sourceLaneId."), + }; +} + +function parseImportBranchArgs(value: Record): ImportBranchLaneArgs { + return { + branchRef: requireString(value.branchRef, "lanes.importBranch requires branchRef."), + ...(asTrimmedString(value.name) ? { name: asTrimmedString(value.name)! } : {}), + ...(asTrimmedString(value.description) ? { description: asTrimmedString(value.description)! } : {}), + ...(asTrimmedString(value.baseBranch) ? { baseBranch: asTrimmedString(value.baseBranch)! } : {}), + }; +} + +function parseAttachLaneArgs(value: Record): AttachLaneArgs { + return { + name: requireString(value.name, "lanes.attach requires name."), + attachedPath: requireString(value.attachedPath, "lanes.attach requires attachedPath."), + ...(asTrimmedString(value.description) ? { description: asTrimmedString(value.description)! } : {}), + }; +} + +function parseArchiveLaneArgs(value: Record, action: string): ArchiveLaneArgs { + return { + laneId: requireString(value.laneId, `${action} requires laneId.`), + }; +} + +function parseDeleteLaneArgs(value: Record): DeleteLaneArgs { + return { + laneId: requireString(value.laneId, "lanes.delete requires laneId."), + deleteBranch: asOptionalBoolean(value.deleteBranch), + deleteRemoteBranch: asOptionalBoolean(value.deleteRemoteBranch), + ...(asTrimmedString(value.remoteName) ? { remoteName: asTrimmedString(value.remoteName)! } : {}), + force: asOptionalBoolean(value.force), + }; +} + +function parseRenameLaneArgs(value: Record): RenameLaneArgs { + return { + laneId: requireString(value.laneId, "lanes.rename requires laneId."), + name: requireString(value.name, "lanes.rename requires name."), + }; +} + +function parseReparentLaneArgs(value: Record): ReparentLaneArgs { + return { + laneId: requireString(value.laneId, "lanes.reparent requires laneId."), + newParentLaneId: requireString(value.newParentLaneId, "lanes.reparent requires newParentLaneId."), + }; +} + +function parseUpdateLaneAppearanceArgs(value: Record): UpdateLaneAppearanceArgs { + const parsed: UpdateLaneAppearanceArgs = { + laneId: requireString(value.laneId, "lanes.updateAppearance requires laneId."), + }; + if ("color" in value) { + parsed.color = value.color == null ? null : asTrimmedString(value.color) ?? null; + } + if ("icon" in value) { + parsed.icon = value.icon == null ? null : (asTrimmedString(value.icon) as UpdateLaneAppearanceArgs["icon"]); + } + if ("tags" in value) { + parsed.tags = value.tags == null ? null : asStringArray(value.tags); + } + return parsed; +} + +function parseRebaseStartArgs(value: Record): RebaseStartArgs { + return { + laneId: requireString(value.laneId, "lanes.rebaseStart requires laneId."), + ...(asTrimmedString(value.scope) ? { scope: value.scope as RebaseStartArgs["scope"] } : {}), + ...(asTrimmedString(value.pushMode) ? { pushMode: value.pushMode as RebaseStartArgs["pushMode"] } : {}), + ...(asTrimmedString(value.actor) ? { actor: asTrimmedString(value.actor)! } : {}), + ...(asTrimmedString(value.reason) ? { reason: asTrimmedString(value.reason)! } : {}), + ...(asTrimmedString(value.baseBranchOverride) ? { baseBranchOverride: asTrimmedString(value.baseBranchOverride)! } : {}), + }; +} + +function parseRebasePushArgs(value: Record): RebasePushArgs { + return { + runId: requireString(value.runId, "lanes.rebasePush requires runId."), + laneIds: requireStringArray(value.laneIds, "lanes.rebasePush requires laneIds."), + }; +} + +function parseRunIdArgs(value: Record, action: string): { runId: string } { + return { + runId: requireString(value.runId, `${action} requires runId.`), + }; +} + +function parseListSessionsArgs(value: Record): ListSessionsArgs { + const laneId = asTrimmedString(value.laneId); + const status = asTrimmedString(value.status) as ListSessionsArgs["status"]; + const limit = asOptionalNumber(value.limit); + return { + ...(laneId ? { laneId } : {}), + ...(status ? { status } : {}), + ...(typeof limit === "number" ? { limit } : {}), + }; +} + +function parseUpdateSessionMetaArgs(value: Record): UpdateSessionMetaArgs { + const parsed: UpdateSessionMetaArgs = { + sessionId: requireString(value.sessionId, "work.updateSessionMeta requires sessionId."), + }; + + if ("pinned" in value) parsed.pinned = value.pinned === true; + if ("manuallyNamed" in value) parsed.manuallyNamed = value.manuallyNamed === true; + if ("title" in value) parsed.title = value.title == null ? undefined : requireString(value.title, "work.updateSessionMeta requires a non-empty title when title is provided."); + if ("goal" in value) parsed.goal = value.goal == null ? null : asTrimmedString(value.goal) ?? null; + if ("toolType" in value) { + parsed.toolType = value.toolType == null + ? null + : asTrimmedString(value.toolType) as UpdateSessionMetaArgs["toolType"]; + } + if ("resumeCommand" in value) { + parsed.resumeCommand = value.resumeCommand == null ? null : asTrimmedString(value.resumeCommand) ?? null; + } + + return parsed; +} + +function parseQuickCommandArgs(value: Record): SyncRunQuickCommandArgs { + const laneId = requireString(value.laneId, "work.runQuickCommand requires laneId."); + const title = requireString(value.title, "work.runQuickCommand requires title."); + const toolType = asTrimmedString(value.toolType); + const startupCommand = asTrimmedString(value.startupCommand); + if (!startupCommand && toolType !== "shell") { + throw new Error("work.runQuickCommand requires startupCommand unless toolType is shell."); + } + return { + laneId, + title, + ...(startupCommand ? { startupCommand } : {}), + cols: asOptionalNumber(value.cols), + rows: asOptionalNumber(value.rows), + toolType, + tracked: asOptionalBoolean(value.tracked), + }; +} + +const DEFAULT_CLI_COLS = 120; +const DEFAULT_CLI_ROWS = 36; + +function clampCliDimension(value: number | undefined, fallback: number, min: number, max: number): number { + return Math.max(min, Math.min(max, Math.floor(value ?? fallback))); +} + +function parseCliProvider(value: unknown): SyncStartCliSessionArgs["provider"] { + const provider = asTrimmedString(value)?.toLowerCase(); + if (!isLaunchProfile(provider)) throw new Error("work.startCliSession requires provider."); + return provider; +} + +function parseCliPermissionMode(value: unknown): SyncStartCliSessionArgs["permissionMode"] { + const mode = asTrimmedString(value); + return isTrackedCliPermissionMode(mode) ? mode : "default"; +} + +function parseStartCliSessionArgs(value: Record): SyncStartCliSessionArgs { + const laneId = requireString(value.laneId, "work.startCliSession requires laneId."); + const provider = parseCliProvider(value.provider); + const initialInput = typeof value.initialInput === "string" && value.initialInput.trim().length > 0 + ? value.initialInput.slice(0, 20_000) + : null; + return { + laneId, + provider, + permissionMode: parseCliPermissionMode(value.permissionMode), + title: asTrimmedString(value.title), + initialInput, + cols: asOptionalNumber(value.cols), + rows: asOptionalNumber(value.rows), + resumeSessionId: asTrimmedString(value.resumeSessionId), + }; +} + +function requireResumeSessionForProvider( + sessionService: ReturnType, + sessionId: string, + provider: SyncStartCliSessionArgs["provider"], +): TerminalSessionSummary { + const session = sessionService.get(sessionId) as TerminalSessionSummary | null; + if (!session) throw new Error(`work.startCliSession resumeSessionId '${sessionId}' was not found.`); + const existingProvider = launchProfileForTerminalSession(session); + if (existingProvider && existingProvider !== provider) { + throw new Error(`work.startCliSession resumeSessionId '${sessionId}' belongs to ${existingProvider}, not ${provider}.`); + } + return session; +} + +function isChatToolType(toolType: string | null | undefined): boolean { + if (!toolType) return false; + const t = toolType.trim().toLowerCase(); + return t === "cursor" || t.endsWith("-chat"); +} + +async function listRemoteWorkSessions( + args: SyncRemoteCommandServiceArgs, + filters: ListSessionsArgs, +) { + const sessions = args.ptyService.enrichSessions(args.sessionService.list(filters)); + const laneId = typeof filters.laneId === "string" ? filters.laneId.trim() : ""; + const allChats = await args.agentChatService + ?.listSessions(laneId || undefined, { includeIdentity: true }) + .catch(() => [] as AgentChatSessionSummary[]) ?? []; + + const identitySessionIds = new Set( + allChats.filter((chat) => Boolean(chat.identityKey)).map((chat) => chat.sessionId), + ); + const visibleSessions = identitySessionIds.size > 0 + ? sessions.filter((session) => !identitySessionIds.has(session.id)) + : sessions; + + const chatSummaryBySessionId = new Map( + allChats.filter((chat) => !chat.identityKey).map((chat) => [chat.sessionId, chat] as const), + ); + if (chatSummaryBySessionId.size === 0) return visibleSessions; + + return visibleSessions.map((session) => { + if (!isChatToolType(session.toolType) || session.status !== "running") return session; + const chat = chatSummaryBySessionId.get(session.id); + if (!chat) return session; + if (chat.awaitingInput) return { ...session, runtimeState: "waiting-input" as const, chatIdleSinceAt: null }; + if (chat.status === "active") return { ...session, runtimeState: "running" as const, chatIdleSinceAt: null }; + if (chat.status === "idle") return { ...session, runtimeState: "idle" as const, chatIdleSinceAt: chat.idleSinceAt ?? null }; + return session; + }); +} + +function parseCloseSessionArgs(value: Record): { sessionId: string } { + return { + sessionId: requireString(value.sessionId, "work.closeSession requires sessionId."), + }; +} + +function parseAgentChatListArgs(value: Record): AgentChatListArgs { + return { + ...(asTrimmedString(value.laneId) ? { laneId: asTrimmedString(value.laneId)! } : {}), + includeAutomation: asOptionalBoolean(value.includeAutomation), + }; +} + +function parseAgentChatGetSummaryArgs(value: Record): AgentChatGetSummaryArgs { + return { + sessionId: requireString(value.sessionId, "chat.getSummary requires sessionId."), + }; +} + +function parseAgentChatCreateArgs(value: Record): AgentChatCreateArgs { + const parsed: AgentChatCreateArgs = { + laneId: requireString(value.laneId, "chat.create requires laneId."), + provider: (asTrimmedString(value.provider) ?? "codex") as AgentChatCreateArgs["provider"], + model: asTrimmedString(value.model) ?? "", + ...(asTrimmedString(value.modelId) ? { modelId: asTrimmedString(value.modelId)! } : {}), + ...(asTrimmedString(value.reasoningEffort) ? { reasoningEffort: asTrimmedString(value.reasoningEffort)! } : {}), + }; + + if ("sessionProfile" in value) parsed.sessionProfile = value.sessionProfile == null ? undefined : asTrimmedString(value.sessionProfile) as AgentChatCreateArgs["sessionProfile"]; + if ("permissionMode" in value) parsed.permissionMode = value.permissionMode == null ? undefined : asTrimmedString(value.permissionMode) as AgentChatCreateArgs["permissionMode"]; + if ("interactionMode" in value) parsed.interactionMode = value.interactionMode == null ? null : asTrimmedString(value.interactionMode) as AgentChatCreateArgs["interactionMode"]; + if ("claudePermissionMode" in value) parsed.claudePermissionMode = value.claudePermissionMode == null ? undefined : asTrimmedString(value.claudePermissionMode) as AgentChatCreateArgs["claudePermissionMode"]; + if ("codexApprovalPolicy" in value) parsed.codexApprovalPolicy = value.codexApprovalPolicy == null ? undefined : asTrimmedString(value.codexApprovalPolicy) as AgentChatCreateArgs["codexApprovalPolicy"]; + if ("codexSandbox" in value) parsed.codexSandbox = value.codexSandbox == null ? undefined : asTrimmedString(value.codexSandbox) as AgentChatCreateArgs["codexSandbox"]; + if ("codexConfigSource" in value) parsed.codexConfigSource = value.codexConfigSource == null ? undefined : asTrimmedString(value.codexConfigSource) as AgentChatCreateArgs["codexConfigSource"]; + if ("codexFastMode" in value) parsed.codexFastMode = asOptionalBoolean(value.codexFastMode); + if ("opencodePermissionMode" in value) parsed.opencodePermissionMode = value.opencodePermissionMode == null ? undefined : asTrimmedString(value.opencodePermissionMode) as AgentChatCreateArgs["opencodePermissionMode"]; + if ("droidPermissionMode" in value) parsed.droidPermissionMode = value.droidPermissionMode == null ? undefined : (asTrimmedString(value.droidPermissionMode) ?? undefined) as AgentChatCreateArgs["droidPermissionMode"]; + if ("cursorModeId" in value) parsed.cursorModeId = value.cursorModeId == null ? null : asTrimmedString(value.cursorModeId) ?? null; + if ("cursorConfigValues" in value) parsed.cursorConfigValues = parseCursorConfigValues(value.cursorConfigValues); + if ("requestedCwd" in value) parsed.requestedCwd = value.requestedCwd == null ? undefined : requireString(value.requestedCwd, "chat.create requires a non-empty requestedCwd when provided."); + + return parsed; +} + +function parseAgentChatSendArgs(value: Record): AgentChatSendArgs { + const attachments = parseAgentChatFileRefs(value.attachments); + return { + sessionId: requireString(value.sessionId, "chat.send requires sessionId."), + text: requireString(value.text, "chat.send requires text."), + ...(asTrimmedString(value.displayText) ? { displayText: asTrimmedString(value.displayText)! } : {}), + ...(attachments?.length ? { attachments } : {}), + ...(asTrimmedString(value.reasoningEffort) ? { reasoningEffort: asTrimmedString(value.reasoningEffort)! } : {}), + ...(asTrimmedString(value.executionMode) ? { executionMode: asTrimmedString(value.executionMode)! as AgentChatSendArgs["executionMode"] } : {}), + ...(asTrimmedString(value.interactionMode) ? { interactionMode: asTrimmedString(value.interactionMode)! as AgentChatSendArgs["interactionMode"] } : {}), + }; +} + +function parseAgentChatSteerArgs(value: Record): AgentChatSteerArgs { + const attachments = parseAgentChatFileRefs(value.attachments); + return { + sessionId: requireString(value.sessionId, "chat.steer requires sessionId."), + text: requireString(value.text, "chat.steer requires text."), + ...(attachments?.length ? { attachments } : {}), + }; +} + +function parseAgentChatCancelSteerArgs(value: Record): AgentChatCancelSteerArgs { + return { + sessionId: requireString(value.sessionId, "chat.cancelSteer requires sessionId."), + steerId: requireString(value.steerId, "chat.cancelSteer requires steerId."), + }; +} + +function parseAgentChatEditSteerArgs(value: Record): AgentChatEditSteerArgs { + return { + sessionId: requireString(value.sessionId, "chat.editSteer requires sessionId."), + steerId: requireString(value.steerId, "chat.editSteer requires steerId."), + text: requireString(value.text, "chat.editSteer requires text."), + }; +} + +function parseAgentChatDispatchSteerArgs(value: Record): AgentChatDispatchSteerArgs { + const mode = value.mode; + if (mode !== "inline" && mode !== "interrupt") { + throw new Error("chat.dispatchSteer requires mode of 'inline' or 'interrupt'."); + } + return { + sessionId: requireString(value.sessionId, "chat.dispatchSteer requires sessionId."), + steerId: requireString(value.steerId, "chat.dispatchSteer requires steerId."), + mode, + }; +} + +function parseAgentChatCancelDispatchedSteerArgs(value: Record): AgentChatCancelDispatchedSteerArgs { + return { + sessionId: requireString(value.sessionId, "chat.cancelDispatchedSteer requires sessionId."), + steerId: requireString(value.steerId, "chat.cancelDispatchedSteer requires steerId."), + }; +} + +function parseAgentChatInterruptArgs(value: Record): AgentChatInterruptArgs { + return { + sessionId: requireString(value.sessionId, "chat.interrupt requires sessionId."), + }; +} + +function parseAgentChatResumeArgs(value: Record): AgentChatResumeArgs { + return { + sessionId: requireString(value.sessionId, "chat.resume requires sessionId."), + }; +} + +function parseAgentChatApproveArgs(value: Record): AgentChatApproveArgs { + return { + sessionId: requireString(value.sessionId, "chat.approve requires sessionId."), + itemId: requireString(value.itemId, "chat.approve requires itemId."), + decision: requireString(value.decision, "chat.approve requires decision.") as AgentChatApproveArgs["decision"], + ...(asTrimmedString(value.responseText) ? { responseText: asTrimmedString(value.responseText)! } : {}), + }; +} + +function parseAgentChatRespondToInputArgs(value: Record): AgentChatRespondToInputArgs { + const parsed: AgentChatRespondToInputArgs = { + sessionId: requireString(value.sessionId, "chat.respondToInput requires sessionId."), + itemId: requireString(value.itemId, "chat.respondToInput requires itemId."), + }; + + if (typeof value.decision === "string" && value.decision.trim().length > 0) { + parsed.decision = value.decision.trim() as AgentChatRespondToInputArgs["decision"]; + } + if (isRecord(value.answers)) { + parsed.answers = Object.fromEntries( + Object.entries(value.answers).map(([key, entry]) => { + if (Array.isArray(entry)) { + return [key, entry.map((item) => String(item))]; + } + return [key, String(entry)]; + }), + ); + } + if (typeof value.responseText === "string" && value.responseText.trim().length > 0) { + parsed.responseText = value.responseText.trim(); + } + return parsed; +} + +function parseAgentChatUpdateSessionArgs(value: Record): AgentChatUpdateSessionArgs { + const parsed: AgentChatUpdateSessionArgs = { + sessionId: requireString(value.sessionId, "chat.updateSession requires sessionId."), + }; + + if ("title" in value) parsed.title = value.title == null ? null : asTrimmedString(value.title) ?? null; + if ("modelId" in value) parsed.modelId = value.modelId == null ? undefined : asTrimmedString(value.modelId) as AgentChatUpdateSessionArgs["modelId"]; + if ("reasoningEffort" in value) parsed.reasoningEffort = value.reasoningEffort == null ? null : asTrimmedString(value.reasoningEffort) ?? null; + if ("permissionMode" in value) parsed.permissionMode = value.permissionMode == null ? undefined : asTrimmedString(value.permissionMode) as AgentChatUpdateSessionArgs["permissionMode"]; + if ("interactionMode" in value) parsed.interactionMode = value.interactionMode == null ? null : asTrimmedString(value.interactionMode) as AgentChatUpdateSessionArgs["interactionMode"]; + if ("claudePermissionMode" in value) parsed.claudePermissionMode = value.claudePermissionMode == null ? undefined : asTrimmedString(value.claudePermissionMode) as AgentChatUpdateSessionArgs["claudePermissionMode"]; + if ("codexApprovalPolicy" in value) parsed.codexApprovalPolicy = value.codexApprovalPolicy == null ? undefined : asTrimmedString(value.codexApprovalPolicy) as AgentChatUpdateSessionArgs["codexApprovalPolicy"]; + if ("codexSandbox" in value) parsed.codexSandbox = value.codexSandbox == null ? undefined : asTrimmedString(value.codexSandbox) as AgentChatUpdateSessionArgs["codexSandbox"]; + if ("codexConfigSource" in value) parsed.codexConfigSource = value.codexConfigSource == null ? undefined : asTrimmedString(value.codexConfigSource) as AgentChatUpdateSessionArgs["codexConfigSource"]; + if ("codexFastMode" in value) parsed.codexFastMode = asOptionalBoolean(value.codexFastMode); + if ("opencodePermissionMode" in value) parsed.opencodePermissionMode = value.opencodePermissionMode == null ? undefined : asTrimmedString(value.opencodePermissionMode) as AgentChatUpdateSessionArgs["opencodePermissionMode"]; + if ("droidPermissionMode" in value) parsed.droidPermissionMode = value.droidPermissionMode == null ? undefined : asTrimmedString(value.droidPermissionMode) as AgentChatUpdateSessionArgs["droidPermissionMode"]; + if ("cursorModeId" in value) parsed.cursorModeId = value.cursorModeId == null ? null : asTrimmedString(value.cursorModeId) ?? null; + if ("cursorConfigValues" in value) { + parsed.cursorConfigValues = parseCursorConfigValues(value.cursorConfigValues); + } + if ("manuallyNamed" in value) parsed.manuallyNamed = value.manuallyNamed === true; + return parsed; +} + +function parseAgentChatDisposeArgs(value: Record): AgentChatDisposeArgs { + return { + sessionId: requireString(value.sessionId, "chat.dispose requires sessionId."), + }; +} + +function parseAgentChatArchiveArgs(value: Record, action: string): AgentChatArchiveArgs { + return { + sessionId: requireString(value.sessionId, `${action} requires sessionId.`), + }; +} + +function parseGetTranscriptArgs(value: Record): { + sessionId: string; + limit?: number; + maxChars?: number; +} { + return { + sessionId: requireString(value.sessionId, "chat.getTranscript requires sessionId."), + limit: asOptionalNumber(value.limit), + maxChars: asOptionalNumber(value.maxChars), + }; +} + +function parseGitFileActionArgs(value: Record, action: string): GitFileActionArgs { + return { + laneId: requireString(value.laneId, `${action} requires laneId.`), + path: requireString(value.path, `${action} requires path.`), + }; +} + +function parseGitBatchFileActionArgs(value: Record, action: string): GitBatchFileActionArgs { + return { + laneId: requireString(value.laneId, `${action} requires laneId.`), + paths: requireStringArray(value.paths, `${action} requires paths.`), + }; +} + +function parseWriteTextAtomicArgs(value: Record): WriteTextAtomicArgs { + if (typeof value.text !== "string") { + throw new Error("files.writeTextAtomic requires text."); + } + return { + laneId: requireString(value.laneId, "files.writeTextAtomic requires laneId."), + path: requireString(value.path, "files.writeTextAtomic requires path."), + text: value.text, + }; +} + +function parseGitCommitArgs(value: Record): GitCommitArgs { + return { + laneId: requireString(value.laneId, "git.commit requires laneId."), + message: requireString(value.message, "git.commit requires message."), + amend: asOptionalBoolean(value.amend), + }; +} + +function parseGitGenerateCommitMessageArgs(value: Record): GitGenerateCommitMessageArgs { + return { + laneId: requireString(value.laneId, "git.generateCommitMessage requires laneId."), + amend: asOptionalBoolean(value.amend), + }; +} + +function parseGitListRecentCommitsArgs(value: Record): { laneId: string; limit?: number } { + return { + laneId: requireString(value.laneId, "git.listRecentCommits requires laneId."), + limit: asOptionalNumber(value.limit), + }; +} + +function parseGitListCommitFilesArgs(value: Record): GitListCommitFilesArgs { + return { + laneId: requireString(value.laneId, "git.listCommitFiles requires laneId."), + commitSha: requireString(value.commitSha, "git.listCommitFiles requires commitSha."), + }; +} + +function parseGitGetCommitMessageArgs(value: Record): GitGetCommitMessageArgs { + return { + laneId: requireString(value.laneId, "git.getCommitMessage requires laneId."), + commitSha: requireString(value.commitSha, "git.getCommitMessage requires commitSha."), + }; +} + +function parseGitGetFileHistoryArgs(value: Record): GitGetFileHistoryArgs { + return { + laneId: requireString(value.laneId, "git.getFileHistory requires laneId."), + path: requireString(value.path, "git.getFileHistory requires path."), + limit: asOptionalNumber(value.limit), + }; +} + +function parseGitRevertArgs(value: Record): GitRevertArgs { + return { + laneId: requireString(value.laneId, "git.revertCommit requires laneId."), + commitSha: requireString(value.commitSha, "git.revertCommit requires commitSha."), + }; +} + +function parseGitCherryPickArgs(value: Record): GitCherryPickArgs { + return { + laneId: requireString(value.laneId, "git.cherryPickCommit requires laneId."), + commitSha: requireString(value.commitSha, "git.cherryPickCommit requires commitSha."), + }; +} + +function parseGitStashPushArgs(value: Record): GitStashPushArgs { + return { + laneId: requireString(value.laneId, "git.stashPush requires laneId."), + ...(asTrimmedString(value.message) ? { message: asTrimmedString(value.message)! } : {}), + includeUntracked: asOptionalBoolean(value.includeUntracked), + }; +} + +function parseGitStashRefArgs(value: Record, action: string): GitStashRefArgs { + return { + laneId: requireString(value.laneId, `${action} requires laneId.`), + stashRef: requireString(value.stashRef, `${action} requires stashRef.`), + }; +} + +function parseGitSyncArgs(value: Record): GitSyncArgs { + return { + laneId: requireString(value.laneId, "git.sync requires laneId."), + ...(asTrimmedString(value.mode) ? { mode: value.mode as GitSyncArgs["mode"] } : {}), + ...(asTrimmedString(value.baseRef) ? { baseRef: asTrimmedString(value.baseRef)! } : {}), + }; +} + +function parseGitPushArgs(value: Record): GitPushArgs { + return { + laneId: requireString(value.laneId, "git.push requires laneId."), + forceWithLease: asOptionalBoolean(value.forceWithLease), + }; +} + +function parseGetDiffChangesArgs(value: Record): GetDiffChangesArgs { + return { + laneId: requireString(value.laneId, "git.getChanges requires laneId."), + }; +} + +function parseGetFileDiffArgs(value: Record): GetFileDiffArgs { + return { + laneId: requireString(value.laneId, "git.getFile requires laneId."), + path: requireString(value.path, "git.getFile requires path."), + mode: requireString(value.mode, "git.getFile requires mode.") as GetFileDiffArgs["mode"], + ...(asTrimmedString(value.compareRef) ? { compareRef: asTrimmedString(value.compareRef)! } : {}), + ...(asTrimmedString(value.compareTo) ? { compareTo: value.compareTo as GetFileDiffArgs["compareTo"] } : {}), + }; +} + +function parseGitListBranchesArgs(value: Record): GitListBranchesArgs { + return { + laneId: requireString(value.laneId, "git.listBranches requires laneId."), + }; +} + +function parseGitCheckoutBranchArgs(value: Record): GitCheckoutBranchArgs { + return { + laneId: requireString(value.laneId, "git.checkoutBranch requires laneId."), + branchName: requireString(value.branchName, "git.checkoutBranch requires branchName."), + ...(asTrimmedString(value.mode) ? { mode: value.mode as GitCheckoutBranchArgs["mode"] } : {}), + ...(asTrimmedString(value.startPoint) ? { startPoint: asTrimmedString(value.startPoint)! } : {}), + ...(asTrimmedString(value.baseRef) ? { baseRef: asTrimmedString(value.baseRef)! } : {}), + ...(asOptionalBoolean(value.acknowledgeActiveWork) !== undefined + ? { acknowledgeActiveWork: asOptionalBoolean(value.acknowledgeActiveWork) } + : {}), + }; +} + +function parseConflictLaneArgs(value: Record, action: string): { laneId: string } { + return { + laneId: requireString(value.laneId, `${action} requires laneId.`), + }; +} + +function parseChatModelsArgs(value: Record): { provider: AgentChatProvider; activateRuntime?: boolean } { + return { + provider: (asTrimmedString(value.provider) ?? "codex") as AgentChatProvider, + ...(value.activateRuntime === true ? { activateRuntime: true } : {}), + }; +} + +function requirePrId(value: Record, action: string): string { + return requireString(value.prId, `${action} requires prId.`); +} + +function parseCreatePrArgs(value: Record): CreatePrFromLaneArgs { + const laneId = asTrimmedString(value.laneId); + const title = asTrimmedString(value.title); + const body = typeof value.body === "string" ? value.body : ""; + if (!laneId || !title) throw new Error("prs.createFromLane requires laneId and title."); + const strategy: CreatePrFromLaneArgs["strategy"] = + normalizePrCreationStrategy(asTrimmedString(value.strategy)) ?? undefined; + return { + laneId, + title, + body, + draft: value.draft === true, + ...(asTrimmedString(value.baseBranch) ? { baseBranch: asTrimmedString(value.baseBranch)! } : {}), + ...(asStringArray(value.labels).length ? { labels: asStringArray(value.labels) } : {}), + ...(asStringArray(value.reviewers).length ? { reviewers: asStringArray(value.reviewers) } : {}), + ...(typeof value.allowDirtyWorktree === "boolean" ? { allowDirtyWorktree: value.allowDirtyWorktree } : {}), + ...(typeof value.closeLinearIssueOnMerge === "boolean" ? { closeLinearIssueOnMerge: value.closeLinearIssueOnMerge } : {}), + ...(strategy ? { strategy } : {}), + }; +} + +function parseLinkPrToLaneArgs(value: Record): LinkPrToLaneArgs { + return { + laneId: requireString(value.laneId, "prs.linkToLane requires laneId."), + prUrlOrNumber: requireString(value.prUrlOrNumber, "prs.linkToLane requires prUrlOrNumber."), + }; +} + +function parseDraftPrDescriptionArgs(value: Record): DraftPrDescriptionArgs { + return { + laneId: requireString(value.laneId, "prs.draftDescription requires laneId."), + ...(asTrimmedString(value.model) ? { model: asTrimmedString(value.model)! } : {}), + ...("reasoningEffort" in value + ? { reasoningEffort: value.reasoningEffort == null ? null : (asTrimmedString(value.reasoningEffort) ?? null) } + : {}), + ...(asTrimmedString(value.baseBranch) ? { baseBranch: asTrimmedString(value.baseBranch)! } : {}), + ...(typeof value.closeLinearIssueOnMerge === "boolean" ? { closeLinearIssueOnMerge: value.closeLinearIssueOnMerge } : {}), + }; +} + +function parseLandPrArgs(value: Record): LandPrArgs { + const prId = requirePrId(value, "prs.land"); + const method = asTrimmedString(value.method) as LandPrArgs["method"]; + if (!method || !["merge", "squash", "rebase"].includes(method)) { + throw new Error("prs.land requires method to be merge, squash, or rebase."); + } + return { prId, method }; +} + +function parseClosePrArgs(value: Record): ClosePrArgs { + return { + prId: requirePrId(value, "prs.close"), + ...(typeof value.comment === "string" ? { comment: value.comment } : {}), + }; +} + +function parseReopenPrArgs(value: Record): ReopenPrArgs { + return { + prId: requirePrId(value, "prs.reopen"), + }; +} + +function parseRequestReviewersArgs(value: Record): RequestPrReviewersArgs { + const prId = requirePrId(value, "prs.requestReviewers"); + const reviewers = asStringArray(value.reviewers); + if (reviewers.length === 0) throw new Error("prs.requestReviewers requires at least one reviewer."); + return { prId, reviewers }; +} + +function parseRerunPrChecksArgs(value: Record): RerunPrChecksArgs { + const checkRunIds = (() => { + if (value.checkRunIds == null) return undefined; + if (!Array.isArray(value.checkRunIds)) { + throw new Error("prs.rerunChecks requires checkRunIds to be an array of numbers when provided."); + } + return value.checkRunIds.map((entry) => { + if (typeof entry !== "number" || !Number.isSafeInteger(entry) || entry <= 0) { + throw new Error("prs.rerunChecks requires checkRunIds to be an array of numbers when provided."); + } + return entry; + }); + })(); + return { + prId: requirePrId(value, "prs.rerunChecks"), + ...(checkRunIds?.length ? { checkRunIds } : {}), + }; +} + +function parseAddPrCommentArgs(value: Record): AddPrCommentArgs { + return { + prId: requirePrId(value, "prs.addComment"), + body: requireString(value.body, "prs.addComment requires body."), + ...(asTrimmedString(value.inReplyToCommentId) ? { inReplyToCommentId: asTrimmedString(value.inReplyToCommentId)! } : {}), + }; +} + +function parseUpdatePrTitleArgs(value: Record): UpdatePrTitleArgs { + return { + prId: requirePrId(value, "prs.updateTitle"), + title: requireString(value.title, "prs.updateTitle requires title."), + }; +} + +function parseUpdatePrBodyArgs(value: Record): UpdatePrBodyArgs { + return { + prId: requirePrId(value, "prs.updateBody"), + body: typeof value.body === "string" ? value.body : "", + }; +} + +function parseSetPrLabelsArgs(value: Record): SetPrLabelsArgs { + return { + prId: requirePrId(value, "prs.setLabels"), + labels: asStringArray(value.labels), + }; +} + +function parseSubmitPrReviewArgs(value: Record): SubmitPrReviewArgs { + const event = asTrimmedString(value.event); + if (event !== "APPROVE" && event !== "REQUEST_CHANGES" && event !== "COMMENT") { + throw new Error("prs.submitReview requires event to be APPROVE, REQUEST_CHANGES, or COMMENT."); + } + return { + prId: requirePrId(value, "prs.submitReview"), + event, + ...(typeof value.body === "string" ? { body: value.body } : {}), + }; +} + +function parseReplyToReviewThreadArgs(value: Record): ReplyToPrReviewThreadArgs { + return { + prId: requirePrId(value, "prs.replyToReviewThread"), + threadId: requireString(value.threadId, "prs.replyToReviewThread requires threadId."), + body: requireString(value.body, "prs.replyToReviewThread requires body."), + }; +} + +function parseSetReviewThreadResolvedArgs(value: Record): SetPrReviewThreadResolvedArgs { + return { + prId: requirePrId(value, "prs.setReviewThreadResolved"), + threadId: requireString(value.threadId, "prs.setReviewThreadResolved requires threadId."), + resolved: value.resolved === true, + }; +} + +function parseReactToCommentArgs(value: Record): ReactToPrCommentArgs { + const content = asTrimmedString(value.content); + if (!content) throw new Error("prs.reactToComment requires content."); + return { + prId: requirePrId(value, "prs.reactToComment"), + commentId: requireString(value.commentId, "prs.reactToComment requires commentId."), + content: content as ReactToPrCommentArgs["content"], + }; +} + +function parseAiReviewSummaryArgs(value: Record): AiReviewSummaryArgs { + return { + prId: requirePrId(value, "prs.aiReviewSummary"), + ...(asTrimmedString(value.model) ? { model: asTrimmedString(value.model)! } : {}), + }; +} + +function parseListIntegrationWorkflowsArgs(value: Record): ListIntegrationWorkflowsArgs { + const view = asTrimmedString(value.view); + return view ? { view: view as ListIntegrationWorkflowsArgs["view"] } : {}; +} + +function parseUpdateIntegrationProposalArgs(value: Record): UpdateIntegrationProposalArgs { + return { + proposalId: requireString(value.proposalId, "prs.updateIntegrationProposal requires proposalId."), + ...(typeof value.title === "string" ? { title: value.title } : {}), + ...(typeof value.body === "string" ? { body: value.body } : {}), + ...(typeof value.draft === "boolean" ? { draft: value.draft } : {}), + ...(typeof value.integrationLaneName === "string" ? { integrationLaneName: value.integrationLaneName } : {}), + ...(typeof value.preferredIntegrationLaneId === "string" || value.preferredIntegrationLaneId === null + ? { preferredIntegrationLaneId: value.preferredIntegrationLaneId } + : {}), + ...(typeof value.mergeIntoHeadSha === "string" || value.mergeIntoHeadSha === null + ? { mergeIntoHeadSha: value.mergeIntoHeadSha } + : {}), + }; +} + +function parseDeleteIntegrationProposalArgs(value: Record): DeleteIntegrationProposalArgs { + return { + proposalId: requireString(value.proposalId, "prs.deleteIntegrationProposal requires proposalId."), + ...(typeof value.deleteIntegrationLane === "boolean" ? { deleteIntegrationLane: value.deleteIntegrationLane } : {}), + }; +} + +function parseDismissIntegrationCleanupArgs(value: Record): DismissIntegrationCleanupArgs { + return { + proposalId: requireString(value.proposalId, "prs.dismissIntegrationCleanup requires proposalId."), + }; +} + +function parseCleanupIntegrationWorkflowArgs(value: Record): CleanupIntegrationWorkflowArgs { + const rawLaneIds = Array.isArray(value.archiveSourceLaneIds) ? value.archiveSourceLaneIds : []; + const archiveSourceLaneIds = rawLaneIds + .map((entry) => (typeof entry === "string" ? entry.trim() : "")) + .filter((entry) => entry.length > 0); + return { + proposalId: requireString(value.proposalId, "prs.cleanupIntegrationWorkflow requires proposalId."), + ...(typeof value.archiveIntegrationLane === "boolean" ? { archiveIntegrationLane: value.archiveIntegrationLane } : {}), + ...(archiveSourceLaneIds.length > 0 ? { archiveSourceLaneIds } : {}), + }; +} + +function parseCreateIntegrationLaneForProposalArgs(value: Record): CreateIntegrationLaneForProposalArgs { + return { + proposalId: requireString(value.proposalId, "prs.createIntegrationLaneForProposal requires proposalId."), + }; +} + +function parseStartIntegrationResolutionArgs(value: Record): StartIntegrationResolutionArgs { + return { + proposalId: requireString(value.proposalId, "prs.startIntegrationResolution requires proposalId."), + laneId: requireString(value.laneId, "prs.startIntegrationResolution requires laneId."), + }; +} + +function parseRecheckIntegrationStepArgs(value: Record): RecheckIntegrationStepArgs { + return { + proposalId: requireString(value.proposalId, "prs.recheckIntegrationStep requires proposalId."), + laneId: requireString(value.laneId, "prs.recheckIntegrationStep requires laneId."), + }; +} + +function parseLandQueueNextArgs(value: Record): LandQueueNextArgs { + const method = asTrimmedString(value.method) as LandQueueNextArgs["method"]; + if (!method || !["merge", "squash", "rebase"].includes(method)) { + throw new Error("prs.landQueueNext requires method to be merge, squash, or rebase."); + } + return { + groupId: requireString(value.groupId, "prs.landQueueNext requires groupId."), + method, + ...(typeof value.archiveLane === "boolean" ? { archiveLane: value.archiveLane } : {}), + ...(typeof value.autoResolve === "boolean" ? { autoResolve: value.autoResolve } : {}), + ...(asOptionalNumber(value.confidenceThreshold) != null ? { confidenceThreshold: asOptionalNumber(value.confidenceThreshold)! } : {}), + }; +} + +function parseReorderQueuePrsArgs(value: Record): ReorderQueuePrsArgs { + return { + groupId: requireString(value.groupId, "prs.reorderQueue requires groupId."), + prIds: requireStringArray(value.prIds, "prs.reorderQueue requires prIds."), + }; +} + +function parsePauseQueueAutomationArgs(value: Record): PauseQueueAutomationArgs { + return { + queueId: requireString(value.queueId, "prs.pauseQueueAutomation requires queueId."), + }; +} + +function parseResumeQueueAutomationArgs(value: Record): ResumeQueueAutomationArgs { + const method = asTrimmedString(value.method); + if (method && !["merge", "squash", "rebase"].includes(method)) { + throw new Error("prs.resumeQueueAutomation requires method to be merge, squash, or rebase when provided."); + } + return { + queueId: requireString(value.queueId, "prs.resumeQueueAutomation requires queueId."), + ...(method ? { method: method as ResumeQueueAutomationArgs["method"] } : {}), + ...(typeof value.archiveLane === "boolean" ? { archiveLane: value.archiveLane } : {}), + ...(typeof value.autoResolve === "boolean" ? { autoResolve: value.autoResolve } : {}), + ...(typeof value.ciGating === "boolean" ? { ciGating: value.ciGating } : {}), + ...(asOptionalNumber(value.confidenceThreshold) != null ? { confidenceThreshold: asOptionalNumber(value.confidenceThreshold)! } : {}), + ...(asTrimmedString(value.originLabel) ? { originLabel: asTrimmedString(value.originLabel)! } : {}), + }; +} + +function parseCancelQueueAutomationArgs(value: Record): CancelQueueAutomationArgs { + return { + queueId: requireString(value.queueId, "prs.cancelQueueAutomation requires queueId."), + }; +} + +function parseIssueInventoryPrArgs(value: Record, action: string): { prId: string } { + return { + prId: requirePrId(value, action), + }; +} + +function parseIssueInventoryItemsArgs(value: Record, action: string): { prId: string; itemIds: string[] } { + return { + prId: requirePrId(value, action), + itemIds: requireStringArray(value.itemIds, `${action} requires itemIds.`), + }; +} + +function parseIssueInventoryDismissArgs(value: Record): { prId: string; itemIds: string[]; reason: string } { + return { + ...parseIssueInventoryItemsArgs(value, "prs.issueInventory.markDismissed"), + reason: typeof value.reason === "string" ? value.reason : "", + }; +} + +function parsePipelineSettingsPatch(value: Record): { prId: string; settings: Partial } { + const settings = isRecord(value.settings) ? value.settings : value; + const patch: Partial = {}; + if (typeof settings.autoMerge === "boolean") patch.autoMerge = settings.autoMerge; + const mergeMethod = asTrimmedString(settings.mergeMethod); + if (mergeMethod && ["merge", "squash", "rebase", "repo_default"].includes(mergeMethod)) { + patch.mergeMethod = mergeMethod as PipelineSettings["mergeMethod"]; + } + const maxRounds = asOptionalNumber(settings.maxRounds); + if (maxRounds != null && maxRounds >= 1) patch.maxRounds = Math.floor(maxRounds); + const onRebaseNeeded = asTrimmedString(settings.onRebaseNeeded); + if (onRebaseNeeded === "pause" || onRebaseNeeded === "auto_rebase") { + patch.onRebaseNeeded = onRebaseNeeded; + } + const conflictStrategy = asTrimmedString(settings.conflictStrategy); + if (conflictStrategy && ["pause", "rebase", "merge", "auto"].includes(conflictStrategy)) { + patch.conflictStrategy = conflictStrategy as PipelineSettings["conflictStrategy"]; + } + const forceFinalizeMode = asTrimmedString(settings.forceFinalizeMode); + if (forceFinalizeMode && ["off", "conditional", "unconditional"].includes(forceFinalizeMode)) { + patch.forceFinalizeMode = forceFinalizeMode as PipelineSettings["forceFinalizeMode"]; + } + if (typeof settings.forceFinalizeRequireNoCiFailures === "boolean") { + patch.forceFinalizeRequireNoCiFailures = settings.forceFinalizeRequireNoCiFailures; + } + if (typeof settings.earlyMergeOnGreen === "boolean") { + patch.earlyMergeOnGreen = settings.earlyMergeOnGreen; + } + const atCapPolicy = asTrimmedString(settings.atCapPolicy); + if (atCapPolicy && ["stop", "wait_for_ci", "ci_retry_once", "ci_retry_loop", "force_merge"].includes(atCapPolicy)) { + patch.atCapPolicy = atCapPolicy as PipelineSettings["atCapPolicy"]; + } + const atCapWaitMinutes = asOptionalNumber(settings.atCapWaitMinutes); + if (atCapWaitMinutes != null && atCapWaitMinutes >= 1) patch.atCapWaitMinutes = Math.floor(atCapWaitMinutes); + const atCapCiRetryMax = asOptionalNumber(settings.atCapCiRetryMax); + if (atCapCiRetryMax != null && atCapCiRetryMax >= 1) patch.atCapCiRetryMax = Math.floor(atCapCiRetryMax); + if (typeof settings.forceMergeRequiresConfirmation === "boolean") { + patch.forceMergeRequiresConfirmation = settings.forceMergeRequiresConfirmation; + } + if (isRecord(settings.autoAgentSettings)) { + const autoAgentSettings: Partial = {}; + const provider = settings.autoAgentSettings.provider; + if (provider === null || provider === "claude" || provider === "codex") autoAgentSettings.provider = provider; + for (const key of ["model", "reasoningEffort"] as const) { + const value = settings.autoAgentSettings[key]; + if (value === null || typeof value === "string") autoAgentSettings[key] = value; + } + const permissionMode = settings.autoAgentSettings.permissionMode; + if ( + permissionMode === null || + permissionMode === "read_only" || + permissionMode === "guarded_edit" || + permissionMode === "full_edit" || + permissionMode === "default" || + permissionMode === "plan" || + permissionMode === "edit" || + permissionMode === "full-auto" || + permissionMode === "config-toml" + ) { + autoAgentSettings.permissionMode = permissionMode; + } + const confidenceThreshold = asOptionalNumber(settings.autoAgentSettings.confidenceThreshold); + if (settings.autoAgentSettings.confidenceThreshold === null || (confidenceThreshold != null && confidenceThreshold >= 0 && confidenceThreshold <= 1)) { + autoAgentSettings.confidenceThreshold = settings.autoAgentSettings.confidenceThreshold === null ? null : confidenceThreshold; + } + if (Object.keys(autoAgentSettings).length > 0) patch.autoAgentSettings = autoAgentSettings as PipelineSettings["autoAgentSettings"]; + } + return { + prId: requirePrId(value, "prs.pipelineSettings.save"), + settings: patch, + }; +} + +function parseConvergenceStatePatch(value: Record): { prId: string; state: PrConvergenceStatePatch } { + const raw = isRecord(value.state) ? value.state : value; + const patch: PrConvergenceStatePatch = {}; + const statuses = new Set(["idle", "launching", "running", "polling", "paused", "converged", "merged", "failed", "cancelled", "stopped"]); + const pollerStatuses = new Set(["idle", "scheduled", "polling", "waiting_for_checks", "waiting_for_comments", "paused", "stopped"]); + if (typeof raw.autoConvergeEnabled === "boolean") patch.autoConvergeEnabled = raw.autoConvergeEnabled; + const status = asTrimmedString(raw.status); + if (status && statuses.has(status)) patch.status = status as ConvergenceRuntimeState["status"]; + const pollerStatus = asTrimmedString(raw.pollerStatus); + if (pollerStatus && pollerStatuses.has(pollerStatus)) patch.pollerStatus = pollerStatus as ConvergenceRuntimeState["pollerStatus"]; + const currentRound = asOptionalNumber(raw.currentRound); + if (currentRound != null && currentRound >= 0) patch.currentRound = Math.floor(currentRound); + if (typeof raw.forceFinalizeUsed === "boolean") patch.forceFinalizeUsed = raw.forceFinalizeUsed; + const ciRetryAttemptsUsed = asOptionalNumber(raw.ciRetryAttemptsUsed); + if (ciRetryAttemptsUsed != null && ciRetryAttemptsUsed >= 0) patch.ciRetryAttemptsUsed = Math.floor(ciRetryAttemptsUsed); + const pauseRepeatCount = asOptionalNumber(raw.pauseRepeatCount); + if (pauseRepeatCount != null && pauseRepeatCount >= 0) patch.pauseRepeatCount = Math.floor(pauseRepeatCount); + for (const key of [ + "activeSessionId", + "activeLaneId", + "activeHref", + "pauseReason", + "errorMessage", + "waitForCiStartedAt", + "lastDispatchHeadSha", + "lastPauseReasonHash", + "lastStartedAt", + "lastPolledAt", + "lastPausedAt", + "lastStoppedAt", + ] as const) { + const next = raw[key]; + if (next === null || typeof next === "string") { + (patch as Record)[key] = next; + } + } + return { + prId: requirePrId(value, "prs.convergenceState.save"), + state: patch, + }; +} + +function mergeLaneDockerConfig( + current: { composePath?: string; services?: string[]; projectPrefix?: string } | undefined, + next: { composePath?: string; services?: string[]; projectPrefix?: string } | undefined, +) { + if (!current && !next) return undefined; + if (!current) return next ? { ...next, ...(next.services ? { services: [...next.services] } : {}) } : undefined; + if (!next) return { ...current, ...(current.services ? { services: [...current.services] } : {}) }; + return { + ...current, + ...next, + ...(next.services != null + ? { services: [...next.services] } + : current.services != null + ? { services: [...current.services] } + : {}), + }; +} + +function mergeLaneEnvInitConfig( + current: LaneEnvInitConfig | undefined, + next: LaneEnvInitConfig | undefined, +): LaneEnvInitConfig | undefined { + if (!current && !next) return undefined; + if (!current) { + return next + ? { + ...(next.envFiles ? { envFiles: [...next.envFiles] } : {}), + ...(mergeLaneDockerConfig(undefined, next.docker) ? { docker: mergeLaneDockerConfig(undefined, next.docker) } : {}), + ...(next.dependencies ? { dependencies: [...next.dependencies] } : {}), + ...(next.mountPoints ? { mountPoints: [...next.mountPoints] } : {}), + ...(next.copyPaths ? { copyPaths: [...next.copyPaths] } : {}), + } + : undefined; + } + if (!next) { + return { + ...(current.envFiles ? { envFiles: [...current.envFiles] } : {}), + ...(mergeLaneDockerConfig(undefined, current.docker) ? { docker: mergeLaneDockerConfig(undefined, current.docker) } : {}), + ...(current.dependencies ? { dependencies: [...current.dependencies] } : {}), + ...(current.mountPoints ? { mountPoints: [...current.mountPoints] } : {}), + ...(current.copyPaths ? { copyPaths: [...current.copyPaths] } : {}), + }; + } + return { + envFiles: [...(current.envFiles ?? []), ...(next.envFiles ?? [])], + ...(mergeLaneDockerConfig(current.docker, next.docker) ? { docker: mergeLaneDockerConfig(current.docker, next.docker) } : {}), + dependencies: [...(current.dependencies ?? []), ...(next.dependencies ?? [])], + mountPoints: [...(current.mountPoints ?? []), ...(next.mountPoints ?? [])], + copyPaths: [...(current.copyPaths ?? []), ...(next.copyPaths ?? [])], + }; +} + +function mergeLaneOverrides(base: LaneOverlayOverrides, next: Partial): LaneOverlayOverrides { + return { + ...base, + ...next, + ...(base.env || next.env ? { env: { ...(base.env ?? {}), ...(next.env ?? {}) } } : {}), + ...(base.processIds || next.processIds ? { processIds: [...(next.processIds ?? base.processIds ?? [])] } : {}), + ...(base.testSuiteIds || next.testSuiteIds ? { testSuiteIds: [...(next.testSuiteIds ?? base.testSuiteIds ?? [])] } : {}), + ...(mergeLaneEnvInitConfig(base.envInit, next.envInit) ? { envInit: mergeLaneEnvInitConfig(base.envInit, next.envInit) } : {}), + }; +} + +function applyLeaseToOverrides( + overrides: LaneOverlayOverrides, + lease: { status: string; rangeStart: number; rangeEnd: number } | null, +): LaneOverlayOverrides { + if (!lease || lease.status !== "active" || overrides.portRange) { + return { ...overrides }; + } + return { + ...overrides, + portRange: { start: lease.rangeStart, end: lease.rangeEnd }, + }; +} + +/** + * Strict resolver for identity-pinned sessions (CTO + worker agents). Never + * slips a foreign lane through via a `lanes[0]` fallback — if no primary lane + * exists, the caller must error out rather than silently host the identity on + * a non-primary lane. + */ +async function resolvePrimaryLaneIdOnlyForSync(args: SyncRemoteCommandServiceArgs): Promise { + await args.laneService.ensurePrimaryLane?.().catch(() => {}); + const lanes = await args.laneService.list({ includeArchived: false, includeStatus: false }); + return lanes.find((lane) => lane.laneType === "primary")?.id ?? ""; +} + +async function resolveLaneOverlayContext(args: SyncRemoteCommandServiceArgs, laneId: string) { + const projectConfigService = requireService(args.projectConfigService, "Project config service not available."); + const lanes = await args.laneService.list({ includeStatus: false }); + const lane = lanes.find((entry) => entry.id === laneId); + if (!lane) throw new Error(`Lane not found: ${laneId}`); + + const config = projectConfigService.getEffective(); + const overlayOverrides = matchLaneOverlayPolicies(lane, config.laneOverlayPolicies ?? []); + const lease = args.portAllocationService?.getLease(lane.id) ?? null; + const overrides = applyLeaseToOverrides(overlayOverrides, lease); + const envInitConfig = args.laneEnvironmentService?.resolveEnvInitConfig(config.laneEnvInit, overrides); + + return { + lane, + overrides, + envInitConfig, + }; +} + +async function resolveChatCreateArgs( + service: ReturnType, + payload: AgentChatCreateArgs, +): Promise { + if (payload.model.trim().length > 0) return payload; + const available = await service.getAvailableModels({ + provider: payload.provider, + ...(payload.provider === "opencode" ? { activateRuntime: true } : {}), + }); + const chosen = available[0]; + if (!chosen) { + throw new Error(`No configured ${payload.provider} chat model is available on the host.`); + } + return { + ...payload, + model: chosen.id, + ...(!payload.modelId && chosen.modelId ? { modelId: chosen.modelId } : {}), + }; +} + +function sessionStatusBucket(argsIn: { + status: string; + lastOutputPreview: string | null | undefined; + runtimeState?: string | null; +}): "running" | "awaiting-input" | "ended" { + if (argsIn.status === "running") { + if (argsIn.runtimeState === "waiting-input") return "awaiting-input"; + const preview = argsIn.lastOutputPreview ?? ""; + if (/\b(?:waiting|awaiting)\b.{0,28}\b(?:input|confirmation|response|prompt)\b/i.test(preview)) { + return "awaiting-input"; + } + if (/\((?:y\/n|yes\/no)\)/i.test(preview) || /\[(?:y\/n|yes\/no)\]/i.test(preview)) { + return "awaiting-input"; + } + return "running"; + } + return "ended"; +} + +function summarizeLaneRuntime( + laneId: string, + sessions: Array<{ + laneId: string; + status: string; + lastOutputPreview: string | null; + runtimeState?: string | null; + }>, +): LaneListSnapshot["runtime"] { + let runningCount = 0; + let awaitingInputCount = 0; + let endedCount = 0; + let sessionCount = 0; + for (const session of sessions) { + if (session.laneId !== laneId) continue; + sessionCount += 1; + const bucket = sessionStatusBucket(session); + if (bucket === "running") runningCount += 1; + else if (bucket === "awaiting-input") awaitingInputCount += 1; + else endedCount += 1; + } + const bucket = runningCount > 0 + ? "running" + : awaitingInputCount > 0 + ? "awaiting-input" + : endedCount > 0 + ? "ended" + : "none"; + return { + bucket, + runningCount, + awaitingInputCount, + endedCount, + sessionCount, + }; +} + +async function buildLaneListSnapshots( + args: SyncRemoteCommandServiceArgs, + lanes: Awaited["list"]>>, +): Promise { + const [sessions, rebaseSuggestions, autoRebaseStatuses, stateSnapshots, batchAssessment] = await Promise.all([ + Promise.resolve(args.sessionService.list({ limit: 500 })), + Promise.resolve(args.rebaseSuggestionService?.listSuggestions() ?? []), + Promise.resolve(args.autoRebaseService?.listStatuses() ?? []), + Promise.resolve(args.laneService.listStateSnapshots()), + args.conflictService?.getBatchAssessment({ lanes }).catch(() => null) ?? Promise.resolve(null), + ]); + + const rebaseByLaneId = new Map(rebaseSuggestions.map((entry) => [entry.laneId, entry] as const)); + const autoRebaseByLaneId = new Map(autoRebaseStatuses.map((entry) => [entry.laneId, entry] as const)); + const stateByLaneId = new Map(stateSnapshots.map((entry) => [entry.laneId, entry] as const)); + const conflictByLaneId = new Map((batchAssessment?.lanes ?? []).map((entry) => [entry.laneId, entry] as const)); + + return lanes.map((lane) => ({ + lane, + runtime: summarizeLaneRuntime(lane.id, sessions), + rebaseSuggestion: rebaseByLaneId.get(lane.id) ?? null, + autoRebaseStatus: autoRebaseByLaneId.get(lane.id) ?? null, + conflictStatus: conflictByLaneId.get(lane.id) ?? null, + stateSnapshot: stateByLaneId.get(lane.id) ?? null, + adoptableAttached: lane.laneType === "attached" && lane.archivedAt == null, + })); +} + +async function buildLaneDetailPayload(args: SyncRemoteCommandServiceArgs, laneId: string): Promise { + const lane = (await args.laneService.list({ includeArchived: true, includeStatus: true })).find((entry) => entry.id === laneId) ?? null; + if (!lane) throw new Error(`Lane not found: ${laneId}`); + + const [ + stackChain, + children, + sessions, + chatSessions, + rebaseSuggestions, + autoRebaseStatuses, + stateSnapshot, + recentCommits, + diffChanges, + stashes, + syncStatus, + conflictState, + conflictStatus, + overlaps, + envInitProgress, + ] = await Promise.all([ + args.laneService.getStackChain(laneId), + args.laneService.getChildren(laneId), + Promise.resolve(args.sessionService.list({ laneId, limit: 200 })), + args.agentChatService?.listSessions(laneId, { includeAutomation: true }) ?? Promise.resolve([]), + Promise.resolve(args.rebaseSuggestionService?.listSuggestions() ?? []), + Promise.resolve(args.autoRebaseService?.listStatuses() ?? []), + Promise.resolve(args.laneService.getStateSnapshot(laneId)), + args.gitService?.listRecentCommits({ laneId, limit: 20 }) ?? Promise.resolve([]), + args.diffService?.getChanges(laneId).catch(() => null) ?? Promise.resolve(null), + args.gitService?.listStashes({ laneId }) ?? Promise.resolve([]), + args.gitService?.getSyncStatus({ laneId }).catch(() => null) ?? Promise.resolve(null), + args.gitService?.getConflictState({ laneId }).catch(() => null) ?? Promise.resolve(null), + args.conflictService?.getLaneStatus({ laneId }).catch(() => null) ?? Promise.resolve(null), + args.conflictService?.listOverlaps({ laneId }).catch(() => []) ?? Promise.resolve([]), + Promise.resolve(args.laneEnvironmentService?.getProgress(laneId) ?? null), + ]); + + return { + lane, + runtime: summarizeLaneRuntime(laneId, sessions), + stackChain, + children, + stateSnapshot: stateSnapshot as LaneStateSnapshotSummary | null, + rebaseSuggestion: rebaseSuggestions.find((entry) => entry.laneId === laneId) ?? null, + autoRebaseStatus: autoRebaseStatuses.find((entry) => entry.laneId === laneId) ?? null, + conflictStatus, + overlaps, + syncStatus, + conflictState, + recentCommits, + diffChanges, + stashes, + envInitProgress, + sessions, + chatSessions, + }; +} + +export function createSyncRemoteCommandService(args: SyncRemoteCommandServiceArgs) { + const registry = new Map(); + + const register = ( + action: SyncRemoteCommandAction, + policy: SyncRemoteCommandPolicy, + handler: (payload: Record) => Promise, + scope: SyncRemoteCommandDescriptor["scope"] = "project", + ) => { + registry.set(action, { + descriptor: { action, scope, policy }, + handler, + }); + }; + + register("lanes.list", { viewerAllowed: true }, async (payload) => args.laneService.list(parseListLanesArgs(payload))); + register("lanes.refreshSnapshots", { viewerAllowed: true }, async (payload) => { + const refreshed = await args.laneService.refreshSnapshots(parseListLanesArgs(payload)); + return { + ...refreshed, + snapshots: await buildLaneListSnapshots(args, refreshed.lanes), + }; + }); + register("lanes.getDetail", { viewerAllowed: true }, async (payload) => + buildLaneDetailPayload(args, requireString(payload.laneId, "lanes.getDetail requires laneId."))); + register("lanes.create", { viewerAllowed: true, queueable: true }, async (payload) => args.laneService.create(parseCreateLaneArgs(payload))); + register("lanes.createChild", { viewerAllowed: true, queueable: true }, async (payload) => args.laneService.createChild(parseCreateChildLaneArgs(payload))); + register("lanes.createFromUnstaged", { viewerAllowed: true, queueable: true }, async (payload) => + args.laneService.createFromUnstaged(parseCreateLaneFromUnstagedArgs(payload))); + register("lanes.importBranch", { viewerAllowed: true, queueable: true }, async (payload) => + args.laneService.importBranch(parseImportBranchArgs(payload))); + register("lanes.previewBranchSwitch", { viewerAllowed: true }, async (payload) => + args.laneService.previewBranchSwitch(parseGitCheckoutBranchArgs(payload))); + register("lanes.attach", { viewerAllowed: true, queueable: true }, async (payload) => args.laneService.attach(parseAttachLaneArgs(payload))); + register("lanes.adoptAttached", { viewerAllowed: true, queueable: true }, async (payload) => + args.laneService.adoptAttached({ laneId: requireString(payload.laneId, "lanes.adoptAttached requires laneId.") })); + register("lanes.rename", { viewerAllowed: true, queueable: true }, async (payload) => { + args.laneService.rename(parseRenameLaneArgs(payload)); + return { ok: true }; + }); + register("lanes.reparent", { viewerAllowed: true, queueable: true }, async (payload) => + args.laneService.reparent(parseReparentLaneArgs(payload))); + register("lanes.updateAppearance", { viewerAllowed: true, queueable: true }, async (payload) => { + args.laneService.updateAppearance(parseUpdateLaneAppearanceArgs(payload)); + return { ok: true }; + }); + register("lanes.archive", { viewerAllowed: true, queueable: true }, async (payload) => { + await args.laneService.archive(parseArchiveLaneArgs(payload, "lanes.archive")); + return { ok: true }; + }); + register("lanes.unarchive", { viewerAllowed: true, queueable: true }, async (payload) => { + await args.laneService.unarchive(parseArchiveLaneArgs(payload, "lanes.unarchive")); + return { ok: true }; + }); + register("lanes.delete", { viewerAllowed: true, queueable: true }, async (payload) => { + await args.laneService.delete(parseDeleteLaneArgs(payload)); + return { ok: true }; + }); + register("lanes.getStackChain", { viewerAllowed: true }, async (payload) => + args.laneService.getStackChain(requireString(payload.laneId, "lanes.getStackChain requires laneId."))); + register("lanes.getChildren", { viewerAllowed: true }, async (payload) => + args.laneService.getChildren(requireString(payload.laneId, "lanes.getChildren requires laneId."))); + register("lanes.rebaseStart", { viewerAllowed: true, queueable: true }, async (payload) => args.laneService.rebaseStart(parseRebaseStartArgs(payload))); + register("lanes.rebasePush", { viewerAllowed: true, queueable: true }, async (payload) => args.laneService.rebasePush(parseRebasePushArgs(payload))); + register("lanes.rebaseRollback", { viewerAllowed: true, queueable: true }, async (payload) => args.laneService.rebaseRollback(parseRunIdArgs(payload, "lanes.rebaseRollback"))); + register("lanes.rebaseAbort", { viewerAllowed: true, queueable: true }, async (payload) => args.laneService.rebaseAbort(parseRunIdArgs(payload, "lanes.rebaseAbort"))); + register("lanes.listRebaseSuggestions", { viewerAllowed: true }, async () => args.rebaseSuggestionService?.listSuggestions() ?? []); + register("lanes.dismissRebaseSuggestion", { viewerAllowed: true, queueable: true }, async (payload) => { + const laneId = requireString(payload.laneId, "lanes.dismissRebaseSuggestion requires laneId."); + args.conflictService?.dismissRebase(laneId); + if (args.rebaseSuggestionService) { + await args.rebaseSuggestionService.dismiss({ laneId }); + } + return { ok: true }; + }); + register("lanes.deferRebaseSuggestion", { viewerAllowed: true, queueable: true }, async (payload) => { + const laneId = requireString(payload.laneId, "lanes.deferRebaseSuggestion requires laneId."); + const minutes = Math.max(5, Math.min(7 * 24 * 60, Math.floor(asOptionalNumber(payload.minutes) ?? 60))); + const until = new Date(Date.now() + minutes * 60_000).toISOString(); + args.conflictService?.deferRebase(laneId, until); + if (args.rebaseSuggestionService) { + await args.rebaseSuggestionService.defer({ + laneId, + minutes, + }); + } + return { ok: true }; + }); + register("lanes.listAutoRebaseStatuses", { viewerAllowed: true }, async () => args.autoRebaseService?.listStatuses() ?? []); + register("lanes.dismissAutoRebaseStatus", { viewerAllowed: true, queueable: true }, async (payload) => { + if (!args.autoRebaseService) return { ok: true }; + await args.autoRebaseService.dismissStatus({ + laneId: requireString(payload.laneId, "lanes.dismissAutoRebaseStatus requires laneId."), + }); + return { ok: true }; + }); + register("lanes.listTemplates", { viewerAllowed: true }, async () => args.laneTemplateService?.listTemplates() ?? []); + register("lanes.getDefaultTemplate", { viewerAllowed: true }, async () => args.laneTemplateService?.getDefaultTemplateId() ?? null); + register("lanes.getEnvStatus", { viewerAllowed: true }, async (payload) => args.laneEnvironmentService?.getProgress(requireString(payload.laneId, "lanes.getEnvStatus requires laneId.")) ?? null); + register("lanes.initEnv", { viewerAllowed: true, queueable: true }, async (payload) => { + const laneEnvironmentService = requireService(args.laneEnvironmentService, "Lane environment service not available."); + const laneId = requireString(payload.laneId, "lanes.initEnv requires laneId."); + const context = await resolveLaneOverlayContext(args, laneId); + if (!context.envInitConfig) { + const now = new Date().toISOString(); + return { + laneId, + steps: [], + startedAt: now, + completedAt: now, + overallStatus: "completed", + } satisfies LaneEnvInitProgress; + } + return await laneEnvironmentService.initLaneEnvironment(context.lane, context.envInitConfig, context.overrides); + }); + register("lanes.applyTemplate", { viewerAllowed: true, queueable: true }, async (payload) => { + const laneTemplateService = requireService(args.laneTemplateService, "Lane template service not available."); + const laneEnvironmentService = requireService(args.laneEnvironmentService, "Lane environment service not available."); + const parsed = { + laneId: requireString(payload.laneId, "lanes.applyTemplate requires laneId."), + templateId: requireString(payload.templateId, "lanes.applyTemplate requires templateId."), + } satisfies ApplyLaneTemplateArgs; + const context = await resolveLaneOverlayContext(args, parsed.laneId); + const template = laneTemplateService.getTemplate(parsed.templateId); + if (!template) throw new Error(`Template not found: ${parsed.templateId}`); + const templateEnvInit = laneTemplateService.resolveTemplateAsEnvInit(template); + const mergedOverrides = mergeLaneOverrides(context.overrides, { + ...(template.envVars ? { env: template.envVars } : {}), + ...(!context.overrides.portRange && template.portRange ? { portRange: template.portRange } : {}), + envInit: templateEnvInit, + }); + const mergedEnvInitConfig = mergeLaneEnvInitConfig(context.envInitConfig, templateEnvInit) ?? templateEnvInit; + return await laneEnvironmentService.initLaneEnvironment(context.lane, mergedEnvInitConfig, mergedOverrides); + }); + + register("work.listSessions", { viewerAllowed: true }, async (payload) => listRemoteWorkSessions(args, parseListSessionsArgs(payload))); + register("work.updateSessionMeta", { viewerAllowed: true, queueable: true }, async (payload) => { + args.sessionService.updateMeta(parseUpdateSessionMetaArgs(payload)); + return { ok: true }; + }); + register("work.runQuickCommand", { viewerAllowed: true, queueable: true }, async (payload) => { + const parsed = parseQuickCommandArgs(payload); + return await args.ptyService.create({ + laneId: parsed.laneId, + title: parsed.title, + ...(parsed.toolType === "shell" || !parsed.startupCommand ? {} : { startupCommand: parsed.startupCommand }), + tracked: parsed.tracked ?? true, + cols: parsed.cols ?? 120, + rows: parsed.rows ?? 36, + toolType: (parsed.toolType ?? "run-shell") as TerminalToolType, + }); + }); + register("work.startCliSession", { viewerAllowed: true, queueable: true }, async (payload) => { + const parsed = parseStartCliSessionArgs(payload); + const cols = clampCliDimension(parsed.cols, DEFAULT_CLI_COLS, 20, 240); + const rows = clampCliDimension(parsed.rows, DEFAULT_CLI_ROWS, 4, 120); + const resumeSessionId = parsed.resumeSessionId?.trim() || undefined; + const { provider } = parsed; + const permissionMode = parsed.permissionMode ?? "default"; + validateLaunchProfilePermissionMode(provider, permissionMode); + const resumeSession = resumeSessionId + ? requireResumeSessionForProvider(args.sessionService, resumeSessionId, provider) + : null; + const toolType = LAUNCH_PROFILE_TOOL_TYPE[provider] as TerminalToolType; + const title = parsed.title?.trim() || LAUNCH_PROFILE_TITLE[provider]; + const preassignedSessionId = provider === "claude" && !resumeSessionId ? randomUUID() : undefined; + + function resolveLaunch(): { startupCommand?: string; command?: string; args?: string[]; env?: Record } { + if (provider === "shell") return {}; + if (resumeSessionId) { + if (!resumeSession) throw new Error(`work.startCliSession resumeSessionId '${resumeSessionId}' was not found.`); + const startupCommand = resolveTrackedCliResumeCommand(resumeSession) + ?? buildTrackedCliResumeCommand({ + provider, + targetKind: "session", + targetId: null, + launch: { permissionMode }, + }); + return { startupCommand }; + } + return buildTrackedCliLaunchCommand({ provider, permissionMode, sessionId: preassignedSessionId }); + } + + const sessionId = resumeSessionId ?? preassignedSessionId; + const result = await args.ptyService.create({ + ...(sessionId ? { sessionId } : {}), + allowNewSessionId: Boolean(preassignedSessionId), + laneId: parsed.laneId, + title, + tracked: true, + toolType, + cols, + rows, + ...resolveLaunch(), + }); + + if (parsed.initialInput && provider !== "shell") { + const written = args.ptyService.writeBySessionId(result.sessionId, `${parsed.initialInput}\r`); + if (!written) { + try { + args.ptyService.dispose({ ptyId: result.ptyId, sessionId: result.sessionId }); + } catch (err) { + args.logger.warn("sync_remote.start_cli_session_initial_input_cleanup_failed", { + sessionId: result.sessionId, + err: String(err), + }); + } + throw new Error("work.startCliSession created a terminal session but could not write initialInput."); + } + } + + const session = args.sessionService.get(result.sessionId); + const enriched = session ? args.ptyService.enrichSessions([session])[0] ?? session : null; + return { + sessionId: result.sessionId, + ptyId: result.ptyId, + session: enriched, + } satisfies SyncStartCliSessionResult; + }); + register("work.closeSession", { viewerAllowed: true, queueable: true }, async (payload) => { + const { sessionId } = parseCloseSessionArgs(payload); + const session = args.sessionService.get(sessionId); + if (session?.ptyId) { + await args.ptyService.dispose({ ptyId: session.ptyId, sessionId }); + } + return { ok: true }; + }); + + register("processes.listDefinitions", { viewerAllowed: true }, async () => + requireService(args.processService, "Process service not available.").listDefinitions()); + register("processes.listRuntime", { viewerAllowed: true }, async (payload) => + requireService(args.processService, "Process service not available.").listRuntime( + parseProcessLaneArgs(payload, "processes.listRuntime").laneId, + )); + register("processes.start", { viewerAllowed: true, queueable: true }, async (payload) => + requireService(args.processService, "Process service not available.").start( + parseProcessActionArgs(payload, "processes.start"), + )); + register("processes.stop", { viewerAllowed: true, queueable: true }, async (payload) => + requireService(args.processService, "Process service not available.").stop( + parseProcessActionArgs(payload, "processes.stop"), + )); + register("processes.kill", { viewerAllowed: true, queueable: false }, async (payload) => + requireService(args.processService, "Process service not available.").kill( + parseProcessActionArgs(payload, "processes.kill"), + )); + + register("chat.listSessions", { viewerAllowed: true }, async (payload) => { + const agentChatService = requireService(args.agentChatService, "Agent chat service not available."); + const parsed = parseAgentChatListArgs(payload); + return agentChatService.listSessions(parsed.laneId, { includeAutomation: parsed.includeAutomation }); + }); + register("chat.getSummary", { viewerAllowed: true }, async (payload) => + requireService(args.agentChatService, "Agent chat service not available.").getSessionSummary(parseAgentChatGetSummaryArgs(payload).sessionId)); + register("chat.getTranscript", { viewerAllowed: true }, async (payload) => + requireService(args.agentChatService, "Agent chat service not available.").getChatTranscript(parseGetTranscriptArgs(payload))); + register("chat.create", { viewerAllowed: true, queueable: true }, async (payload) => { + const agentChatService = requireService(args.agentChatService, "Agent chat service not available."); + const parsed = parseAgentChatCreateArgs(payload); + const session = await agentChatService.createSession(await resolveChatCreateArgs(agentChatService, parsed)); + return summarizeChatSessionForRemote(agentChatService, session); + }); + register("chat.send", { viewerAllowed: true, queueable: true }, async (payload) => { + await requireService(args.agentChatService, "Agent chat service not available.").sendMessage( + parseAgentChatSendArgs(payload), + { awaitDispatch: true }, + ); + return { ok: true }; + }); + register("chat.interrupt", { viewerAllowed: true, queueable: false }, async (payload) => { + await requireService(args.agentChatService, "Agent chat service not available.").interrupt(parseAgentChatInterruptArgs(payload)); + return { ok: true }; + }); + register("chat.steer", { viewerAllowed: true, queueable: false }, async (payload) => { + await requireService(args.agentChatService, "Agent chat service not available.").steer(parseAgentChatSteerArgs(payload)); + return { ok: true }; + }); + register("chat.cancelSteer", { viewerAllowed: true, queueable: false }, async (payload) => { + await requireService(args.agentChatService, "Agent chat service not available.").cancelSteer(parseAgentChatCancelSteerArgs(payload)); + return { ok: true }; + }); + register("chat.editSteer", { viewerAllowed: true, queueable: false }, async (payload) => { + await requireService(args.agentChatService, "Agent chat service not available.").editSteer(parseAgentChatEditSteerArgs(payload)); + return { ok: true }; + }); + register("chat.dispatchSteer", { viewerAllowed: true, queueable: false }, async (payload) => { + const result = await requireService(args.agentChatService, "Agent chat service not available.").dispatchSteer(parseAgentChatDispatchSteerArgs(payload)); + return { ok: true, dispatchedAt: result.dispatchedAt }; + }); + register("chat.cancelDispatchedSteer", { viewerAllowed: true, queueable: false }, async (payload) => { + const result = await requireService(args.agentChatService, "Agent chat service not available.").cancelDispatchedSteer(parseAgentChatCancelDispatchedSteerArgs(payload)); + return { ok: true, cancelled: result.cancelled }; + }); + register("chat.approve", { viewerAllowed: true, queueable: false }, async (payload) => { + await requireService(args.agentChatService, "Agent chat service not available.").approveToolUse(parseAgentChatApproveArgs(payload)); + return { ok: true }; + }); + register("chat.respondToInput", { viewerAllowed: true, queueable: false }, async (payload) => { + await requireService(args.agentChatService, "Agent chat service not available.").respondToInput(parseAgentChatRespondToInputArgs(payload)); + return { ok: true }; + }); + register("chat.resume", { viewerAllowed: true, queueable: true }, async (payload) => + requireService(args.agentChatService, "Agent chat service not available.").resumeSession(parseAgentChatResumeArgs(payload))); + // Restart: fired by iOS Live Activity + Attention Drawer "Restart" pill on + // a failed agent. Alias to resumeSession — same runtime-rewire behaviour. + // Keep as a distinct action name so telemetry can distinguish explicit + // restart intent from ordinary resume. + register("chat.restart", { viewerAllowed: true, queueable: true }, async (payload) => + requireService(args.agentChatService, "Agent chat service not available.").resumeSession(parseAgentChatResumeArgs(payload))); + register("chat.updateSession", { viewerAllowed: true, queueable: true }, async (payload) => + requireService(args.agentChatService, "Agent chat service not available.").updateSession(parseAgentChatUpdateSessionArgs(payload))); + register("chat.dispose", { viewerAllowed: true, queueable: true }, async (payload) => { + await requireService(args.agentChatService, "Agent chat service not available.").dispose(parseAgentChatDisposeArgs(payload)); + return { ok: true }; + }); + register("chat.archive", { viewerAllowed: true, queueable: true }, async (payload) => { + await requireService(args.agentChatService, "Agent chat service not available.").archiveSession(parseAgentChatArchiveArgs(payload, "chat.archive")); + return { ok: true }; + }); + register("chat.unarchive", { viewerAllowed: true, queueable: true }, async (payload) => { + await requireService(args.agentChatService, "Agent chat service not available.").unarchiveSession(parseAgentChatArchiveArgs(payload, "chat.unarchive")); + return { ok: true }; + }); + register("chat.delete", { viewerAllowed: true, queueable: true }, async (payload) => { + await requireService(args.agentChatService, "Agent chat service not available.").deleteSession(parseAgentChatArchiveArgs(payload, "chat.delete")); + return { ok: true }; + }); + register("chat.models", { viewerAllowed: true }, async (payload) => + requireService(args.agentChatService, "Agent chat service not available.").getAvailableModels(parseChatModelsArgs(payload))); + register("chat.modelCatalog", { viewerAllowed: true }, async () => + requireService(args.agentChatService, "Agent chat service not available.").getModelCatalog()); + + register("cto.getRoster", { viewerAllowed: true }, async () => { + const agentChatService = requireService(args.agentChatService, "Agent chat service not available."); + const workerAgentService = requireService(args.workerAgentService, "Worker agent service not available."); + const sessions = await agentChatService.listSessions(undefined, { includeIdentity: true }); + const activityTimestamp = (value: string | null | undefined): number => { + if (!value) return 0; + const parsed = Date.parse(value); + return Number.isFinite(parsed) ? parsed : 0; + }; + const sortedByRecency = [...sessions].sort( + (a, b) => activityTimestamp(b.lastActivityAt) - activityTimestamp(a.lastActivityAt), + ); + const ctoSummary = sortedByRecency.find((entry) => entry.identityKey === "cto") ?? null; + const agents = workerAgentService.listAgents(); + const knownAgentIds = new Set(agents.map((agent) => agent.id)); + const liveWorkers = agents.map((agent) => { + const sessionSummary = sortedByRecency.find( + (entry) => entry.identityKey === `agent:${agent.id}`, + ) ?? null; + return { + agentId: agent.id, + name: agent.name, + avatarSeed: agent.slug || null, + status: agent.status as string, + sessionSummary, + }; + }); + // Include agent: sessions whose identity is no longer in the roster + // so mobile users can still see / resume orphan chats. These are marked + // with a synthetic "orphaned" status and no avatar seed. + const orphanPrefix = "agent:"; + const orphanWorkers: typeof liveWorkers = []; + const seenOrphanIds = new Set(); + for (const entry of sortedByRecency) { + const key = entry.identityKey ?? ""; + if (!key.startsWith(orphanPrefix)) continue; + const agentId = key.slice(orphanPrefix.length); + if (!agentId.length) continue; + if (knownAgentIds.has(agentId)) continue; + if (seenOrphanIds.has(agentId)) continue; + seenOrphanIds.add(agentId); + orphanWorkers.push({ + agentId, + name: agentId, + avatarSeed: null, + status: "orphaned", + sessionSummary: entry, + }); + } + liveWorkers.sort((a, b) => a.name.localeCompare(b.name)); + orphanWorkers.sort((a, b) => a.name.localeCompare(b.name)); + const workers = [...liveWorkers, ...orphanWorkers]; + return { cto: ctoSummary, workers }; + }); + register("cto.ensureSession", { viewerAllowed: true }, async (payload) => { + const agentChatService = requireService(args.agentChatService, "Agent chat service not available."); + const laneId = await resolvePrimaryLaneIdOnlyForSync(args); + if (!laneId) throw new Error("No primary lane is available to host the CTO chat session."); + const modelId = asTrimmedString(payload.modelId); + const reasoningEffort = asTrimmedString(payload.reasoningEffort); + const session = await agentChatService.ensureIdentitySession({ + identityKey: "cto", + laneId, + modelId: modelId ?? null, + reasoningEffort: reasoningEffort ?? null, + permissionMode: "full-auto", + }); + return summarizeChatSessionForRemote(agentChatService, session); + }); + register("cto.ensureAgentSession", { viewerAllowed: true }, async (payload) => { + const agentChatService = requireService(args.agentChatService, "Agent chat service not available."); + const workerAgentService = requireService(args.workerAgentService, "Worker agent service not available."); + const agentId = requireString(payload.agentId, "cto.ensureAgentSession requires agentId."); + // Reject unknown agentIds before we spin up an identity-bound session — + // otherwise clients could spawn orphan `agent:` sessions for agents + // that don't exist. + const agent = typeof workerAgentService.getAgent === "function" + ? workerAgentService.getAgent(agentId) + : workerAgentService.listAgents().find((entry) => entry.id === agentId) ?? null; + if (!agent) { + throw new Error(`cto.ensureAgentSession: unknown agentId '${agentId}'`); + } + const laneId = await resolvePrimaryLaneIdOnlyForSync(args); + if (!laneId) throw new Error("No primary lane is available to host the agent chat session."); + const modelId = asTrimmedString(payload.modelId); + const reasoningEffort = asTrimmedString(payload.reasoningEffort); + const session = await agentChatService.ensureIdentitySession({ + identityKey: `agent:${agentId}`, + laneId, + modelId: modelId ?? null, + reasoningEffort: reasoningEffort ?? null, + permissionMode: "full-auto", + }); + return summarizeChatSessionForRemote(agentChatService, session); + }); + + register("cto.getState", { viewerAllowed: true }, async (payload) => { + const ctoStateService = requireService(args.ctoStateService, "CTO state service not available."); + const recentLimit = asOptionalNumber(payload.recentLimit); + return ctoStateService.getSnapshot(recentLimit ?? 20); + }); + register("cto.listAgents", { viewerAllowed: true }, async (payload) => { + const workerAgentService = requireService(args.workerAgentService, "Worker agent service not available."); + const includeDeleted = asOptionalBoolean(payload.includeDeleted); + return workerAgentService.listAgents(includeDeleted === undefined ? {} : { includeDeleted }); + }); + register("cto.getBudgetSnapshot", { viewerAllowed: true }, async (payload) => { + const workerBudgetService = requireService(args.workerBudgetService, "Worker budget service not available."); + const monthKey = asTrimmedString(payload.monthKey); + return workerBudgetService.getBudgetSnapshot(monthKey ? { monthKey } : {}); + }); + register("cto.getAgentCoreMemory", { viewerAllowed: true }, async (payload) => { + const workerHeartbeatService = requireService(args.workerHeartbeatService, "Worker heartbeat service not available."); + const agentId = requireString(payload.agentId, "cto.getAgentCoreMemory requires agentId."); + return workerHeartbeatService.getAgentCoreMemory(agentId); + }); + register("cto.listAgentRuns", { viewerAllowed: true }, async (payload) => { + const workerHeartbeatService = requireService(args.workerHeartbeatService, "Worker heartbeat service not available."); + const agentId = requireString(payload.agentId, "cto.listAgentRuns requires agentId."); + const limit = asOptionalNumber(payload.limit); + return workerHeartbeatService.listRuns({ agentId, ...(typeof limit === "number" ? { limit } : {}) }); + }); + register("cto.listAgentSessionLogs", { viewerAllowed: true }, async (payload) => { + const workerHeartbeatService = requireService(args.workerHeartbeatService, "Worker heartbeat service not available."); + const agentId = requireString(payload.agentId, "cto.listAgentSessionLogs requires agentId."); + const limit = asOptionalNumber(payload.limit); + return workerHeartbeatService.listAgentSessionLogs(agentId, limit ?? 40); + }); + register("cto.listAgentRevisions", { viewerAllowed: true }, async (payload) => { + const workerRevisionService = requireService(args.workerRevisionService, "Worker revision service not available."); + const agentId = requireString(payload.agentId, "cto.listAgentRevisions requires agentId."); + const limit = asOptionalNumber(payload.limit); + return workerRevisionService.listAgentRevisions(agentId, limit ?? 20); + }); + register("cto.getFlowPolicy", { viewerAllowed: true }, async () => { + const flowPolicyService = requireService(args.flowPolicyService, "Flow policy service not available."); + return flowPolicyService.getPolicy(); + }); + register("cto.getLinearConnectionStatus", { viewerAllowed: true }, async () => { + const linearCredentialService = requireService(args.linearCredentialService, "Linear credential service not available."); + const credentialStatus = linearCredentialService.getStatus(); + const tokenStored = Boolean(credentialStatus.tokenStored); + const checkedAt = new Date().toISOString(); + const linearIssueTracker = args.getLinearIssueTracker?.() ?? null; + if (!linearIssueTracker || !tokenStored) { + return { + tokenStored, + connected: false, + viewerId: null, + viewerName: null, + checkedAt, + authMode: credentialStatus.authMode, + oauthAvailable: credentialStatus.oauthConfigured, + tokenExpiresAt: credentialStatus.tokenExpiresAt, + message: tokenStored ? "Linear tracker service unavailable." : "Linear token not configured.", + }; + } + const status = await linearIssueTracker.getConnectionStatus(); + return { + tokenStored, + connected: status.connected, + viewerId: status.viewerId, + viewerName: status.viewerName, + organizationId: status.organizationId, + organizationName: status.organizationName, + organizationUrlKey: status.organizationUrlKey, + organizationLogoUrl: status.organizationLogoUrl, + checkedAt, + authMode: credentialStatus.authMode, + oauthAvailable: credentialStatus.oauthConfigured, + tokenExpiresAt: credentialStatus.tokenExpiresAt, + message: status.message, + }; + }); + register("cto.getLinearSyncDashboard", { viewerAllowed: true }, async () => { + const linearSyncService = requireService(args.getLinearSyncService?.() ?? null, "Linear sync service not available."); + return linearSyncService.getDashboard(); + }); + register("cto.listLinearSyncQueue", { viewerAllowed: true }, async () => { + const linearSyncService = requireService(args.getLinearSyncService?.() ?? null, "Linear sync service not available."); + return linearSyncService.listQueue({ limit: 300 }); + }); + register("cto.listLinearIngressEvents", { viewerAllowed: true }, async (payload) => { + const linearIngressService = requireService(args.getLinearIngressService?.() ?? null, "Linear ingress service not available."); + const limit = asOptionalNumber(payload.limit); + return linearIngressService.listRecentEvents(limit ?? 20); + }); + register("cto.updateIdentity", { viewerAllowed: true, queueable: true }, async (payload) => { + const ctoStateService = requireService(args.ctoStateService, "CTO state service not available."); + const patch = isRecord(payload.patch) ? (payload.patch as Partial) : {}; + return ctoStateService.updateIdentity(patch); + }); + register("cto.updateCoreMemory", { viewerAllowed: true, queueable: true }, async (payload) => { + const ctoStateService = requireService(args.ctoStateService, "CTO state service not available."); + const patch = isRecord(payload.patch) ? (payload.patch as Partial) : {}; + return ctoStateService.updateCoreMemory(patch); + }); + register("cto.setAgentStatus", { viewerAllowed: true, queueable: true }, async (payload) => { + const workerAgentService = requireService(args.workerAgentService, "Worker agent service not available."); + const agentId = requireString(payload.agentId, "cto.setAgentStatus requires agentId."); + const status = requireString(payload.status, "cto.setAgentStatus requires status.") as AgentStatus; + workerAgentService.setAgentStatus(agentId, status); + return {}; + }); + register("cto.triggerAgentWakeup", { viewerAllowed: true, queueable: true }, async (payload) => { + const workerHeartbeatService = requireService(args.workerHeartbeatService, "Worker heartbeat service not available."); + const agentId = requireString(payload.agentId, "cto.triggerAgentWakeup requires agentId."); + const reason = asTrimmedString(payload.reason); + const context = isRecord(payload.context) ? payload.context : undefined; + return workerHeartbeatService.triggerWakeup({ + agentId, + ...(reason ? { reason: reason as CtoTriggerAgentWakeupArgs["reason"] } : {}), + ...(context ? { context } : {}), + }); + }); + register("cto.rollbackAgentRevision", { viewerAllowed: true, queueable: true }, async (payload) => { + const workerRevisionService = requireService(args.workerRevisionService, "Worker revision service not available."); + const agentId = requireString(payload.agentId, "cto.rollbackAgentRevision requires agentId."); + const revisionId = requireString(payload.revisionId, "cto.rollbackAgentRevision requires revisionId."); + await workerRevisionService.rollbackAgentRevision(agentId, revisionId, "user"); + return {}; + }); + + register("git.getChanges", { viewerAllowed: true }, async (payload) => + requireService(args.diffService, "Diff service not available.").getChanges(parseGetDiffChangesArgs(payload).laneId)); + register("git.getFile", { viewerAllowed: true }, async (payload) => { + const diffService = requireService(args.diffService, "Diff service not available."); + const parsed = parseGetFileDiffArgs(payload); + return await diffService.getFileDiff({ + laneId: parsed.laneId, + filePath: parsed.path, + mode: parsed.mode, + compareRef: parsed.compareRef, + compareTo: parsed.compareTo, + }); + }); + register("files.writeTextAtomic", { viewerAllowed: true, queueable: true }, async (payload) => { + const parsed = parseWriteTextAtomicArgs(payload); + args.fileService.writeTextAtomic({ laneId: parsed.laneId, relPath: parsed.path, text: parsed.text }); + return { ok: true }; + }); + register("git.stageFile", { viewerAllowed: true, queueable: true }, async (payload) => + requireService(args.gitService, "Git service not available.").stageFile(parseGitFileActionArgs(payload, "git.stageFile"))); + register("git.stageAll", { viewerAllowed: true, queueable: true }, async (payload) => + requireService(args.gitService, "Git service not available.").stageAll(parseGitBatchFileActionArgs(payload, "git.stageAll"))); + register("git.unstageFile", { viewerAllowed: true, queueable: true }, async (payload) => + requireService(args.gitService, "Git service not available.").unstageFile(parseGitFileActionArgs(payload, "git.unstageFile"))); + register("git.unstageAll", { viewerAllowed: true, queueable: true }, async (payload) => + requireService(args.gitService, "Git service not available.").unstageAll(parseGitBatchFileActionArgs(payload, "git.unstageAll"))); + register("git.discardFile", { viewerAllowed: true, queueable: true }, async (payload) => + requireService(args.gitService, "Git service not available.").discardFile(parseGitFileActionArgs(payload, "git.discardFile"))); + register("git.restoreStagedFile", { viewerAllowed: true, queueable: true }, async (payload) => + requireService(args.gitService, "Git service not available.").restoreStagedFile(parseGitFileActionArgs(payload, "git.restoreStagedFile"))); + register("git.commit", { viewerAllowed: true, queueable: true }, async (payload) => + requireService(args.gitService, "Git service not available.").commit(parseGitCommitArgs(payload))); + register("git.generateCommitMessage", { viewerAllowed: true }, async (payload) => + requireService(args.gitService, "Git service not available.").generateCommitMessage(parseGitGenerateCommitMessageArgs(payload))); + register("git.listRecentCommits", { viewerAllowed: true }, async (payload) => + requireService(args.gitService, "Git service not available.").listRecentCommits(parseGitListRecentCommitsArgs(payload))); + register("git.listCommitFiles", { viewerAllowed: true }, async (payload) => + requireService(args.gitService, "Git service not available.").listCommitFiles(parseGitListCommitFilesArgs(payload))); + register("git.getFileHistory", { viewerAllowed: true }, async (payload) => + requireService(args.gitService, "Git service not available.").getFileHistory(parseGitGetFileHistoryArgs(payload))); + register("git.getCommitMessage", { viewerAllowed: true }, async (payload) => + requireService(args.gitService, "Git service not available.").getCommitMessage(parseGitGetCommitMessageArgs(payload))); + register("git.revertCommit", { viewerAllowed: true, queueable: true }, async (payload) => + requireService(args.gitService, "Git service not available.").revertCommit(parseGitRevertArgs(payload))); + register("git.cherryPickCommit", { viewerAllowed: true, queueable: true }, async (payload) => + requireService(args.gitService, "Git service not available.").cherryPickCommit(parseGitCherryPickArgs(payload))); + register("git.stashPush", { viewerAllowed: true, queueable: true }, async (payload) => + requireService(args.gitService, "Git service not available.").stashPush(parseGitStashPushArgs(payload))); + register("git.stashList", { viewerAllowed: true }, async (payload) => + requireService(args.gitService, "Git service not available.").listStashes(parseConflictLaneArgs(payload, "git.stashList"))); + register("git.stashApply", { viewerAllowed: true, queueable: true }, async (payload) => + requireService(args.gitService, "Git service not available.").stashApply(parseGitStashRefArgs(payload, "git.stashApply"))); + register("git.stashPop", { viewerAllowed: true, queueable: true }, async (payload) => + requireService(args.gitService, "Git service not available.").stashPop(parseGitStashRefArgs(payload, "git.stashPop"))); + register("git.stashDrop", { viewerAllowed: true, queueable: true }, async (payload) => + requireService(args.gitService, "Git service not available.").stashDrop(parseGitStashRefArgs(payload, "git.stashDrop"))); + register("git.fetch", { viewerAllowed: true, queueable: true }, async (payload) => + requireService(args.gitService, "Git service not available.").fetch(parseConflictLaneArgs(payload, "git.fetch"))); + register("git.pull", { viewerAllowed: true, queueable: true }, async (payload) => + requireService(args.gitService, "Git service not available.").pull(parseConflictLaneArgs(payload, "git.pull"))); + register("git.getSyncStatus", { viewerAllowed: true }, async (payload) => + requireService(args.gitService, "Git service not available.").getSyncStatus(parseConflictLaneArgs(payload, "git.getSyncStatus"))); + register("git.sync", { viewerAllowed: true, queueable: true }, async (payload) => + requireService(args.gitService, "Git service not available.").sync(parseGitSyncArgs(payload))); + register("git.push", { viewerAllowed: true, queueable: true }, async (payload) => + requireService(args.gitService, "Git service not available.").push(parseGitPushArgs(payload))); + register("git.getConflictState", { viewerAllowed: true }, async (payload) => + requireService(args.gitService, "Git service not available.").getConflictState(parseConflictLaneArgs(payload, "git.getConflictState"))); + register("git.rebaseContinue", { viewerAllowed: true, queueable: true }, async (payload) => + requireService(args.gitService, "Git service not available.").rebaseContinue(parseConflictLaneArgs(payload, "git.rebaseContinue"))); + register("git.rebaseAbort", { viewerAllowed: true, queueable: true }, async (payload) => + requireService(args.gitService, "Git service not available.").rebaseAbort(parseConflictLaneArgs(payload, "git.rebaseAbort"))); + register("git.listBranches", { viewerAllowed: true }, async (payload) => + requireService(args.gitService, "Git service not available.").listBranches(parseGitListBranchesArgs(payload))); + register("git.checkoutBranch", { viewerAllowed: true, queueable: true }, async (payload) => + requireService(args.gitService, "Git service not available.").checkoutBranch(parseGitCheckoutBranchArgs(payload))); + + register("conflicts.getLaneStatus", { viewerAllowed: true }, async (payload) => + requireService(args.conflictService, "Conflict service not available.").getLaneStatus(parseConflictLaneArgs(payload, "conflicts.getLaneStatus"))); + register("conflicts.listOverlaps", { viewerAllowed: true }, async (payload) => + requireService(args.conflictService, "Conflict service not available.").listOverlaps(parseConflictLaneArgs(payload, "conflicts.listOverlaps"))); + register("conflicts.getBatchAssessment", { viewerAllowed: true }, async () => + requireService(args.conflictService, "Conflict service not available.").getBatchAssessment()); + + register("prs.list", { viewerAllowed: true }, async () => args.prService.listAll()); + register("prs.refresh", { viewerAllowed: true }, async (payload) => { + const prId = asTrimmedString(payload.prId); + const prIds = asStringArray(payload.prIds); + let refreshArgs: { prId?: string; prIds?: string[] } = {}; + if (prId) refreshArgs = { prId }; + else if (prIds.length > 0) refreshArgs = { prIds }; + await args.prService.refresh(refreshArgs); + const prs = await args.prService.listAll(); + let refreshedCount = prs.length; + if (prId) refreshedCount = 1; + else if (prIds.length > 0) refreshedCount = prIds.length; + return { + refreshedCount, + prs, + snapshots: args.prService.listSnapshots(), + }; + }); + register("prs.getDetail", { viewerAllowed: true }, async (payload) => args.prService.getDetail(requirePrId(payload, "prs.getDetail"))); + register("prs.getStatus", { viewerAllowed: true }, async (payload) => args.prService.getStatus(requirePrId(payload, "prs.getStatus"))); + register("prs.getChecks", { viewerAllowed: true }, async (payload) => args.prService.getChecks(requirePrId(payload, "prs.getChecks"))); + register("prs.getReviews", { viewerAllowed: true }, async (payload) => args.prService.getReviews(requirePrId(payload, "prs.getReviews"))); + register("prs.getComments", { viewerAllowed: true }, async (payload) => args.prService.getComments(requirePrId(payload, "prs.getComments"))); + register("prs.getFiles", { viewerAllowed: true }, async (payload) => args.prService.getFiles(requirePrId(payload, "prs.getFiles"))); + register("prs.getGitHubSnapshot", { viewerAllowed: true }, async (payload) => + args.prService.getGithubSnapshot({ force: payload.force === true })); + register("prs.getReviewThreads", { viewerAllowed: true }, async (payload) => args.prService.getReviewThreads(requirePrId(payload, "prs.getReviewThreads"))); + register("prs.getActionRuns", { viewerAllowed: true }, async (payload) => args.prService.getActionRuns(requirePrId(payload, "prs.getActionRuns"))); + register("prs.getActivity", { viewerAllowed: true }, async (payload) => args.prService.getActivity(requirePrId(payload, "prs.getActivity"))); + register("prs.getDeployments", { viewerAllowed: true }, async (payload) => args.prService.getDeployments(requirePrId(payload, "prs.getDeployments"))); + register("prs.createFromLane", { viewerAllowed: true, queueable: true }, async (payload) => args.prService.createFromLane(parseCreatePrArgs(payload))); + register("prs.linkToLane", { viewerAllowed: true, queueable: true }, async (payload) => args.prService.linkToLane(parseLinkPrToLaneArgs(payload))); + register("prs.draftDescription", { viewerAllowed: true, queueable: true }, async (payload) => + args.prService.draftDescription(parseDraftPrDescriptionArgs(payload))); + register("prs.land", { viewerAllowed: true, queueable: true }, async (payload) => args.prService.land(parseLandPrArgs(payload))); + register("prs.close", { viewerAllowed: true, queueable: true }, async (payload) => { + await args.prService.closePr(parseClosePrArgs(payload)); + return { ok: true }; + }); + register("prs.reopen", { viewerAllowed: true, queueable: true }, async (payload) => { + await args.prService.reopenPr(parseReopenPrArgs(payload)); + return { ok: true }; + }); + register("prs.requestReviewers", { viewerAllowed: true, queueable: true }, async (payload) => { + await args.prService.requestReviewers(parseRequestReviewersArgs(payload)); + return { ok: true }; + }); + register("prs.rerunChecks", { viewerAllowed: true, queueable: true }, async (payload) => { + await args.prService.rerunChecks(parseRerunPrChecksArgs(payload)); + return { ok: true }; + }); + register("prs.addComment", { viewerAllowed: true, queueable: true }, async (payload) => + args.prService.addComment(parseAddPrCommentArgs(payload))); + register("prs.updateTitle", { viewerAllowed: true, queueable: true }, async (payload) => { + await args.prService.updateTitle(parseUpdatePrTitleArgs(payload)); + return { ok: true }; + }); + register("prs.updateBody", { viewerAllowed: true, queueable: true }, async (payload) => { + await args.prService.updateBody(parseUpdatePrBodyArgs(payload)); + return { ok: true }; + }); + register("prs.setLabels", { viewerAllowed: true, queueable: true }, async (payload) => { + await args.prService.setLabels(parseSetPrLabelsArgs(payload)); + return { ok: true }; + }); + register("prs.submitReview", { viewerAllowed: true, queueable: true }, async (payload) => { + await args.prService.submitReview(parseSubmitPrReviewArgs(payload)); + return { ok: true }; + }); + register("prs.replyToReviewThread", { viewerAllowed: true, queueable: true }, async (payload) => + args.prService.replyToReviewThread(parseReplyToReviewThreadArgs(payload))); + register("prs.setReviewThreadResolved", { viewerAllowed: true, queueable: true }, async (payload) => + args.prService.setReviewThreadResolved(parseSetReviewThreadResolvedArgs(payload))); + register("prs.reactToComment", { viewerAllowed: true, queueable: true }, async (payload) => { + await args.prService.reactToComment(parseReactToCommentArgs(payload)); + return { ok: true }; + }); + register("prs.aiReviewSummary", { viewerAllowed: true, queueable: true }, async (payload) => + args.prService.aiReviewSummary(parseAiReviewSummaryArgs(payload))); + register("prs.listIntegrationWorkflows", { viewerAllowed: true }, async (payload) => + args.prService.listIntegrationWorkflows(parseListIntegrationWorkflowsArgs(payload))); + register("prs.updateIntegrationProposal", { viewerAllowed: true, queueable: true }, async (payload) => { + args.prService.updateIntegrationProposal(parseUpdateIntegrationProposalArgs(payload)); + return { ok: true }; + }); + register("prs.deleteIntegrationProposal", { viewerAllowed: true, queueable: true }, async (payload) => + args.prService.deleteIntegrationProposal(parseDeleteIntegrationProposalArgs(payload))); + register("prs.dismissIntegrationCleanup", { viewerAllowed: true, queueable: true }, async (payload) => + args.prService.dismissIntegrationCleanup(parseDismissIntegrationCleanupArgs(payload))); + register("prs.cleanupIntegrationWorkflow", { viewerAllowed: true, queueable: true }, async (payload) => + args.prService.cleanupIntegrationWorkflow(parseCleanupIntegrationWorkflowArgs(payload))); + register("prs.createIntegrationLaneForProposal", { viewerAllowed: true, queueable: true }, async (payload) => + args.prService.createIntegrationLaneForProposal(parseCreateIntegrationLaneForProposalArgs(payload))); + register("prs.startIntegrationResolution", { viewerAllowed: true, queueable: true }, async (payload) => + args.prService.startIntegrationResolution(parseStartIntegrationResolutionArgs(payload))); + register("prs.recheckIntegrationStep", { viewerAllowed: true, queueable: true }, async (payload) => + args.prService.recheckIntegrationStep(parseRecheckIntegrationStepArgs(payload))); + register("prs.landQueueNext", { viewerAllowed: true, queueable: true }, async (payload) => + args.prService.landQueueNext(parseLandQueueNextArgs(payload))); + register("prs.pauseQueueAutomation", { viewerAllowed: true, queueable: true }, async (payload) => { + if (!args.queueLandingService) throw new Error("Queue automation is not available."); + return args.queueLandingService.pauseQueue(parsePauseQueueAutomationArgs(payload).queueId); + }); + register("prs.resumeQueueAutomation", { viewerAllowed: true, queueable: true }, async (payload) => { + if (!args.queueLandingService) throw new Error("Queue automation is not available."); + return args.queueLandingService.resumeQueue(parseResumeQueueAutomationArgs(payload)); + }); + register("prs.cancelQueueAutomation", { viewerAllowed: true, queueable: true }, async (payload) => { + if (!args.queueLandingService) throw new Error("Queue automation is not available."); + return args.queueLandingService.cancelQueue(parseCancelQueueAutomationArgs(payload).queueId); + }); + register("prs.reorderQueue", { viewerAllowed: true, queueable: true }, async (payload) => { + await args.prService.reorderQueuePrs(parseReorderQueuePrsArgs(payload)); + return { ok: true }; + }); + register("prs.issueInventory.sync", { viewerAllowed: true, queueable: true }, async (payload) => { + if (!args.issueInventoryService) throw new Error("Issue inventory is not available."); + const { prId } = parseIssueInventoryPrArgs(payload, "prs.issueInventory.sync"); + const [checks, reviewThreads, comments] = await Promise.all([ + args.prService.getChecks(prId), + args.prService.getReviewThreads(prId), + args.prService.getComments(prId).catch(() => []), + ]); + return args.issueInventoryService.syncFromPrData(prId, checks, reviewThreads, comments); + }); + register("prs.issueInventory.get", { viewerAllowed: true }, async (payload) => { + if (!args.issueInventoryService) throw new Error("Issue inventory is not available."); + return args.issueInventoryService.getInventory(parseIssueInventoryPrArgs(payload, "prs.issueInventory.get").prId); + }); + register("prs.issueInventory.getNew", { viewerAllowed: true }, async (payload) => { + if (!args.issueInventoryService) throw new Error("Issue inventory is not available."); + return args.issueInventoryService.getNewItems(parseIssueInventoryPrArgs(payload, "prs.issueInventory.getNew").prId); + }); + register("prs.issueInventory.markFixed", { viewerAllowed: true, queueable: true }, async (payload) => { + if (!args.issueInventoryService) throw new Error("Issue inventory is not available."); + const parsed = parseIssueInventoryItemsArgs(payload, "prs.issueInventory.markFixed"); + args.issueInventoryService.markFixed(parsed.prId, parsed.itemIds); + return { ok: true }; + }); + register("prs.issueInventory.markDismissed", { viewerAllowed: true, queueable: true }, async (payload) => { + if (!args.issueInventoryService) throw new Error("Issue inventory is not available."); + const parsed = parseIssueInventoryDismissArgs(payload); + args.issueInventoryService.markDismissed(parsed.prId, parsed.itemIds, parsed.reason); + return { ok: true }; + }); + register("prs.issueInventory.markEscalated", { viewerAllowed: true, queueable: true }, async (payload) => { + if (!args.issueInventoryService) throw new Error("Issue inventory is not available."); + const parsed = parseIssueInventoryItemsArgs(payload, "prs.issueInventory.markEscalated"); + args.issueInventoryService.markEscalated(parsed.prId, parsed.itemIds); + return { ok: true }; + }); + register("prs.issueInventory.getConvergence", { viewerAllowed: true }, async (payload) => { + if (!args.issueInventoryService) throw new Error("Issue inventory is not available."); + return args.issueInventoryService.getConvergenceStatus(parseIssueInventoryPrArgs(payload, "prs.issueInventory.getConvergence").prId); + }); + register("prs.issueInventory.reset", { viewerAllowed: true, queueable: true }, async (payload) => { + if (!args.issueInventoryService) throw new Error("Issue inventory is not available."); + args.issueInventoryService.resetInventory(parseIssueInventoryPrArgs(payload, "prs.issueInventory.reset").prId); + return { ok: true }; + }); + register("prs.convergenceState.get", { viewerAllowed: true }, async (payload) => { + if (!args.issueInventoryService) throw new Error("Issue inventory is not available."); + return args.issueInventoryService.getConvergenceRuntime(parseIssueInventoryPrArgs(payload, "prs.convergenceState.get").prId); + }); + register("prs.convergenceState.save", { viewerAllowed: true, queueable: true }, async (payload) => { + if (!args.issueInventoryService) throw new Error("Issue inventory is not available."); + const parsed = parseConvergenceStatePatch(payload); + return args.issueInventoryService.saveConvergenceRuntime(parsed.prId, parsed.state); + }); + register("prs.convergenceState.delete", { viewerAllowed: true, queueable: true }, async (payload) => { + if (!args.issueInventoryService) throw new Error("Issue inventory is not available."); + args.issueInventoryService.resetConvergenceRuntime(parseIssueInventoryPrArgs(payload, "prs.convergenceState.delete").prId); + return { ok: true }; + }); + register("prs.pipelineSettings.get", { viewerAllowed: true }, async (payload) => { + if (!args.issueInventoryService) throw new Error("Issue inventory is not available."); + return args.issueInventoryService.getPipelineSettings(parseIssueInventoryPrArgs(payload, "prs.pipelineSettings.get").prId); + }); + register("prs.pipelineSettings.save", { viewerAllowed: true, queueable: true }, async (payload) => { + if (!args.issueInventoryService) throw new Error("Issue inventory is not available."); + const parsed = parsePipelineSettingsPatch(payload); + args.issueInventoryService.savePipelineSettings(parsed.prId, parsed.settings); + return { ok: true }; + }); + register("prs.pipelineSettings.delete", { viewerAllowed: true, queueable: true }, async (payload) => { + if (!args.issueInventoryService) throw new Error("Issue inventory is not available."); + args.issueInventoryService.deletePipelineSettings(parseIssueInventoryPrArgs(payload, "prs.pipelineSettings.delete").prId); + return { ok: true }; + }); + register("prs.pathToMerge.start", { viewerAllowed: true, queueable: true }, async (payload) => { + if (!args.pathToMergeOrchestrator) { + throw new Error("Path to Merge orchestrator is not available in this build."); + } + const { prId } = parseIssueInventoryPrArgs(payload, "prs.pathToMerge.start"); + const modelId = typeof payload?.modelId === "string" ? payload.modelId : null; + const reasoning = typeof payload?.reasoning === "string" ? payload.reasoning : null; + const additionalInstructions = typeof payload?.additionalInstructions === "string" + ? payload.additionalInstructions + : null; + const rawScope = payload?.scope; + const scope = rawScope === "checks" || rawScope === "comments" || rawScope === "both" + ? rawScope + : undefined; + return args.pathToMergeOrchestrator.startPathToMerge({ + prId, + modelId, + reasoning, + scope, + additionalInstructions, + }); + }); + register("prs.pathToMerge.stop", { viewerAllowed: true, queueable: true }, async (payload) => { + if (!args.pathToMergeOrchestrator) { + throw new Error("Path to Merge orchestrator is not available in this build."); + } + const { prId } = parseIssueInventoryPrArgs(payload, "prs.pathToMerge.stop"); + const reason = typeof payload?.reason === "string" ? payload.reason : null; + return args.pathToMergeOrchestrator.stopPathToMerge({ prId, reason }); + }); + register("prs.getMobileSnapshot", { viewerAllowed: true }, async () => args.prService.getMobileSnapshot()); + + return { + getSupportedActions(): SyncRemoteCommandAction[] { + return [...registry.keys()]; + }, + + getDescriptors(): SyncRemoteCommandDescriptor[] { + return [...registry.values()].map((entry) => entry.descriptor); + }, + + getPolicy(action: string): SyncRemoteCommandPolicy | null { + return registry.get(action as SyncRemoteCommandAction)?.descriptor.policy ?? null; + }, + + getDescriptor(action: string): SyncRemoteCommandDescriptor | null { + return registry.get(action as SyncRemoteCommandAction)?.descriptor ?? null; + }, + + async execute(payload: SyncCommandPayload): Promise { + const handler = registry.get(payload.action as SyncRemoteCommandAction); + if (!handler) { + throw new Error(`Unsupported remote command: ${payload.action}`); + } + const commandArgs = isRecord(payload.args) ? payload.args : {}; + args.logger.debug?.("sync.remote_command.execute", { + action: payload.action, + scope: handler.descriptor.scope, + policy: handler.descriptor.policy, + }); + return await handler.handler(commandArgs); + }, + }; +} + +export type SyncRemoteCommandService = ReturnType; diff --git a/apps/ade-cli/src/services/sync/syncService.ts b/apps/ade-cli/src/services/sync/syncService.ts new file mode 100644 index 000000000..bf9dacab0 --- /dev/null +++ b/apps/ade-cli/src/services/sync/syncService.ts @@ -0,0 +1,1155 @@ +import fs from "node:fs"; +import path from "node:path"; +import { randomInt } from "node:crypto"; +import { resolveAdeLayout } from "../../../../desktop/src/shared/adeLayout"; +import type { + SyncAddressCandidate, + SyncDesktopConnectionDraft, + SyncDeviceRuntimeState, + SyncGetStatusArgs, + SyncPairingConnectInfo, + SyncProjectCatalogPayload, + SyncProjectSwitchRequestPayload, + SyncProjectSwitchResultPayload, + SyncRoleSnapshot, + SyncTailnetDiscoveryStatus, + SyncTransferBlocker, + SyncTransferReadiness, +} from "../../../../desktop/src/shared/types"; +import type { Logger } from "../../../../desktop/src/main/services/logging/logger"; +import type { createAgentChatService } from "../../../../desktop/src/main/services/chat/agentChatService"; +import type { createCtoStateService } from "../../../../desktop/src/main/services/cto/ctoStateService"; +import type { createFlowPolicyService } from "../../../../desktop/src/main/services/cto/flowPolicyService"; +import type { createLinearCredentialService } from "../../../../desktop/src/main/services/cto/linearCredentialService"; +import type { createLinearIngressService } from "../../../../desktop/src/main/services/cto/linearIngressService"; +import type { createLinearIssueTracker } from "../../../../desktop/src/main/services/cto/linearIssueTracker"; +import type { createLinearSyncService } from "../../../../desktop/src/main/services/cto/linearSyncService"; +import type { createWorkerAgentService } from "../../../../desktop/src/main/services/cto/workerAgentService"; +import type { createWorkerBudgetService } from "../../../../desktop/src/main/services/cto/workerBudgetService"; +import type { createWorkerHeartbeatService } from "../../../../desktop/src/main/services/cto/workerHeartbeatService"; +import type { createWorkerRevisionService } from "../../../../desktop/src/main/services/cto/workerRevisionService"; +import type { createComputerUseArtifactBrokerService } from "../../../../desktop/src/main/services/computerUse/computerUseArtifactBrokerService"; +import type { createProjectConfigService } from "../../../../desktop/src/main/services/config/projectConfigService"; +import type { createFileService } from "../../../../desktop/src/main/services/files/fileService"; +import type { createDiffService } from "../../../../desktop/src/main/services/diffs/diffService"; +import type { createGitOperationsService } from "../../../../desktop/src/main/services/git/gitOperationsService"; +import type { createConflictService } from "../../../../desktop/src/main/services/conflicts/conflictService"; +import type { createLaneEnvironmentService } from "../../../../desktop/src/main/services/lanes/laneEnvironmentService"; +import type { createLaneService } from "../../../../desktop/src/main/services/lanes/laneService"; +import type { createLaneTemplateService } from "../../../../desktop/src/main/services/lanes/laneTemplateService"; +import type { createAutoRebaseService } from "../../../../desktop/src/main/services/lanes/autoRebaseService"; +import type { createPortAllocationService } from "../../../../desktop/src/main/services/lanes/portAllocationService"; +import type { createRebaseSuggestionService } from "../../../../desktop/src/main/services/lanes/rebaseSuggestionService"; +import type { createMissionService } from "../../../../desktop/src/main/services/missions/missionService"; +import type { createProcessService } from "../../../../desktop/src/main/services/processes/processService"; +import type { createIssueInventoryService } from "../../../../desktop/src/main/services/prs/issueInventoryService"; +import type { PathToMergeOrchestrator } from "../../../../desktop/src/main/services/prs/pathToMergeOrchestrator"; +import type { createPrService } from "../../../../desktop/src/main/services/prs/prService"; +import type { createQueueLandingService } from "../../../../desktop/src/main/services/prs/queueLandingService"; +import type { createPtyService } from "../../../../desktop/src/main/services/pty/ptyService"; +import type { createSessionService } from "../../../../desktop/src/main/services/sessions/sessionService"; +import type { NotificationEventBus } from "../../../../desktop/src/main/services/notifications/notificationEventBus"; +import type { AdeDb } from "../../../../desktop/src/main/services/state/kvDb"; +import { nowIso, safeJsonParse, sleep, writeTextAtomic } from "../../../../desktop/src/main/services/shared/utils"; +import { createDeviceRegistryService } from "./deviceRegistryService"; +import { + createSyncHostService, + SYNC_TAILNET_DISCOVERY_SERVICE_NAME, + SYNC_TAILNET_DISCOVERY_SERVICE_PORT, + type SyncHostService, + type SyncRuntimeKind, +} from "./syncHostService"; +import { createSyncPeerService } from "./syncPeerService"; +import { createSyncPinStore } from "./syncPinStore"; +import { DEFAULT_SYNC_HOST_PORT } from "./syncProtocol"; +import { createSyncRemoteCommandService, type SyncRemoteCommandService } from "./syncRemoteCommandService"; + +type SyncServiceArgs = { + db: AdeDb; + logger: Logger; + projectId?: string | null; + projectRoot: string; + appVersion?: string; + runtimeKind?: SyncRuntimeKind; + localDeviceIdPath?: string; + phonePairingStateDir?: string; + fileService: ReturnType; + laneService: ReturnType; + gitService?: ReturnType; + diffService?: ReturnType; + conflictService?: ReturnType; + prService: ReturnType; + issueInventoryService?: ReturnType | null; + /** + * Optional Path-to-Merge orchestrator forwarded to the embedded sync host so + * iOS callers can drive the convergence loop via remote commands. + */ + pathToMergeOrchestrator?: PathToMergeOrchestrator | null; + queueLandingService?: ReturnType | null; + sessionService: ReturnType; + ptyService: ReturnType; + projectConfigService?: ReturnType; + portAllocationService?: ReturnType; + laneEnvironmentService?: ReturnType; + laneTemplateService?: ReturnType; + rebaseSuggestionService?: ReturnType< + typeof createRebaseSuggestionService + > | null; + autoRebaseService?: ReturnType | null; + computerUseArtifactBrokerService: ReturnType< + typeof createComputerUseArtifactBrokerService + >; + missionService: ReturnType; + agentChatService: ReturnType; + workerAgentService?: ReturnType | null; + workerBudgetService?: ReturnType | null; + workerHeartbeatService?: ReturnType | null; + workerRevisionService?: ReturnType | null; + ctoStateService?: ReturnType | null; + flowPolicyService?: ReturnType | null; + linearCredentialService?: ReturnType | null; + /** + * Resolvers for services that are constructed AFTER createSyncService in + * main.ts. Using lazy getters lets the sync router forward remote commands + * to them without requiring a specific init order. + */ + getLinearIngressService?: () => ReturnType | null; + getLinearIssueTracker?: () => ReturnType | null; + getLinearSyncService?: () => ReturnType | null; + processService: ReturnType; + hostStartupEnabled?: boolean; + hostDiscoveryEnabled?: boolean; + /** + * Phone sync is hosted by the local ADE service. When enabled, legacy + * machine-to-machine viewer state stored in a project DB cannot demote the + * phone sync surface into viewer mode. + */ + forceHostRole?: boolean; + onStatusChanged?: (snapshot: SyncRoleSnapshot) => void; + /** + * Optional notification bus forwarded to the sync host. The host publishes + * chat/PR/mission/system events and invokes `sendInAppNotification` for + * connected iOS peers. + */ + notificationEventBus?: NotificationEventBus | null; + projectCatalogProvider?: { + listProjects: () => Promise; + prepareProjectConnection: (args: SyncProjectSwitchRequestPayload) => Promise; + completeProjectConnection?: ( + args: SyncProjectSwitchRequestPayload, + result: SyncProjectSwitchResultPayload, + ) => Promise; + }; + remoteCommandExecutor?: Pick; +}; + +const DRAFT_FILE = "sync-peer-draft.json"; +const TOKEN_FILE = "sync-bootstrap-token"; +const PIN_FILE = "sync-pin.json"; +const PAIRED_DEVICES_FILE = "sync-paired-devices.json"; + +function readPairingRecords(filePath: string): Record { + try { + const parsed = JSON.parse(fs.readFileSync(filePath, "utf8")) as unknown; + return parsed && typeof parsed === "object" && !Array.isArray(parsed) + ? parsed as Record + : {}; + } catch { + return {}; + } +} + +function migrateLegacySyncSecretFile(args: { + legacyPath: string; + appPath: string; + logger: Logger; + label: string; +}): void { + if (args.legacyPath === args.appPath) return; + if (!fs.existsSync(args.legacyPath)) return; + if (args.label === PAIRED_DEVICES_FILE && fs.existsSync(args.appPath)) { + const merged = readPairingRecords(args.appPath); + const legacy = readPairingRecords(args.legacyPath); + let changed = false; + for (const [deviceId, record] of Object.entries(legacy)) { + if (!deviceId.trim() || Object.prototype.hasOwnProperty.call(merged, deviceId)) continue; + merged[deviceId] = record; + changed = true; + } + if (!changed) return; + try { + fs.mkdirSync(path.dirname(args.appPath), { recursive: true }); + fs.writeFileSync(args.appPath, `${JSON.stringify(merged, null, 2)}\n`, { mode: 0o600 }); + args.logger.info("sync.app_pairing_state_merged", { + label: args.label, + legacyPath: args.legacyPath, + appPath: args.appPath, + }); + } catch (error) { + args.logger.warn("sync.app_pairing_state_migration_failed", { + label: args.label, + legacyPath: args.legacyPath, + appPath: args.appPath, + error: error instanceof Error ? error.message : String(error), + }); + } + return; + } + if (fs.existsSync(args.appPath)) return; + try { + fs.mkdirSync(path.dirname(args.appPath), { recursive: true }); + fs.copyFileSync(args.legacyPath, args.appPath, fs.constants.COPYFILE_EXCL); + args.logger.info("sync.app_pairing_state_migrated", { + label: args.label, + legacyPath: args.legacyPath, + appPath: args.appPath, + }); + } catch (error) { + if ((error as NodeJS.ErrnoException | null | undefined)?.code === "EEXIST") return; + args.logger.warn("sync.app_pairing_state_migration_failed", { + label: args.label, + legacyPath: args.legacyPath, + appPath: args.appPath, + error: error instanceof Error ? error.message : String(error), + }); + } +} +const RUNNING_PROCESS_STATES = new Set(["starting", "running", "degraded"]); +const CHAT_TOOL_TYPES = new Set(["codex-chat", "claude-chat", "opencode-chat"]); +const SYNC_HOST_PORT_RETRY_WINDOW = 12; +const LOCAL_LANE_PRESENCE_HEARTBEAT_MS = 30_000; +const TRANSFER_READINESS_CACHE_MS = 15_000; + +function generatePairingPin(): string { + return randomInt(0, 1_000_000).toString().padStart(6, "0"); +} + +function buildSkippedTransferReadiness(): SyncTransferReadiness { + return { + ready: false, + blockers: [], + survivableState: [ + "Transfer readiness was skipped for this lightweight sync status request.", + ], + }; +} + +function sanitizeDraft( + raw: unknown, + token: string | null, +): SyncDesktopConnectionDraft | null { + if (!raw || typeof raw !== "object" || !token) return null; + const row = raw as Record; + const host = typeof row.host === "string" ? row.host.trim() : ""; + const port = Number(row.port ?? 0); + if (!host || !Number.isFinite(port) || port <= 0) return null; + return { + host, + port: Math.floor(port), + token, + authKind: row.authKind === "paired" ? "paired" : "bootstrap", + pairedDeviceId: + typeof row.pairedDeviceId === "string" ? row.pairedDeviceId : null, + lastRemoteDbVersion: Number.isFinite(row.lastRemoteDbVersion) + ? Number(row.lastRemoteDbVersion) + : 0, + }; +} + +function normalizeHost(host: string | null | undefined): string | null { + if (!host) return null; + const normalized = host.trim().toLowerCase(); + return normalized.length > 0 ? normalized : null; +} + +function tailscaleDnsNameFromDevice( + localDevice: SyncRoleSnapshot["localDevice"], +): string | null { + const value = localDevice.metadata?.tailscaleDnsName; + return typeof value === "string" && value.trim().toLowerCase().endsWith(".ts.net") + ? value.trim().replace(/\.$/, "").toLowerCase() + : null; +} + +function buildAddressCandidates( + localDevice: SyncRoleSnapshot["localDevice"], +): SyncAddressCandidate[] { + const candidates: SyncAddressCandidate[] = []; + const seen = new Set(); + const append = ( + host: string | null | undefined, + kind: SyncAddressCandidate["kind"], + ) => { + const normalized = normalizeHost(host); + if (!normalized || seen.has(normalized)) return; + seen.add(normalized); + candidates.push({ host: normalized, kind }); + }; + const preferredSavedHost = normalizeHost(localDevice.lastHost); + const preferredSavedHostIsCurrent = preferredSavedHost != null && ( + localDevice.ipAddresses.some((host) => normalizeHost(host) === preferredSavedHost) + || normalizeHost(localDevice.tailscaleIp) === preferredSavedHost + || tailscaleDnsNameFromDevice(localDevice) === preferredSavedHost + ); + if (preferredSavedHostIsCurrent) { + append(localDevice.lastHost, "saved"); + } + for (const lanAddress of localDevice.ipAddresses) { + append(lanAddress, "lan"); + } + if (!preferredSavedHostIsCurrent) { + append(localDevice.lastHost, "saved"); + } + append(tailscaleDnsNameFromDevice(localDevice), "tailscale"); + append(localDevice.tailscaleIp, "tailscale"); + append("127.0.0.1", "loopback"); + return candidates; +} + +function buildPairingConnectInfo(argsIn: { + localDevice: SyncRoleSnapshot["localDevice"]; +}): SyncPairingConnectInfo { + const port = argsIn.localDevice.lastPort ?? DEFAULT_SYNC_HOST_PORT; + const addressCandidates = buildAddressCandidates(argsIn.localDevice); + const hostIdentity = { + deviceId: argsIn.localDevice.deviceId, + siteId: argsIn.localDevice.siteId, + name: argsIn.localDevice.name, + platform: argsIn.localDevice.platform, + deviceType: argsIn.localDevice.deviceType, + }; + return { + hostIdentity, + port, + addressCandidates, + }; +} + +function isRetryableHostBindError(error: unknown): boolean { + const code = (error as NodeJS.ErrnoException | null | undefined)?.code ?? ""; + return code === "EADDRINUSE" || code === "EACCES"; +} + +function createInactiveTailnetDiscoveryStatus( + error: string, +): SyncTailnetDiscoveryStatus { + return { + state: "disabled", + serviceName: SYNC_TAILNET_DISCOVERY_SERVICE_NAME, + servicePort: SYNC_TAILNET_DISCOVERY_SERVICE_PORT, + target: null, + updatedAt: null, + error, + stderr: null, + }; +} + +function buildHostPortCandidates(preferredPort: number | null | undefined): number[] { + const preferred = Number.isFinite(preferredPort) + ? Math.max(0, Math.min(65_535, Math.floor(Number(preferredPort)))) + : DEFAULT_SYNC_HOST_PORT; + const candidates: number[] = []; + const seen = new Set(); + const add = (port: number) => { + const normalized = Math.max(0, Math.min(65_535, Math.floor(port))); + if (seen.has(normalized)) return; + seen.add(normalized); + candidates.push(normalized); + }; + add(preferred); + if (preferred !== DEFAULT_SYNC_HOST_PORT) { + add(DEFAULT_SYNC_HOST_PORT); + } + for (let offset = 1; offset <= SYNC_HOST_PORT_RETRY_WINDOW; offset += 1) { + if (preferred + offset <= 65_535) { + add(preferred + offset); + } + } + if (preferred !== DEFAULT_SYNC_HOST_PORT) { + for (let offset = 1; offset <= Math.min(4, SYNC_HOST_PORT_RETRY_WINDOW); offset += 1) { + if (DEFAULT_SYNC_HOST_PORT + offset <= 65_535) { + add(DEFAULT_SYNC_HOST_PORT + offset); + } + } + } + add(0); + return candidates; +} + +export function createSyncService(args: SyncServiceArgs) { + const layout = resolveAdeLayout(args.projectRoot); + const pairingStateDir = args.phonePairingStateDir ?? layout.secretsDir; + const draftPath = path.join(pairingStateDir, DRAFT_FILE); + const tokenPath = path.join(pairingStateDir, TOKEN_FILE); + const pinPath = path.join(pairingStateDir, PIN_FILE); + const pairingSecretsPath = path.join(pairingStateDir, PAIRED_DEVICES_FILE); + migrateLegacySyncSecretFile({ + legacyPath: path.join(layout.secretsDir, DRAFT_FILE), + appPath: draftPath, + logger: args.logger, + label: DRAFT_FILE, + }); + migrateLegacySyncSecretFile({ + legacyPath: path.join(layout.secretsDir, TOKEN_FILE), + appPath: tokenPath, + logger: args.logger, + label: TOKEN_FILE, + }); + migrateLegacySyncSecretFile({ + legacyPath: path.join(layout.secretsDir, PIN_FILE), + appPath: pinPath, + logger: args.logger, + label: PIN_FILE, + }); + migrateLegacySyncSecretFile({ + legacyPath: path.join(layout.secretsDir, PAIRED_DEVICES_FILE), + appPath: pairingSecretsPath, + logger: args.logger, + label: PAIRED_DEVICES_FILE, + }); + fs.mkdirSync(path.dirname(draftPath), { recursive: true }); + + const pinStore = createSyncPinStore({ filePath: pinPath }); + + const deviceRegistryService = createDeviceRegistryService({ + db: args.db, + logger: args.logger, + projectRoot: args.projectRoot, + localDeviceIdPath: args.localDeviceIdPath, + }); + + let hostService: SyncHostService | null = null; + let refreshRunning = false; + let refreshQueued = false; + let disposed = false; + // Mobile project switch can fire `sync.initialize` as a background task and + // then immediately await `service.initialize()` from the dialog handler. + // Coalesce concurrent calls so the second await rides the first promise + // rather than re-running ensureLocalDevice/refreshRoleState in parallel. + let initializingPromise: Promise | null = null; + let initialized = false; + let hostStartupEnabled = args.hostStartupEnabled !== false; + let hostDiscoveryEnabled = args.hostDiscoveryEnabled !== false; + let transferReadinessCache: { value: SyncTransferReadiness; expiresAtMs: number } | null = null; + let transferReadinessInFlight: Promise | null = null; + const forceHostRole = args.forceHostRole === true; + const isCrdtSyncAvailable = (): boolean => args.db.sync.isAvailable?.() !== false; + const assertPhonePairingAvailable = (): void => { + if (!hostStartupEnabled) { + throw new Error( + "Phone pairing is unavailable because the sync host is disabled for this ADE process.", + ); + } + if (!isCrdtSyncAvailable()) { + throw new Error( + "Phone pairing is unavailable because the CRDT database extension is unavailable on this platform.", + ); + } + }; + let activeLocalLanePresenceIds: string[] = []; + const localLanePresenceHeartbeatTimer = setInterval(() => { + if (disposed || !hostService || activeLocalLanePresenceIds.length === 0) return; + hostService.setLocalActiveLanePresence?.(activeLocalLanePresenceIds); + }, LOCAL_LANE_PRESENCE_HEARTBEAT_MS); + + const readToken = (): string | null => { + if (!fs.existsSync(tokenPath)) return null; + const value = fs.readFileSync(tokenPath, "utf8").trim(); + return value.length > 0 ? value : null; + }; + + const writeToken = (token: string): void => { + writeTextAtomic(tokenPath, `${token.trim()}\n`); + }; + + const readSavedDraft = (): SyncDesktopConnectionDraft | null => { + if (forceHostRole) return null; + if (!fs.existsSync(draftPath)) return null; + const token = readToken(); + return sanitizeDraft( + safeJsonParse(fs.readFileSync(draftPath, "utf8"), null), + token, + ); + }; + + const writeSavedDraft = (draft: SyncDesktopConnectionDraft | null): void => { + if (!draft) { + try { + fs.rmSync(draftPath, { force: true }); + } catch { + // ignore + } + return; + } + writeToken(draft.token); + writeTextAtomic( + draftPath, + `${JSON.stringify( + { + host: draft.host, + port: draft.port, + authKind: draft.authKind ?? "bootstrap", + pairedDeviceId: draft.pairedDeviceId ?? null, + lastRemoteDbVersion: draft.lastRemoteDbVersion ?? 0, + }, + null, + 2, + )}\n`, + ); + }; + + const syncPeerService = createSyncPeerService({ + db: args.db, + logger: args.logger, + deviceRegistryService, + onStatusChange: (status) => { + if (forceHostRole) return; + if (status.savedDraft) { + const token = readToken(); + if (token) { + writeSavedDraft({ + host: status.savedDraft.host, + port: status.savedDraft.port, + token, + authKind: status.savedDraft.authKind ?? "bootstrap", + pairedDeviceId: status.savedDraft.pairedDeviceId ?? null, + lastRemoteDbVersion: status.savedDraft.lastRemoteDbVersion ?? 0, + }); + } + } + void emitStatus(); + }, + onBrainStatus: (payload) => { + deviceRegistryService.applyBrainStatus(payload); + void emitStatus(); + }, + onRemoteChangesApplied: () => { + void refreshRoleState(); + }, + }); + + const remoteCommandService = createSyncRemoteCommandService({ + laneService: args.laneService, + prService: args.prService, + issueInventoryService: args.issueInventoryService, + pathToMergeOrchestrator: args.pathToMergeOrchestrator, + queueLandingService: args.queueLandingService, + ptyService: args.ptyService, + sessionService: args.sessionService, + fileService: args.fileService, + gitService: args.gitService, + diffService: args.diffService, + conflictService: args.conflictService, + agentChatService: args.agentChatService, + workerAgentService: args.workerAgentService, + workerBudgetService: args.workerBudgetService, + workerHeartbeatService: args.workerHeartbeatService, + workerRevisionService: args.workerRevisionService, + ctoStateService: args.ctoStateService, + flowPolicyService: args.flowPolicyService, + linearCredentialService: args.linearCredentialService, + getLinearIngressService: args.getLinearIngressService, + getLinearIssueTracker: args.getLinearIssueTracker, + getLinearSyncService: args.getLinearSyncService, + projectConfigService: args.projectConfigService, + processService: args.processService, + portAllocationService: args.portAllocationService, + laneEnvironmentService: args.laneEnvironmentService, + laneTemplateService: args.laneTemplateService, + rebaseSuggestionService: args.rebaseSuggestionService ?? undefined, + autoRebaseService: args.autoRebaseService ?? undefined, + logger: args.logger, + }); + + const emitStatus = async (): Promise => { + if (disposed) return; + args.onStatusChanged?.(await service.getStatus()); + }; + + const startHostIfNeeded = async (): Promise => { + if (!hostStartupEnabled || !isCrdtSyncAvailable()) { + if (hostService) { + await stopHostIfRunning(); + } + const currentLocalDevice = deviceRegistryService.ensureLocalDevice(); + deviceRegistryService.touchLocalDevice({ + lastSeenAt: nowIso(), + lastHost: currentLocalDevice.ipAddresses[0] ?? currentLocalDevice.tailscaleIp ?? currentLocalDevice.lastHost, + }); + return; + } + if (hostService) { + const currentLocalDevice = deviceRegistryService.ensureLocalDevice(); + deviceRegistryService.touchLocalDevice({ + lastSeenAt: nowIso(), + lastHost: currentLocalDevice.ipAddresses[0] ?? currentLocalDevice.tailscaleIp ?? currentLocalDevice.lastHost, + lastPort: hostService.getPort(), + }); + hostService.refreshLanDiscovery?.(); + return; + } + const localDevice = deviceRegistryService.ensureLocalDevice(); + const preferredPort = localDevice.lastPort ?? DEFAULT_SYNC_HOST_PORT; + let lastError: unknown = null; + for (const attemptedPort of buildHostPortCandidates(preferredPort)) { + const candidateHostService = createSyncHostService({ + db: args.db, + logger: args.logger, + projectId: args.projectId ?? null, + projectRoot: args.projectRoot, + fileService: args.fileService, + laneService: args.laneService, + gitService: args.gitService, + diffService: args.diffService, + conflictService: args.conflictService, + prService: args.prService, + issueInventoryService: args.issueInventoryService, + pathToMergeOrchestrator: args.pathToMergeOrchestrator, + queueLandingService: args.queueLandingService, + sessionService: args.sessionService, + ptyService: args.ptyService, + processService: args.processService, + agentChatService: args.agentChatService, + workerAgentService: args.workerAgentService, + workerBudgetService: args.workerBudgetService, + workerHeartbeatService: args.workerHeartbeatService, + workerRevisionService: args.workerRevisionService, + ctoStateService: args.ctoStateService, + flowPolicyService: args.flowPolicyService, + linearCredentialService: args.linearCredentialService, + getLinearIngressService: args.getLinearIngressService, + getLinearIssueTracker: args.getLinearIssueTracker, + getLinearSyncService: args.getLinearSyncService, + projectConfigService: args.projectConfigService, + portAllocationService: args.portAllocationService, + laneEnvironmentService: args.laneEnvironmentService, + laneTemplateService: args.laneTemplateService, + rebaseSuggestionService: args.rebaseSuggestionService ?? undefined, + autoRebaseService: args.autoRebaseService ?? undefined, + computerUseArtifactBrokerService: args.computerUseArtifactBrokerService, + pinStore, + bootstrapTokenPath: tokenPath, + pairingSecretsPath, + port: attemptedPort, + discoveryEnabled: hostDiscoveryEnabled, + runtimeKind: args.runtimeKind ?? "desktop-embedded", + runtimeVersion: args.appVersion ?? "", + deviceRegistryService, + notificationEventBus: args.notificationEventBus ?? null, + projectCatalogProvider: args.projectCatalogProvider, + remoteCommandService, + remoteCommandExecutor: args.remoteCommandExecutor, + onStateChanged: () => { + void refreshRoleState(); + }, + }); + try { + const resolvedPort = await candidateHostService.waitUntilListening(); + hostService = candidateHostService; + hostService.setLocalActiveLanePresence?.(activeLocalLanePresenceIds); + deviceRegistryService.touchLocalDevice({ + lastSeenAt: nowIso(), + lastHost: localDevice.ipAddresses[0] ?? localDevice.tailscaleIp ?? localDevice.lastHost, + lastPort: resolvedPort, + }); + return; + } catch (error) { + lastError = error; + await candidateHostService.dispose().catch(() => {}); + const retryable = isRetryableHostBindError(error) && attemptedPort !== 0; + args.logger.warn( + retryable ? "sync.host_start_port_conflict" : "sync.host_start_failed", + { + preferredPort, + attemptedPort, + error: error instanceof Error ? error.message : String(error), + code: (error as NodeJS.ErrnoException | null | undefined)?.code ?? null, + }, + ); + if (!retryable) { + throw error; + } + } + } + throw lastError instanceof Error + ? lastError + : new Error("Unable to start the sync host."); + }; + + const stopHostIfRunning = async (): Promise => { + if (!hostService) return; + const current = hostService; + hostService = null; + await current.dispose(); + }; + + const resolveViewerDraftFromRegistry = + (): SyncDesktopConnectionDraft | null => { + if (forceHostRole) return null; + const cluster = deviceRegistryService.getClusterState(); + const token = readToken(); + if (!cluster || !token) return null; + const brain = deviceRegistryService.getDevice(cluster.brainDeviceId); + const host = + brain != null ? buildAddressCandidates(brain)[0]?.host ?? null : null; + const port = brain?.lastPort ?? DEFAULT_SYNC_HOST_PORT; + if (!host) return null; + return { + host, + port, + token, + lastRemoteDbVersion: + syncPeerService.getStatus().lastRemoteDbVersion ?? 0, + }; + }; + + const refreshRoleState = async (): Promise => { + if (disposed) return; + if (refreshRunning) { + refreshQueued = true; + return; + } + refreshRunning = true; + try { + do { + refreshQueued = false; + const savedDraft = readSavedDraft(); + syncPeerService.setSavedDraft(savedDraft); + const localDevice = deviceRegistryService.ensureLocalDevice(); + let cluster = deviceRegistryService.getClusterState(); + if (forceHostRole) { + if (!cluster || cluster.brainDeviceId !== localDevice.deviceId) { + cluster = deviceRegistryService.setClusterState({ + brainDeviceId: localDevice.deviceId, + brainEpoch: (cluster?.brainEpoch ?? 0) + 1, + updatedByDeviceId: localDevice.deviceId, + }); + } + } else if (!cluster && !savedDraft) { + cluster = deviceRegistryService.bootstrapLocalBrainIfNeeded(); + } + const isLocalBrain = forceHostRole || (cluster + ? cluster.brainDeviceId === localDevice.deviceId + : !savedDraft); + if (isLocalBrain) { + if (syncPeerService.isConnected()) { + syncPeerService.disconnect({ preserveDraft: true }); + } + await startHostIfNeeded(); + } else { + await stopHostIfRunning(); + if (!isCrdtSyncAvailable()) { + if (syncPeerService.isConnected()) { + syncPeerService.disconnect({ preserveDraft: true }); + } + continue; + } + const draft = savedDraft ?? resolveViewerDraftFromRegistry(); + if (draft && !syncPeerService.isConnected()) { + syncPeerService.setSavedDraft(draft); + try { + await syncPeerService.connect(draft); + deviceRegistryService.touchLocalDevice({ lastSeenAt: nowIso() }); + syncPeerService.flushLocalChanges(); + } catch (error) { + args.logger.warn("sync.role.viewer_connect_failed", { + error: error instanceof Error ? error.message : String(error), + }); + } + } + } + } while (refreshQueued); + } finally { + refreshRunning = false; + await emitStatus(); + } + }; + + const listRuntimeDevices = async (): Promise => { + const devices = deviceRegistryService.listDevices(); + const cluster = deviceRegistryService.getClusterState(); + const currentBrainId = cluster?.brainDeviceId ?? null; + const peerStates = hostService + ? hostService.getPeerStates() + : (syncPeerService.getLatestBrainStatus()?.connectedPeers ?? []); + const localDeviceId = deviceRegistryService.getLocalDeviceId(); + return devices.map((device) => { + const peer = + peerStates.find((entry) => entry.deviceId === device.deviceId) ?? null; + const isLocal = device.deviceId === localDeviceId; + return { + ...device, + isLocal, + isBrain: device.deviceId === currentBrainId, + connectionState: isLocal ? "self" : peer ? "connected" : "disconnected", + connectedAt: peer?.connectedAt ?? null, + lastAppliedAt: peer?.lastAppliedAt ?? null, + remoteAddress: peer?.remoteAddress ?? null, + remotePort: peer?.remotePort ?? null, + latencyMs: peer?.latencyMs ?? null, + syncLag: peer?.syncLag ?? null, + }; + }); + }; + + const computeTransferReadiness = async (): Promise => { + const blockers: SyncTransferBlocker[] = []; + + for (const mission of args.missionService.list({ + status: "active", + limit: 200, + })) { + blockers.push({ + kind: "mission_run", + id: mission.id, + label: mission.title || mission.id, + detail: `Mission is ${mission.status}. Paused missions can transfer, but active mission work cannot.`, + }); + } + + const chats = await args.agentChatService.listSessions(undefined, { + includeIdentity: true, + includeAutomation: true, + }); + const chatSummaries = new Map( + chats.map((chat) => [chat.sessionId, chat] as const), + ); + + for (const session of args.sessionService.list({ + status: "running", + limit: 500, + })) { + if (CHAT_TOOL_TYPES.has(session.toolType ?? "")) { + const chat = chatSummaries.get(session.id); + const isCto = chat?.identityKey === "cto"; + blockers.push({ + kind: "chat_runtime", + id: session.id, + label: chat?.title || (isCto ? "CTO thread" : session.title), + detail: isCto + ? "A running CTO turn must stop before handoff. CTO history and idle threads still transfer." + : "Live chat sessions do not hot-transfer. Let the turn finish or interrupt it first.", + }); + continue; + } + blockers.push({ + kind: "terminal_session", + id: session.id, + label: session.title, + detail: + "Running terminal sessions must stop before the host role can move.", + }); + } + + const lanes = args.db.all<{ id: string }>( + "select id from lanes where status != 'archived'", + ); + for (const lane of lanes) { + for (const runtime of args.processService.listRuntime(lane.id)) { + if (!RUNNING_PROCESS_STATES.has(runtime.status)) continue; + blockers.push({ + kind: "managed_process", + id: `${lane.id}:${runtime.processId}`, + label: runtime.processId, + detail: + "Managed run processes must stop before the host role can move.", + }); + } + } + + return { + ready: blockers.length === 0, + blockers, + survivableState: [ + "Paused missions remain paused and can resume on the new host.", + "CTO history and idle threads remain available on the new host.", + "Idle and ended agent chats remain available and resumable on the new host.", + ], + }; + }; + + const getTransferReadiness = async (options?: { force?: boolean }): Promise => { + const now = Date.now(); + if (!options?.force && transferReadinessCache && transferReadinessCache.expiresAtMs > now) { + return transferReadinessCache.value; + } + // `force` should skip the cached value but still share the in-flight + // promise — otherwise overlapping forced callers each spawn their own + // computeTransferReadiness() run. + if (transferReadinessInFlight) return transferReadinessInFlight; + transferReadinessInFlight = computeTransferReadiness() + .then((value) => { + transferReadinessCache = { + value, + expiresAtMs: Date.now() + TRANSFER_READINESS_CACHE_MS, + }; + return value; + }) + .finally(() => { + transferReadinessInFlight = null; + }); + return transferReadinessInFlight; + }; + + const service = { + async initialize(): Promise { + if (initialized) return; + if (initializingPromise) return initializingPromise; + initializingPromise = (async () => { + deviceRegistryService.ensureLocalDevice(); + await refreshRoleState(); + initialized = true; + })().finally(() => { + initializingPromise = null; + }); + return initializingPromise; + }, + + async getStatus(options?: SyncGetStatusArgs): Promise { + const localDevice = deviceRegistryService.ensureLocalDevice(); + const cluster = deviceRegistryService.getClusterState(); + const savedDraft = readSavedDraft(); + const currentBrain = cluster + ? deviceRegistryService.getDevice(cluster.brainDeviceId) + : localDevice; + const isLocalBrain = forceHostRole || (cluster + ? cluster.brainDeviceId === localDevice.deviceId + : !savedDraft && !syncPeerService.isConnected()); + const role = isLocalBrain ? "brain" : "viewer"; + const crdtSyncAvailable = isCrdtSyncAvailable(); + const canHostPhonePairing = role === "brain" && hostStartupEnabled && crdtSyncAvailable; + const client = syncPeerService.getStatus(); + const mode = + role === "viewer" + ? "viewer" + : client.state === "connected" + ? "brain" + : "standalone"; + return { + mode, + role, + localDevice, + currentBrain, + clusterState: cluster, + bootstrapToken: + canHostPhonePairing ? readToken() : null, + pairingPin: canHostPhonePairing ? pinStore.getPin() : null, + pairingPinConfigured: canHostPhonePairing ? pinStore.hasPin() : false, + pairingConnectInfo: + canHostPhonePairing + ? buildPairingConnectInfo({ localDevice }) + : null, + connectedPeers: hostService + ? hostService.getPeerStates() + : (syncPeerService.getLatestBrainStatus()?.connectedPeers ?? []), + tailnetDiscovery: canHostPhonePairing && hostService + ? hostService.getTailnetDiscoveryStatus() + : createInactiveTailnetDiscoveryStatus( + canHostPhonePairing + ? "Tailnet discovery is waiting for the machine sync host to start." + : "Tailnet discovery is only published by the host machine.", + ), + client, + transferReadiness: options?.includeTransferReadiness === false + ? (transferReadinessCache?.value ?? buildSkippedTransferReadiness()) + : await getTransferReadiness({ force: options?.forceTransferReadiness === true }), + survivableStateText: + crdtSyncAvailable + ? "Paused and idle state will remain available on the new host." + : "Machine sync is disabled because the CRDT database extension is unavailable on this platform.", + blockingStateText: + crdtSyncAvailable + ? "Live missions, chats, terminals, or run processes must stop first." + : "Install Windows cr-sqlite support before pairing or syncing devices.", + }; + }, + + async listDevices(): Promise { + return await listRuntimeDevices(); + }, + + async refreshDiscovery(): Promise { + hostService?.refreshLanDiscovery?.({ forceTailnet: true }); + const snapshot = await this.getStatus(); + args.onStatusChanged?.(snapshot); + return snapshot; + }, + + setHostDiscoveryEnabled(enabled: boolean): void { + if (hostDiscoveryEnabled === enabled) return; + hostDiscoveryEnabled = enabled; + hostService?.setDiscoveryEnabled(enabled); + void emitStatus(); + }, + + async setHostStartupEnabled(enabled: boolean): Promise { + if (hostStartupEnabled === enabled) return; + hostStartupEnabled = enabled; + await refreshRoleState(); + }, + + async updateLocalDevice(argsIn: { + name?: string; + deviceType?: "desktop" | "phone" | "vps" | "unknown"; + }) { + const updated = deviceRegistryService.updateLocalDevice(argsIn); + hostService?.setLocalActiveLanePresence(activeLocalLanePresenceIds); + await emitStatus(); + return updated; + }, + + async connectToBrain( + draft: SyncDesktopConnectionDraft, + ): Promise { + if (!isCrdtSyncAvailable()) { + throw new Error("Machine sync is unavailable because the CRDT database extension is not loaded."); + } + await stopHostIfRunning(); + deviceRegistryService.clearClusterRegistryForViewerJoin(); + writeSavedDraft(draft); + syncPeerService.setSavedDraft(draft); + try { + await syncPeerService.connect(draft); + deviceRegistryService.touchLocalDevice({ lastSeenAt: nowIso() }); + syncPeerService.flushLocalChanges(); + await sleep(150); + await refreshRoleState(); + return await this.getStatus(); + } catch (error) { + writeSavedDraft(null); + syncPeerService.setSavedDraft(null); + await refreshRoleState(); + throw error; + } + }, + + async disconnectFromBrain(): Promise { + syncPeerService.disconnect(); + writeSavedDraft(null); + deviceRegistryService.clearClusterRegistryForViewerJoin(); + await refreshRoleState(); + return await this.getStatus(); + }, + + getPin(): string | null { + return pinStore.getPin(); + }, + + async setPin(pin: string): Promise { + assertPhonePairingAvailable(); + const current = await service.getStatus(); + if (current.role !== "brain") { + throw new Error("Phone pairing PINs can only be managed on the host machine."); + } + pinStore.setPin(pin); + const snapshot = await service.getStatus(); + args.onStatusChanged?.(snapshot); + return snapshot; + }, + + async generatePin(): Promise { + return await service.setPin(generatePairingPin()); + }, + + async clearPin(): Promise { + assertPhonePairingAvailable(); + const current = await service.getStatus(); + if (current.role !== "brain") { + throw new Error("Phone pairing PINs can only be managed on the host machine."); + } + pinStore.clearPin(); + const snapshot = await service.getStatus(); + args.onStatusChanged?.(snapshot); + return snapshot; + }, + + async setActiveLanePresence(laneIds: string[]): Promise { + const normalized = Array.isArray(laneIds) + ? [...new Set( + laneIds + .map((laneId) => (typeof laneId === "string" ? laneId.trim() : "")) + .filter((laneId) => laneId.length > 0), + )] + : []; + activeLocalLanePresenceIds = normalized; + hostService?.setLocalActiveLanePresence(activeLocalLanePresenceIds); + }, + + async forgetDevice(deviceId: string): Promise { + hostService?.revokePairedDevice(deviceId); + deviceRegistryService.forgetDevice(deviceId); + await emitStatus(); + return await this.getStatus(); + }, + + async getTransferReadiness(): Promise { + return await getTransferReadiness({ force: true }); + }, + + async transferBrainToLocal(): Promise { + const current = await this.getStatus({ forceTransferReadiness: true }); + if (current.role === "brain") return current; + if (!current.transferReadiness.ready) { + throw new Error( + "Stop live missions, chats, terminals, and run processes before transferring the host role.", + ); + } + const localDevice = deviceRegistryService.ensureLocalDevice(); + const currentCluster = deviceRegistryService.getClusterState(); + deviceRegistryService.touchLocalDevice({ + lastSeenAt: nowIso(), + lastHost: localDevice.lastHost, + lastPort: localDevice.lastPort ?? DEFAULT_SYNC_HOST_PORT, + }); + deviceRegistryService.setClusterState({ + brainDeviceId: localDevice.deviceId, + brainEpoch: (currentCluster?.brainEpoch ?? 0) + 1, + updatedByDeviceId: localDevice.deviceId, + }); + syncPeerService.flushLocalChanges(); + await sleep(300); + await refreshRoleState(); + return await this.getStatus(); + }, + + handlePtyData( + event: Parameters[0], + ): void { + hostService?.handlePtyData(event); + }, + + handlePtyExit( + event: Parameters[0], + ): void { + hostService?.handlePtyExit(event); + }, + + getHostService(): SyncHostService | null { + return hostService; + }, + + getRemoteCommandDescriptor(action: string) { + return remoteCommandService.getDescriptor(action); + }, + + async executeRemoteCommand(payload: Parameters[0]): Promise { + return await remoteCommandService.execute(payload); + }, + + getDeviceRegistryService() { + return deviceRegistryService; + }, + + async dispose(): Promise { + disposed = true; + syncPeerService.disconnect(); + clearInterval(localLanePresenceHeartbeatTimer); + await stopHostIfRunning(); + await syncPeerService.dispose(); + }, + }; + + return service; +} + +export type SyncService = ReturnType; diff --git a/apps/ade-cli/src/stdioRpcDaemon.test.ts b/apps/ade-cli/src/stdioRpcDaemon.test.ts new file mode 100644 index 000000000..55fde0a39 --- /dev/null +++ b/apps/ade-cli/src/stdioRpcDaemon.test.ts @@ -0,0 +1,297 @@ +import { spawn, type ChildProcessWithoutNullStreams } from "node:child_process"; +import fs from "node:fs"; +import net from "node:net"; +import os from "node:os"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { describe, expect, it } from "vitest"; + +type JsonRpcResponse = { + id?: number; + result?: unknown; + error?: { + message?: string; + }; +}; + +type PendingRequest = { + resolve: (value: unknown) => void; + reject: (error: Error) => void; + timer: ReturnType; +}; + +function withTsxNodeOptions(value: string | undefined): string { + const existing = value?.trim(); + return existing ? `${existing} --import tsx` : "--import tsx"; +} + +async function waitForSocket(socketPath: string, timeoutMs = 10_000): Promise { + const startedAt = Date.now(); + let lastError: Error | null = null; + while (Date.now() - startedAt < timeoutMs) { + try { + await new Promise((resolve, reject) => { + const socket = net.createConnection(socketPath); + const timer = setTimeout(() => { + socket.destroy(); + reject(new Error(`Timed out connecting to ${socketPath}`)); + }, 500); + socket.once("connect", () => { + clearTimeout(timer); + socket.destroy(); + resolve(); + }); + socket.once("error", (error) => { + clearTimeout(timer); + socket.destroy(); + reject(error); + }); + }); + return; + } catch (error) { + lastError = error instanceof Error ? error : new Error(String(error)); + await new Promise((resolve) => setTimeout(resolve, 100)); + } + } + throw lastError ?? new Error(`ADE runtime socket did not become available: ${socketPath}`); +} + +function startServeProcess(args: { + cliPath: string; + cwd: string; + env: NodeJS.ProcessEnv; + socketPath: string; +}): ChildProcessWithoutNullStreams { + return spawn(process.execPath, [args.cliPath, "serve", "--socket", args.socketPath, "--no-sync"], { + cwd: args.cwd, + env: args.env, + stdio: ["pipe", "pipe", "pipe"], + }); +} + +class StdioRpcProcess { + private nextId = 1; + private stdout = ""; + private stderr = ""; + private readonly pending = new Map(); + + constructor(private readonly child: ChildProcessWithoutNullStreams) { + child.stdout.on("data", (chunk) => this.handleStdout(chunk.toString("utf8"))); + child.stderr.on("data", (chunk) => { + this.stderr += chunk.toString("utf8"); + }); + child.once("exit", (code, signal) => { + const error = new Error(`ADE stdio RPC process exited before response: code=${code} signal=${signal} stderr=${this.stderr.trim()}`); + for (const [id, pending] of this.pending) { + this.pending.delete(id); + clearTimeout(pending.timer); + pending.reject(error); + } + }); + } + + static start(args: { + cliPath: string; + cwd: string; + env: NodeJS.ProcessEnv; + }): StdioRpcProcess { + return new StdioRpcProcess(spawn(process.execPath, [args.cliPath, "rpc", "--stdio"], { + cwd: args.cwd, + env: args.env, + stdio: ["pipe", "pipe", "pipe"], + })); + } + + request(method: string, params?: unknown): Promise { + const id = this.nextId++; + const payload = { + jsonrpc: "2.0", + id, + method, + ...(params !== undefined ? { params } : {}), + }; + + return new Promise((resolve, reject) => { + const timer = setTimeout(() => { + this.pending.delete(id); + reject(new Error(`Timed out waiting for ${method}. stderr=${this.stderr.trim()}`)); + }, 15_000); + this.pending.set(id, { resolve, reject, timer }); + this.child.stdin.write(`${JSON.stringify(payload)}\n`, "utf8", (error) => { + if (!error) return; + this.pending.delete(id); + clearTimeout(timer); + reject(error); + }); + }); + } + + closeInput(): void { + this.child.stdin.end(); + } + + waitForExit(): Promise<{ code: number | null; signal: NodeJS.Signals | null }> { + return new Promise((resolve, reject) => { + const timer = setTimeout(() => { + reject(new Error(`ADE stdio RPC process did not exit. stderr=${this.stderr.trim()}`)); + }, 15_000); + this.child.once("exit", (code, signal) => { + clearTimeout(timer); + resolve({ code, signal }); + }); + }); + } + + kill(): void { + try { + this.child.kill(); + } catch { + // Best-effort cleanup. + } + } + + private handleStdout(chunk: string): void { + this.stdout += chunk; + while (true) { + const newline = this.stdout.indexOf("\n"); + if (newline < 0) return; + const line = this.stdout.slice(0, newline).trim(); + this.stdout = this.stdout.slice(newline + 1); + if (!line) continue; + let parsed: JsonRpcResponse; + try { + parsed = JSON.parse(line) as JsonRpcResponse; + } catch { + continue; + } + if (typeof parsed.id !== "number") continue; + const pending = this.pending.get(parsed.id); + if (!pending) continue; + this.pending.delete(parsed.id); + clearTimeout(pending.timer); + if (parsed.error) { + pending.reject(new Error(parsed.error.message ?? "ADE JSON-RPC request failed.")); + } else { + pending.resolve(parsed.result); + } + } + } +} + +const itUnix = process.platform === "win32" ? it.skip : it; + +describe("ade rpc --stdio daemon bridge", () => { + itUnix("keeps the machine runtime alive after the stdio client exits", async () => { + const packageRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); + const cliPath = path.join(packageRoot, "src", "cli.ts"); + const adeHome = fs.mkdtempSync(path.join(os.tmpdir(), "ade-stdio-rpc-")); + const projectRoot = fs.mkdtempSync(path.join(os.tmpdir(), "ade-stdio-rpc-project-")); + const socketPath = path.join(adeHome, "sock", "ade.sock"); + const env = { + ...process.env, + ADE_HOME: adeHome, + ADE_RUNTIME_SOCKET_PATH: socketPath, + NODE_OPTIONS: withTsxNodeOptions(process.env.NODE_OPTIONS), + }; + + let first: StdioRpcProcess | null = null; + let second: StdioRpcProcess | null = null; + try { + first = StdioRpcProcess.start({ cliPath, cwd: packageRoot, env }); + const initialize = await first.request("ade/initialize", { + protocolVersion: "2025-06-18", + clientName: "stdio-daemon-test-1", + identity: { role: "external", callerId: "stdio-daemon-test-1" }, + }); + const project = await first.request("projects.add", { rootPath: projectRoot }); + + first.closeInput(); + await expect(first.waitForExit()).resolves.toMatchObject({ code: 0, signal: null }); + + second = StdioRpcProcess.start({ cliPath, cwd: packageRoot, env }); + await second.request("ade/initialize", { + protocolVersion: "2025-06-18", + clientName: "stdio-daemon-test-2", + identity: { role: "external", callerId: "stdio-daemon-test-2" }, + }); + const persisted = await second.request("projects.list"); + + expect(initialize).toMatchObject({ + runtimeInfo: { + multiProject: true, + }, + }); + expect(project).toMatchObject({ + rootPath: projectRoot, + }); + expect(Array.isArray(persisted)).toBe(true); + expect((persisted as Array<{ projectId?: string }>)).toContainEqual( + expect.objectContaining({ + projectId: (project as { projectId: string }).projectId, + }), + ); + + await expect(second.request("shutdown")).resolves.toEqual({}); + second.closeInput(); + await expect(second.waitForExit()).resolves.toMatchObject({ code: 0, signal: null }); + } finally { + first?.kill(); + second?.kill(); + } + }, 45_000); + + itUnix("restarts a stale daemon before bridging stdio requests", async () => { + const packageRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); + const cliPath = path.join(packageRoot, "src", "cli.ts"); + const adeHome = fs.mkdtempSync(path.join(os.tmpdir(), "ade-stdio-rpc-version-")); + const socketPath = path.join(adeHome, "sock", "ade.sock"); + const baseEnv = { + ...process.env, + ADE_HOME: adeHome, + ADE_RUNTIME_SOCKET_PATH: socketPath, + NODE_OPTIONS: withTsxNodeOptions(process.env.NODE_OPTIONS), + }; + const oldDaemon = startServeProcess({ + cliPath, + cwd: packageRoot, + env: { + ...baseEnv, + ADE_CLI_VERSION: "1.0.0", + }, + socketPath, + }); + + let proxy: StdioRpcProcess | null = null; + try { + await waitForSocket(socketPath); + + proxy = StdioRpcProcess.start({ + cliPath, + cwd: packageRoot, + env: { + ...baseEnv, + ADE_CLI_VERSION: "2.0.0", + }, + }); + const initialize = await proxy.request("ade/initialize", { + protocolVersion: "2025-06-18", + clientName: "stdio-daemon-version-test", + identity: { role: "external", callerId: "stdio-daemon-version-test" }, + }); + + expect(initialize).toMatchObject({ + runtimeInfo: { + version: "2.0.0", + multiProject: true, + }, + }); + + await expect(proxy.request("shutdown")).resolves.toEqual({}); + proxy.closeInput(); + await expect(proxy.waitForExit()).resolves.toMatchObject({ code: 0, signal: null }); + } finally { + proxy?.kill(); + if (!oldDaemon.killed) oldDaemon.kill(); + } + }, 45_000); +}); diff --git a/apps/ade-cli/src/transports/stdioTransport.ts b/apps/ade-cli/src/transports/stdioTransport.ts new file mode 100644 index 000000000..fa543bafa --- /dev/null +++ b/apps/ade-cli/src/transports/stdioTransport.ts @@ -0,0 +1,18 @@ +import { Buffer } from "node:buffer"; +import type { JsonRpcTransport } from "../jsonrpc"; + +export function createStdioTransport(): JsonRpcTransport { + return { + onData(callback) { + process.stdin.on("data", (chunk) => { + callback(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)); + }); + }, + write(data) { + process.stdout.write(data); + }, + close() { + process.stdin.pause(); + }, + }; +} diff --git a/apps/ade-code/src/__tests__/adeApi.test.ts b/apps/ade-cli/src/tuiClient/__tests__/adeApi.test.ts similarity index 92% rename from apps/ade-code/src/__tests__/adeApi.test.ts rename to apps/ade-cli/src/tuiClient/__tests__/adeApi.test.ts index 5c261b294..c58672c49 100644 --- a/apps/ade-code/src/__tests__/adeApi.test.ts +++ b/apps/ade-cli/src/tuiClient/__tests__/adeApi.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import type { AgentChatEventEnvelope } from "../../../desktop/src/shared/types/chat"; +import type { AgentChatEventEnvelope } from "../../../../desktop/src/shared/types/chat"; import { latestTokenStats } from "../adeApi"; function envelope( diff --git a/apps/ade-code/src/__tests__/commands.test.ts b/apps/ade-cli/src/tuiClient/__tests__/commands.test.ts similarity index 100% rename from apps/ade-code/src/__tests__/commands.test.ts rename to apps/ade-cli/src/tuiClient/__tests__/commands.test.ts diff --git a/apps/ade-cli/src/tuiClient/__tests__/connection.test.ts b/apps/ade-cli/src/tuiClient/__tests__/connection.test.ts new file mode 100644 index 000000000..454ff3f14 --- /dev/null +++ b/apps/ade-cli/src/tuiClient/__tests__/connection.test.ts @@ -0,0 +1,154 @@ +import fs from "node:fs"; +import net from "node:net"; +import os from "node:os"; +import path from "node:path"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { connectToAde } from "../connection"; +import type { ProjectLaunchContext } from "../types"; + +const embedded = vi.hoisted(() => { + const requests: Array<{ jsonrpc: string; id: number; method: string; params?: unknown }> = []; + const runtime = { + dispose: vi.fn(), + agentChatService: { + subscribeToEvents: vi.fn(() => vi.fn()), + }, + }; + const handler = Object.assign( + vi.fn(async (message: { jsonrpc: string; id: number; method: string; params?: unknown }) => { + requests.push(message); + return { ok: true, method: message.method }; + }), + { dispose: vi.fn() }, + ); + + return { + requests, + runtime, + handler, + createAdeRuntime: vi.fn(async () => runtime), + createAdeRpcRequestHandler: vi.fn(() => handler), + }; +}); + +vi.mock("../../bootstrap", () => ({ + createAdeRuntime: embedded.createAdeRuntime, +})); + +vi.mock("../../adeRpcServer", () => ({ + createAdeRpcRequestHandler: embedded.createAdeRpcRequestHandler, +})); + +const project: ProjectLaunchContext = { + launchCwd: "/tmp/ade-code", + projectRoot: "/tmp/ade-code", + workspaceRoot: "/tmp/ade-code", + laneHint: null, +}; + +describe("connectToAde embedded mode", () => { + beforeEach(() => { + embedded.requests.length = 0; + embedded.runtime.dispose.mockClear(); + embedded.runtime.agentChatService.subscribeToEvents.mockClear(); + embedded.handler.mockClear(); + embedded.handler.dispose.mockClear(); + embedded.createAdeRuntime.mockClear(); + embedded.createAdeRpcRequestHandler.mockClear(); + }); + + it("uses unique JSON-RPC ids for direct embedded requests", async () => { + const connection = await connectToAde({ + project, + forceEmbedded: true, + }); + + try { + await Promise.all([ + connection.request("ade/actions/list"), + connection.request("ping"), + ]); + } finally { + await connection.close(); + } + + expect(embedded.requests.map((request) => request.method)).toEqual([ + "ade/initialize", + "ade/initialized", + "ade/actions/list", + "ping", + ]); + expect(embedded.requests.map((request) => request.id)).toEqual([1, 2, 3, 4]); + expect(new Set(embedded.requests.map((request) => request.id)).size).toBe(4); + }); + + it("does not silently fall back to embedded mode when socket attach fails", async () => { + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "ade-code-missing-socket-")); + const socketPath = path.join(tmpDir, "missing.sock"); + + await expect(connectToAde({ + project, + socketPath, + })).rejects.toThrow(/ade code --embedded/); + + expect(embedded.createAdeRuntime).not.toHaveBeenCalled(); + }); + + it("registers the project and injects projectId when attached to the machine daemon", async () => { + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "ade-code-connection-")); + const socketPath = path.join(tmpDir, "ade.sock"); + const requests: Array<{ method: string; params?: Record }> = []; + const server = net.createServer((socket) => { + let buffer = ""; + socket.on("data", (chunk) => { + buffer += chunk.toString("utf8"); + while (true) { + const newline = buffer.indexOf("\n"); + if (newline < 0) return; + const line = buffer.slice(0, newline).trim(); + buffer = buffer.slice(newline + 1); + if (!line) continue; + const request = JSON.parse(line) as { id: number; method: string; params?: Record }; + requests.push({ method: request.method, params: request.params }); + const result = (() => { + if (request.method === "ade/initialize") { + return { + runtimeInfo: { multiProject: true }, + capabilities: { projects: true }, + }; + } + if (request.method === "projects.add") { + return { projectId: "project-daemon", rootPath: project.projectRoot }; + } + if (request.method === "ade/actions/list") { + return { projectId: request.params?.projectId ?? null }; + } + return null; + })(); + socket.write(`${JSON.stringify({ jsonrpc: "2.0", id: request.id, result })}\n`); + } + }); + }); + await new Promise((resolve) => server.listen(socketPath, resolve)); + + const connection = await connectToAde({ + project, + socketPath, + }); + try { + const listed = await connection.request<{ projectId: string }>("ade/actions/list", {}); + expect(listed.projectId).toBe("project-daemon"); + } finally { + await connection.close(); + await new Promise((resolve) => server.close(() => resolve())); + } + + expect(requests.map((request) => request.method)).toEqual([ + "ade/initialize", + "ade/initialized", + "projects.add", + "ade/actions/list", + ]); + expect(requests.at(-1)?.params).toMatchObject({ projectId: "project-daemon" }); + }); +}); diff --git a/apps/ade-code/src/__tests__/format.test.ts b/apps/ade-cli/src/tuiClient/__tests__/format.test.ts similarity index 100% rename from apps/ade-code/src/__tests__/format.test.ts rename to apps/ade-cli/src/tuiClient/__tests__/format.test.ts diff --git a/apps/ade-code/src/__tests__/heartbeat.test.ts b/apps/ade-cli/src/tuiClient/__tests__/heartbeat.test.ts similarity index 100% rename from apps/ade-code/src/__tests__/heartbeat.test.ts rename to apps/ade-cli/src/tuiClient/__tests__/heartbeat.test.ts diff --git a/apps/ade-code/src/__tests__/jsonRpcClient.test.ts b/apps/ade-cli/src/tuiClient/__tests__/jsonRpcClient.test.ts similarity index 100% rename from apps/ade-code/src/__tests__/jsonRpcClient.test.ts rename to apps/ade-cli/src/tuiClient/__tests__/jsonRpcClient.test.ts diff --git a/apps/ade-code/src/__tests__/linearCommands.test.ts b/apps/ade-cli/src/tuiClient/__tests__/linearCommands.test.ts similarity index 100% rename from apps/ade-code/src/__tests__/linearCommands.test.ts rename to apps/ade-cli/src/tuiClient/__tests__/linearCommands.test.ts diff --git a/apps/ade-code/src/__tests__/pendingInput.test.ts b/apps/ade-cli/src/tuiClient/__tests__/pendingInput.test.ts similarity index 98% rename from apps/ade-code/src/__tests__/pendingInput.test.ts rename to apps/ade-cli/src/tuiClient/__tests__/pendingInput.test.ts index d1764ba94..6f363fb19 100644 --- a/apps/ade-code/src/__tests__/pendingInput.test.ts +++ b/apps/ade-cli/src/tuiClient/__tests__/pendingInput.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import type { AgentChatEventEnvelope, PendingInputRequest } from "../../../desktop/src/shared/types/chat"; +import type { AgentChatEventEnvelope, PendingInputRequest } from "../../../../desktop/src/shared/types/chat"; import { buildPendingInputAnswers, latestPendingApproval } from "../pendingInput"; const baseRequest: PendingInputRequest = { diff --git a/apps/ade-code/src/__tests__/project.test.ts b/apps/ade-cli/src/tuiClient/__tests__/project.test.ts similarity index 95% rename from apps/ade-code/src/__tests__/project.test.ts rename to apps/ade-cli/src/tuiClient/__tests__/project.test.ts index 93872bdf0..bdb138060 100644 --- a/apps/ade-code/src/__tests__/project.test.ts +++ b/apps/ade-cli/src/tuiClient/__tests__/project.test.ts @@ -1,7 +1,7 @@ import path from "node:path"; import { describe, expect, it } from "vitest"; import { chooseInitialLane } from "../project"; -import type { LaneSummary } from "../../../desktop/src/shared/types/lanes"; +import type { LaneSummary } from "../../../../desktop/src/shared/types/lanes"; function lane(overrides: Partial): LaneSummary { return { diff --git a/apps/ade-code/src/adeApi.ts b/apps/ade-cli/src/tuiClient/adeApi.ts similarity index 94% rename from apps/ade-code/src/adeApi.ts rename to apps/ade-cli/src/tuiClient/adeApi.ts index 683515f94..ae46c8a80 100644 --- a/apps/ade-code/src/adeApi.ts +++ b/apps/ade-cli/src/tuiClient/adeApi.ts @@ -1,4 +1,4 @@ -import { getDefaultModelDescriptor, type ModelProviderGroup } from "../../desktop/src/shared/modelRegistry"; +import { getDefaultModelDescriptor, type ModelProviderGroup } from "../../../desktop/src/shared/modelRegistry"; import type { AgentChatEventEnvelope, AgentChatFileRef, @@ -7,8 +7,8 @@ import type { AgentChatSession, AgentChatSessionSummary, AgentChatSlashCommand, -} from "../../desktop/src/shared/types/chat"; -import type { LaneSummary } from "../../desktop/src/shared/types/lanes"; +} from "../../../desktop/src/shared/types/chat"; +import type { LaneSummary } from "../../../desktop/src/shared/types/lanes"; import type { AdeCodeConnection, ChatHistorySnapshot, CreatedChat, NavigateRequest, NavigateResult } from "./types"; export async function listLanes(connection: AdeCodeConnection): Promise { @@ -183,11 +183,12 @@ export function latestTokenStats(events: AgentChatEventEnvelope[]): TokenStats { if (event.type === "tokens") { inputTokens = typeof event.inputTokens === "number" ? event.inputTokens : inputTokens; outputTokens = typeof event.outputTokens === "number" ? event.outputTokens : outputTokens; - const used = typeof event.totalTokens === "number" - ? event.totalTokens - : inputTokens != null || outputTokens != null - ? (inputTokens ?? 0) + (outputTokens ?? 0) - : null; + let used: number | null = null; + if (typeof event.totalTokens === "number") { + used = event.totalTokens; + } else if (inputTokens != null || outputTokens != null) { + used = (inputTokens ?? 0) + (outputTokens ?? 0); + } const limit = typeof event.contextWindow === "number" ? event.contextWindow : null; if (used != null && limit != null && limit > 0) { percent = Math.max(0, Math.min(100, Math.round((used / limit) * 100))); diff --git a/apps/ade-code/src/app.tsx b/apps/ade-cli/src/tuiClient/app.tsx similarity index 98% rename from apps/ade-code/src/app.tsx rename to apps/ade-cli/src/tuiClient/app.tsx index b23bd847a..ba0e88b54 100644 --- a/apps/ade-code/src/app.tsx +++ b/apps/ade-cli/src/tuiClient/app.tsx @@ -2,15 +2,15 @@ import React, { useCallback, useEffect, useMemo, useRef, useState } from "react" import { spawn } from "node:child_process"; import path from "node:path"; import { Box, Text, useApp, useInput } from "ink"; -import { getDefaultModelDescriptor } from "../../desktop/src/shared/modelRegistry"; +import { getDefaultModelDescriptor } from "../../../desktop/src/shared/modelRegistry"; import type { AgentChatEventEnvelope, AgentChatFileRef, AgentChatModelInfo, AgentChatSessionSummary, AgentChatSlashCommand, -} from "../../desktop/src/shared/types/chat"; -import type { LaneSummary } from "../../desktop/src/shared/types/lanes"; +} from "../../../desktop/src/shared/types/chat"; +import type { LaneSummary } from "../../../desktop/src/shared/types/lanes"; import { approveToolUse, createChatSession, @@ -412,8 +412,10 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath } const nextLaneId = nextLane?.id ?? null; const nextSessions = await listChatSessions(conn); const laneSessions = nextSessions.filter((session) => session.laneId === nextLaneId); - const nextSession = nextSessions.find((session) => session.sessionId === activeSessionIdRef.current) - ?? newestSession(laneSessions); + const activeSessionId = activeSessionIdRef.current; + const nextSession = activeSessionId + ? nextSessions.find((session) => session.sessionId === activeSessionId) ?? null + : null; const nextSessionId = nextSession?.sessionId ?? null; let nextEvents: AgentChatEventEnvelope[] = []; if (nextSessionId) { @@ -1457,11 +1459,18 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath } const centerWidth = Math.max(40, columns - (drawerOpen ? 30 : 0) - (rightOpen ? 40 : 0)); const laneName = activeLane?.name ?? "main"; + const chromeRows = 5 + + (desktopDriving ? 1 : 0) + + (streaming ? 1 : 0) + + (contextPercent != null ? 1 : 0) + + (pendingApproval && !pendingApproval.highStakes ? 3 : 0) + + (error ? 1 : 0); + const chatMaxRows = Math.max(4, rows - chromeRows); if (error && !connection) { return ( - ade-code failed to start + ade code failed to start {error} ); @@ -1512,6 +1521,7 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath } projectName={projectName} laneName={laneName} expandedLineIds={expandedLineIds} + maxRows={chatMaxRows} /> diff --git a/apps/ade-code/src/cli.tsx b/apps/ade-cli/src/tuiClient/cli.tsx similarity index 81% rename from apps/ade-code/src/cli.tsx rename to apps/ade-cli/src/tuiClient/cli.tsx index 1f096e00f..1444f93c5 100644 --- a/apps/ade-code/src/cli.tsx +++ b/apps/ade-cli/src/tuiClient/cli.tsx @@ -1,4 +1,5 @@ #!/usr/bin/env node +import { pathToFileURL } from "node:url"; import React from "react"; import { render } from "ink"; @@ -36,15 +37,15 @@ function parseArgs(argv: string[]): CliOptions { } function printHelp(): void { - process.stdout.write(`ade-code + process.stdout.write(`ade code Terminal-native ADE Work chat. Usage: - ade-code [--project-root ] [--workspace-root ] [--socket ] - ade-code --embedded - ade-code --require-socket - ade-code --print-state + ade code [--project-root ] [--workspace-root ] [--socket ] + ade code --embedded + ade code --require-socket + ade code --print-state Keys: ctrl-b toggle lanes and chats @@ -107,15 +108,15 @@ async function printState(options: CliOptions): Promise { } } -async function main(): Promise { - const options = parseArgs(process.argv.slice(2)); +export async function runAdeCodeCli(argv: string[] = process.argv.slice(2)): Promise { + const options = parseArgs(argv); if (options.help) { printHelp(); - return; + return 0; } if (options.printState) { await printState(options); - process.exit(0); + return 0; } suppressTerminalWarnings(); const { AdeCodeApp } = await import("./app"); @@ -124,7 +125,7 @@ async function main(): Promise { projectRoot: options.projectRoot, workspaceRoot: options.workspaceRoot, }); - render( + const instance = render( { socketPath={options.socketPath} />, ); + await instance.waitUntilExit(); + return 0; } -void main().catch((error: unknown) => { - const message = error instanceof Error ? error.message : String(error); - process.stderr.write(`ade-code: ${message}\n`); - process.exit(1); -}); +const isDirectEntry = process.argv[1] + ? import.meta.url === pathToFileURL(process.argv[1]).href + : false; + +if (isDirectEntry) { + void runAdeCodeCli().then((exitCode) => { + process.exitCode = exitCode; + }).catch((error: unknown) => { + const message = error instanceof Error ? error.message : String(error); + process.stderr.write(`ade code: ${message}\n`); + process.exitCode = 1; + }); +} diff --git a/apps/ade-code/src/commands.ts b/apps/ade-cli/src/tuiClient/commands.ts similarity index 96% rename from apps/ade-code/src/commands.ts rename to apps/ade-cli/src/tuiClient/commands.ts index 3ca4dca38..f5dda49ba 100644 --- a/apps/ade-code/src/commands.ts +++ b/apps/ade-cli/src/tuiClient/commands.ts @@ -1,4 +1,4 @@ -import type { AgentChatSlashCommand } from "../../desktop/src/shared/types/chat"; +import type { AgentChatSlashCommand } from "../../../desktop/src/shared/types/chat"; export type CommandPlacement = "inline" | "right" | "overlay" | "chat"; @@ -15,7 +15,7 @@ export const BUILTIN_COMMANDS: BuiltinCommand[] = [ { name: "/clear", description: "Clear the local terminal transcript view", placement: "inline" }, { name: "/end", description: "End the active chat runtime", placement: "inline" }, { name: "/open", description: "Open this ADE context in desktop", placement: "inline" }, - { name: "/quit", description: "Exit ade-code", placement: "inline" }, + { name: "/quit", description: "Exit ade code", placement: "inline" }, { name: "/remember", description: "Write durable ADE memory", placement: "inline", argumentHint: "" }, { name: "/new lane", description: "Create a new lane", placement: "right" }, { name: "/new chat", description: "Create a new chat", placement: "right", argumentHint: "[title]" }, @@ -132,7 +132,5 @@ export function paletteCommands( } export function commandPlacement(command: ParsedCommand): CommandPlacement { - if (command.spec) return command.spec.placement; - if (command.userCommand) return "chat"; - return "chat"; + return command.spec?.placement ?? "chat"; } diff --git a/apps/ade-code/src/components/ApprovalPrompt.tsx b/apps/ade-cli/src/tuiClient/components/ApprovalPrompt.tsx similarity index 66% rename from apps/ade-code/src/components/ApprovalPrompt.tsx rename to apps/ade-cli/src/tuiClient/components/ApprovalPrompt.tsx index 73a6a00b3..8be16b5ce 100644 --- a/apps/ade-code/src/components/ApprovalPrompt.tsx +++ b/apps/ade-cli/src/tuiClient/components/ApprovalPrompt.tsx @@ -11,6 +11,17 @@ export function ApprovalPrompt({ }) { if (!approval) return null; const question = approval.request?.questions[0] ?? null; + + let title: string; + if (approval.mode === "question") title = "Input requested"; + else if (approval.highStakes) title = "High-stakes approval required"; + else title = "Approval required"; + + let footer: string; + if (approval.mode === "question") footer = "Type an answer, option number/value, deny, or cancel."; + else if (approval.highStakes) footer = "Type approve or deny, then press enter."; + else footer = "Press a to approve, d to deny."; + const card = ( - - {approval.mode === "question" - ? "Input requested" - : approval.highStakes - ? "High-stakes approval required" - : "Approval required"} - + {title} {question?.question ?? approval.description} {question?.options?.length ? ( @@ -37,13 +42,7 @@ export function ApprovalPrompt({ ))} ) : null} - {approval.mode === "question" ? ( - Type an answer, option number/value, deny, or cancel. - ) : approval.highStakes ? ( - Type approve or deny, then press enter. - ) : ( - Press a to approve, d to deny. - )} + {footer} ); if (!modal) return card; diff --git a/apps/ade-code/src/components/ChatView.tsx b/apps/ade-cli/src/tuiClient/components/ChatView.tsx similarity index 55% rename from apps/ade-code/src/components/ChatView.tsx rename to apps/ade-cli/src/tuiClient/components/ChatView.tsx index 96006bd81..5e16bcef6 100644 --- a/apps/ade-code/src/components/ChatView.tsx +++ b/apps/ade-cli/src/tuiClient/components/ChatView.tsx @@ -1,8 +1,8 @@ import React from "react"; import { Box, Text } from "ink"; -import type { AgentChatEventEnvelope, AgentChatSessionSummary } from "../../../desktop/src/shared/types/chat"; +import type { AgentChatEventEnvelope, AgentChatSessionSummary } from "../../../../desktop/src/shared/types/chat"; import type { LocalNotice } from "../types"; -import { renderChatLines } from "../format"; +import { renderChatLines, type RenderedChatLine } from "../format"; const COLORS = { user: "#A78BFA", @@ -22,7 +22,7 @@ export function BootHero({ laneName: string; }) { return ( - + ██▄ ██▄ ██▀ █ █ █ █ █▀ ██▀ ██▀ ██▄ @@ -36,6 +36,42 @@ export function BootHero({ ); } +function clipBodyToRows(body: string, rows: number): string { + if (rows <= 0) return ""; + const lines = body.split(/\r?\n/); + if (lines.length <= rows) return body; + return lines.slice(-rows).join("\n"); +} + +function rowCount(line: RenderedChatLine): number { + return (line.header ? 1 : 0) + Math.max(1, line.body.split(/\r?\n/).length); +} + +function visibleRows(lines: RenderedChatLine[], maxRows: number): RenderedChatLine[] { + if (maxRows <= 0) return []; + const visible: RenderedChatLine[] = []; + let remaining = maxRows; + for (let index = lines.length - 1; index >= 0 && remaining > 0; index -= 1) { + const line = lines[index]!; + const needed = rowCount(line); + if (needed <= remaining) { + visible.unshift(line); + remaining -= needed; + continue; + } + const headerRows = line.header ? 1 : 0; + const bodyRows = Math.max(0, remaining - headerRows); + if (bodyRows > 0) { + visible.unshift({ + ...line, + body: clipBodyToRows(line.body, bodyRows), + }); + } + break; + } + return visible; +} + export function ChatView({ events, notices, @@ -43,6 +79,8 @@ export function ChatView({ projectName, laneName, expandedLineIds, + maxLines = 64, + maxRows = 24, }: { events: AgentChatEventEnvelope[]; notices: LocalNotice[]; @@ -50,14 +88,17 @@ export function ChatView({ projectName: string; laneName: string; expandedLineIds?: Set; + maxLines?: number; + maxRows?: number; }) { - const lines = renderChatLines({ events, notices, activeSession, expandedLineIds, maxLines: 64 }); + const lines = renderChatLines({ events, notices, activeSession, expandedLineIds, maxLines }); if (!lines.length) { return ; } + const clippedLines = visibleRows(lines, maxRows); return ( - {lines.map((line) => ( + {clippedLines.map((line) => ( {line.header ? {line.header} : null} {line.body} diff --git a/apps/ade-code/src/components/Drawer.tsx b/apps/ade-cli/src/tuiClient/components/Drawer.tsx similarity index 65% rename from apps/ade-code/src/components/Drawer.tsx rename to apps/ade-cli/src/tuiClient/components/Drawer.tsx index 2d5b7d454..7982ae517 100644 --- a/apps/ade-code/src/components/Drawer.tsx +++ b/apps/ade-cli/src/tuiClient/components/Drawer.tsx @@ -1,12 +1,24 @@ import React from "react"; import { Box, Text } from "ink"; -import type { AgentChatSessionSummary } from "../../../desktop/src/shared/types/chat"; -import type { LaneSummary } from "../../../desktop/src/shared/types/lanes"; +import type { AgentChatSessionSummary } from "../../../../desktop/src/shared/types/chat"; +import type { LaneSummary } from "../../../../desktop/src/shared/types/lanes"; import { formatLaneLabel, formatSessionLabel } from "../format"; const PURPLE = "#A78BFA"; const AMBER = "#F59E0B"; +function laneColor(laneId: string, activeLaneId: string | null, browsingLaneId: string | null): string | undefined { + if (laneId === activeLaneId) return AMBER; + if (laneId === browsingLaneId) return "white"; + return undefined; +} + +function laneMarker(laneId: string, activeLaneId: string | null, browsingLaneId: string | null): string { + if (laneId === activeLaneId) return "●"; + if (laneId === browsingLaneId) return "◐"; + return "○"; +} + export function Drawer({ lanes, sessions, @@ -30,8 +42,8 @@ export function Drawer({ LANES {lanes.slice(0, 10).map((lane, index) => ( - - {index === selectedLaneIndex ? "›" : " "} {lane.id === activeLaneId ? "●" : lane.id === browsingLaneId ? "◐" : "○"} {formatLaneLabel(lane).slice(0, 20)} + + {index === selectedLaneIndex ? "›" : " "} {laneMarker(lane.id, activeLaneId, browsingLaneId)} {formatLaneLabel(lane).slice(0, 20)} ))} + new lane diff --git a/apps/ade-code/src/components/Header.tsx b/apps/ade-cli/src/tuiClient/components/Header.tsx similarity index 82% rename from apps/ade-code/src/components/Header.tsx rename to apps/ade-cli/src/tuiClient/components/Header.tsx index fab0834b6..1a13eae6f 100644 --- a/apps/ade-code/src/components/Header.tsx +++ b/apps/ade-cli/src/tuiClient/components/Header.tsx @@ -1,6 +1,6 @@ import React from "react"; import { Box, Text } from "ink"; -import type { LaneSummary } from "../../../desktop/src/shared/types/lanes"; +import type { LaneSummary } from "../../../../desktop/src/shared/types/lanes"; import type { AdeCodeModelState, RuntimeMode } from "../types"; import { formatLaneLabel } from "../format"; @@ -20,7 +20,9 @@ export function Header({ mode: RuntimeMode | "connecting"; tuiCount: number; }) { - const modeColor = mode === "attached" ? "green" : mode === "embedded" ? "yellow" : "gray"; + let modeColor: string = "gray"; + if (mode === "attached") modeColor = "green"; + else if (mode === "embedded") modeColor = "yellow"; return ( ▌ ADE diff --git a/apps/ade-code/src/components/MentionPalette.tsx b/apps/ade-cli/src/tuiClient/components/MentionPalette.tsx similarity index 100% rename from apps/ade-code/src/components/MentionPalette.tsx rename to apps/ade-cli/src/tuiClient/components/MentionPalette.tsx diff --git a/apps/ade-code/src/components/RightPane.tsx b/apps/ade-cli/src/tuiClient/components/RightPane.tsx similarity index 100% rename from apps/ade-code/src/components/RightPane.tsx rename to apps/ade-cli/src/tuiClient/components/RightPane.tsx diff --git a/apps/ade-code/src/components/SlashPalette.tsx b/apps/ade-cli/src/tuiClient/components/SlashPalette.tsx similarity index 91% rename from apps/ade-code/src/components/SlashPalette.tsx rename to apps/ade-cli/src/tuiClient/components/SlashPalette.tsx index 0bcd376f4..9e2dbad53 100644 --- a/apps/ade-code/src/components/SlashPalette.tsx +++ b/apps/ade-cli/src/tuiClient/components/SlashPalette.tsx @@ -1,6 +1,6 @@ import React from "react"; import { Box, Text } from "ink"; -import type { AgentChatSlashCommand } from "../../../desktop/src/shared/types/chat"; +import type { AgentChatSlashCommand } from "../../../../desktop/src/shared/types/chat"; import { paletteCommands } from "../commands"; export function SlashPalette({ diff --git a/apps/ade-cli/src/tuiClient/connection.ts b/apps/ade-cli/src/tuiClient/connection.ts new file mode 100644 index 000000000..4a6ec3dc2 --- /dev/null +++ b/apps/ade-cli/src/tuiClient/connection.ts @@ -0,0 +1,533 @@ +import { spawn } from "node:child_process"; +import fs from "node:fs"; +import path from "node:path"; +import { fileURLToPath, pathToFileURL } from "node:url"; +import { resolveAdeLayout } from "../../../desktop/src/shared/adeLayout"; +import { resolveMachineAdeLayout } from "../services/projects/machineLayout"; +import { JsonRpcClient } from "./jsonRpcClient"; +import type { AdeCodeConnection, ProjectLaunchContext } from "./types"; +import type { AgentChatEventEnvelope } from "../../../desktop/src/shared/types/chat"; + +type RpcResponseEnvelope = + | T + | { + ok: false; + error: { message?: string }; + }; + +type AdeRpcRequest = (method: string, params?: unknown) => Promise; + +type AdeActionHelpers = Pick< + AdeCodeConnection, + "tool" | "action" | "actionList" +>; + +type InitializeResult = { + runtimeInfo?: { + multiProject?: boolean; + }; + capabilities?: { + projects?: boolean; + }; +}; + +type ProjectRecord = { + projectId: string; +}; + +type EmbeddedRuntime = { + dispose: () => void; + agentChatService?: { + subscribeToEvents?: ( + callback: (event: AgentChatEventEnvelope) => void, + ) => () => void; + }; +}; + +type DirectHandler = { + (message: unknown): Promise; + dispose: () => void; +}; + +type CreateEmbeddedRuntime = (args: { + projectRoot: string; + workspaceRoot: string; + chatRuntime: "agent"; + runtimeProfile: "chat"; +}) => Promise; + +type CreateEmbeddedRpcRequestHandler = (args: { + runtime: EmbeddedRuntime; + serverVersion: string; +}) => DirectHandler; + +const MULTI_PROJECT_RUNTIME_METHODS = new Set([ + "ade/initialize", + "ade/initialized", + "ping", + "shutdown", + "exit", + "runtime/info", + "machineInfo.get", + "projects.list", + "projects.add", + "projects.remove", + "projects.touch", + "projects.browseDirectories", + "projects.getDetail", + "projects.getDefaultParentDir", + "projects.create", + "projects.clone", + "projects.listMyGitHubRepos", +]); + +async function importRuntimeModule(specifier: string): Promise { + return (await import(specifier)) as T; +} + +function resolveBuiltRuntimeModules(): { + bootstrap: string; + rpc: string; +} | null { + const moduleDir = path.dirname(fileURLToPath(import.meta.url)); + const candidates = [ + { + bootstrap: path.join(moduleDir, "bootstrap.cjs"), + rpc: path.join(moduleDir, "adeRpcServer.cjs"), + }, + { + bootstrap: path.join(moduleDir, "..", "bootstrap.cjs"), + rpc: path.join(moduleDir, "..", "adeRpcServer.cjs"), + }, + ]; + for (const candidate of candidates) { + if (!fs.existsSync(candidate.bootstrap) || !fs.existsSync(candidate.rpc)) { + continue; + } + return { + bootstrap: pathToFileURL(candidate.bootstrap).href, + rpc: pathToFileURL(candidate.rpc).href, + }; + } + return null; +} + +async function loadEmbeddedAdeCli(): Promise<{ + createAdeRuntime: (args: { + projectRoot: string; + workspaceRoot: string; + chatRuntime: "agent"; + runtimeProfile: "chat"; + }) => Promise; + createAdeRpcRequestHandler: CreateEmbeddedRpcRequestHandler; +}> { + const builtModules = resolveBuiltRuntimeModules(); + const [bootstrap, rpc] = await Promise.all([ + importRuntimeModule( + builtModules?.bootstrap ?? "../bootstrap", + ), + importRuntimeModule( + builtModules?.rpc ?? "../adeRpcServer", + ), + ]); + return { + createAdeRuntime: + bootstrap.createAdeRuntime as unknown as CreateEmbeddedRuntime, + createAdeRpcRequestHandler: + rpc.createAdeRpcRequestHandler as unknown as CreateEmbeddedRpcRequestHandler, + }; +} + +function failedEnvelopeMessage(payload: unknown): string | null { + if ( + !payload || + typeof payload !== "object" || + !("ok" in payload) || + (payload as { ok?: unknown }).ok !== false + ) { + return null; + } + const error = (payload as { error?: { message?: string } }).error; + return typeof error?.message === "string" ? error.message : ""; +} + +function unwrapActionResult( + payload: RpcResponseEnvelope, + domain: string, + action: string, +): T { + const errorMessage = failedEnvelopeMessage(payload); + if (errorMessage !== null) { + throw new Error(errorMessage || `ADE action failed: ${domain}.${action}`); + } + return (payload as { result?: unknown }).result as T; +} + +function createAdeActionHelpers(request: AdeRpcRequest): AdeActionHelpers { + return { + tool: async ( + name: string, + toolArgs?: Record, + ): Promise => { + const payload = await request("ade/actions/call", { + name, + arguments: toolArgs ?? {}, + }); + const errorMessage = failedEnvelopeMessage(payload); + if (errorMessage !== null) { + throw new Error(errorMessage || `ADE tool failed: ${name}`); + } + return payload as T; + }, + action: async ( + domain: string, + action: string, + actionArgs?: Record, + ): Promise => { + const payload = await request("ade/actions/call", { + name: "run_ade_action", + arguments: { domain, action, args: actionArgs ?? {} }, + }); + return unwrapActionResult(payload, domain, action); + }, + actionList: async ( + domain: string, + action: string, + argsList: unknown[], + ): Promise => { + const payload = await request("ade/actions/call", { + name: "run_ade_action", + arguments: { domain, action, argsList }, + }); + return unwrapActionResult(payload, domain, action); + }, + }; +} + +async function initialize(request: AdeRpcRequest): Promise { + const result = await request("ade/initialize", { + protocolVersion: "2025-06-18", + clientName: "ade-code", + identity: { + role: "cto", + callerId: `ade-code:${process.pid}`, + }, + }); + await request("ade/initialized"); + return result; +} + +async function withTimeout( + promise: Promise, + timeoutMs: number, + message: string, +): Promise { + let timer: NodeJS.Timeout | null = null; + try { + return await Promise.race([ + promise, + new Promise((_, reject) => { + timer = setTimeout(() => reject(new Error(message)), timeoutMs); + timer.unref(); + }), + ]); + } finally { + if (timer) clearTimeout(timer); + } +} + +function isRecord(value: unknown): value is Record { + return Boolean(value) && typeof value === "object" && !Array.isArray(value); +} + +function errorMessage(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} + +function isMultiProjectRuntime(result: InitializeResult): boolean { + return ( + result.runtimeInfo?.multiProject === true || + result.capabilities?.projects === true + ); +} + +function withProjectId( + method: string, + params: unknown, + projectId: string, +): unknown { + if (MULTI_PROJECT_RUNTIME_METHODS.has(method)) return params; + if (isRecord(params)) { + const existing = + typeof params.projectId === "string" && params.projectId.trim().length > 0 + ? params.projectId.trim() + : null; + return existing ? params : { ...params, projectId }; + } + return { projectId }; +} + +function resolveCliEntrypoint(): string | null { + const moduleDir = path.dirname(fileURLToPath(import.meta.url)); + const candidates = [ + path.join(moduleDir, "..", "cli.cjs"), + path.join(moduleDir, "..", "cli.js"), + path.join(moduleDir, "..", "cli.mjs"), + process.argv[1], + ].filter( + (candidate): candidate is string => + typeof candidate === "string" && candidate.trim().length > 0, + ); + for (const candidate of candidates) { + try { + const resolved = path.resolve(candidate); + if (fs.existsSync(resolved) && fs.statSync(resolved).isFile()) + return resolved; + } catch { + // Try the next candidate. + } + } + return null; +} + +function spawnDaemon(socketPath: string): boolean { + const cliEntrypoint = resolveCliEntrypoint(); + if (!cliEntrypoint) return false; + const child = spawn( + process.execPath, + [cliEntrypoint, "serve", "--socket", socketPath], + { + detached: true, + stdio: "ignore", + env: { + ...process.env, + ADE_RPC_SOCKET_PATH: socketPath, + }, + }, + ); + child.unref(); + return true; +} + +async function connectAttachedSocket(args: { + socketPath: string; + project: ProjectLaunchContext; +}): Promise { + let client: JsonRpcClient | null = await JsonRpcClient.connect( + args.socketPath, + ); + try { + const connectedClient = client; + const rawRequest: AdeRpcRequest = (method: string, params?: unknown) => + connectedClient.request(method, params); + const initializeResult = await withTimeout( + initialize(rawRequest), + 3000, + "ADE RPC socket did not finish initialization.", + ); + let request = rawRequest; + if (isMultiProjectRuntime(initializeResult)) { + const project = await rawRequest("projects.add", { + rootPath: args.project.projectRoot, + }); + const projectId = + typeof project.projectId === "string" && + project.projectId.trim().length > 0 + ? project.projectId.trim() + : null; + if (!projectId) { + throw new Error( + "ADE daemon did not return a projectId for this project.", + ); + } + request = (method: string, params?: unknown) => + rawRequest(method, withProjectId(method, params, projectId)); + } + const attachedClient = connectedClient; + client = null; + return { + mode: "attached", + projectRoot: args.project.projectRoot, + workspaceRoot: args.project.workspaceRoot, + socketPath: args.socketPath, + request, + ...createAdeActionHelpers(request), + onChatEvent: (callback: (event: AgentChatEventEnvelope) => void) => + attachedClient.onNotification("chat/event", (params) => + callback(params as AgentChatEventEnvelope), + ), + close: async () => attachedClient.close(), + }; + } catch (error) { + client?.close(); + throw error; + } +} + +async function connectAttachedSocketWithRetry(args: { + socketPath: string; + project: ProjectLaunchContext; + attempts: number; + delayMs: number; +}): Promise { + let lastError: unknown = null; + for (let attempt = 0; attempt < Math.max(1, args.attempts); attempt += 1) { + try { + return await connectAttachedSocket({ + socketPath: args.socketPath, + project: args.project, + }); + } catch (error) { + lastError = error; + if (attempt + 1 >= args.attempts) break; + await new Promise((resolve) => setTimeout(resolve, args.delayMs)); + } + } + throw lastError instanceof Error ? lastError : new Error(String(lastError)); +} + +export async function connectToAde(args: { + project: ProjectLaunchContext; + forceEmbedded?: boolean; + requireSocket?: boolean; + socketPath?: string | null; +}): Promise { + const layout = resolveAdeLayout(args.project.projectRoot); + const explicitSocketPath = + args.socketPath?.trim() || process.env.ADE_RPC_SOCKET_PATH?.trim() || null; + const machineSocketPath = resolveMachineAdeLayout().socketPath; + const socketPath = explicitSocketPath ?? machineSocketPath; + + if (args.forceEmbedded && args.requireSocket) { + throw new Error("Cannot use embedded mode when an ADE socket is required."); + } + + if (!args.forceEmbedded && explicitSocketPath) { + try { + return await connectAttachedSocketWithRetry({ + socketPath: explicitSocketPath, + project: args.project, + attempts: 1, + delayMs: 0, + }); + } catch (error) { + const message = errorMessage(error); + if (args.requireSocket) { + throw new Error( + `ADE RPC socket is required but unavailable at ${explicitSocketPath}: ${message}`, + ); + } + throw new Error( + `ADE RPC socket is unavailable at ${explicitSocketPath}: ${message}. ` + + "Start ade serve or run ade code --embedded to use the legacy embedded fallback.", + ); + } + } + + let attachError: unknown = null; + if (!args.forceEmbedded && !explicitSocketPath) { + const tryDaemon = async (attempts: number): Promise => + connectAttachedSocketWithRetry({ + socketPath: machineSocketPath, + project: args.project, + attempts, + delayMs: 200, + }); + try { + if (!fs.existsSync(machineSocketPath)) { + const spawned = spawnDaemon(machineSocketPath); + return await tryDaemon(spawned ? 25 : 1); + } + return await tryDaemon(1); + } catch (firstError) { + try { + const spawned = spawnDaemon(machineSocketPath); + if (spawned) return await tryDaemon(25); + } catch (error) { + attachError = error; + } + const projectSocketPath = layout.socketPath; + if ( + projectSocketPath && + (args.requireSocket || fs.existsSync(projectSocketPath)) + ) { + try { + return await connectAttachedSocketWithRetry({ + socketPath: projectSocketPath, + project: args.project, + attempts: 1, + delayMs: 0, + }); + } catch (projectError) { + if (args.requireSocket) { + throw new Error( + `ADE RPC socket is required but unavailable at ${projectSocketPath}: ${errorMessage(projectError)}`, + ); + } + attachError = projectError; + } + } + if (args.requireSocket) { + throw new Error( + `ADE RPC socket is required but unavailable at ${machineSocketPath}: ${errorMessage(firstError)}`, + ); + } + attachError ??= firstError; + } + } + + if (!args.forceEmbedded) { + const message = + attachError instanceof Error ? ` Last error: ${attachError.message}` : ""; + throw new Error( + `Unable to attach to the ADE service at ${socketPath}.${message} ` + + "Start ade serve or run ade code --embedded to use the legacy embedded fallback.", + ); + } + + const { createAdeRuntime, createAdeRpcRequestHandler } = + await loadEmbeddedAdeCli(); + const runtime = await createAdeRuntime({ + projectRoot: args.project.projectRoot, + workspaceRoot: args.project.workspaceRoot, + chatRuntime: "agent", + runtimeProfile: "chat", + }); + const handler: DirectHandler = createAdeRpcRequestHandler({ + runtime, + serverVersion: "ade-code", + }); + let nextRequestId = 1; + const request: AdeRpcRequest = async ( + method: string, + params?: unknown, + ): Promise => { + return (await handler({ + jsonrpc: "2.0", + id: nextRequestId++, + method, + params, + })) as T; + }; + await initialize(request); + const chatEvents = + typeof runtime.agentChatService?.subscribeToEvents === "function" + ? runtime.agentChatService.subscribeToEvents.bind( + runtime.agentChatService, + ) + : () => () => {}; + + return { + mode: "embedded", + projectRoot: args.project.projectRoot, + workspaceRoot: args.project.workspaceRoot, + socketPath: null, + request, + ...createAdeActionHelpers(request), + onChatEvent: (callback) => chatEvents(callback), + close: async () => { + handler.dispose(); + runtime.dispose(); + }, + }; +} diff --git a/apps/ade-code/src/format.ts b/apps/ade-cli/src/tuiClient/format.ts similarity index 94% rename from apps/ade-code/src/format.ts rename to apps/ade-cli/src/tuiClient/format.ts index fb3896dbb..27bbe58a3 100644 --- a/apps/ade-code/src/format.ts +++ b/apps/ade-cli/src/tuiClient/format.ts @@ -1,6 +1,6 @@ import path from "node:path"; -import type { AgentChatEventEnvelope, AgentChatSessionSummary } from "../../desktop/src/shared/types/chat"; -import type { LaneSummary } from "../../desktop/src/shared/types/lanes"; +import type { AgentChatEventEnvelope, AgentChatSessionSummary } from "../../../desktop/src/shared/types/chat"; +import type { LaneSummary } from "../../../desktop/src/shared/types/lanes"; import type { LocalNotice } from "./types"; function timeLabel(value: string): string { @@ -88,7 +88,7 @@ export function renderChatLines(args: { lines.push({ id: notice.id, tone: notice.tone === "error" ? "error" : "notice", - header: `- ade-code · ${timeLabel(notice.timestamp)} ${"-".repeat(20)}`, + header: `- ade code · ${timeLabel(notice.timestamp)} ${"-".repeat(20)}`, body: notice.text, }); } @@ -202,7 +202,9 @@ export function formatLaneLabel(lane: LaneSummary | null): string { export function formatSessionLabel(session: AgentChatSessionSummary): string { const label = (session.title ?? session.goal ?? session.summary ?? session.sessionId).trim(); - const state = session.awaitingInput ? " ?" : session.status === "active" ? " ●" : ""; + let state = ""; + if (session.awaitingInput) state = " ?"; + else if (session.status === "active") state = " ●"; return `${label}${state}`; } @@ -218,7 +220,9 @@ export function renderObject(value: unknown, maxLines = 24): string { export function summarizeDiffChanges(value: unknown): Array<{ path: string; additions?: number; deletions?: number; body?: string }> { const record = value && typeof value === "object" ? value as Record : {}; - const files = Array.isArray(record.files) ? record.files : Array.isArray(record.changes) ? record.changes : []; + let files: unknown[] = []; + if (Array.isArray(record.files)) files = record.files; + else if (Array.isArray(record.changes)) files = record.changes; return files .map((entry) => { const item = entry && typeof entry === "object" ? entry as Record : {}; diff --git a/apps/ade-code/src/heartbeat.ts b/apps/ade-cli/src/tuiClient/heartbeat.ts similarity index 88% rename from apps/ade-code/src/heartbeat.ts rename to apps/ade-cli/src/tuiClient/heartbeat.ts index 1832ecbc6..23abf064d 100644 --- a/apps/ade-code/src/heartbeat.ts +++ b/apps/ade-cli/src/tuiClient/heartbeat.ts @@ -29,7 +29,13 @@ function safeUnlink(filePath: string): void { function cleanupAndCount(dir: string, now = Date.now()): number { let count = 0; - for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { + let entries: fs.Dirent[]; + try { + entries = fs.readdirSync(dir, { withFileTypes: true }); + } catch { + return 0; + } + for (const entry of entries) { if (!entry.isFile() || !entry.name.endsWith(".json")) continue; const filePath = path.join(dir, entry.name); try { @@ -98,11 +104,15 @@ export function startTuiHeartbeat(projectRoot: string): TuiHeartbeat { const filePath = path.join(dir, `${process.pid}.json`); const startedAt = new Date().toISOString(); const write = () => { - fs.writeFileSync(filePath, JSON.stringify({ - pid: process.pid, - startedAt, - updatedAt: Date.now(), - }), "utf8"); + try { + fs.writeFileSync(filePath, JSON.stringify({ + pid: process.pid, + startedAt, + updatedAt: Date.now(), + }), "utf8"); + } catch (error) { + console.error("ADE TUI heartbeat write failed", { filePath, error }); + } }; write(); const timer = setInterval(() => { diff --git a/apps/ade-code/src/jsonRpcClient.ts b/apps/ade-cli/src/tuiClient/jsonRpcClient.ts similarity index 90% rename from apps/ade-code/src/jsonRpcClient.ts rename to apps/ade-cli/src/tuiClient/jsonRpcClient.ts index 461d86aad..25c2bf5f0 100644 --- a/apps/ade-code/src/jsonRpcClient.ts +++ b/apps/ade-cli/src/tuiClient/jsonRpcClient.ts @@ -36,15 +36,16 @@ export class JsonRpcClient { static connect(socketPath: string): Promise { return new Promise((resolve, reject) => { - const socket = socketPath.startsWith("tcp://") - ? (() => { - const parsed = new URL(socketPath); - return net.createConnection({ - host: parsed.hostname || "127.0.0.1", - port: Number.parseInt(parsed.port, 10), - }); - })() - : net.createConnection(socketPath); + let socket: net.Socket; + if (socketPath.startsWith("tcp://")) { + const parsed = new URL(socketPath); + socket = net.createConnection({ + host: parsed.hostname || "127.0.0.1", + port: Number.parseInt(parsed.port, 10), + }); + } else { + socket = net.createConnection(socketPath); + } const cleanup = () => { socket.off("connect", onConnect); socket.off("error", onError); @@ -138,11 +139,9 @@ export class JsonRpcClient { const crlfBoundary = this.buffer.indexOf("\r\n\r\n"); const lfBoundary = this.buffer.indexOf("\n\n"); - const boundary = crlfBoundary >= 0 - ? { index: crlfBoundary, length: 4 } - : lfBoundary >= 0 - ? { index: lfBoundary, length: 2 } - : null; + let boundary: { index: number; length: number } | null = null; + if (crlfBoundary >= 0) boundary = { index: crlfBoundary, length: 4 }; + else if (lfBoundary >= 0) boundary = { index: lfBoundary, length: 2 }; if (!boundary) return null; const header = this.buffer.subarray(0, boundary.index).toString("ascii"); const match = /^content-length\s*:\s*(\d+)\s*$/im.exec(header); diff --git a/apps/ade-code/src/linearCommands.ts b/apps/ade-cli/src/tuiClient/linearCommands.ts similarity index 100% rename from apps/ade-code/src/linearCommands.ts rename to apps/ade-cli/src/tuiClient/linearCommands.ts diff --git a/apps/ade-code/src/pendingInput.ts b/apps/ade-cli/src/tuiClient/pendingInput.ts similarity index 98% rename from apps/ade-code/src/pendingInput.ts rename to apps/ade-cli/src/tuiClient/pendingInput.ts index ad1740304..a569665b2 100644 --- a/apps/ade-code/src/pendingInput.ts +++ b/apps/ade-cli/src/tuiClient/pendingInput.ts @@ -3,7 +3,7 @@ import type { PendingInputOption, PendingInputQuestion, PendingInputRequest, -} from "../../desktop/src/shared/types/chat"; +} from "../../../desktop/src/shared/types/chat"; import { renderObject } from "./format"; import type { PendingApproval } from "./types"; diff --git a/apps/ade-code/src/project.ts b/apps/ade-cli/src/tuiClient/project.ts similarity index 97% rename from apps/ade-code/src/project.ts rename to apps/ade-cli/src/tuiClient/project.ts index 68d28d20d..f83695103 100644 --- a/apps/ade-code/src/project.ts +++ b/apps/ade-cli/src/tuiClient/project.ts @@ -1,7 +1,7 @@ import { execFileSync } from "node:child_process"; import fs from "node:fs"; import path from "node:path"; -import type { LaneSummary } from "../../desktop/src/shared/types/lanes"; +import type { LaneSummary } from "../../../desktop/src/shared/types/lanes"; import type { ProjectLaunchContext } from "./types"; function normalizeRoot(value: string): string { diff --git a/apps/ade-cli/src/tuiClient/reactDevtoolsStub.ts b/apps/ade-cli/src/tuiClient/reactDevtoolsStub.ts new file mode 100644 index 000000000..c985cb2d3 --- /dev/null +++ b/apps/ade-cli/src/tuiClient/reactDevtoolsStub.ts @@ -0,0 +1,7 @@ +const reactDevtoolsStub = { + connectToDevTools(): void { + // ADE's packaged TUI does not ship React DevTools. + }, +}; + +export default reactDevtoolsStub; diff --git a/apps/ade-code/src/types.ts b/apps/ade-cli/src/tuiClient/types.ts similarity index 95% rename from apps/ade-code/src/types.ts rename to apps/ade-cli/src/tuiClient/types.ts index 03dc95cc1..e51a7c59b 100644 --- a/apps/ade-code/src/types.ts +++ b/apps/ade-cli/src/tuiClient/types.ts @@ -1,4 +1,4 @@ -import type { AppNavigationRequest, AppNavigationResult } from "../../desktop/src/shared/types/core"; +import type { AppNavigationRequest, AppNavigationResult } from "../../../desktop/src/shared/types/core"; import type { AgentChatEventEnvelope, AgentChatModelInfo, @@ -6,8 +6,8 @@ import type { AgentChatSessionSummary, AgentChatSlashCommand, PendingInputRequest, -} from "../../desktop/src/shared/types/chat"; -import type { LaneSummary } from "../../desktop/src/shared/types/lanes"; +} from "../../../desktop/src/shared/types/chat"; +import type { LaneSummary } from "../../../desktop/src/shared/types/lanes"; export type RuntimeMode = "attached" | "embedded"; diff --git a/apps/ade-cli/tsconfig.json b/apps/ade-cli/tsconfig.json index 6e74af44a..4fdc544f0 100644 --- a/apps/ade-cli/tsconfig.json +++ b/apps/ade-cli/tsconfig.json @@ -4,6 +4,7 @@ "lib": ["ES2022", "DOM", "DOM.Iterable"], "module": "ESNext", "moduleResolution": "Bundler", + "jsx": "react-jsx", "strict": true, "esModuleInterop": true, "skipLibCheck": true, diff --git a/apps/ade-cli/tsup.config.ts b/apps/ade-cli/tsup.config.ts index 58ed59bfc..74c24fc3d 100644 --- a/apps/ade-cli/tsup.config.ts +++ b/apps/ade-cli/tsup.config.ts @@ -1,23 +1,71 @@ import { defineConfig } from "tsup"; +import { readFileSync } from "node:fs"; +import { fileURLToPath } from "node:url"; +import path from "node:path"; -export default defineConfig({ - entry: { - cli: "src/cli.ts" +const external = ["node-pty", "sql.js", "node:sqlite", "@cursor/sdk", "sqlite3"]; +const packageRoot = path.dirname(fileURLToPath(import.meta.url)); +const packageJson = JSON.parse(readFileSync(path.join(packageRoot, "package.json"), "utf8")) as { version?: string }; +const version = process.env.ADE_CLI_VERSION?.trim() || packageJson.version || "0.0.0"; + +export default defineConfig([ + { + entry: { + cli: "src/cli.ts", + bootstrap: "src/bootstrap.ts", + adeRpcServer: "src/adeRpcServer.ts" + }, + format: ["cjs"], + platform: "node", + target: "node22", + outDir: "dist", + sourcemap: true, + clean: true, + outExtension: () => ({ + js: ".cjs" + }), + external, + esbuildOptions(options) { + options.define = { + ...(options.define ?? {}), + __ADE_VERSION__: JSON.stringify(version), + }; + options.alias = { + ...(options.alias ?? {}), + sqlite: "node:sqlite", + "react-devtools-core": path.join(packageRoot, "src", "tuiClient", "reactDevtoolsStub.ts"), + }; + }, }, - format: ["cjs"], - platform: "node", - target: "node22", - outDir: "dist", - sourcemap: true, - clean: true, - outExtension: () => ({ - js: ".cjs" - }), - external: ["node-pty", "sql.js", "node:sqlite", "@cursor/sdk", "sqlite3"], - esbuildOptions(options) { - options.alias = { - ...(options.alias ?? {}), - sqlite: "node:sqlite", - }; + { + entry: { + "tuiClient/cli": "src/tuiClient/cli.tsx" + }, + format: ["esm"], + platform: "node", + target: "node22", + outDir: "dist", + sourcemap: true, + clean: false, + splitting: false, + noExternal: ["ink", "ink-text-input", "react", "react/jsx-runtime"], + banner: { + js: "import { createRequire as __adeCreateRequire } from 'node:module'; const require = __adeCreateRequire(import.meta.url);", + }, + outExtension: () => ({ + js: ".mjs" + }), + external, + esbuildOptions(options) { + options.define = { + ...(options.define ?? {}), + __ADE_VERSION__: JSON.stringify(version), + }; + options.alias = { + ...(options.alias ?? {}), + sqlite: "node:sqlite", + "react-devtools-core": path.join(packageRoot, "src", "tuiClient", "reactDevtoolsStub.ts"), + }; + }, }, -}); +]); diff --git a/apps/ade-code/package-lock.json b/apps/ade-code/package-lock.json deleted file mode 100644 index 20cdd297a..000000000 --- a/apps/ade-code/package-lock.json +++ /dev/null @@ -1,4907 +0,0 @@ -{ - "name": "ade-code", - "version": "0.0.0", - "lockfileVersion": 3, - "requires": true, - "packages": { - "": { - "name": "ade-code", - "version": "0.0.0", - "dependencies": { - "@cursor/sdk": "^1.0.9", - "ink": "^5.2.1", - "ink-text-input": "^6.0.0", - "node-cron": "^3.0.3", - "node-pty": "^1.1.0", - "react": "^18.3.1", - "sql.js": "^1.13.0", - "yaml": "^2.8.2" - }, - "bin": { - "ade-code": "dist/cli.cjs" - }, - "devDependencies": { - "@types/node": "^22.19.18", - "@types/react": "^18.3.18", - "ink-testing-library": "^4.0.0", - "tsup": "^8.3.5", - "tsx": "^4.20.6", - "typescript": "^5.7.3", - "vitest": "^0.34.6" - }, - "engines": { - "node": ">=22.0.0" - } - }, - "node_modules/@alcalzone/ansi-tokenize": { - "version": "0.1.3", - "resolved": "https://registry.npmjs.org/@alcalzone/ansi-tokenize/-/ansi-tokenize-0.1.3.tgz", - "integrity": "sha512-3yWxPTq3UQ/FY9p1ErPxIyfT64elWaMvM9lIHnaqpyft63tkxodF5aUElYHrdisWve5cETkh1+KBw1yJuW0aRw==", - "license": "MIT", - "dependencies": { - "ansi-styles": "^6.2.1", - "is-fullwidth-code-point": "^4.0.0" - }, - "engines": { - "node": ">=14.13.1" - } - }, - "node_modules/@bufbuild/protobuf": { - "version": "1.10.0", - "resolved": "https://registry.npmjs.org/@bufbuild/protobuf/-/protobuf-1.10.0.tgz", - "integrity": "sha512-QDdVFLoN93Zjg36NoQPZfsVH9tZew7wKDKyV5qRdj8ntT4wQCOradQjRaTdwMhWUYsgKsvCINKKm87FdEk96Ag==", - "license": "(Apache-2.0 AND BSD-3-Clause)" - }, - "node_modules/@connectrpc/connect": { - "version": "1.7.0", - "resolved": "https://registry.npmjs.org/@connectrpc/connect/-/connect-1.7.0.tgz", - "integrity": "sha512-iNKdJRi69YP3mq6AePRT8F/HrxWCewrhxnLMNm0vpqXAR8biwzRtO6Hjx80C6UvtKJ5sFmffQT7I4Baecz389w==", - "license": "Apache-2.0", - "peerDependencies": { - "@bufbuild/protobuf": "^1.10.0" - } - }, - "node_modules/@connectrpc/connect-node": { - "version": "1.7.0", - "resolved": "https://registry.npmjs.org/@connectrpc/connect-node/-/connect-node-1.7.0.tgz", - "integrity": "sha512-6vaPIkG/NyhxlYgytLoR9KYbPhczEboFB2OYWkA9qvUz1K7efXfeGrlRxoLtpa+r8VxyIOw73w5ktNe743nD+A==", - "license": "Apache-2.0", - "dependencies": { - "undici": "^5.28.4" - }, - "engines": { - "node": ">=16.0.0" - }, - "peerDependencies": { - "@bufbuild/protobuf": "^1.10.0", - "@connectrpc/connect": "1.7.0" - } - }, - "node_modules/@cursor/sdk": { - "version": "1.0.12", - "resolved": "https://registry.npmjs.org/@cursor/sdk/-/sdk-1.0.12.tgz", - "integrity": "sha512-jGx0wFY1N9uIdIKr303CfM6m/dLXmRCUnU/0yNP/oiOpkBXqgqaThGbgYbcOeVrYonMZc/DZJ9EydXOEPJLcbg==", - "license": "SEE LICENSE IN LICENSE.md", - "dependencies": { - "@bufbuild/protobuf": "1.10.0", - "@connectrpc/connect": "^1.6.1", - "@connectrpc/connect-node": "^1.6.1", - "@statsig/js-client": "3.31.0", - "sqlite3": "^5.1.7", - "zod": "^3.25.0" - }, - "engines": { - "node": ">=18" - }, - "optionalDependencies": { - "@cursor/sdk-darwin-arm64": "1.0.12", - "@cursor/sdk-darwin-x64": "1.0.12", - "@cursor/sdk-linux-arm64": "1.0.12", - "@cursor/sdk-linux-x64": "1.0.12", - "@cursor/sdk-win32-x64": "1.0.12" - } - }, - "node_modules/@cursor/sdk-darwin-arm64": { - "version": "1.0.12", - "resolved": "https://registry.npmjs.org/@cursor/sdk-darwin-arm64/-/sdk-darwin-arm64-1.0.12.tgz", - "integrity": "sha512-AOFx+aX+4SntAeC66YncHACXk5duxp+HzDrxxF4Tl93N6nLjHaHEKSAXbt87ivL34MCHop4v/3c70QzBhamB2g==", - "cpu": [ - "arm64" - ], - "license": "SEE LICENSE IN LICENSE.md", - "optional": true, - "os": [ - "darwin" - ] - }, - "node_modules/@cursor/sdk-darwin-x64": { - "version": "1.0.12", - "resolved": "https://registry.npmjs.org/@cursor/sdk-darwin-x64/-/sdk-darwin-x64-1.0.12.tgz", - "integrity": "sha512-/ZDAYFUrnPd8hAGRky9ZGcROqZSZ2b5W+aEjTdINzLhJ8x5ZNXtjaz0ZYSHabOn2BeErjXgTcq+4bX2/To4C1A==", - "cpu": [ - "x64" - ], - "license": "SEE LICENSE IN LICENSE.md", - "optional": true, - "os": [ - "darwin" - ] - }, - "node_modules/@cursor/sdk-linux-arm64": { - "version": "1.0.12", - "resolved": "https://registry.npmjs.org/@cursor/sdk-linux-arm64/-/sdk-linux-arm64-1.0.12.tgz", - "integrity": "sha512-kAxNqiB3dPtlW9fVjjIZEdbIGEGLA9moOM3zYwsXh8J1Qw942nJYMGDGR4o8x0zglwZ24a1JpovvZamrCaC3Yw==", - "cpu": [ - "arm64" - ], - "license": "SEE LICENSE IN LICENSE.md", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@cursor/sdk-linux-x64": { - "version": "1.0.12", - "resolved": "https://registry.npmjs.org/@cursor/sdk-linux-x64/-/sdk-linux-x64-1.0.12.tgz", - "integrity": "sha512-RmBiBCPKMZC5McDerGk2Rk4P47xz2A+uzRoRgH6sMoOjklc33ry11iAZC0D5F5xH85chgY878086A/Q8+XrAuA==", - "cpu": [ - "x64" - ], - "license": "SEE LICENSE IN LICENSE.md", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@cursor/sdk-win32-x64": { - "version": "1.0.12", - "resolved": "https://registry.npmjs.org/@cursor/sdk-win32-x64/-/sdk-win32-x64-1.0.12.tgz", - "integrity": "sha512-uH4shdHrKOdtNLapy1uuScJ9lL2Pc8zc9I9ZKC6b6bx+0UX6xLAqjPP7dqVPfO6D9u61yLq1Hs86XOLs5ZVkPA==", - "cpu": [ - "x64" - ], - "license": "SEE LICENSE IN LICENSE.md", - "optional": true, - "os": [ - "win32" - ] - }, - "node_modules/@esbuild/aix-ppc64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.7.tgz", - "integrity": "sha512-EKX3Qwmhz1eMdEJokhALr0YiD0lhQNwDqkPYyPhiSwKrh7/4KRjQc04sZ8db+5DVVnZ1LmbNDI1uAMPEUBnQPg==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "aix" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/android-arm": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.7.tgz", - "integrity": "sha512-jbPXvB4Yj2yBV7HUfE2KHe4GJX51QplCN1pGbYjvsyCZbQmies29EoJbkEc+vYuU5o45AfQn37vZlyXy4YJ8RQ==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/android-arm64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.7.tgz", - "integrity": "sha512-62dPZHpIXzvChfvfLJow3q5dDtiNMkwiRzPylSCfriLvZeq0a1bWChrGx/BbUbPwOrsWKMn8idSllklzBy+dgQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/android-x64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.7.tgz", - "integrity": "sha512-x5VpMODneVDb70PYV2VQOmIUUiBtY3D3mPBG8NxVk5CogneYhkR7MmM3yR/uMdITLrC1ml/NV1rj4bMJuy9MCg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/darwin-arm64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.7.tgz", - "integrity": "sha512-5lckdqeuBPlKUwvoCXIgI2D9/ABmPq3Rdp7IfL70393YgaASt7tbju3Ac+ePVi3KDH6N2RqePfHnXkaDtY9fkw==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/darwin-x64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.7.tgz", - "integrity": "sha512-rYnXrKcXuT7Z+WL5K980jVFdvVKhCHhUwid+dDYQpH+qu+TefcomiMAJpIiC2EM3Rjtq0sO3StMV/+3w3MyyqQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/freebsd-arm64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.7.tgz", - "integrity": "sha512-B48PqeCsEgOtzME2GbNM2roU29AMTuOIN91dsMO30t+Ydis3z/3Ngoj5hhnsOSSwNzS+6JppqWsuhTp6E82l2w==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/freebsd-x64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.7.tgz", - "integrity": "sha512-jOBDK5XEjA4m5IJK3bpAQF9/Lelu/Z9ZcdhTRLf4cajlB+8VEhFFRjWgfy3M1O4rO2GQ/b2dLwCUGpiF/eATNQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-arm": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.7.tgz", - "integrity": "sha512-RkT/YXYBTSULo3+af8Ib0ykH8u2MBh57o7q/DAs3lTJlyVQkgQvlrPTnjIzzRPQyavxtPtfg0EopvDyIt0j1rA==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-arm64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.7.tgz", - "integrity": "sha512-RZPHBoxXuNnPQO9rvjh5jdkRmVizktkT7TCDkDmQ0W2SwHInKCAV95GRuvdSvA7w4VMwfCjUiPwDi0ZO6Nfe9A==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-ia32": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.7.tgz", - "integrity": "sha512-GA48aKNkyQDbd3KtkplYWT102C5sn/EZTY4XROkxONgruHPU72l+gW+FfF8tf2cFjeHaRbWpOYa/uRBz/Xq1Pg==", - "cpu": [ - "ia32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-loong64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.7.tgz", - "integrity": "sha512-a4POruNM2oWsD4WKvBSEKGIiWQF8fZOAsycHOt6JBpZ+JN2n2JH9WAv56SOyu9X5IqAjqSIPTaJkqN8F7XOQ5Q==", - "cpu": [ - "loong64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-mips64el": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.7.tgz", - "integrity": "sha512-KabT5I6StirGfIz0FMgl1I+R1H73Gp0ofL9A3nG3i/cYFJzKHhouBV5VWK1CSgKvVaG4q1RNpCTR2LuTVB3fIw==", - "cpu": [ - "mips64el" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-ppc64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.7.tgz", - "integrity": "sha512-gRsL4x6wsGHGRqhtI+ifpN/vpOFTQtnbsupUF5R5YTAg+y/lKelYR1hXbnBdzDjGbMYjVJLJTd2OFmMewAgwlQ==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-riscv64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.7.tgz", - "integrity": "sha512-hL25LbxO1QOngGzu2U5xeXtxXcW+/GvMN3ejANqXkxZ/opySAZMrc+9LY/WyjAan41unrR3YrmtTsUpwT66InQ==", - "cpu": [ - "riscv64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-s390x": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.7.tgz", - "integrity": "sha512-2k8go8Ycu1Kb46vEelhu1vqEP+UeRVj2zY1pSuPdgvbd5ykAw82Lrro28vXUrRmzEsUV0NzCf54yARIK8r0fdw==", - "cpu": [ - "s390x" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-x64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.7.tgz", - "integrity": "sha512-hzznmADPt+OmsYzw1EE33ccA+HPdIqiCRq7cQeL1Jlq2gb1+OyWBkMCrYGBJ+sxVzve2ZJEVeePbLM2iEIZSxA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/netbsd-arm64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.7.tgz", - "integrity": "sha512-b6pqtrQdigZBwZxAn1UpazEisvwaIDvdbMbmrly7cDTMFnw/+3lVxxCTGOrkPVnsYIosJJXAsILG9XcQS+Yu6w==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "netbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/netbsd-x64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.7.tgz", - "integrity": "sha512-OfatkLojr6U+WN5EDYuoQhtM+1xco+/6FSzJJnuWiUw5eVcicbyK3dq5EeV/QHT1uy6GoDhGbFpprUiHUYggrw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "netbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/openbsd-arm64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.7.tgz", - "integrity": "sha512-AFuojMQTxAz75Fo8idVcqoQWEHIXFRbOc1TrVcFSgCZtQfSdc1RXgB3tjOn/krRHENUB4j00bfGjyl2mJrU37A==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/openbsd-x64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.7.tgz", - "integrity": "sha512-+A1NJmfM8WNDv5CLVQYJ5PshuRm/4cI6WMZRg1by1GwPIQPCTs1GLEUHwiiQGT5zDdyLiRM/l1G0Pv54gvtKIg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/openharmony-arm64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.7.tgz", - "integrity": "sha512-+KrvYb/C8zA9CU/g0sR6w2RBw7IGc5J2BPnc3dYc5VJxHCSF1yNMxTV5LQ7GuKteQXZtspjFbiuW5/dOj7H4Yw==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openharmony" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/sunos-x64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.7.tgz", - "integrity": "sha512-ikktIhFBzQNt/QDyOL580ti9+5mL/YZeUPKU2ivGtGjdTYoqz6jObj6nOMfhASpS4GU4Q/Clh1QtxWAvcYKamA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "sunos" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/win32-arm64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.7.tgz", - "integrity": "sha512-7yRhbHvPqSpRUV7Q20VuDwbjW5kIMwTHpptuUzV+AA46kiPze5Z7qgt6CLCK3pWFrHeNfDd1VKgyP4O+ng17CA==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/win32-ia32": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.7.tgz", - "integrity": "sha512-SmwKXe6VHIyZYbBLJrhOoCJRB/Z1tckzmgTLfFYOfpMAx63BJEaL9ExI8x7v0oAO3Zh6D/Oi1gVxEYr5oUCFhw==", - "cpu": [ - "ia32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/win32-x64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.7.tgz", - "integrity": "sha512-56hiAJPhwQ1R4i+21FVF7V8kSD5zZTdHcVuRFMW0hn753vVfQN8xlx4uOPT4xoGH0Z/oVATuR82AiqSTDIpaHg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@fastify/busboy": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/@fastify/busboy/-/busboy-2.1.1.tgz", - "integrity": "sha512-vBZP4NlzfOlerQTnba4aqZoMhE/a9HY7HRqoOPaETQcSQuWEIyZMHGfVu6w9wGtGK5fED5qRs2DteVCjOH60sA==", - "license": "MIT", - "engines": { - "node": ">=14" - } - }, - "node_modules/@gar/promisify": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/@gar/promisify/-/promisify-1.1.3.tgz", - "integrity": "sha512-k2Ty1JcVojjJFwrg/ThKi2ujJ7XNLYaFGNB/bWT9wGR+oSMJHMa5w+CUq6p/pVrKeNNgA7pCqEcjSnHVoqJQFw==", - "license": "MIT", - "optional": true - }, - "node_modules/@jest/schemas": { - "version": "29.6.3", - "resolved": "https://registry.npmjs.org/@jest/schemas/-/schemas-29.6.3.tgz", - "integrity": "sha512-mo5j5X+jIZmJQveBKeS/clAueipV7KgiX1vMgCxam1RNYiqE1w62n0/tJJnHtjW8ZHcQco5gY85jA3mi0L+nSA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@sinclair/typebox": "^0.27.8" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - } - }, - "node_modules/@jridgewell/gen-mapping": { - "version": "0.3.13", - "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", - "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jridgewell/sourcemap-codec": "^1.5.0", - "@jridgewell/trace-mapping": "^0.3.24" - } - }, - "node_modules/@jridgewell/resolve-uri": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", - "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6.0.0" - } - }, - "node_modules/@jridgewell/sourcemap-codec": { - "version": "1.5.5", - "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", - "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", - "dev": true, - "license": "MIT" - }, - "node_modules/@jridgewell/trace-mapping": { - "version": "0.3.31", - "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", - "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jridgewell/resolve-uri": "^3.1.0", - "@jridgewell/sourcemap-codec": "^1.4.14" - } - }, - "node_modules/@npmcli/fs": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/@npmcli/fs/-/fs-1.1.1.tgz", - "integrity": "sha512-8KG5RD0GVP4ydEzRn/I4BNDuxDtqVbOdm8675T49OIG/NGhaK0pjPX7ZcDlvKYbA+ulvVK3ztfcF4uBdOxuJbQ==", - "license": "ISC", - "optional": true, - "dependencies": { - "@gar/promisify": "^1.0.1", - "semver": "^7.3.5" - } - }, - "node_modules/@npmcli/move-file": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/@npmcli/move-file/-/move-file-1.1.2.tgz", - "integrity": "sha512-1SUf/Cg2GzGDyaf15aR9St9TWlb+XvbZXWpDx8YKs7MLzMH/BCeopv+y9vzrzgkfykCGuWOlSu3mZhj2+FQcrg==", - "deprecated": "This functionality has been moved to @npmcli/fs", - "license": "MIT", - "optional": true, - "dependencies": { - "mkdirp": "^1.0.4", - "rimraf": "^3.0.2" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/@rollup/rollup-android-arm-eabi": { - "version": "4.60.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.60.3.tgz", - "integrity": "sha512-x35CNW/ANXG3hE/EZpRU8MXX1JDN86hBb2wMGAtltkz7pc6cxgjpy1OMMfDosOQ+2hWqIkag/fGok1Yady9nGw==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ] - }, - "node_modules/@rollup/rollup-android-arm64": { - "version": "4.60.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.60.3.tgz", - "integrity": "sha512-xw3xtkDApIOGayehp2+Rz4zimfkaX65r4t47iy+ymQB2G4iJCBBfj0ogVg5jpvjpn8UWn/+q9tprxleYeNp3Hw==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ] - }, - "node_modules/@rollup/rollup-darwin-arm64": { - "version": "4.60.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.60.3.tgz", - "integrity": "sha512-vo6Y5Qfpx7/5EaamIwi0WqW2+zfiusVihKatLvtN1VFVy3D13uERk/6gZLU1UiHRL6fDXqj/ELIeVRGnvcTE1g==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ] - }, - "node_modules/@rollup/rollup-darwin-x64": { - "version": "4.60.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.60.3.tgz", - "integrity": "sha512-D+0QGcZhBzTN82weOnsSlY7V7+RMmPuF1CkbxyMAGE8+ZHeUjyb76ZiWmBlCu//AQQONvxcqRbwZTajZKqjuOw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ] - }, - "node_modules/@rollup/rollup-freebsd-arm64": { - "version": "4.60.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.60.3.tgz", - "integrity": "sha512-6HnvHCT7fDyj6R0Ph7A6x8dQS/S38MClRWeDLqc0MdfWkxjiu1HSDYrdPhqSILzjTIC/pnXbbJbo+ft+gy/9hQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ] - }, - "node_modules/@rollup/rollup-freebsd-x64": { - "version": "4.60.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.60.3.tgz", - "integrity": "sha512-KHLgC3WKlUYW3ShFKnnosZDOJ0xjg9zp7au3sIm2bs/tGBeC2ipmvRh/N7JKi0t9Ue20C0dpEshi8WUubg+cnA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ] - }, - "node_modules/@rollup/rollup-linux-arm-gnueabihf": { - "version": "4.60.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.60.3.tgz", - "integrity": "sha512-DV6fJoxEYWJOvaZIsok7KrYl0tPvga5OZ2yvKHNNYyk/2roMLqQAbGhr78EQ5YhHpnhLKJD3S1WFusAkmUuV5g==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-arm-musleabihf": { - "version": "4.60.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.60.3.tgz", - "integrity": "sha512-mQKoJAzvuOs6F+TZybQO4GOTSMUu7v0WdxEk24krQ/uUxXoPTtHjuaUuPmFhtBcM4K0ons8nrE3JyhTuCFtT/w==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-arm64-gnu": { - "version": "4.60.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.60.3.tgz", - "integrity": "sha512-Whjj2qoiJ6+OOJMGptTYazaJvjOJm+iKHpXQM1P3LzGjt7Ff++Tp7nH4N8J/BUA7R9IHfDyx4DJIflifwnbmIA==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-arm64-musl": { - "version": "4.60.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.60.3.tgz", - "integrity": "sha512-4YTNHKqGng5+yiZt3mg77nmyuCfmNfX4fPmyUapBcIk+BdwSwmCWGXOUxhXbBEkFHtoN5boLj/5NON+u5QC9tg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-loong64-gnu": { - "version": "4.60.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.60.3.tgz", - "integrity": "sha512-SU3kNlhkpI4UqlUc2VXPGK9o886ZsSeGfMAX2ba2b8DKmMXq4AL7KUrkSWVbb7koVqx41Yczx6dx5PNargIrEA==", - "cpu": [ - "loong64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-loong64-musl": { - "version": "4.60.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.60.3.tgz", - "integrity": "sha512-6lDLl5h4TXpB1mTf2rQWnAk/LcXrx9vBfu/DT5TIPhvMhRWaZ5MxkIc8u4lJAmBo6klTe1ywXIUHFjylW505sg==", - "cpu": [ - "loong64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-ppc64-gnu": { - "version": "4.60.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.60.3.tgz", - "integrity": "sha512-BMo8bOw8evlup/8G+cj5xWtPyp93xPdyoSN16Zy90Q2QZ0ZYRhCt6ZJSwbrRzG9HApFabjwj2p25TUPDWrhzqQ==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-ppc64-musl": { - "version": "4.60.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.60.3.tgz", - "integrity": "sha512-E0L8X1dZN1/Rph+5VPF6Xj2G7JJvMACVXtamTJIDrVI44Y3K+G8gQaMEAavbqCGTa16InptiVrX6eM6pmJ+7qA==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-riscv64-gnu": { - "version": "4.60.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.60.3.tgz", - "integrity": "sha512-oZJ/WHaVfHUiRAtmTAeo3DcevNsVvH8mbvodjZy7D5QKvCefO371SiKRpxoDcCxB3PTRTLayWBkvmDQKTcX/sw==", - "cpu": [ - "riscv64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-riscv64-musl": { - "version": "4.60.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.60.3.tgz", - "integrity": "sha512-Dhbyh7j9FybM3YaTgaHmVALwA8AkUwTPccyCQ79TG9AJUsMQqgN1DDEZNr4+QUfwiWvLDumW5vdwzoeUF+TNxQ==", - "cpu": [ - "riscv64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-s390x-gnu": { - "version": "4.60.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.60.3.tgz", - "integrity": "sha512-cJd1X5XhHHlltkaypz1UcWLA8AcoIi1aWhsvaWDskD1oz2eKCypnqvTQ8ykMNI0RSmm7NkTdSqSSD7zM0xa6Ig==", - "cpu": [ - "s390x" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-x64-gnu": { - "version": "4.60.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.60.3.tgz", - "integrity": "sha512-DAZDBHQfG2oQuhY7mc6I3/qB4LU2fQCjRvxbDwd/Jdvb9fypP4IJ4qmtu6lNjes6B531AI8cg1aKC2di97bUxA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-x64-musl": { - "version": "4.60.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.60.3.tgz", - "integrity": "sha512-cRxsE8c13mZOh3vP+wLDxpQBRrOHDIGOWyDL93Sy0Ga8y515fBcC2pjUfFwUe5T7tqvTvWbCpg1URM/AXdWIXA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-openbsd-x64": { - "version": "4.60.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.60.3.tgz", - "integrity": "sha512-QaWcIgRxqEdQdhJqW4DJctsH6HCmo5vHxY0krHSX4jMtOqfzC+dqDGuHM87bu4H8JBeibWx7jFz+h6/4C8wA5Q==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openbsd" - ] - }, - "node_modules/@rollup/rollup-openharmony-arm64": { - "version": "4.60.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.60.3.tgz", - "integrity": "sha512-AaXwSvUi3QIPtroAUw1t5yHGIyqKEXwH54WUocFolZhpGDruJcs8c+xPNDRn4XiQsS7MEwnYsHW2l0MBLDMkWg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openharmony" - ] - }, - "node_modules/@rollup/rollup-win32-arm64-msvc": { - "version": "4.60.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.60.3.tgz", - "integrity": "sha512-65LAKM/bAWDqKNEelHlcHvm2V+Vfb8C6INFxQXRHCvaVN1rJfwr4NvdP4FyzUaLqWfaCGaadf6UbTm8xJeYfEg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ] - }, - "node_modules/@rollup/rollup-win32-ia32-msvc": { - "version": "4.60.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.60.3.tgz", - "integrity": "sha512-EEM2gyhBF5MFnI6vMKdX1LAosE627RGBzIoGMdLloPZkXrUN0Ckqgr2Qi8+J3zip/8NVVro3/FjB+tjhZUgUHA==", - "cpu": [ - "ia32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ] - }, - "node_modules/@rollup/rollup-win32-x64-gnu": { - "version": "4.60.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.60.3.tgz", - "integrity": "sha512-E5Eb5H/DpxaoXH++Qkv28RcUJboMopmdDUALBczvHMf7hNIxaDZqwY5lK12UK1BHacSmvupoEWGu+n993Z0y1A==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ] - }, - "node_modules/@rollup/rollup-win32-x64-msvc": { - "version": "4.60.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.60.3.tgz", - "integrity": "sha512-hPt/bgL5cE+Qp+/TPHBqptcAgPzgj46mPcg/16zNUmbQk0j+mOEQV/+Lqu8QRtDV3Ek95Q6FeFITpuhl6OTsAA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ] - }, - "node_modules/@sinclair/typebox": { - "version": "0.27.10", - "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.27.10.tgz", - "integrity": "sha512-MTBk/3jGLNB2tVxv6uLlFh1iu64iYOQ2PbdOSK3NW8JZsmlaOh2q6sdtKowBhfw8QFLmYNzTW4/oK4uATIi6ZA==", - "dev": true, - "license": "MIT" - }, - "node_modules/@statsig/client-core": { - "version": "3.31.0", - "resolved": "https://registry.npmjs.org/@statsig/client-core/-/client-core-3.31.0.tgz", - "integrity": "sha512-SuxQD6TmVszPG7FoMKwTk/uyBuVFk7XnxI3T/E0uyb7PL7GNjONtfsoh+NqBBVUJVse0CUeSFfgJPoZy1ZOslQ==", - "license": "ISC" - }, - "node_modules/@statsig/js-client": { - "version": "3.31.0", - "resolved": "https://registry.npmjs.org/@statsig/js-client/-/js-client-3.31.0.tgz", - "integrity": "sha512-LFa5E0LjT6sTfZv3sNGoyRLSZ1078+agdgOA+Vm1ecjG+KbSOfBLTW7hMwimrJ29slRwbYDzbtKaPJo/R37N2g==", - "license": "ISC", - "dependencies": { - "@statsig/client-core": "3.31.0" - } - }, - "node_modules/@tootallnate/once": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/@tootallnate/once/-/once-1.1.2.tgz", - "integrity": "sha512-RbzJvlNzmRq5c3O09UipeuXno4tA1FE6ikOjxZK0tuxVv3412l64l5t1W5pj4+rJq9vpkm/kwiR07aZXnsKPxw==", - "license": "MIT", - "optional": true, - "engines": { - "node": ">= 6" - } - }, - "node_modules/@types/chai": { - "version": "4.3.20", - "resolved": "https://registry.npmjs.org/@types/chai/-/chai-4.3.20.tgz", - "integrity": "sha512-/pC9HAB5I/xMlc5FP77qjCnI16ChlJfW0tGa0IUcFn38VJrTV6DeZ60NU5KZBtaOZqjdpwTWohz5HU1RrhiYxQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/@types/chai-subset": { - "version": "1.3.6", - "resolved": "https://registry.npmjs.org/@types/chai-subset/-/chai-subset-1.3.6.tgz", - "integrity": "sha512-m8lERkkQj+uek18hXOZuec3W/fCRTrU4hrnXjH3qhHy96ytuPaPiWGgu7sJb7tZxZonO75vYAjCvpe/e4VUwRw==", - "dev": true, - "license": "MIT", - "peerDependencies": { - "@types/chai": "<5.2.0" - } - }, - "node_modules/@types/estree": { - "version": "1.0.8", - "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", - "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", - "dev": true, - "license": "MIT" - }, - "node_modules/@types/node": { - "version": "22.19.18", - "resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.18.tgz", - "integrity": "sha512-9v00a+dn2yWVsYDEunWC4g/TcRKVq3r8N5FuZp7u0SGrPvdN9c2yXI9bBuf5Fl0hNCb+QTIePTn5pJs2pwBOQQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "undici-types": "~6.21.0" - } - }, - "node_modules/@types/prop-types": { - "version": "15.7.15", - "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz", - "integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==", - "devOptional": true, - "license": "MIT" - }, - "node_modules/@types/react": { - "version": "18.3.28", - "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.28.tgz", - "integrity": "sha512-z9VXpC7MWrhfWipitjNdgCauoMLRdIILQsAEV+ZesIzBq/oUlxk0m3ApZuMFCXdnS4U7KrI+l3WRUEGQ8K1QKw==", - "devOptional": true, - "license": "MIT", - "dependencies": { - "@types/prop-types": "*", - "csstype": "^3.2.2" - } - }, - "node_modules/@vitest/expect": { - "version": "0.34.6", - "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-0.34.6.tgz", - "integrity": "sha512-QUzKpUQRc1qC7qdGo7rMK3AkETI7w18gTCUrsNnyjjJKYiuUB9+TQK3QnR1unhCnWRC0AbKv2omLGQDF/mIjOw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@vitest/spy": "0.34.6", - "@vitest/utils": "0.34.6", - "chai": "^4.3.10" - }, - "funding": { - "url": "https://opencollective.com/vitest" - } - }, - "node_modules/@vitest/runner": { - "version": "0.34.6", - "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-0.34.6.tgz", - "integrity": "sha512-1CUQgtJSLF47NnhN+F9X2ycxUP0kLHQ/JWvNHbeBfwW8CzEGgeskzNnHDyv1ieKTltuR6sdIHV+nmR6kPxQqzQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@vitest/utils": "0.34.6", - "p-limit": "^4.0.0", - "pathe": "^1.1.1" - }, - "funding": { - "url": "https://opencollective.com/vitest" - } - }, - "node_modules/@vitest/runner/node_modules/pathe": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/pathe/-/pathe-1.1.2.tgz", - "integrity": "sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/@vitest/snapshot": { - "version": "0.34.6", - "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-0.34.6.tgz", - "integrity": "sha512-B3OZqYn6k4VaN011D+ve+AA4whM4QkcwcrwaKwAbyyvS/NB1hCWjFIBQxAQQSQir9/RtyAAGuq+4RJmbn2dH4w==", - "dev": true, - "license": "MIT", - "dependencies": { - "magic-string": "^0.30.1", - "pathe": "^1.1.1", - "pretty-format": "^29.5.0" - }, - "funding": { - "url": "https://opencollective.com/vitest" - } - }, - "node_modules/@vitest/snapshot/node_modules/pathe": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/pathe/-/pathe-1.1.2.tgz", - "integrity": "sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/@vitest/spy": { - "version": "0.34.6", - "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-0.34.6.tgz", - "integrity": "sha512-xaCvneSaeBw/cz8ySmF7ZwGvL0lBjfvqc1LpQ/vcdHEvpLn3Ff1vAvjw+CoGn0802l++5L/pxb7whwcWAw+DUQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "tinyspy": "^2.1.1" - }, - "funding": { - "url": "https://opencollective.com/vitest" - } - }, - "node_modules/@vitest/utils": { - "version": "0.34.6", - "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-0.34.6.tgz", - "integrity": "sha512-IG5aDD8S6zlvloDsnzHw0Ut5xczlF+kv2BOTo+iXfPr54Yhi5qbVOgGB1hZaVq4iJ4C/MZ2J0y15IlsV/ZcI0A==", - "dev": true, - "license": "MIT", - "dependencies": { - "diff-sequences": "^29.4.3", - "loupe": "^2.3.6", - "pretty-format": "^29.5.0" - }, - "funding": { - "url": "https://opencollective.com/vitest" - } - }, - "node_modules/abbrev": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/abbrev/-/abbrev-1.1.1.tgz", - "integrity": "sha512-nne9/IiQ/hzIhY6pdDnbBtz7DjPTKrY00P/zvPSm5pOFkl6xuGrGnXn/VtTNNfNtAfZ9/1RtehkszU9qcTii0Q==", - "license": "ISC", - "optional": true - }, - "node_modules/acorn": { - "version": "8.16.0", - "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", - "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", - "dev": true, - "license": "MIT", - "bin": { - "acorn": "bin/acorn" - }, - "engines": { - "node": ">=0.4.0" - } - }, - "node_modules/acorn-walk": { - "version": "8.3.5", - "resolved": "https://registry.npmjs.org/acorn-walk/-/acorn-walk-8.3.5.tgz", - "integrity": "sha512-HEHNfbars9v4pgpW6SO1KSPkfoS0xVOM/9UzkJltjlsHZmJasxg8aXkuZa7SMf8vKGIBhpUsPluQSqhJFCqebw==", - "dev": true, - "license": "MIT", - "dependencies": { - "acorn": "^8.11.0" - }, - "engines": { - "node": ">=0.4.0" - } - }, - "node_modules/agent-base": { - "version": "6.0.2", - "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz", - "integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==", - "license": "MIT", - "optional": true, - "dependencies": { - "debug": "4" - }, - "engines": { - "node": ">= 6.0.0" - } - }, - "node_modules/agentkeepalive": { - "version": "4.6.0", - "resolved": "https://registry.npmjs.org/agentkeepalive/-/agentkeepalive-4.6.0.tgz", - "integrity": "sha512-kja8j7PjmncONqaTsB8fQ+wE2mSU2DJ9D4XKoJ5PFWIdRMa6SLSN1ff4mOr4jCbfRSsxR4keIiySJU0N9T5hIQ==", - "license": "MIT", - "optional": true, - "dependencies": { - "humanize-ms": "^1.2.1" - }, - "engines": { - "node": ">= 8.0.0" - } - }, - "node_modules/aggregate-error": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/aggregate-error/-/aggregate-error-3.1.0.tgz", - "integrity": "sha512-4I7Td01quW/RpocfNayFdFVk1qSuoh0E7JrbRJ16nH01HhKFQ88INq9Sd+nd72zqRySlr9BmDA8xlEJ6vJMrYA==", - "license": "MIT", - "optional": true, - "dependencies": { - "clean-stack": "^2.0.0", - "indent-string": "^4.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/aggregate-error/node_modules/indent-string": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/indent-string/-/indent-string-4.0.0.tgz", - "integrity": "sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==", - "license": "MIT", - "optional": true, - "engines": { - "node": ">=8" - } - }, - "node_modules/ansi-escapes": { - "version": "7.3.0", - "resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-7.3.0.tgz", - "integrity": "sha512-BvU8nYgGQBxcmMuEeUEmNTvrMVjJNSH7RgW24vXexN4Ven6qCvy4TntnvlnwnMLTVlcRQQdbRY8NKnaIoeWDNg==", - "license": "MIT", - "dependencies": { - "environment": "^1.0.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/ansi-regex": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", - "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", - "license": "MIT", - "optional": true, - "engines": { - "node": ">=8" - } - }, - "node_modules/ansi-styles": { - "version": "6.2.3", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", - "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } - }, - "node_modules/any-promise": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/any-promise/-/any-promise-1.3.0.tgz", - "integrity": "sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==", - "dev": true, - "license": "MIT" - }, - "node_modules/aproba": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/aproba/-/aproba-2.1.0.tgz", - "integrity": "sha512-tLIEcj5GuR2RSTnxNKdkK0dJ/GrC7P38sUkiDmDuHfsHmbagTFAxDVIBltoklXEVIQ/f14IL8IMJ5pn9Hez1Ew==", - "license": "ISC", - "optional": true - }, - "node_modules/are-we-there-yet": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/are-we-there-yet/-/are-we-there-yet-3.0.1.tgz", - "integrity": "sha512-QZW4EDmGwlYur0Yyf/b2uGucHQMa8aFUP7eu9ddR73vvhFyt4V0Vl3QHPcTNJ8l6qYOBdxgXdnBXQrHilfRQBg==", - "deprecated": "This package is no longer supported.", - "license": "ISC", - "optional": true, - "dependencies": { - "delegates": "^1.0.0", - "readable-stream": "^3.6.0" - }, - "engines": { - "node": "^12.13.0 || ^14.15.0 || >=16.0.0" - } - }, - "node_modules/assertion-error": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-1.1.0.tgz", - "integrity": "sha512-jgsaNduz+ndvGyFt3uSuWqvy4lCnIJiovtouQN5JZHOKCS2QuhEdbcQHFhVksz2N2U9hXJo8odG7ETyWlEeuDw==", - "dev": true, - "license": "MIT", - "engines": { - "node": "*" - } - }, - "node_modules/auto-bind": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/auto-bind/-/auto-bind-5.0.1.tgz", - "integrity": "sha512-ooviqdwwgfIfNmDwo94wlshcdzfO64XV0Cg6oDsDYBJfITDz1EngD2z7DkbvCWn+XIMsIqW27sEVF6qcpJrRcg==", - "license": "MIT", - "engines": { - "node": "^12.20.0 || ^14.13.1 || >=16.0.0" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/balanced-match": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", - "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", - "license": "MIT", - "optional": true - }, - "node_modules/base64-js": { - "version": "1.5.1", - "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", - "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "license": "MIT" - }, - "node_modules/bindings": { - "version": "1.5.0", - "resolved": "https://registry.npmjs.org/bindings/-/bindings-1.5.0.tgz", - "integrity": "sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ==", - "license": "MIT", - "dependencies": { - "file-uri-to-path": "1.0.0" - } - }, - "node_modules/bl": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz", - "integrity": "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==", - "license": "MIT", - "dependencies": { - "buffer": "^5.5.0", - "inherits": "^2.0.4", - "readable-stream": "^3.4.0" - } - }, - "node_modules/brace-expansion": { - "version": "1.1.14", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.14.tgz", - "integrity": "sha512-MWPGfDxnyzKU7rNOW9SP/c50vi3xrmrua/+6hfPbCS2ABNWfx24vPidzvC7krjU/RTo235sV776ymlsMtGKj8g==", - "license": "MIT", - "optional": true, - "dependencies": { - "balanced-match": "^1.0.0", - "concat-map": "0.0.1" - } - }, - "node_modules/buffer": { - "version": "5.7.1", - "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz", - "integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "license": "MIT", - "dependencies": { - "base64-js": "^1.3.1", - "ieee754": "^1.1.13" - } - }, - "node_modules/bundle-require": { - "version": "5.1.0", - "resolved": "https://registry.npmjs.org/bundle-require/-/bundle-require-5.1.0.tgz", - "integrity": "sha512-3WrrOuZiyaaZPWiEt4G3+IffISVC9HYlWueJEBWED4ZH4aIAC2PnkdnuRrR94M+w6yGWn4AglWtJtBI8YqvgoA==", - "dev": true, - "license": "MIT", - "dependencies": { - "load-tsconfig": "^0.2.3" - }, - "engines": { - "node": "^12.20.0 || ^14.13.1 || >=16.0.0" - }, - "peerDependencies": { - "esbuild": ">=0.18" - } - }, - "node_modules/cac": { - "version": "6.7.14", - "resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz", - "integrity": "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/cacache": { - "version": "15.3.0", - "resolved": "https://registry.npmjs.org/cacache/-/cacache-15.3.0.tgz", - "integrity": "sha512-VVdYzXEn+cnbXpFgWs5hTT7OScegHVmLhJIR8Ufqk3iFD6A6j5iSX1KuBTfNEv4tdJWE2PzA6IVFtcLC7fN9wQ==", - "license": "ISC", - "optional": true, - "dependencies": { - "@npmcli/fs": "^1.0.0", - "@npmcli/move-file": "^1.0.1", - "chownr": "^2.0.0", - "fs-minipass": "^2.0.0", - "glob": "^7.1.4", - "infer-owner": "^1.0.4", - "lru-cache": "^6.0.0", - "minipass": "^3.1.1", - "minipass-collect": "^1.0.2", - "minipass-flush": "^1.0.5", - "minipass-pipeline": "^1.2.2", - "mkdirp": "^1.0.3", - "p-map": "^4.0.0", - "promise-inflight": "^1.0.1", - "rimraf": "^3.0.2", - "ssri": "^8.0.1", - "tar": "^6.0.2", - "unique-filename": "^1.1.1" - }, - "engines": { - "node": ">= 10" - } - }, - "node_modules/chai": { - "version": "4.5.0", - "resolved": "https://registry.npmjs.org/chai/-/chai-4.5.0.tgz", - "integrity": "sha512-RITGBfijLkBddZvnn8jdqoTypxvqbOLYQkGGxXzeFjVHvudaPw0HNFD9x928/eUwYWd2dPCugVqspGALTZZQKw==", - "dev": true, - "license": "MIT", - "dependencies": { - "assertion-error": "^1.1.0", - "check-error": "^1.0.3", - "deep-eql": "^4.1.3", - "get-func-name": "^2.0.2", - "loupe": "^2.3.6", - "pathval": "^1.1.1", - "type-detect": "^4.1.0" - }, - "engines": { - "node": ">=4" - } - }, - "node_modules/chalk": { - "version": "5.6.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.6.2.tgz", - "integrity": "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==", - "license": "MIT", - "engines": { - "node": "^12.17.0 || ^14.13 || >=16.0.0" - }, - "funding": { - "url": "https://github.com/chalk/chalk?sponsor=1" - } - }, - "node_modules/check-error": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/check-error/-/check-error-1.0.3.tgz", - "integrity": "sha512-iKEoDYaRmd1mxM90a2OEfWhjsjPpYPuQ+lMYsoxB126+t8fw7ySEO48nmDg5COTjxDI65/Y2OWpeEHk3ZOe8zg==", - "dev": true, - "license": "MIT", - "dependencies": { - "get-func-name": "^2.0.2" - }, - "engines": { - "node": "*" - } - }, - "node_modules/chokidar": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz", - "integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==", - "dev": true, - "license": "MIT", - "dependencies": { - "readdirp": "^4.0.1" - }, - "engines": { - "node": ">= 14.16.0" - }, - "funding": { - "url": "https://paulmillr.com/funding/" - } - }, - "node_modules/chownr": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/chownr/-/chownr-2.0.0.tgz", - "integrity": "sha512-bIomtDF5KGpdogkLd9VspvFzk9KfpyyGlS8YFVZl7TGPBHL5snIOnxeshwVgPteQ9b4Eydl+pVbIyE1DcvCWgQ==", - "license": "ISC", - "engines": { - "node": ">=10" - } - }, - "node_modules/clean-stack": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/clean-stack/-/clean-stack-2.2.0.tgz", - "integrity": "sha512-4diC9HaTE+KRAMWhDhrGOECgWZxoevMc5TlkObMqNSsVU62PYzXZ/SMTjzyGAFF1YusgxGcSWTEXBhp0CPwQ1A==", - "license": "MIT", - "optional": true, - "engines": { - "node": ">=6" - } - }, - "node_modules/cli-boxes": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/cli-boxes/-/cli-boxes-3.0.0.tgz", - "integrity": "sha512-/lzGpEWL/8PfI0BmBOPRwp0c/wFNX1RdUML3jK/RcSBA9T8mZDdQpqYBKtCFTOfQbwPqWEOpjqW+Fnayc0969g==", - "license": "MIT", - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/cli-cursor": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/cli-cursor/-/cli-cursor-4.0.0.tgz", - "integrity": "sha512-VGtlMu3x/4DOtIUwEkRezxUZ2lBacNJCHash0N0WeZDBS+7Ux1dm3XWAgWYxLJFMMdOeXMHXorshEFhbMSGelg==", - "license": "MIT", - "dependencies": { - "restore-cursor": "^4.0.0" - }, - "engines": { - "node": "^12.20.0 || ^14.13.1 || >=16.0.0" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/cli-truncate": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/cli-truncate/-/cli-truncate-4.0.0.tgz", - "integrity": "sha512-nPdaFdQ0h/GEigbPClz11D0v/ZJEwxmeVZGeMo3Z5StPtUTkA9o1lD6QwoirYiSDzbcwn2XcjwmCp68W1IS4TA==", - "license": "MIT", - "dependencies": { - "slice-ansi": "^5.0.0", - "string-width": "^7.0.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/cli-truncate/node_modules/slice-ansi": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-5.0.0.tgz", - "integrity": "sha512-FC+lgizVPfie0kkhqUScwRu1O/lF6NOgJmlCgK+/LYxDCTk8sGelYaHDhFcDN+Sn3Cv+3VSa4Byeo+IMCzpMgQ==", - "license": "MIT", - "dependencies": { - "ansi-styles": "^6.0.0", - "is-fullwidth-code-point": "^4.0.0" - }, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/slice-ansi?sponsor=1" - } - }, - "node_modules/code-excerpt": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/code-excerpt/-/code-excerpt-4.0.0.tgz", - "integrity": "sha512-xxodCmBen3iy2i0WtAK8FlFNrRzjUqjRsMfho58xT/wvZU1YTM3fCnRjcy1gJPMepaRlgm/0e6w8SpWHpn3/cA==", - "license": "MIT", - "dependencies": { - "convert-to-spaces": "^2.0.1" - }, - "engines": { - "node": "^12.20.0 || ^14.13.1 || >=16.0.0" - } - }, - "node_modules/color-support": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/color-support/-/color-support-1.1.3.tgz", - "integrity": "sha512-qiBjkpbMLO/HL68y+lh4q0/O1MZFj2RX6X/KmMa3+gJD3z+WwI1ZzDHysvqHGS3mP6mznPckpXmw1nI9cJjyRg==", - "license": "ISC", - "optional": true, - "bin": { - "color-support": "bin.js" - } - }, - "node_modules/commander": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/commander/-/commander-4.1.1.tgz", - "integrity": "sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 6" - } - }, - "node_modules/concat-map": { - "version": "0.0.1", - "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", - "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", - "license": "MIT", - "optional": true - }, - "node_modules/confbox": { - "version": "0.1.8", - "resolved": "https://registry.npmjs.org/confbox/-/confbox-0.1.8.tgz", - "integrity": "sha512-RMtmw0iFkeR4YV+fUOSucriAQNb9g8zFR52MWCtl+cCZOFRNL6zeB395vPzFhEjjn4fMxXudmELnl/KF/WrK6w==", - "dev": true, - "license": "MIT" - }, - "node_modules/consola": { - "version": "3.4.2", - "resolved": "https://registry.npmjs.org/consola/-/consola-3.4.2.tgz", - "integrity": "sha512-5IKcdX0nnYavi6G7TtOhwkYzyjfJlatbjMjuLSfE2kYT5pMDOilZ4OvMhi637CcDICTmz3wARPoyhqyX1Y+XvA==", - "dev": true, - "license": "MIT", - "engines": { - "node": "^14.18.0 || >=16.10.0" - } - }, - "node_modules/console-control-strings": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/console-control-strings/-/console-control-strings-1.1.0.tgz", - "integrity": "sha512-ty/fTekppD2fIwRvnZAVdeOiGd1c7YXEixbgJTNzqcxJWKQnjJ/V1bNEEE6hygpM3WjwHFUVK6HTjWSzV4a8sQ==", - "license": "ISC", - "optional": true - }, - "node_modules/convert-to-spaces": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/convert-to-spaces/-/convert-to-spaces-2.0.1.tgz", - "integrity": "sha512-rcQ1bsQO9799wq24uE5AM2tAILy4gXGIK/njFWcVQkGNZ96edlpY+A7bjwvzjYvLDyzmG1MmMLZhpcsb+klNMQ==", - "license": "MIT", - "engines": { - "node": "^12.20.0 || ^14.13.1 || >=16.0.0" - } - }, - "node_modules/csstype": { - "version": "3.2.3", - "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", - "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", - "devOptional": true, - "license": "MIT" - }, - "node_modules/debug": { - "version": "4.4.3", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", - "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", - "devOptional": true, - "license": "MIT", - "dependencies": { - "ms": "^2.1.3" - }, - "engines": { - "node": ">=6.0" - }, - "peerDependenciesMeta": { - "supports-color": { - "optional": true - } - } - }, - "node_modules/decompress-response": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz", - "integrity": "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==", - "license": "MIT", - "dependencies": { - "mimic-response": "^3.1.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/deep-eql": { - "version": "4.1.4", - "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-4.1.4.tgz", - "integrity": "sha512-SUwdGfqdKOwxCPeVYjwSyRpJ7Z+fhpwIAtmCUdZIWZ/YP5R9WAsyuSgpLVDi9bjWoN2LXHNss/dk3urXtdQxGg==", - "dev": true, - "license": "MIT", - "dependencies": { - "type-detect": "^4.0.0" - }, - "engines": { - "node": ">=6" - } - }, - "node_modules/deep-extend": { - "version": "0.6.0", - "resolved": "https://registry.npmjs.org/deep-extend/-/deep-extend-0.6.0.tgz", - "integrity": "sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==", - "license": "MIT", - "engines": { - "node": ">=4.0.0" - } - }, - "node_modules/delegates": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/delegates/-/delegates-1.0.0.tgz", - "integrity": "sha512-bd2L678uiWATM6m5Z1VzNCErI3jiGzt6HGY8OVICs40JQq/HALfbyNJmp0UDakEY4pMMaN0Ly5om/B1VI/+xfQ==", - "license": "MIT", - "optional": true - }, - "node_modules/detect-libc": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", - "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", - "license": "Apache-2.0", - "engines": { - "node": ">=8" - } - }, - "node_modules/diff-sequences": { - "version": "29.6.3", - "resolved": "https://registry.npmjs.org/diff-sequences/-/diff-sequences-29.6.3.tgz", - "integrity": "sha512-EjePK1srD3P08o2j4f0ExnylqRs5B9tJjcp9t1krH2qRi8CCdsYfwe9JgSLurFBWwq4uOlipzfk5fHNvwFKr8Q==", - "dev": true, - "license": "MIT", - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - } - }, - "node_modules/emoji-regex": { - "version": "10.6.0", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.6.0.tgz", - "integrity": "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==", - "license": "MIT" - }, - "node_modules/encoding": { - "version": "0.1.13", - "resolved": "https://registry.npmjs.org/encoding/-/encoding-0.1.13.tgz", - "integrity": "sha512-ETBauow1T35Y/WZMkio9jiM0Z5xjHHmJ4XmjZOq1l/dXz3lr2sRn87nJy20RupqSh1F2m3HHPSp8ShIPQJrJ3A==", - "license": "MIT", - "optional": true, - "dependencies": { - "iconv-lite": "^0.6.2" - } - }, - "node_modules/end-of-stream": { - "version": "1.4.5", - "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz", - "integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==", - "license": "MIT", - "dependencies": { - "once": "^1.4.0" - } - }, - "node_modules/env-paths": { - "version": "2.2.1", - "resolved": "https://registry.npmjs.org/env-paths/-/env-paths-2.2.1.tgz", - "integrity": "sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==", - "license": "MIT", - "optional": true, - "engines": { - "node": ">=6" - } - }, - "node_modules/environment": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/environment/-/environment-1.1.0.tgz", - "integrity": "sha512-xUtoPkMggbz0MPyPiIWr1Kp4aeWJjDZ6SMvURhimjdZgsRuDplF5/s9hcgGhyXMhs+6vpnuoiZ2kFiu3FMnS8Q==", - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/err-code": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/err-code/-/err-code-2.0.3.tgz", - "integrity": "sha512-2bmlRpNKBxT/CRmPOlyISQpNj+qSeYvcym/uT0Jx2bMOlKLtSy1ZmLuVxSEKKyor/N5yhvp/ZiG1oE3DEYMSFA==", - "license": "MIT", - "optional": true - }, - "node_modules/es-toolkit": { - "version": "1.46.1", - "resolved": "https://registry.npmjs.org/es-toolkit/-/es-toolkit-1.46.1.tgz", - "integrity": "sha512-5eNtXOs3tbfxXOj04tjjseeWkRWaoCjdEI+96DgwzZoe6c9juL49pXlzAFTI72aWC9Y8p7168g6XIKjh7k6pyQ==", - "license": "MIT", - "workspaces": [ - "docs", - "benchmarks" - ] - }, - "node_modules/esbuild": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.7.tgz", - "integrity": "sha512-IxpibTjyVnmrIQo5aqNpCgoACA/dTKLTlhMHihVHhdkxKyPO1uBBthumT0rdHmcsk9uMonIWS0m4FljWzILh3w==", - "dev": true, - "hasInstallScript": true, - "license": "MIT", - "bin": { - "esbuild": "bin/esbuild" - }, - "engines": { - "node": ">=18" - }, - "optionalDependencies": { - "@esbuild/aix-ppc64": "0.27.7", - "@esbuild/android-arm": "0.27.7", - "@esbuild/android-arm64": "0.27.7", - "@esbuild/android-x64": "0.27.7", - "@esbuild/darwin-arm64": "0.27.7", - "@esbuild/darwin-x64": "0.27.7", - "@esbuild/freebsd-arm64": "0.27.7", - "@esbuild/freebsd-x64": "0.27.7", - "@esbuild/linux-arm": "0.27.7", - "@esbuild/linux-arm64": "0.27.7", - "@esbuild/linux-ia32": "0.27.7", - "@esbuild/linux-loong64": "0.27.7", - "@esbuild/linux-mips64el": "0.27.7", - "@esbuild/linux-ppc64": "0.27.7", - "@esbuild/linux-riscv64": "0.27.7", - "@esbuild/linux-s390x": "0.27.7", - "@esbuild/linux-x64": "0.27.7", - "@esbuild/netbsd-arm64": "0.27.7", - "@esbuild/netbsd-x64": "0.27.7", - "@esbuild/openbsd-arm64": "0.27.7", - "@esbuild/openbsd-x64": "0.27.7", - "@esbuild/openharmony-arm64": "0.27.7", - "@esbuild/sunos-x64": "0.27.7", - "@esbuild/win32-arm64": "0.27.7", - "@esbuild/win32-ia32": "0.27.7", - "@esbuild/win32-x64": "0.27.7" - } - }, - "node_modules/escape-string-regexp": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-2.0.0.tgz", - "integrity": "sha512-UpzcLCXolUWcNu5HtVMHYdXJjArjsF9C0aNnquZYY4uW/Vu0miy5YoWvbV345HauVvcAUnpRuhMMcqTcGOY2+w==", - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/expand-template": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/expand-template/-/expand-template-2.0.3.tgz", - "integrity": "sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==", - "license": "(MIT OR WTFPL)", - "engines": { - "node": ">=6" - } - }, - "node_modules/fdir": { - "version": "6.5.0", - "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", - "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12.0.0" - }, - "peerDependencies": { - "picomatch": "^3 || ^4" - }, - "peerDependenciesMeta": { - "picomatch": { - "optional": true - } - } - }, - "node_modules/file-uri-to-path": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz", - "integrity": "sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==", - "license": "MIT" - }, - "node_modules/fix-dts-default-cjs-exports": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/fix-dts-default-cjs-exports/-/fix-dts-default-cjs-exports-1.0.1.tgz", - "integrity": "sha512-pVIECanWFC61Hzl2+oOCtoJ3F17kglZC/6N94eRWycFgBH35hHx0Li604ZIzhseh97mf2p0cv7vVrOZGoqhlEg==", - "dev": true, - "license": "MIT", - "dependencies": { - "magic-string": "^0.30.17", - "mlly": "^1.7.4", - "rollup": "^4.34.8" - } - }, - "node_modules/fs-constants": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz", - "integrity": "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==", - "license": "MIT" - }, - "node_modules/fs-minipass": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/fs-minipass/-/fs-minipass-2.1.0.tgz", - "integrity": "sha512-V/JgOLFCS+R6Vcq0slCuaeWEdNC3ouDlJMNIsacH2VtALiu9mV4LPrHc5cDl8k5aw6J8jwgWWpiTo5RYhmIzvg==", - "license": "ISC", - "dependencies": { - "minipass": "^3.0.0" - }, - "engines": { - "node": ">= 8" - } - }, - "node_modules/fs.realpath": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", - "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==", - "license": "ISC", - "optional": true - }, - "node_modules/fsevents": { - "version": "2.3.3", - "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", - "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", - "dev": true, - "hasInstallScript": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": "^8.16.0 || ^10.6.0 || >=11.0.0" - } - }, - "node_modules/gauge": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/gauge/-/gauge-4.0.4.tgz", - "integrity": "sha512-f9m+BEN5jkg6a0fZjleidjN51VE1X+mPFQ2DJ0uv1V39oCLCbsGe6yjbBnp7eK7z/+GAon99a3nHuqbuuthyPg==", - "deprecated": "This package is no longer supported.", - "license": "ISC", - "optional": true, - "dependencies": { - "aproba": "^1.0.3 || ^2.0.0", - "color-support": "^1.1.3", - "console-control-strings": "^1.1.0", - "has-unicode": "^2.0.1", - "signal-exit": "^3.0.7", - "string-width": "^4.2.3", - "strip-ansi": "^6.0.1", - "wide-align": "^1.1.5" - }, - "engines": { - "node": "^12.13.0 || ^14.15.0 || >=16.0.0" - } - }, - "node_modules/gauge/node_modules/emoji-regex": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", - "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", - "license": "MIT", - "optional": true - }, - "node_modules/gauge/node_modules/is-fullwidth-code-point": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", - "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", - "license": "MIT", - "optional": true, - "engines": { - "node": ">=8" - } - }, - "node_modules/gauge/node_modules/string-width": { - "version": "4.2.3", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", - "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", - "license": "MIT", - "optional": true, - "dependencies": { - "emoji-regex": "^8.0.0", - "is-fullwidth-code-point": "^3.0.0", - "strip-ansi": "^6.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/get-east-asian-width": { - "version": "1.6.0", - "resolved": "https://registry.npmjs.org/get-east-asian-width/-/get-east-asian-width-1.6.0.tgz", - "integrity": "sha512-QRbvDIbx6YklUe6RxeTeleMR0yv3cYH6PsPZHcnVn7xv7zO1BHN8r0XETu8n6Ye3Q+ahtSarc3WgtNWmehIBfA==", - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/get-func-name": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/get-func-name/-/get-func-name-2.0.2.tgz", - "integrity": "sha512-8vXOvuE167CtIc3OyItco7N/dpRtBbYOsPsXCz7X/PMnlGjYjSGuZJgM1Y7mmew7BKf9BqvLX2tnOVy1BBUsxQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": "*" - } - }, - "node_modules/get-tsconfig": { - "version": "4.14.0", - "resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.14.0.tgz", - "integrity": "sha512-yTb+8DXzDREzgvYmh6s9vHsSVCHeC0G3PI5bEXNBHtmshPnO+S5O7qgLEOn0I5QvMy6kpZN8K1NKGyilLb93wA==", - "dev": true, - "license": "MIT", - "dependencies": { - "resolve-pkg-maps": "^1.0.0" - }, - "funding": { - "url": "https://github.com/privatenumber/get-tsconfig?sponsor=1" - } - }, - "node_modules/github-from-package": { - "version": "0.0.0", - "resolved": "https://registry.npmjs.org/github-from-package/-/github-from-package-0.0.0.tgz", - "integrity": "sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==", - "license": "MIT" - }, - "node_modules/glob": { - "version": "7.2.3", - "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", - "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", - "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", - "license": "ISC", - "optional": true, - "dependencies": { - "fs.realpath": "^1.0.0", - "inflight": "^1.0.4", - "inherits": "2", - "minimatch": "^3.1.1", - "once": "^1.3.0", - "path-is-absolute": "^1.0.0" - }, - "engines": { - "node": "*" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/graceful-fs": { - "version": "4.2.11", - "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", - "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", - "license": "ISC", - "optional": true - }, - "node_modules/has-unicode": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/has-unicode/-/has-unicode-2.0.1.tgz", - "integrity": "sha512-8Rf9Y83NBReMnx0gFzA8JImQACstCYWUplepDa9xprwwtmgEZUF0h/i5xSA625zB/I37EtrswSST6OXxwaaIJQ==", - "license": "ISC", - "optional": true - }, - "node_modules/http-cache-semantics": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/http-cache-semantics/-/http-cache-semantics-4.2.0.tgz", - "integrity": "sha512-dTxcvPXqPvXBQpq5dUr6mEMJX4oIEFv6bwom3FDwKRDsuIjjJGANqhBuoAn9c1RQJIdAKav33ED65E2ys+87QQ==", - "license": "BSD-2-Clause", - "optional": true - }, - "node_modules/http-proxy-agent": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-4.0.1.tgz", - "integrity": "sha512-k0zdNgqWTGA6aeIRVpvfVob4fL52dTfaehylg0Y4UvSySvOq/Y+BOyPrgpUrA7HylqvU8vIZGsRuXmspskV0Tg==", - "license": "MIT", - "optional": true, - "dependencies": { - "@tootallnate/once": "1", - "agent-base": "6", - "debug": "4" - }, - "engines": { - "node": ">= 6" - } - }, - "node_modules/https-proxy-agent": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz", - "integrity": "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==", - "license": "MIT", - "optional": true, - "dependencies": { - "agent-base": "6", - "debug": "4" - }, - "engines": { - "node": ">= 6" - } - }, - "node_modules/humanize-ms": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/humanize-ms/-/humanize-ms-1.2.1.tgz", - "integrity": "sha512-Fl70vYtsAFb/C06PTS9dZBo7ihau+Tu/DNCk/OyHhea07S+aeMWpFFkUaXRa8fI+ScZbEI8dfSxwY7gxZ9SAVQ==", - "license": "MIT", - "optional": true, - "dependencies": { - "ms": "^2.0.0" - } - }, - "node_modules/iconv-lite": { - "version": "0.6.3", - "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", - "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", - "license": "MIT", - "optional": true, - "dependencies": { - "safer-buffer": ">= 2.1.2 < 3.0.0" - }, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/ieee754": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", - "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "license": "BSD-3-Clause" - }, - "node_modules/imurmurhash": { - "version": "0.1.4", - "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", - "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", - "license": "MIT", - "optional": true, - "engines": { - "node": ">=0.8.19" - } - }, - "node_modules/indent-string": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/indent-string/-/indent-string-5.0.0.tgz", - "integrity": "sha512-m6FAo/spmsW2Ab2fU35JTYwtOKa2yAwXSwgjSv1TJzh4Mh7mC3lzAOVLBprb72XsTrgkEIsl7YrFNAiDiRhIGg==", - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/infer-owner": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/infer-owner/-/infer-owner-1.0.4.tgz", - "integrity": "sha512-IClj+Xz94+d7irH5qRyfJonOdfTzuDaifE6ZPWfx0N0+/ATZCbuTPq2prFl526urkQd90WyUKIh1DfBQ2hMz9A==", - "license": "ISC", - "optional": true - }, - "node_modules/inflight": { - "version": "1.0.6", - "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", - "integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==", - "deprecated": "This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful.", - "license": "ISC", - "optional": true, - "dependencies": { - "once": "^1.3.0", - "wrappy": "1" - } - }, - "node_modules/inherits": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", - "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", - "license": "ISC" - }, - "node_modules/ini": { - "version": "1.3.8", - "resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz", - "integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==", - "license": "ISC" - }, - "node_modules/ink": { - "version": "5.2.1", - "resolved": "https://registry.npmjs.org/ink/-/ink-5.2.1.tgz", - "integrity": "sha512-BqcUyWrG9zq5HIwW6JcfFHsIYebJkWWb4fczNah1goUO0vv5vneIlfwuS85twyJ5hYR/y18FlAYUxrO9ChIWVg==", - "license": "MIT", - "dependencies": { - "@alcalzone/ansi-tokenize": "^0.1.3", - "ansi-escapes": "^7.0.0", - "ansi-styles": "^6.2.1", - "auto-bind": "^5.0.1", - "chalk": "^5.3.0", - "cli-boxes": "^3.0.0", - "cli-cursor": "^4.0.0", - "cli-truncate": "^4.0.0", - "code-excerpt": "^4.0.0", - "es-toolkit": "^1.22.0", - "indent-string": "^5.0.0", - "is-in-ci": "^1.0.0", - "patch-console": "^2.0.0", - "react-reconciler": "^0.29.0", - "scheduler": "^0.23.0", - "signal-exit": "^3.0.7", - "slice-ansi": "^7.1.0", - "stack-utils": "^2.0.6", - "string-width": "^7.2.0", - "type-fest": "^4.27.0", - "widest-line": "^5.0.0", - "wrap-ansi": "^9.0.0", - "ws": "^8.18.0", - "yoga-layout": "~3.2.1" - }, - "engines": { - "node": ">=18" - }, - "peerDependencies": { - "@types/react": ">=18.0.0", - "react": ">=18.0.0", - "react-devtools-core": "^4.19.1" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "react-devtools-core": { - "optional": true - } - } - }, - "node_modules/ink-testing-library": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/ink-testing-library/-/ink-testing-library-4.0.0.tgz", - "integrity": "sha512-yF92kj3pmBvk7oKbSq5vEALO//o7Z9Ck/OaLNlkzXNeYdwfpxMQkSowGTFUCS5MSu9bWfSZMewGpp7bFc66D7Q==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18" - }, - "peerDependencies": { - "@types/react": ">=18.0.0" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, - "node_modules/ink-text-input": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/ink-text-input/-/ink-text-input-6.0.0.tgz", - "integrity": "sha512-Fw64n7Yha5deb1rHY137zHTAbSTNelUKuB5Kkk2HACXEtwIHBCf9OH2tP/LQ9fRYTl1F0dZgbW0zPnZk6FA9Lw==", - "license": "MIT", - "dependencies": { - "chalk": "^5.3.0", - "type-fest": "^4.18.2" - }, - "engines": { - "node": ">=18" - }, - "peerDependencies": { - "ink": ">=5", - "react": ">=18" - } - }, - "node_modules/ip-address": { - "version": "10.2.0", - "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.2.0.tgz", - "integrity": "sha512-/+S6j4E9AHvW9SWMSEY9Xfy66O5PWvVEJ08O0y5JGyEKQpojb0K0GKpz/v5HJ/G0vi3D2sjGK78119oXZeE0qA==", - "license": "MIT", - "optional": true, - "engines": { - "node": ">= 12" - } - }, - "node_modules/is-fullwidth-code-point": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-4.0.0.tgz", - "integrity": "sha512-O4L094N2/dZ7xqVdrXhh9r1KODPJpFms8B5sGdJLPy664AgvXsreZUyCQQNItZRDlYug4xStLjNp/sz3HvBowQ==", - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/is-in-ci": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/is-in-ci/-/is-in-ci-1.0.0.tgz", - "integrity": "sha512-eUuAjybVTHMYWm/U+vBO1sY/JOCgoPCXRxzdju0K+K0BiGW0SChEL1MLC0PoCIR1OlPo5YAp8HuQoUlsWEICwg==", - "license": "MIT", - "bin": { - "is-in-ci": "cli.js" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/is-lambda": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/is-lambda/-/is-lambda-1.0.1.tgz", - "integrity": "sha512-z7CMFGNrENq5iFB9Bqo64Xk6Y9sg+epq1myIcdHaGnbMTYOxvzsEtdYqQUylB7LxfkvgrrjP32T6Ywciio9UIQ==", - "license": "MIT", - "optional": true - }, - "node_modules/isexe": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", - "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", - "license": "ISC", - "optional": true - }, - "node_modules/joycon": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/joycon/-/joycon-3.1.1.tgz", - "integrity": "sha512-34wB/Y7MW7bzjKRjUKTa46I2Z7eV62Rkhva+KkopW7Qvv/OSWBqvkSY7vusOPrNuZcUG3tApvdVgNB8POj3SPw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=10" - } - }, - "node_modules/js-tokens": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", - "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", - "license": "MIT" - }, - "node_modules/lilconfig": { - "version": "3.1.3", - "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.3.tgz", - "integrity": "sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=14" - }, - "funding": { - "url": "https://github.com/sponsors/antonk52" - } - }, - "node_modules/lines-and-columns": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", - "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==", - "dev": true, - "license": "MIT" - }, - "node_modules/load-tsconfig": { - "version": "0.2.5", - "resolved": "https://registry.npmjs.org/load-tsconfig/-/load-tsconfig-0.2.5.tgz", - "integrity": "sha512-IXO6OCs9yg8tMKzfPZ1YmheJbZCiEsnBdcB03l0OcfK9prKnJb96siuHCr5Fl37/yo9DnKU+TLpxzTUspw9shg==", - "dev": true, - "license": "MIT", - "engines": { - "node": "^12.20.0 || ^14.13.1 || >=16.0.0" - } - }, - "node_modules/local-pkg": { - "version": "0.4.3", - "resolved": "https://registry.npmjs.org/local-pkg/-/local-pkg-0.4.3.tgz", - "integrity": "sha512-SFppqq5p42fe2qcZQqqEOiVRXl+WCP1MdT6k7BDEW1j++sp5fIY+/fdRQitvKgB5BrBcmrs5m/L0v2FrU5MY1g==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=14" - }, - "funding": { - "url": "https://github.com/sponsors/antfu" - } - }, - "node_modules/loose-envify": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", - "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", - "license": "MIT", - "dependencies": { - "js-tokens": "^3.0.0 || ^4.0.0" - }, - "bin": { - "loose-envify": "cli.js" - } - }, - "node_modules/loupe": { - "version": "2.3.7", - "resolved": "https://registry.npmjs.org/loupe/-/loupe-2.3.7.tgz", - "integrity": "sha512-zSMINGVYkdpYSOBmLi0D1Uo7JU9nVdQKrHxC8eYlV+9YKK9WePqAlL7lSlorG/U2Fw1w0hTBmaa/jrQ3UbPHtA==", - "dev": true, - "license": "MIT", - "dependencies": { - "get-func-name": "^2.0.1" - } - }, - "node_modules/lru-cache": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", - "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==", - "license": "ISC", - "optional": true, - "dependencies": { - "yallist": "^4.0.0" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/magic-string": { - "version": "0.30.21", - "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", - "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jridgewell/sourcemap-codec": "^1.5.5" - } - }, - "node_modules/make-fetch-happen": { - "version": "9.1.0", - "resolved": "https://registry.npmjs.org/make-fetch-happen/-/make-fetch-happen-9.1.0.tgz", - "integrity": "sha512-+zopwDy7DNknmwPQplem5lAZX/eCOzSvSNNcSKm5eVwTkOBzoktEfXsa9L23J/GIRhxRsaxzkPEhrJEpE2F4Gg==", - "license": "ISC", - "optional": true, - "dependencies": { - "agentkeepalive": "^4.1.3", - "cacache": "^15.2.0", - "http-cache-semantics": "^4.1.0", - "http-proxy-agent": "^4.0.1", - "https-proxy-agent": "^5.0.0", - "is-lambda": "^1.0.1", - "lru-cache": "^6.0.0", - "minipass": "^3.1.3", - "minipass-collect": "^1.0.2", - "minipass-fetch": "^1.3.2", - "minipass-flush": "^1.0.5", - "minipass-pipeline": "^1.2.4", - "negotiator": "^0.6.2", - "promise-retry": "^2.0.1", - "socks-proxy-agent": "^6.0.0", - "ssri": "^8.0.0" - }, - "engines": { - "node": ">= 10" - } - }, - "node_modules/mimic-fn": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-2.1.0.tgz", - "integrity": "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==", - "license": "MIT", - "engines": { - "node": ">=6" - } - }, - "node_modules/mimic-response": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-3.1.0.tgz", - "integrity": "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==", - "license": "MIT", - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/minimatch": { - "version": "3.1.5", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", - "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", - "license": "ISC", - "optional": true, - "dependencies": { - "brace-expansion": "^1.1.7" - }, - "engines": { - "node": "*" - } - }, - "node_modules/minimist": { - "version": "1.2.8", - "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", - "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/minipass": { - "version": "3.3.6", - "resolved": "https://registry.npmjs.org/minipass/-/minipass-3.3.6.tgz", - "integrity": "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==", - "license": "ISC", - "dependencies": { - "yallist": "^4.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/minipass-collect": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/minipass-collect/-/minipass-collect-1.0.2.tgz", - "integrity": "sha512-6T6lH0H8OG9kITm/Jm6tdooIbogG9e0tLgpY6mphXSm/A9u8Nq1ryBG+Qspiub9LjWlBPsPS3tWQ/Botq4FdxA==", - "license": "ISC", - "optional": true, - "dependencies": { - "minipass": "^3.0.0" - }, - "engines": { - "node": ">= 8" - } - }, - "node_modules/minipass-fetch": { - "version": "1.4.1", - "resolved": "https://registry.npmjs.org/minipass-fetch/-/minipass-fetch-1.4.1.tgz", - "integrity": "sha512-CGH1eblLq26Y15+Azk7ey4xh0J/XfJfrCox5LDJiKqI2Q2iwOLOKrlmIaODiSQS8d18jalF6y2K2ePUm0CmShw==", - "license": "MIT", - "optional": true, - "dependencies": { - "minipass": "^3.1.0", - "minipass-sized": "^1.0.3", - "minizlib": "^2.0.0" - }, - "engines": { - "node": ">=8" - }, - "optionalDependencies": { - "encoding": "^0.1.12" - } - }, - "node_modules/minipass-flush": { - "version": "1.0.7", - "resolved": "https://registry.npmjs.org/minipass-flush/-/minipass-flush-1.0.7.tgz", - "integrity": "sha512-TbqTz9cUwWyHS2Dy89P3ocAGUGxKjjLuR9z8w4WUTGAVgEj17/4nhgo2Du56i0Fm3Pm30g4iA8Lcqctc76jCzA==", - "license": "BlueOak-1.0.0", - "optional": true, - "dependencies": { - "minipass": "^3.0.0" - }, - "engines": { - "node": ">= 8" - } - }, - "node_modules/minipass-pipeline": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/minipass-pipeline/-/minipass-pipeline-1.2.4.tgz", - "integrity": "sha512-xuIq7cIOt09RPRJ19gdi4b+RiNvDFYe5JH+ggNvBqGqpQXcru3PcRmOZuHBKWK1Txf9+cQ+HMVN4d6z46LZP7A==", - "license": "ISC", - "optional": true, - "dependencies": { - "minipass": "^3.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/minipass-sized": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/minipass-sized/-/minipass-sized-1.0.3.tgz", - "integrity": "sha512-MbkQQ2CTiBMlA2Dm/5cY+9SWFEN8pzzOXi6rlM5Xxq0Yqbda5ZQy9sU75a673FE9ZK0Zsbr6Y5iP6u9nktfg2g==", - "license": "ISC", - "optional": true, - "dependencies": { - "minipass": "^3.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/minizlib": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/minizlib/-/minizlib-2.1.2.tgz", - "integrity": "sha512-bAxsR8BVfj60DWXHE3u30oHzfl4G7khkSuPW+qvpd7jFRHm7dLxOjUk1EHACJ/hxLY8phGJ0YhYHZo7jil7Qdg==", - "license": "MIT", - "dependencies": { - "minipass": "^3.0.0", - "yallist": "^4.0.0" - }, - "engines": { - "node": ">= 8" - } - }, - "node_modules/mkdirp": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-1.0.4.tgz", - "integrity": "sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw==", - "license": "MIT", - "bin": { - "mkdirp": "bin/cmd.js" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/mkdirp-classic": { - "version": "0.5.3", - "resolved": "https://registry.npmjs.org/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz", - "integrity": "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==", - "license": "MIT" - }, - "node_modules/mlly": { - "version": "1.8.2", - "resolved": "https://registry.npmjs.org/mlly/-/mlly-1.8.2.tgz", - "integrity": "sha512-d+ObxMQFmbt10sretNDytwt85VrbkhhUA/JBGm1MPaWJ65Cl4wOgLaB1NYvJSZ0Ef03MMEU/0xpPMXUIQ29UfA==", - "dev": true, - "license": "MIT", - "dependencies": { - "acorn": "^8.16.0", - "pathe": "^2.0.3", - "pkg-types": "^1.3.1", - "ufo": "^1.6.3" - } - }, - "node_modules/ms": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", - "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", - "devOptional": true, - "license": "MIT" - }, - "node_modules/mz": { - "version": "2.7.0", - "resolved": "https://registry.npmjs.org/mz/-/mz-2.7.0.tgz", - "integrity": "sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "any-promise": "^1.0.0", - "object-assign": "^4.0.1", - "thenify-all": "^1.0.0" - } - }, - "node_modules/nanoid": { - "version": "3.3.12", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.12.tgz", - "integrity": "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "license": "MIT", - "bin": { - "nanoid": "bin/nanoid.cjs" - }, - "engines": { - "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" - } - }, - "node_modules/napi-build-utils": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/napi-build-utils/-/napi-build-utils-2.0.0.tgz", - "integrity": "sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA==", - "license": "MIT" - }, - "node_modules/negotiator": { - "version": "0.6.4", - "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.4.tgz", - "integrity": "sha512-myRT3DiWPHqho5PrJaIRyaMv2kgYf0mUVgBNOYMuCH5Ki1yEiQaf/ZJuQ62nvpc44wL5WDbTX7yGJi1Neevw8w==", - "license": "MIT", - "optional": true, - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/node-abi": { - "version": "3.92.0", - "resolved": "https://registry.npmjs.org/node-abi/-/node-abi-3.92.0.tgz", - "integrity": "sha512-KdHvFWZjEKDf0cakgFjebl371GPsISX2oZHcuyKqM7DtogIsHrqKeLTo8wBHxaXRAQlY2PsPlZmfo+9ZCxEREQ==", - "license": "MIT", - "dependencies": { - "semver": "^7.3.5" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/node-addon-api": { - "version": "7.1.1", - "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-7.1.1.tgz", - "integrity": "sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==", - "license": "MIT" - }, - "node_modules/node-cron": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/node-cron/-/node-cron-3.0.3.tgz", - "integrity": "sha512-dOal67//nohNgYWb+nWmg5dkFdIwDm8EpeGYMekPMrngV3637lqnX0lbUcCtgibHTz6SEz7DAIjKvKDFYCnO1A==", - "license": "ISC", - "dependencies": { - "uuid": "8.3.2" - }, - "engines": { - "node": ">=6.0.0" - } - }, - "node_modules/node-gyp": { - "version": "8.4.1", - "resolved": "https://registry.npmjs.org/node-gyp/-/node-gyp-8.4.1.tgz", - "integrity": "sha512-olTJRgUtAb/hOXG0E93wZDs5YiJlgbXxTwQAFHyNlRsXQnYzUaF2aGgujZbw+hR8aF4ZG/rST57bWMWD16jr9w==", - "license": "MIT", - "optional": true, - "dependencies": { - "env-paths": "^2.2.0", - "glob": "^7.1.4", - "graceful-fs": "^4.2.6", - "make-fetch-happen": "^9.1.0", - "nopt": "^5.0.0", - "npmlog": "^6.0.0", - "rimraf": "^3.0.2", - "semver": "^7.3.5", - "tar": "^6.1.2", - "which": "^2.0.2" - }, - "bin": { - "node-gyp": "bin/node-gyp.js" - }, - "engines": { - "node": ">= 10.12.0" - } - }, - "node_modules/node-pty": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/node-pty/-/node-pty-1.1.0.tgz", - "integrity": "sha512-20JqtutY6JPXTUnL0ij1uad7Qe1baT46lyolh2sSENDd4sTzKZ4nmAFkeAARDKwmlLjPx6XKRlwRUxwjOy+lUg==", - "hasInstallScript": true, - "license": "MIT", - "dependencies": { - "node-addon-api": "^7.1.0" - } - }, - "node_modules/nopt": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/nopt/-/nopt-5.0.0.tgz", - "integrity": "sha512-Tbj67rffqceeLpcRXrT7vKAN8CwfPeIBgM7E6iBkmKLV7bEMwpGgYLGv0jACUsECaa/vuxP0IjEont6umdMgtQ==", - "license": "ISC", - "optional": true, - "dependencies": { - "abbrev": "1" - }, - "bin": { - "nopt": "bin/nopt.js" - }, - "engines": { - "node": ">=6" - } - }, - "node_modules/npmlog": { - "version": "6.0.2", - "resolved": "https://registry.npmjs.org/npmlog/-/npmlog-6.0.2.tgz", - "integrity": "sha512-/vBvz5Jfr9dT/aFWd0FIRf+T/Q2WBsLENygUaFUqstqsycmZAP/t5BvFJTK0viFmSUxiUKTUplWy5vt+rvKIxg==", - "deprecated": "This package is no longer supported.", - "license": "ISC", - "optional": true, - "dependencies": { - "are-we-there-yet": "^3.0.0", - "console-control-strings": "^1.1.0", - "gauge": "^4.0.3", - "set-blocking": "^2.0.0" - }, - "engines": { - "node": "^12.13.0 || ^14.15.0 || >=16.0.0" - } - }, - "node_modules/object-assign": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", - "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/once": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", - "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", - "license": "ISC", - "dependencies": { - "wrappy": "1" - } - }, - "node_modules/onetime": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/onetime/-/onetime-5.1.2.tgz", - "integrity": "sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==", - "license": "MIT", - "dependencies": { - "mimic-fn": "^2.1.0" - }, - "engines": { - "node": ">=6" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/p-limit": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-4.0.0.tgz", - "integrity": "sha512-5b0R4txpzjPWVw/cXXUResoD4hb6U/x9BH08L7nw+GN1sezDzPdxeRvpc9c433fZhBan/wusjbCsqwqm4EIBIQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "yocto-queue": "^1.0.0" - }, - "engines": { - "node": "^12.20.0 || ^14.13.1 || >=16.0.0" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/p-map": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/p-map/-/p-map-4.0.0.tgz", - "integrity": "sha512-/bjOqmgETBYB5BoEeGVea8dmvHb2m9GLy1E9W43yeyfP6QQCZGFNa+XRceJEuDB6zqr+gKpIAmlLebMpykw/MQ==", - "license": "MIT", - "optional": true, - "dependencies": { - "aggregate-error": "^3.0.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/patch-console": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/patch-console/-/patch-console-2.0.0.tgz", - "integrity": "sha512-0YNdUceMdaQwoKce1gatDScmMo5pu/tfABfnzEqeG0gtTmd7mh/WcwgUjtAeOU7N8nFFlbQBnFK2gXW5fGvmMA==", - "license": "MIT", - "engines": { - "node": "^12.20.0 || ^14.13.1 || >=16.0.0" - } - }, - "node_modules/path-is-absolute": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", - "integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==", - "license": "MIT", - "optional": true, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/pathe": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", - "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", - "dev": true, - "license": "MIT" - }, - "node_modules/pathval": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/pathval/-/pathval-1.1.1.tgz", - "integrity": "sha512-Dp6zGqpTdETdR63lehJYPeIOqpiNBNtc7BpWSLrOje7UaIsE5aY92r/AunQA7rsXvet3lrJ3JnZX29UPTKXyKQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": "*" - } - }, - "node_modules/picocolors": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", - "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", - "dev": true, - "license": "ISC" - }, - "node_modules/picomatch": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", - "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/jonschlinkert" - } - }, - "node_modules/pirates": { - "version": "4.0.7", - "resolved": "https://registry.npmjs.org/pirates/-/pirates-4.0.7.tgz", - "integrity": "sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 6" - } - }, - "node_modules/pkg-types": { - "version": "1.3.1", - "resolved": "https://registry.npmjs.org/pkg-types/-/pkg-types-1.3.1.tgz", - "integrity": "sha512-/Jm5M4RvtBFVkKWRu2BLUTNP8/M2a+UwuAX+ae4770q1qVGtfjG+WTCupoZixokjmHiry8uI+dlY8KXYV5HVVQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "confbox": "^0.1.8", - "mlly": "^1.7.4", - "pathe": "^2.0.1" - } - }, - "node_modules/postcss": { - "version": "8.5.14", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.14.tgz", - "integrity": "sha512-SoSL4+OSEtR99LHFZQiJLkT59C5B1amGO1NzTwj7TT1qCUgUO6hxOvzkOYxD+vMrXBM3XJIKzokoERdqQq/Zmg==", - "dev": true, - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/postcss/" - }, - { - "type": "tidelift", - "url": "https://tidelift.com/funding/github/npm/postcss" - }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "license": "MIT", - "dependencies": { - "nanoid": "^3.3.11", - "picocolors": "^1.1.1", - "source-map-js": "^1.2.1" - }, - "engines": { - "node": "^10 || ^12 || >=14" - } - }, - "node_modules/postcss-load-config": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/postcss-load-config/-/postcss-load-config-6.0.1.tgz", - "integrity": "sha512-oPtTM4oerL+UXmx+93ytZVN82RrlY/wPUV8IeDxFrzIjXOLF1pN+EmKPLbubvKHT2HC20xXsCAH2Z+CKV6Oz/g==", - "dev": true, - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/postcss/" - }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "license": "MIT", - "dependencies": { - "lilconfig": "^3.1.1" - }, - "engines": { - "node": ">= 18" - }, - "peerDependencies": { - "jiti": ">=1.21.0", - "postcss": ">=8.0.9", - "tsx": "^4.8.1", - "yaml": "^2.4.2" - }, - "peerDependenciesMeta": { - "jiti": { - "optional": true - }, - "postcss": { - "optional": true - }, - "tsx": { - "optional": true - }, - "yaml": { - "optional": true - } - } - }, - "node_modules/prebuild-install": { - "version": "7.1.3", - "resolved": "https://registry.npmjs.org/prebuild-install/-/prebuild-install-7.1.3.tgz", - "integrity": "sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug==", - "deprecated": "No longer maintained. Please contact the author of the relevant native addon; alternatives are available.", - "license": "MIT", - "dependencies": { - "detect-libc": "^2.0.0", - "expand-template": "^2.0.3", - "github-from-package": "0.0.0", - "minimist": "^1.2.3", - "mkdirp-classic": "^0.5.3", - "napi-build-utils": "^2.0.0", - "node-abi": "^3.3.0", - "pump": "^3.0.0", - "rc": "^1.2.7", - "simple-get": "^4.0.0", - "tar-fs": "^2.0.0", - "tunnel-agent": "^0.6.0" - }, - "bin": { - "prebuild-install": "bin.js" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/pretty-format": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-29.7.0.tgz", - "integrity": "sha512-Pdlw/oPxN+aXdmM9R00JVC9WVFoCLTKJvDVLgmJ+qAffBMxsV85l/Lu7sNx4zSzPyoL2euImuEwHhOXdEgNFZQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jest/schemas": "^29.6.3", - "ansi-styles": "^5.0.0", - "react-is": "^18.0.0" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - } - }, - "node_modules/pretty-format/node_modules/ansi-styles": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", - "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } - }, - "node_modules/promise-inflight": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/promise-inflight/-/promise-inflight-1.0.1.tgz", - "integrity": "sha512-6zWPyEOFaQBJYcGMHBKTKJ3u6TBsnMFOIZSa6ce1e/ZrrsOlnHRHbabMjLiBYKp+n44X9eUI6VUPaukCXHuG4g==", - "license": "ISC", - "optional": true - }, - "node_modules/promise-retry": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/promise-retry/-/promise-retry-2.0.1.tgz", - "integrity": "sha512-y+WKFlBR8BGXnsNlIHFGPZmyDf3DFMoLhaflAnyZgV6rG6xu+JwesTo2Q9R6XwYmtmwAFCkAk3e35jEdoeh/3g==", - "license": "MIT", - "optional": true, - "dependencies": { - "err-code": "^2.0.2", - "retry": "^0.12.0" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/pump": { - "version": "3.0.4", - "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.4.tgz", - "integrity": "sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==", - "license": "MIT", - "dependencies": { - "end-of-stream": "^1.1.0", - "once": "^1.3.1" - } - }, - "node_modules/rc": { - "version": "1.2.8", - "resolved": "https://registry.npmjs.org/rc/-/rc-1.2.8.tgz", - "integrity": "sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==", - "license": "(BSD-2-Clause OR MIT OR Apache-2.0)", - "dependencies": { - "deep-extend": "^0.6.0", - "ini": "~1.3.0", - "minimist": "^1.2.0", - "strip-json-comments": "~2.0.1" - }, - "bin": { - "rc": "cli.js" - } - }, - "node_modules/react": { - "version": "18.3.1", - "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", - "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", - "license": "MIT", - "dependencies": { - "loose-envify": "^1.1.0" - }, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/react-is": { - "version": "18.3.1", - "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz", - "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==", - "dev": true, - "license": "MIT" - }, - "node_modules/react-reconciler": { - "version": "0.29.2", - "resolved": "https://registry.npmjs.org/react-reconciler/-/react-reconciler-0.29.2.tgz", - "integrity": "sha512-zZQqIiYgDCTP/f1N/mAR10nJGrPD2ZR+jDSEsKWJHYC7Cm2wodlwbR3upZRdC3cjIjSlTLNVyO7Iu0Yy7t2AYg==", - "license": "MIT", - "dependencies": { - "loose-envify": "^1.1.0", - "scheduler": "^0.23.2" - }, - "engines": { - "node": ">=0.10.0" - }, - "peerDependencies": { - "react": "^18.3.1" - } - }, - "node_modules/readable-stream": { - "version": "3.6.2", - "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", - "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", - "license": "MIT", - "dependencies": { - "inherits": "^2.0.3", - "string_decoder": "^1.1.1", - "util-deprecate": "^1.0.1" - }, - "engines": { - "node": ">= 6" - } - }, - "node_modules/readdirp": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz", - "integrity": "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 14.18.0" - }, - "funding": { - "type": "individual", - "url": "https://paulmillr.com/funding/" - } - }, - "node_modules/resolve-from": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-5.0.0.tgz", - "integrity": "sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/resolve-pkg-maps": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz", - "integrity": "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==", - "dev": true, - "license": "MIT", - "funding": { - "url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1" - } - }, - "node_modules/restore-cursor": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/restore-cursor/-/restore-cursor-4.0.0.tgz", - "integrity": "sha512-I9fPXU9geO9bHOt9pHHOhOkYerIMsmVaWB0rA2AI9ERh/+x/i7MV5HKBNrg+ljO5eoPVgCcnFuRjJ9uH6I/3eg==", - "license": "MIT", - "dependencies": { - "onetime": "^5.1.0", - "signal-exit": "^3.0.2" - }, - "engines": { - "node": "^12.20.0 || ^14.13.1 || >=16.0.0" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/retry": { - "version": "0.12.0", - "resolved": "https://registry.npmjs.org/retry/-/retry-0.12.0.tgz", - "integrity": "sha512-9LkiTwjUh6rT555DtE9rTX+BKByPfrMzEAtnlEtdEwr3Nkffwiihqe2bWADg+OQRjt9gl6ICdmB/ZFDCGAtSow==", - "license": "MIT", - "optional": true, - "engines": { - "node": ">= 4" - } - }, - "node_modules/rimraf": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz", - "integrity": "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==", - "deprecated": "Rimraf versions prior to v4 are no longer supported", - "license": "ISC", - "optional": true, - "dependencies": { - "glob": "^7.1.3" - }, - "bin": { - "rimraf": "bin.js" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/rollup": { - "version": "4.60.3", - "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.60.3.tgz", - "integrity": "sha512-pAQK9HalE84QSm4Po3EmWIZPd3FnjkShVkiMlz1iligWYkWQ7wHYd1PF/T7QZ5TVSD6uSTon5gBVMSM4JfBV+A==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/estree": "1.0.8" - }, - "bin": { - "rollup": "dist/bin/rollup" - }, - "engines": { - "node": ">=18.0.0", - "npm": ">=8.0.0" - }, - "optionalDependencies": { - "@rollup/rollup-android-arm-eabi": "4.60.3", - "@rollup/rollup-android-arm64": "4.60.3", - "@rollup/rollup-darwin-arm64": "4.60.3", - "@rollup/rollup-darwin-x64": "4.60.3", - "@rollup/rollup-freebsd-arm64": "4.60.3", - "@rollup/rollup-freebsd-x64": "4.60.3", - "@rollup/rollup-linux-arm-gnueabihf": "4.60.3", - "@rollup/rollup-linux-arm-musleabihf": "4.60.3", - "@rollup/rollup-linux-arm64-gnu": "4.60.3", - "@rollup/rollup-linux-arm64-musl": "4.60.3", - "@rollup/rollup-linux-loong64-gnu": "4.60.3", - "@rollup/rollup-linux-loong64-musl": "4.60.3", - "@rollup/rollup-linux-ppc64-gnu": "4.60.3", - "@rollup/rollup-linux-ppc64-musl": "4.60.3", - "@rollup/rollup-linux-riscv64-gnu": "4.60.3", - "@rollup/rollup-linux-riscv64-musl": "4.60.3", - "@rollup/rollup-linux-s390x-gnu": "4.60.3", - "@rollup/rollup-linux-x64-gnu": "4.60.3", - "@rollup/rollup-linux-x64-musl": "4.60.3", - "@rollup/rollup-openbsd-x64": "4.60.3", - "@rollup/rollup-openharmony-arm64": "4.60.3", - "@rollup/rollup-win32-arm64-msvc": "4.60.3", - "@rollup/rollup-win32-ia32-msvc": "4.60.3", - "@rollup/rollup-win32-x64-gnu": "4.60.3", - "@rollup/rollup-win32-x64-msvc": "4.60.3", - "fsevents": "~2.3.2" - } - }, - "node_modules/safe-buffer": { - "version": "5.2.1", - "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", - "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "license": "MIT" - }, - "node_modules/safer-buffer": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", - "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", - "license": "MIT", - "optional": true - }, - "node_modules/scheduler": { - "version": "0.23.2", - "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz", - "integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==", - "license": "MIT", - "dependencies": { - "loose-envify": "^1.1.0" - } - }, - "node_modules/semver": { - "version": "7.8.0", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.0.tgz", - "integrity": "sha512-AcM7dV/5ul4EekoQ29Agm5vri8JNqRyj39o0qpX6vDF2GZrtutZl5RwgD1XnZjiTAfncsJhMI48QQH3sN87YNA==", - "license": "ISC", - "bin": { - "semver": "bin/semver.js" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/set-blocking": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz", - "integrity": "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==", - "license": "ISC", - "optional": true - }, - "node_modules/siginfo": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", - "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", - "dev": true, - "license": "ISC" - }, - "node_modules/signal-exit": { - "version": "3.0.7", - "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", - "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", - "license": "ISC" - }, - "node_modules/simple-concat": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/simple-concat/-/simple-concat-1.0.1.tgz", - "integrity": "sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "license": "MIT" - }, - "node_modules/simple-get": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/simple-get/-/simple-get-4.0.1.tgz", - "integrity": "sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "license": "MIT", - "dependencies": { - "decompress-response": "^6.0.0", - "once": "^1.3.1", - "simple-concat": "^1.0.0" - } - }, - "node_modules/slice-ansi": { - "version": "7.1.2", - "resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-7.1.2.tgz", - "integrity": "sha512-iOBWFgUX7caIZiuutICxVgX1SdxwAVFFKwt1EvMYYec/NWO5meOJ6K5uQxhrYBdQJne4KxiqZc+KptFOWFSI9w==", - "license": "MIT", - "dependencies": { - "ansi-styles": "^6.2.1", - "is-fullwidth-code-point": "^5.0.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/chalk/slice-ansi?sponsor=1" - } - }, - "node_modules/slice-ansi/node_modules/is-fullwidth-code-point": { - "version": "5.1.0", - "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-5.1.0.tgz", - "integrity": "sha512-5XHYaSyiqADb4RnZ1Bdad6cPp8Toise4TzEjcOYDHZkTCbKgiUl7WTUCpNWHuxmDt91wnsZBc9xinNzopv3JMQ==", - "license": "MIT", - "dependencies": { - "get-east-asian-width": "^1.3.1" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/smart-buffer": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/smart-buffer/-/smart-buffer-4.2.0.tgz", - "integrity": "sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg==", - "license": "MIT", - "optional": true, - "engines": { - "node": ">= 6.0.0", - "npm": ">= 3.0.0" - } - }, - "node_modules/socks": { - "version": "2.8.9", - "resolved": "https://registry.npmjs.org/socks/-/socks-2.8.9.tgz", - "integrity": "sha512-LJhUYUvItdQ0LkJTmPeaEObWXAqFyfmP85x0tch/ez9cahmhlBBLbIqDFnvBnUJGagb0JbIQrkBs1wJ+yRYpEw==", - "license": "MIT", - "optional": true, - "dependencies": { - "ip-address": "^10.1.1", - "smart-buffer": "^4.2.0" - }, - "engines": { - "node": ">= 10.0.0", - "npm": ">= 3.0.0" - } - }, - "node_modules/socks-proxy-agent": { - "version": "6.2.1", - "resolved": "https://registry.npmjs.org/socks-proxy-agent/-/socks-proxy-agent-6.2.1.tgz", - "integrity": "sha512-a6KW9G+6B3nWZ1yB8G7pJwL3ggLy1uTzKAgCb7ttblwqdz9fMGJUuTy3uFzEP48FAs9FLILlmzDlE2JJhVQaXQ==", - "license": "MIT", - "optional": true, - "dependencies": { - "agent-base": "^6.0.2", - "debug": "^4.3.3", - "socks": "^2.6.2" - }, - "engines": { - "node": ">= 10" - } - }, - "node_modules/source-map": { - "version": "0.7.6", - "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.7.6.tgz", - "integrity": "sha512-i5uvt8C3ikiWeNZSVZNWcfZPItFQOsYTUAOkcUPGd8DqDy1uOUikjt5dG+uRlwyvR108Fb9DOd4GvXfT0N2/uQ==", - "dev": true, - "license": "BSD-3-Clause", - "engines": { - "node": ">= 12" - } - }, - "node_modules/source-map-js": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", - "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", - "dev": true, - "license": "BSD-3-Clause", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/sql.js": { - "version": "1.14.1", - "resolved": "https://registry.npmjs.org/sql.js/-/sql.js-1.14.1.tgz", - "integrity": "sha512-gcj8zBWU5cFsi9WUP+4bFNXAyF1iRpA3LLyS/DP5xlrNzGmPIizUeBggKa8DbDwdqaKwUcTEnChtd2grWo/x/A==", - "license": "MIT" - }, - "node_modules/sqlite3": { - "version": "5.1.7", - "resolved": "https://registry.npmjs.org/sqlite3/-/sqlite3-5.1.7.tgz", - "integrity": "sha512-GGIyOiFaG+TUra3JIfkI/zGP8yZYLPQ0pl1bH+ODjiX57sPhrLU5sQJn1y9bDKZUFYkX1crlrPfSYt0BKKdkog==", - "hasInstallScript": true, - "license": "BSD-3-Clause", - "dependencies": { - "bindings": "^1.5.0", - "node-addon-api": "^7.0.0", - "prebuild-install": "^7.1.1", - "tar": "^6.1.11" - }, - "optionalDependencies": { - "node-gyp": "8.x" - }, - "peerDependencies": { - "node-gyp": "8.x" - }, - "peerDependenciesMeta": { - "node-gyp": { - "optional": true - } - } - }, - "node_modules/ssri": { - "version": "8.0.1", - "resolved": "https://registry.npmjs.org/ssri/-/ssri-8.0.1.tgz", - "integrity": "sha512-97qShzy1AiyxvPNIkLWoGua7xoQzzPjQ0HAH4B0rWKo7SZ6USuPcrUiAFrws0UH8RrbWmgq3LMTObhPIHbbBeQ==", - "license": "ISC", - "optional": true, - "dependencies": { - "minipass": "^3.1.1" - }, - "engines": { - "node": ">= 8" - } - }, - "node_modules/stack-utils": { - "version": "2.0.6", - "resolved": "https://registry.npmjs.org/stack-utils/-/stack-utils-2.0.6.tgz", - "integrity": "sha512-XlkWvfIm6RmsWtNJx+uqtKLS8eqFbxUg0ZzLXqY0caEy9l7hruX8IpiDnjsLavoBgqCCR71TqWO8MaXYheJ3RQ==", - "license": "MIT", - "dependencies": { - "escape-string-regexp": "^2.0.0" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/stackback": { - "version": "0.0.2", - "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", - "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", - "dev": true, - "license": "MIT" - }, - "node_modules/std-env": { - "version": "3.10.0", - "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz", - "integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==", - "dev": true, - "license": "MIT" - }, - "node_modules/string_decoder": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", - "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", - "license": "MIT", - "dependencies": { - "safe-buffer": "~5.2.0" - } - }, - "node_modules/string-width": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz", - "integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==", - "license": "MIT", - "dependencies": { - "emoji-regex": "^10.3.0", - "get-east-asian-width": "^1.0.0", - "strip-ansi": "^7.1.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/string-width/node_modules/ansi-regex": { - "version": "6.2.2", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", - "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/ansi-regex?sponsor=1" - } - }, - "node_modules/string-width/node_modules/strip-ansi": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz", - "integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==", - "license": "MIT", - "dependencies": { - "ansi-regex": "^6.2.2" - }, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/strip-ansi?sponsor=1" - } - }, - "node_modules/strip-ansi": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", - "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", - "license": "MIT", - "optional": true, - "dependencies": { - "ansi-regex": "^5.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/strip-json-comments": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-2.0.1.tgz", - "integrity": "sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==", - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/strip-literal": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/strip-literal/-/strip-literal-1.3.0.tgz", - "integrity": "sha512-PugKzOsyXpArk0yWmUwqOZecSO0GH0bPoctLcqNDH9J04pVW3lflYE0ujElBGTloevcxF5MofAOZ7C5l2b+wLg==", - "dev": true, - "license": "MIT", - "dependencies": { - "acorn": "^8.10.0" - }, - "funding": { - "url": "https://github.com/sponsors/antfu" - } - }, - "node_modules/sucrase": { - "version": "3.35.1", - "resolved": "https://registry.npmjs.org/sucrase/-/sucrase-3.35.1.tgz", - "integrity": "sha512-DhuTmvZWux4H1UOnWMB3sk0sbaCVOoQZjv8u1rDoTV0HTdGem9hkAZtl4JZy8P2z4Bg0nT+YMeOFyVr4zcG5Tw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jridgewell/gen-mapping": "^0.3.2", - "commander": "^4.0.0", - "lines-and-columns": "^1.1.6", - "mz": "^2.7.0", - "pirates": "^4.0.1", - "tinyglobby": "^0.2.11", - "ts-interface-checker": "^0.1.9" - }, - "bin": { - "sucrase": "bin/sucrase", - "sucrase-node": "bin/sucrase-node" - }, - "engines": { - "node": ">=16 || 14 >=14.17" - } - }, - "node_modules/tar": { - "version": "6.2.1", - "resolved": "https://registry.npmjs.org/tar/-/tar-6.2.1.tgz", - "integrity": "sha512-DZ4yORTwrbTj/7MZYq2w+/ZFdI6OZ/f9SFHR+71gIVUZhOQPHzVCLpvRnPgyaMpfWxxk/4ONva3GQSyNIKRv6A==", - "deprecated": "Old versions of tar are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", - "license": "ISC", - "dependencies": { - "chownr": "^2.0.0", - "fs-minipass": "^2.0.0", - "minipass": "^5.0.0", - "minizlib": "^2.1.1", - "mkdirp": "^1.0.3", - "yallist": "^4.0.0" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/tar-fs": { - "version": "2.1.4", - "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.4.tgz", - "integrity": "sha512-mDAjwmZdh7LTT6pNleZ05Yt65HC3E+NiQzl672vQG38jIrehtJk/J3mNwIg+vShQPcLF/LV7CMnDW6vjj6sfYQ==", - "license": "MIT", - "dependencies": { - "chownr": "^1.1.1", - "mkdirp-classic": "^0.5.2", - "pump": "^3.0.0", - "tar-stream": "^2.1.4" - } - }, - "node_modules/tar-fs/node_modules/chownr": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.4.tgz", - "integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==", - "license": "ISC" - }, - "node_modules/tar-stream": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-2.2.0.tgz", - "integrity": "sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==", - "license": "MIT", - "dependencies": { - "bl": "^4.0.3", - "end-of-stream": "^1.4.1", - "fs-constants": "^1.0.0", - "inherits": "^2.0.3", - "readable-stream": "^3.1.1" - }, - "engines": { - "node": ">=6" - } - }, - "node_modules/tar/node_modules/minipass": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/minipass/-/minipass-5.0.0.tgz", - "integrity": "sha512-3FnjYuehv9k6ovOEbyOswadCDPX1piCfhV8ncmYtHOjuPwylVWsghTLo7rabjC3Rx5xD4HDx8Wm1xnMF7S5qFQ==", - "license": "ISC", - "engines": { - "node": ">=8" - } - }, - "node_modules/thenify": { - "version": "3.3.1", - "resolved": "https://registry.npmjs.org/thenify/-/thenify-3.3.1.tgz", - "integrity": "sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==", - "dev": true, - "license": "MIT", - "dependencies": { - "any-promise": "^1.0.0" - } - }, - "node_modules/thenify-all": { - "version": "1.6.0", - "resolved": "https://registry.npmjs.org/thenify-all/-/thenify-all-1.6.0.tgz", - "integrity": "sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA==", - "dev": true, - "license": "MIT", - "dependencies": { - "thenify": ">= 3.1.0 < 4" - }, - "engines": { - "node": ">=0.8" - } - }, - "node_modules/tinybench": { - "version": "2.9.0", - "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", - "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", - "dev": true, - "license": "MIT" - }, - "node_modules/tinyexec": { - "version": "0.3.2", - "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-0.3.2.tgz", - "integrity": "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==", - "dev": true, - "license": "MIT" - }, - "node_modules/tinyglobby": { - "version": "0.2.16", - "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.16.tgz", - "integrity": "sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==", - "dev": true, - "license": "MIT", - "dependencies": { - "fdir": "^6.5.0", - "picomatch": "^4.0.4" - }, - "engines": { - "node": ">=12.0.0" - }, - "funding": { - "url": "https://github.com/sponsors/SuperchupuDev" - } - }, - "node_modules/tinypool": { - "version": "0.7.0", - "resolved": "https://registry.npmjs.org/tinypool/-/tinypool-0.7.0.tgz", - "integrity": "sha512-zSYNUlYSMhJ6Zdou4cJwo/p7w5nmAH17GRfU/ui3ctvjXFErXXkruT4MWW6poDeXgCaIBlGLrfU6TbTXxyGMww==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=14.0.0" - } - }, - "node_modules/tinyspy": { - "version": "2.2.1", - "resolved": "https://registry.npmjs.org/tinyspy/-/tinyspy-2.2.1.tgz", - "integrity": "sha512-KYad6Vy5VDWV4GH3fjpseMQ/XU2BhIYP7Vzd0LG44qRWm/Yt2WCOTicFdvmgo6gWaqooMQCawTtILVQJupKu7A==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=14.0.0" - } - }, - "node_modules/tree-kill": { - "version": "1.2.2", - "resolved": "https://registry.npmjs.org/tree-kill/-/tree-kill-1.2.2.tgz", - "integrity": "sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==", - "dev": true, - "license": "MIT", - "bin": { - "tree-kill": "cli.js" - } - }, - "node_modules/ts-interface-checker": { - "version": "0.1.13", - "resolved": "https://registry.npmjs.org/ts-interface-checker/-/ts-interface-checker-0.1.13.tgz", - "integrity": "sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==", - "dev": true, - "license": "Apache-2.0" - }, - "node_modules/tsup": { - "version": "8.5.1", - "resolved": "https://registry.npmjs.org/tsup/-/tsup-8.5.1.tgz", - "integrity": "sha512-xtgkqwdhpKWr3tKPmCkvYmS9xnQK3m3XgxZHwSUjvfTjp7YfXe5tT3GgWi0F2N+ZSMsOeWeZFh7ZZFg5iPhing==", - "dev": true, - "license": "MIT", - "dependencies": { - "bundle-require": "^5.1.0", - "cac": "^6.7.14", - "chokidar": "^4.0.3", - "consola": "^3.4.0", - "debug": "^4.4.0", - "esbuild": "^0.27.0", - "fix-dts-default-cjs-exports": "^1.0.0", - "joycon": "^3.1.1", - "picocolors": "^1.1.1", - "postcss-load-config": "^6.0.1", - "resolve-from": "^5.0.0", - "rollup": "^4.34.8", - "source-map": "^0.7.6", - "sucrase": "^3.35.0", - "tinyexec": "^0.3.2", - "tinyglobby": "^0.2.11", - "tree-kill": "^1.2.2" - }, - "bin": { - "tsup": "dist/cli-default.js", - "tsup-node": "dist/cli-node.js" - }, - "engines": { - "node": ">=18" - }, - "peerDependencies": { - "@microsoft/api-extractor": "^7.36.0", - "@swc/core": "^1", - "postcss": "^8.4.12", - "typescript": ">=4.5.0" - }, - "peerDependenciesMeta": { - "@microsoft/api-extractor": { - "optional": true - }, - "@swc/core": { - "optional": true - }, - "postcss": { - "optional": true - }, - "typescript": { - "optional": true - } - } - }, - "node_modules/tsx": { - "version": "4.21.0", - "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.21.0.tgz", - "integrity": "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==", - "dev": true, - "license": "MIT", - "dependencies": { - "esbuild": "~0.27.0", - "get-tsconfig": "^4.7.5" - }, - "bin": { - "tsx": "dist/cli.mjs" - }, - "engines": { - "node": ">=18.0.0" - }, - "optionalDependencies": { - "fsevents": "~2.3.3" - } - }, - "node_modules/tunnel-agent": { - "version": "0.6.0", - "resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz", - "integrity": "sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==", - "license": "Apache-2.0", - "dependencies": { - "safe-buffer": "^5.0.1" - }, - "engines": { - "node": "*" - } - }, - "node_modules/type-detect": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/type-detect/-/type-detect-4.1.0.tgz", - "integrity": "sha512-Acylog8/luQ8L7il+geoSxhEkazvkslg7PSNKOX59mbB9cOveP5aq9h74Y7YU8yDpJwetzQQrfIwtf4Wp4LKcw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=4" - } - }, - "node_modules/type-fest": { - "version": "4.41.0", - "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-4.41.0.tgz", - "integrity": "sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA==", - "license": "(MIT OR CC0-1.0)", - "engines": { - "node": ">=16" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/typescript": { - "version": "5.9.3", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", - "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", - "dev": true, - "license": "Apache-2.0", - "bin": { - "tsc": "bin/tsc", - "tsserver": "bin/tsserver" - }, - "engines": { - "node": ">=14.17" - } - }, - "node_modules/ufo": { - "version": "1.6.4", - "resolved": "https://registry.npmjs.org/ufo/-/ufo-1.6.4.tgz", - "integrity": "sha512-JFNbkD1Svwe0KvGi8GOeLcP4kAWQ609twvCdcHxq1oSL8svv39ZuSvajcD8B+5D0eL4+s1Is2D/O6KN3qcTeRA==", - "dev": true, - "license": "MIT" - }, - "node_modules/undici": { - "version": "5.29.0", - "resolved": "https://registry.npmjs.org/undici/-/undici-5.29.0.tgz", - "integrity": "sha512-raqeBD6NQK4SkWhQzeYKd1KmIG6dllBOTt55Rmkt4HtI9mwdWtJljnrXjAFUBLTSN67HWrOIZ3EPF4kjUw80Bg==", - "license": "MIT", - "dependencies": { - "@fastify/busboy": "^2.0.0" - }, - "engines": { - "node": ">=14.0" - } - }, - "node_modules/undici-types": { - "version": "6.21.0", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", - "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/unique-filename": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/unique-filename/-/unique-filename-1.1.1.tgz", - "integrity": "sha512-Vmp0jIp2ln35UTXuryvjzkjGdRyf9b2lTXuSYUiPmzRcl3FDtYqAwOnTJkAngD9SWhnoJzDbTKwaOrZ+STtxNQ==", - "license": "ISC", - "optional": true, - "dependencies": { - "unique-slug": "^2.0.0" - } - }, - "node_modules/unique-slug": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/unique-slug/-/unique-slug-2.0.2.tgz", - "integrity": "sha512-zoWr9ObaxALD3DOPfjPSqxt4fnZiWblxHIgeWqW8x7UqDzEtHEQLzji2cuJYQFCU6KmoJikOYAZlrTHHebjx2w==", - "license": "ISC", - "optional": true, - "dependencies": { - "imurmurhash": "^0.1.4" - } - }, - "node_modules/util-deprecate": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", - "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", - "license": "MIT" - }, - "node_modules/uuid": { - "version": "8.3.2", - "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz", - "integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==", - "deprecated": "uuid@10 and below is no longer supported. For ESM codebases, update to uuid@latest. For CommonJS codebases, use uuid@11 (but be aware this version will likely be deprecated in 2028).", - "license": "MIT", - "bin": { - "uuid": "dist/bin/uuid" - } - }, - "node_modules/vite": { - "version": "5.4.21", - "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz", - "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==", - "dev": true, - "license": "MIT", - "dependencies": { - "esbuild": "^0.21.3", - "postcss": "^8.4.43", - "rollup": "^4.20.0" - }, - "bin": { - "vite": "bin/vite.js" - }, - "engines": { - "node": "^18.0.0 || >=20.0.0" - }, - "funding": { - "url": "https://github.com/vitejs/vite?sponsor=1" - }, - "optionalDependencies": { - "fsevents": "~2.3.3" - }, - "peerDependencies": { - "@types/node": "^18.0.0 || >=20.0.0", - "less": "*", - "lightningcss": "^1.21.0", - "sass": "*", - "sass-embedded": "*", - "stylus": "*", - "sugarss": "*", - "terser": "^5.4.0" - }, - "peerDependenciesMeta": { - "@types/node": { - "optional": true - }, - "less": { - "optional": true - }, - "lightningcss": { - "optional": true - }, - "sass": { - "optional": true - }, - "sass-embedded": { - "optional": true - }, - "stylus": { - "optional": true - }, - "sugarss": { - "optional": true - }, - "terser": { - "optional": true - } - } - }, - "node_modules/vite-node": { - "version": "0.34.6", - "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-0.34.6.tgz", - "integrity": "sha512-nlBMJ9x6n7/Amaz6F3zJ97EBwR2FkzhBRxF5e+jE6LA3yi6Wtc2lyTij1OnDMIr34v5g/tVQtsVAzhT0jc5ygA==", - "dev": true, - "license": "MIT", - "dependencies": { - "cac": "^6.7.14", - "debug": "^4.3.4", - "mlly": "^1.4.0", - "pathe": "^1.1.1", - "picocolors": "^1.0.0", - "vite": "^3.0.0 || ^4.0.0 || ^5.0.0-0" - }, - "bin": { - "vite-node": "vite-node.mjs" - }, - "engines": { - "node": ">=v14.18.0" - }, - "funding": { - "url": "https://opencollective.com/vitest" - } - }, - "node_modules/vite-node/node_modules/pathe": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/pathe/-/pathe-1.1.2.tgz", - "integrity": "sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/vite/node_modules/@esbuild/aix-ppc64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", - "integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "aix" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/vite/node_modules/@esbuild/android-arm": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.21.5.tgz", - "integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/vite/node_modules/@esbuild/android-arm64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz", - "integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/vite/node_modules/@esbuild/android-x64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.21.5.tgz", - "integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/vite/node_modules/@esbuild/darwin-arm64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz", - "integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/vite/node_modules/@esbuild/darwin-x64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz", - "integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/vite/node_modules/@esbuild/freebsd-arm64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz", - "integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/vite/node_modules/@esbuild/freebsd-x64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz", - "integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/vite/node_modules/@esbuild/linux-arm": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz", - "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/vite/node_modules/@esbuild/linux-arm64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz", - "integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/vite/node_modules/@esbuild/linux-ia32": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz", - "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==", - "cpu": [ - "ia32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/vite/node_modules/@esbuild/linux-loong64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz", - "integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==", - "cpu": [ - "loong64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/vite/node_modules/@esbuild/linux-mips64el": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz", - "integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==", - "cpu": [ - "mips64el" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/vite/node_modules/@esbuild/linux-ppc64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz", - "integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/vite/node_modules/@esbuild/linux-riscv64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz", - "integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==", - "cpu": [ - "riscv64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/vite/node_modules/@esbuild/linux-s390x": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz", - "integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==", - "cpu": [ - "s390x" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/vite/node_modules/@esbuild/linux-x64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz", - "integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/vite/node_modules/@esbuild/netbsd-x64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz", - "integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "netbsd" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/vite/node_modules/@esbuild/openbsd-x64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz", - "integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openbsd" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/vite/node_modules/@esbuild/sunos-x64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz", - "integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "sunos" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/vite/node_modules/@esbuild/win32-arm64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz", - "integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/vite/node_modules/@esbuild/win32-ia32": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz", - "integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==", - "cpu": [ - "ia32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/vite/node_modules/@esbuild/win32-x64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz", - "integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/vite/node_modules/esbuild": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz", - "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==", - "dev": true, - "hasInstallScript": true, - "license": "MIT", - "bin": { - "esbuild": "bin/esbuild" - }, - "engines": { - "node": ">=12" - }, - "optionalDependencies": { - "@esbuild/aix-ppc64": "0.21.5", - "@esbuild/android-arm": "0.21.5", - "@esbuild/android-arm64": "0.21.5", - "@esbuild/android-x64": "0.21.5", - "@esbuild/darwin-arm64": "0.21.5", - "@esbuild/darwin-x64": "0.21.5", - "@esbuild/freebsd-arm64": "0.21.5", - "@esbuild/freebsd-x64": "0.21.5", - "@esbuild/linux-arm": "0.21.5", - "@esbuild/linux-arm64": "0.21.5", - "@esbuild/linux-ia32": "0.21.5", - "@esbuild/linux-loong64": "0.21.5", - "@esbuild/linux-mips64el": "0.21.5", - "@esbuild/linux-ppc64": "0.21.5", - "@esbuild/linux-riscv64": "0.21.5", - "@esbuild/linux-s390x": "0.21.5", - "@esbuild/linux-x64": "0.21.5", - "@esbuild/netbsd-x64": "0.21.5", - "@esbuild/openbsd-x64": "0.21.5", - "@esbuild/sunos-x64": "0.21.5", - "@esbuild/win32-arm64": "0.21.5", - "@esbuild/win32-ia32": "0.21.5", - "@esbuild/win32-x64": "0.21.5" - } - }, - "node_modules/vitest": { - "version": "0.34.6", - "resolved": "https://registry.npmjs.org/vitest/-/vitest-0.34.6.tgz", - "integrity": "sha512-+5CALsOvbNKnS+ZHMXtuUC7nL8/7F1F2DnHGjSsszX8zCjWSSviphCb/NuS9Nzf4Q03KyyDRBAXhF/8lffME4Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/chai": "^4.3.5", - "@types/chai-subset": "^1.3.3", - "@types/node": "*", - "@vitest/expect": "0.34.6", - "@vitest/runner": "0.34.6", - "@vitest/snapshot": "0.34.6", - "@vitest/spy": "0.34.6", - "@vitest/utils": "0.34.6", - "acorn": "^8.9.0", - "acorn-walk": "^8.2.0", - "cac": "^6.7.14", - "chai": "^4.3.10", - "debug": "^4.3.4", - "local-pkg": "^0.4.3", - "magic-string": "^0.30.1", - "pathe": "^1.1.1", - "picocolors": "^1.0.0", - "std-env": "^3.3.3", - "strip-literal": "^1.0.1", - "tinybench": "^2.5.0", - "tinypool": "^0.7.0", - "vite": "^3.1.0 || ^4.0.0 || ^5.0.0-0", - "vite-node": "0.34.6", - "why-is-node-running": "^2.2.2" - }, - "bin": { - "vitest": "vitest.mjs" - }, - "engines": { - "node": ">=v14.18.0" - }, - "funding": { - "url": "https://opencollective.com/vitest" - }, - "peerDependencies": { - "@edge-runtime/vm": "*", - "@vitest/browser": "*", - "@vitest/ui": "*", - "happy-dom": "*", - "jsdom": "*", - "playwright": "*", - "safaridriver": "*", - "webdriverio": "*" - }, - "peerDependenciesMeta": { - "@edge-runtime/vm": { - "optional": true - }, - "@vitest/browser": { - "optional": true - }, - "@vitest/ui": { - "optional": true - }, - "happy-dom": { - "optional": true - }, - "jsdom": { - "optional": true - }, - "playwright": { - "optional": true - }, - "safaridriver": { - "optional": true - }, - "webdriverio": { - "optional": true - } - } - }, - "node_modules/vitest/node_modules/pathe": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/pathe/-/pathe-1.1.2.tgz", - "integrity": "sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/which": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", - "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", - "license": "ISC", - "optional": true, - "dependencies": { - "isexe": "^2.0.0" - }, - "bin": { - "node-which": "bin/node-which" - }, - "engines": { - "node": ">= 8" - } - }, - "node_modules/why-is-node-running": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", - "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", - "dev": true, - "license": "MIT", - "dependencies": { - "siginfo": "^2.0.0", - "stackback": "0.0.2" - }, - "bin": { - "why-is-node-running": "cli.js" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/wide-align": { - "version": "1.1.5", - "resolved": "https://registry.npmjs.org/wide-align/-/wide-align-1.1.5.tgz", - "integrity": "sha512-eDMORYaPNZ4sQIuuYPDHdQvf4gyCF9rEEV/yPxGfwPkRodwEgiMUUXTx/dex+Me0wxx53S+NgUHaP7y3MGlDmg==", - "license": "ISC", - "optional": true, - "dependencies": { - "string-width": "^1.0.2 || 2 || 3 || 4" - } - }, - "node_modules/wide-align/node_modules/emoji-regex": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", - "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", - "license": "MIT", - "optional": true - }, - "node_modules/wide-align/node_modules/is-fullwidth-code-point": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", - "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", - "license": "MIT", - "optional": true, - "engines": { - "node": ">=8" - } - }, - "node_modules/wide-align/node_modules/string-width": { - "version": "4.2.3", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", - "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", - "license": "MIT", - "optional": true, - "dependencies": { - "emoji-regex": "^8.0.0", - "is-fullwidth-code-point": "^3.0.0", - "strip-ansi": "^6.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/widest-line": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/widest-line/-/widest-line-5.0.0.tgz", - "integrity": "sha512-c9bZp7b5YtRj2wOe6dlj32MK+Bx/M/d+9VB2SHM1OtsUHR0aV0tdP6DWh/iMt0kWi1t5g1Iudu6hQRNd1A4PVA==", - "license": "MIT", - "dependencies": { - "string-width": "^7.0.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/wrap-ansi": { - "version": "9.0.2", - "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-9.0.2.tgz", - "integrity": "sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww==", - "license": "MIT", - "dependencies": { - "ansi-styles": "^6.2.1", - "string-width": "^7.0.0", - "strip-ansi": "^7.1.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/chalk/wrap-ansi?sponsor=1" - } - }, - "node_modules/wrap-ansi/node_modules/ansi-regex": { - "version": "6.2.2", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", - "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/ansi-regex?sponsor=1" - } - }, - "node_modules/wrap-ansi/node_modules/strip-ansi": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz", - "integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==", - "license": "MIT", - "dependencies": { - "ansi-regex": "^6.2.2" - }, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/strip-ansi?sponsor=1" - } - }, - "node_modules/wrappy": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", - "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", - "license": "ISC" - }, - "node_modules/ws": { - "version": "8.20.0", - "resolved": "https://registry.npmjs.org/ws/-/ws-8.20.0.tgz", - "integrity": "sha512-sAt8BhgNbzCtgGbt2OxmpuryO63ZoDk/sqaB/znQm94T4fCEsy/yV+7CdC1kJhOU9lboAEU7R3kquuycDoibVA==", - "license": "MIT", - "engines": { - "node": ">=10.0.0" - }, - "peerDependencies": { - "bufferutil": "^4.0.1", - "utf-8-validate": ">=5.0.2" - }, - "peerDependenciesMeta": { - "bufferutil": { - "optional": true - }, - "utf-8-validate": { - "optional": true - } - } - }, - "node_modules/yallist": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", - "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", - "license": "ISC" - }, - "node_modules/yaml": { - "version": "2.8.4", - "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.4.tgz", - "integrity": "sha512-ml/JPOj9fOQK8RNnWojA67GbZ0ApXAUlN2UQclwv2eVgTgn7O9gg9o7paZWKMp4g0H3nTLtS9LVzhkpOFIKzog==", - "license": "ISC", - "bin": { - "yaml": "bin.mjs" - }, - "engines": { - "node": ">= 14.6" - }, - "funding": { - "url": "https://github.com/sponsors/eemeli" - } - }, - "node_modules/yocto-queue": { - "version": "1.2.2", - "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-1.2.2.tgz", - "integrity": "sha512-4LCcse/U2MHZ63HAJVE+v71o7yOdIe4cZ70Wpf8D/IyjDKYQLV5GD46B+hSTjJsvV5PztjvHoU580EftxjDZFQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12.20" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/yoga-layout": { - "version": "3.2.1", - "resolved": "https://registry.npmjs.org/yoga-layout/-/yoga-layout-3.2.1.tgz", - "integrity": "sha512-0LPOt3AxKqMdFBZA3HBAt/t/8vIKq7VaQYbuA8WxCgung+p9TVyKRYdpvCb80HcdTN2NkbIKbhNwKUfm3tQywQ==", - "license": "MIT" - }, - "node_modules/zod": { - "version": "3.25.76", - "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", - "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/colinhacks" - } - } - } -} diff --git a/apps/ade-code/package.json b/apps/ade-code/package.json deleted file mode 100644 index d244eb955..000000000 --- a/apps/ade-code/package.json +++ /dev/null @@ -1,41 +0,0 @@ -{ - "name": "ade-code", - "version": "0.0.0", - "description": "Terminal-native ADE Work chat client", - "type": "module", - "bin": { - "ade-code": "dist/cli.js" - }, - "files": [ - "dist/**/*", - "README.md" - ], - "engines": { - "node": ">=22.0.0" - }, - "scripts": { - "dev": "tsx src/cli.tsx", - "build": "tsup", - "typecheck": "tsc -p tsconfig.json --noEmit", - "test": "vitest run" - }, - "dependencies": { - "@cursor/sdk": "^1.0.9", - "ink": "^5.2.1", - "ink-text-input": "^6.0.0", - "node-cron": "^3.0.3", - "node-pty": "^1.1.0", - "react": "^18.3.1", - "sql.js": "^1.13.0", - "yaml": "^2.8.2" - }, - "devDependencies": { - "@types/node": "^22.19.18", - "@types/react": "^18.3.18", - "ink-testing-library": "^4.0.0", - "tsup": "^8.3.5", - "tsx": "^4.20.6", - "typescript": "^5.7.3", - "vitest": "^0.34.6" - } -} diff --git a/apps/ade-code/src/__tests__/connection.test.ts b/apps/ade-code/src/__tests__/connection.test.ts deleted file mode 100644 index c81e49858..000000000 --- a/apps/ade-code/src/__tests__/connection.test.ts +++ /dev/null @@ -1,80 +0,0 @@ -import { beforeEach, describe, expect, it, vi } from "vitest"; -import { connectToAde } from "../connection"; -import type { ProjectLaunchContext } from "../types"; - -const embedded = vi.hoisted(() => { - const requests: Array<{ jsonrpc: string; id: number; method: string; params?: unknown }> = []; - const runtime = { - dispose: vi.fn(), - agentChatService: { - subscribeToEvents: vi.fn(() => vi.fn()), - }, - }; - const handler = Object.assign( - vi.fn(async (message: { jsonrpc: string; id: number; method: string; params?: unknown }) => { - requests.push(message); - return { ok: true, method: message.method }; - }), - { dispose: vi.fn() }, - ); - - return { - requests, - runtime, - handler, - createAdeRuntime: vi.fn(async () => runtime), - createAdeRpcRequestHandler: vi.fn(() => handler), - }; -}); - -vi.mock("../../../ade-cli/src/bootstrap", () => ({ - createAdeRuntime: embedded.createAdeRuntime, -})); - -vi.mock("../../../ade-cli/src/adeRpcServer", () => ({ - createAdeRpcRequestHandler: embedded.createAdeRpcRequestHandler, -})); - -const project: ProjectLaunchContext = { - launchCwd: "/tmp/ade-code", - projectRoot: "/tmp/ade-code", - workspaceRoot: "/tmp/ade-code", - laneHint: null, -}; - -describe("connectToAde embedded mode", () => { - beforeEach(() => { - embedded.requests.length = 0; - embedded.runtime.dispose.mockClear(); - embedded.runtime.agentChatService.subscribeToEvents.mockClear(); - embedded.handler.mockClear(); - embedded.handler.dispose.mockClear(); - embedded.createAdeRuntime.mockClear(); - embedded.createAdeRpcRequestHandler.mockClear(); - }); - - it("uses unique JSON-RPC ids for direct embedded requests", async () => { - const connection = await connectToAde({ - project, - forceEmbedded: true, - }); - - try { - await Promise.all([ - connection.request("ade/actions/list"), - connection.request("ping"), - ]); - } finally { - await connection.close(); - } - - expect(embedded.requests.map((request) => request.method)).toEqual([ - "ade/initialize", - "ade/initialized", - "ade/actions/list", - "ping", - ]); - expect(embedded.requests.map((request) => request.id)).toEqual([1, 2, 3, 4]); - expect(new Set(embedded.requests.map((request) => request.id)).size).toBe(4); - }); -}); diff --git a/apps/ade-code/src/connection.ts b/apps/ade-code/src/connection.ts deleted file mode 100644 index e5dcd997b..000000000 --- a/apps/ade-code/src/connection.ts +++ /dev/null @@ -1,212 +0,0 @@ -import fs from "node:fs"; -import { resolveAdeLayout } from "../../desktop/src/shared/adeLayout"; -import { JsonRpcClient } from "./jsonRpcClient"; -import type { AdeCodeConnection, ProjectLaunchContext } from "./types"; -import type { AgentChatEventEnvelope } from "../../desktop/src/shared/types/chat"; - -type RpcResponseEnvelope = - | T - | { - ok: false; - error: { message?: string }; - }; - -type AdeRpcRequest = (method: string, params?: unknown) => Promise; - -type AdeActionHelpers = Pick; - -type EmbeddedRuntime = { - dispose: () => void; - agentChatService?: { - subscribeToEvents?: (callback: (event: AgentChatEventEnvelope) => void) => () => void; - }; -}; - -type DirectHandler = { - (message: unknown): Promise; - dispose: () => void; -}; - -type CreateEmbeddedRuntime = (args: { - projectRoot: string; - workspaceRoot: string; - chatRuntime: "agent"; - runtimeProfile: "chat"; -}) => Promise; - -type CreateEmbeddedRpcRequestHandler = (args: { runtime: EmbeddedRuntime; serverVersion: string }) => DirectHandler; - -async function loadEmbeddedAdeCli(): Promise<{ - createAdeRuntime: (args: { - projectRoot: string; - workspaceRoot: string; - chatRuntime: "agent"; - runtimeProfile: "chat"; - }) => Promise; - createAdeRpcRequestHandler: CreateEmbeddedRpcRequestHandler; -}> { - const [bootstrap, rpc] = await Promise.all([ - import("../../ade-cli/src/bootstrap"), - import("../../ade-cli/src/adeRpcServer"), - ]); - return { - createAdeRuntime: bootstrap.createAdeRuntime as unknown as CreateEmbeddedRuntime, - createAdeRpcRequestHandler: rpc.createAdeRpcRequestHandler as unknown as CreateEmbeddedRpcRequestHandler, - }; -} - -function unwrapActionResult(payload: RpcResponseEnvelope, domain: string, action: string): T { - if (payload && typeof payload === "object" && "ok" in payload && payload.ok === false) { - const error = (payload as { error?: { message?: string } }).error; - const message = typeof error?.message === "string" - ? error.message - : `ADE action failed: ${domain}.${action}`; - throw new Error(message); - } - const record = payload as { result?: unknown }; - return record.result as T; -} - -function createAdeActionHelpers(request: AdeRpcRequest): AdeActionHelpers { - return { - tool: async (name: string, toolArgs?: Record): Promise => { - const payload = await request("ade/actions/call", { - name, - arguments: toolArgs ?? {}, - }); - if (payload && typeof payload === "object" && "ok" in payload && payload.ok === false) { - const error = (payload as { error?: { message?: string } }).error; - const message = typeof error?.message === "string" ? error.message : `ADE tool failed: ${name}`; - throw new Error(message); - } - return payload as T; - }, - action: async (domain: string, action: string, actionArgs?: Record): Promise => { - const payload = await request("ade/actions/call", { - name: "run_ade_action", - arguments: { domain, action, args: actionArgs ?? {} }, - }); - return unwrapActionResult(payload, domain, action); - }, - actionList: async (domain: string, action: string, argsList: unknown[]): Promise => { - const payload = await request("ade/actions/call", { - name: "run_ade_action", - arguments: { domain, action, argsList }, - }); - return unwrapActionResult(payload, domain, action); - }, - }; -} - -async function initialize(request: AdeRpcRequest): Promise { - await request("ade/initialize", { - protocolVersion: "2025-06-18", - clientName: "ade-code", - identity: { - role: "cto", - callerId: `ade-code:${process.pid}`, - }, - }); - await request("ade/initialized"); -} - -async function withTimeout(promise: Promise, timeoutMs: number, message: string): Promise { - let timer: NodeJS.Timeout | null = null; - try { - return await Promise.race([ - promise, - new Promise((_, reject) => { - timer = setTimeout(() => reject(new Error(message)), timeoutMs); - timer.unref(); - }), - ]); - } finally { - if (timer) clearTimeout(timer); - } -} - -export async function connectToAde(args: { - project: ProjectLaunchContext; - forceEmbedded?: boolean; - requireSocket?: boolean; - socketPath?: string | null; -}): Promise { - const layout = resolveAdeLayout(args.project.projectRoot); - const socketPath = args.socketPath?.trim() || process.env.ADE_RPC_SOCKET_PATH?.trim() || layout.socketPath; - - if (args.forceEmbedded && args.requireSocket) { - throw new Error("Cannot use embedded mode when a desktop socket is required."); - } - - if (!args.forceEmbedded && socketPath && (args.requireSocket || fs.existsSync(socketPath))) { - let client: JsonRpcClient | null = null; - try { - client = await JsonRpcClient.connect(socketPath); - const connectedClient = client; - const request: AdeRpcRequest = (method: string, params?: unknown) => connectedClient.request(method, params); - await withTimeout(initialize(request), 3000, "ADE RPC socket did not finish initialization."); - return { - mode: "attached", - projectRoot: args.project.projectRoot, - workspaceRoot: args.project.workspaceRoot, - socketPath, - request, - ...createAdeActionHelpers(request), - onChatEvent: (callback: (event: AgentChatEventEnvelope) => void) => ( - connectedClient.onNotification("chat/event", (params) => callback(params as AgentChatEventEnvelope)) - ), - close: async () => connectedClient.close(), - }; - } catch (error) { - client?.close(); - if (args.requireSocket) { - const message = error instanceof Error ? error.message : String(error); - throw new Error(`ADE RPC socket is required but unavailable at ${socketPath}: ${message}`); - } - // Fall through to embedded mode; a stale socket should not strand the TUI. - } - } - - if (args.requireSocket) { - throw new Error(`ADE RPC socket is required but unavailable at ${socketPath}.`); - } - - const { createAdeRuntime, createAdeRpcRequestHandler } = await loadEmbeddedAdeCli(); - const runtime = await createAdeRuntime({ - projectRoot: args.project.projectRoot, - workspaceRoot: args.project.workspaceRoot, - chatRuntime: "agent", - runtimeProfile: "chat", - }); - const handler: DirectHandler = createAdeRpcRequestHandler({ - runtime, - serverVersion: "ade-code", - }); - let nextRequestId = 1; - const request: AdeRpcRequest = async (method: string, params?: unknown): Promise => { - return await handler({ - jsonrpc: "2.0", - id: nextRequestId++, - method, - params, - }) as T; - }; - await initialize(request); - const chatEvents = typeof runtime.agentChatService?.subscribeToEvents === "function" - ? runtime.agentChatService.subscribeToEvents.bind(runtime.agentChatService) - : (() => () => {}); - - return { - mode: "embedded", - projectRoot: args.project.projectRoot, - workspaceRoot: args.project.workspaceRoot, - socketPath: null, - request, - ...createAdeActionHelpers(request), - onChatEvent: (callback) => chatEvents(callback), - close: async () => { - handler.dispose(); - runtime.dispose(); - }, - }; -} diff --git a/apps/ade-code/tsconfig.json b/apps/ade-code/tsconfig.json deleted file mode 100644 index 4fdc544f0..000000000 --- a/apps/ade-code/tsconfig.json +++ /dev/null @@ -1,15 +0,0 @@ -{ - "compilerOptions": { - "target": "ES2022", - "lib": ["ES2022", "DOM", "DOM.Iterable"], - "module": "ESNext", - "moduleResolution": "Bundler", - "jsx": "react-jsx", - "strict": true, - "esModuleInterop": true, - "skipLibCheck": true, - "noEmit": true, - "types": ["node"] - }, - "include": ["src"] -} diff --git a/apps/ade-code/tsup.config.ts b/apps/ade-code/tsup.config.ts deleted file mode 100644 index fdd8bb591..000000000 --- a/apps/ade-code/tsup.config.ts +++ /dev/null @@ -1,24 +0,0 @@ -import { defineConfig } from "tsup"; - -export default defineConfig({ - entry: { - cli: "src/cli.tsx", - }, - format: ["esm"], - platform: "node", - target: "node22", - outDir: "dist", - sourcemap: true, - clean: true, - splitting: false, - banner: { - js: "import { createRequire as __adeCreateRequire } from 'node:module'; const require = __adeCreateRequire(import.meta.url);", - }, - external: ["node-pty", "sql.js", "node:sqlite", "@cursor/sdk", "sqlite3"], - esbuildOptions(options) { - options.alias = { - ...(options.alias ?? {}), - sqlite: "node:sqlite", - }; - }, -}); diff --git a/apps/ade-code/vitest.config.ts b/apps/ade-code/vitest.config.ts deleted file mode 100644 index 840e944d9..000000000 --- a/apps/ade-code/vitest.config.ts +++ /dev/null @@ -1,8 +0,0 @@ -import { defineConfig } from "vitest/config"; - -export default defineConfig({ - test: { - environment: "node", - include: ["src/**/*.test.ts", "src/**/*.test.tsx"], - }, -}); diff --git a/apps/desktop/SYNC_REMOTE_API_ANALYSIS.md b/apps/desktop/SYNC_REMOTE_API_ANALYSIS.md deleted file mode 100644 index d38c9732f..000000000 --- a/apps/desktop/SYNC_REMOTE_API_ANALYSIS.md +++ /dev/null @@ -1,315 +0,0 @@ -# Sync Remote API Analysis for Mobile Chat Client - -## 1. All Existing Remote Commands - -The desktop exposes remote commands via `syncRemoteCommandService.ts`. Each command is routed through a WebSocket-based sync protocol. Commands are registered with a policy (`{ viewerAllowed, queueable? }`). Chat event streaming uses separate sync envelopes (`chat_subscribe`, `chat_unsubscribe`, `chat_event`) and is gated by `hello_ok.features.chatStreaming.enabled`. - -### Chat Commands (13 total) -| Command | Parameters | Response | Policy | -|---------|-----------|----------|--------| -| `chat.listSessions` | `{ laneId?: string, includeAutomation?: boolean }` | `AgentChatSessionSummary[]` | viewerAllowed | -| `chat.getSummary` | `{ sessionId: string }` | `AgentChatSessionSummary \| null` | viewerAllowed | -| `chat.getTranscript` | `{ sessionId: string, limit?: number, maxChars?: number }` | Transcript entries | viewerAllowed | -| `chat.create` | `{ laneId: string, provider?: string, model?: string, modelId?: string, reasoningEffort?: string }` | `AgentChatSession` | viewerAllowed, queueable | -| `chat.send` | `{ sessionId: string, text: string }` | `{ ok: true }` | viewerAllowed, queueable | -| `chat.interrupt` | `{ sessionId: string }` | `{ ok: true }` | viewerAllowed | -| `chat.steer` | `{ sessionId: string, text: string }` | `{ ok: true }` | viewerAllowed | -| `chat.approve` | `{ sessionId: string, itemId: string, decision: string, responseText?: string }` | `{ ok: true }` | viewerAllowed | -| `chat.respondToInput` | `{ sessionId: string, itemId: string, decision?: string, answers?: object, responseText?: string }` | `{ ok: true }` | viewerAllowed | -| `chat.resume` | `{ sessionId: string }` | `AgentChatSession` | viewerAllowed, queueable | -| `chat.updateSession` | `{ sessionId: string, title?, modelId?, reasoningEffort?, permissionMode?, ... }` | `AgentChatSession` | viewerAllowed, queueable | -| `chat.dispose` | `{ sessionId: string }` | `{ ok: true }` | viewerAllowed, queueable | -| `chat.models` | `{ provider?: string }` | `AgentChatModelInfo[]` | viewerAllowed | - -### Lane Commands (29 total) -| Command | Policy | -|---------|--------| -| `lanes.list` | viewerAllowed | -| `lanes.refreshSnapshots` | viewerAllowed | -| `lanes.getDetail` | viewerAllowed | -| `lanes.create` | viewerAllowed, queueable | -| `lanes.createChild` | viewerAllowed, queueable | -| `lanes.createFromUnstaged` | viewerAllowed, queueable | -| `lanes.attach` | viewerAllowed, queueable | -| `lanes.adoptAttached` | viewerAllowed, queueable | -| `lanes.rename` | viewerAllowed, queueable | -| `lanes.reparent` | viewerAllowed, queueable | -| `lanes.updateAppearance` | viewerAllowed, queueable | -| `lanes.archive` | viewerAllowed, queueable | -| `lanes.unarchive` | viewerAllowed, queueable | -| `lanes.delete` | viewerAllowed, queueable | -| `lanes.getStackChain` | viewerAllowed | -| `lanes.getChildren` | viewerAllowed | -| `lanes.rebaseStart` | viewerAllowed, queueable | -| `lanes.rebasePush` | viewerAllowed, queueable | -| `lanes.rebaseRollback` | viewerAllowed, queueable | -| `lanes.rebaseAbort` | viewerAllowed, queueable | -| `lanes.listRebaseSuggestions` | viewerAllowed | -| `lanes.dismissRebaseSuggestion` | viewerAllowed, queueable | -| `lanes.deferRebaseSuggestion` | viewerAllowed, queueable | -| `lanes.listAutoRebaseStatuses` | viewerAllowed | -| `lanes.listTemplates` | viewerAllowed | -| `lanes.getDefaultTemplate` | viewerAllowed | -| `lanes.initEnv` | viewerAllowed, queueable | -| `lanes.getEnvStatus` | viewerAllowed | -| `lanes.applyTemplate` | viewerAllowed, queueable | - -### Work/Session Commands (3 total) -| Command | Parameters | Policy | -|---------|-----------|--------| -| `work.listSessions` | `{ laneId?: string, status?: string, limit?: number }` | viewerAllowed | -| `work.runQuickCommand` | `{ laneId, title, startupCommand?, cols?, rows?, toolType?, tracked? }` | viewerAllowed, queueable | -| `work.closeSession` | `{ sessionId: string }` | viewerAllowed, queueable | - -### Git Commands (30 total) -`git.getChanges`, `git.getFile`, `git.stageFile`, `git.stageAll`, `git.unstageFile`, `git.unstageAll`, `git.discardFile`, `git.restoreStagedFile`, `git.commit`, `git.generateCommitMessage`, `git.listRecentCommits`, `git.listCommitFiles`, `git.getCommitMessage`, `git.revertCommit`, `git.cherryPickCommit`, `git.stashPush`, `git.stashList`, `git.stashApply`, `git.stashPop`, `git.stashDrop`, `git.fetch`, `git.pull`, `git.getSyncStatus`, `git.sync`, `git.push`, `git.getConflictState`, `git.rebaseContinue`, `git.rebaseAbort`, `git.listBranches`, `git.checkoutBranch` - -### File Commands (1) -| `files.writeTextAtomic` | `{ laneId, path, text }` | viewerAllowed, queueable | - -### Conflict Commands (3) -`conflicts.getLaneStatus`, `conflicts.listOverlaps`, `conflicts.getBatchAssessment` - -### PR Commands (13) -`prs.list`, `prs.refresh`, `prs.getDetail`, `prs.getStatus`, `prs.getChecks`, `prs.getReviews`, `prs.getComments`, `prs.getFiles`, `prs.createFromLane`, `prs.land`, `prs.close`, `prs.reopen`, `prs.requestReviewers` - -**Total: 92 registered remote commands** - ---- - -## 2. Streaming/Event Push Mechanism - -### Current State: chat event streaming is available to sync peers - -The desktop has two separate event delivery systems: - -1. **IPC Events (Electron renderer only)**: Chat events flow via `onEvent` callback → `emitProjectEvent(projectRoot, IPC.agentChatEvent, event)` which sends `AgentChatEventEnvelope` objects to the Electron renderer process. - -2. **Sync WebSocket (mobile/external peers)**: The sync host subscribes to `agentChatService` events and broadcasts matching `chat_event` envelopes to peers that sent `chat_subscribe { sessionId }`. `chat_unsubscribe { sessionId }` stops delivery. There is no `chat_snapshot` envelope in the current implementation. - -### How Sync Streaming Works -- Peer sends `chat_subscribe { sessionId }` -- Desktop starts pushing `chat_event` envelopes for that session to the subscribed peer -- Peer sends `chat_unsubscribe { sessionId }` to stop - -### How Terminal Streaming Works -- Peer sends `terminal_subscribe { sessionId, maxBytes? }` → receives `terminal_snapshot` with current transcript -- Desktop pushes `terminal_data` events as PTY data arrives -- Desktop pushes `terminal_exit` when PTY exits -- Peer sends `terminal_unsubscribe { sessionId }` to stop - -### Other Push Events -- `heartbeat` (ping/pong, every 30s) -- `changeset_batch` (cr-sqlite CRDT changes, polled every 400ms) -- `brain_status` (host metrics, every 5s) - ---- - -## 3. MISSING Commands for Full Mobile Chat Client - -The following `agentChatService` public methods have **NO remote command equivalent** in `syncRemoteCommandService`: - -| Missing Command | agentChatService Method | Priority | Description | -|----------------|------------------------|----------|-------------| -| `chat.handoff` | `handoffSession({ sourceSessionId, targetModelId })` | Medium | Switch model mid-session | -| `chat.getCapabilities` | `getSessionCapabilities({ sessionId })` | Medium | Get session capabilities | -| `chat.listSubagents` | `listSubagents({ sessionId })` | Medium | List active subagents | -| `chat.slashCommands` | `getSlashCommands({ sessionId })` | Low | Get available slash commands | -| `chat.fileSearch` | `codexFuzzyFileSearch({ sessionId, query })` | Low | Search for files to attach | -| `chat.warmupModel` | `warmupModel({ sessionId, modelId })` | Low | Pre-warm a model before use | - ---- - -## 4. Session State Machine - -### Session Status (`AgentChatSessionStatus`) -``` -"active" | "idle" | "ended" -``` - -### Turn Status (within `AgentChatEvent.status`) -``` -"started" → "completed" | "interrupted" | "failed" -``` - -### Session Lifecycle -``` -create → idle -idle + send/steer → active (turn started) -active + turn completes → idle -active + interrupt → idle (turn interrupted) -idle + dispose → ended -ended + resume → idle -``` - -### Runtime States -Each session has a `ChatRuntime` which can be: -- `CodexRuntime` (OpenAI Codex process) -- `ClaudeRuntime` (Anthropic Claude CLI SDK) -- `UnifiedRuntime` (Vercel AI SDK, direct API) - -Runtime-specific states: -- **busy**: a turn is currently executing -- **interrupted**: interrupt was requested -- **pendingSteers**: queue of messages to send after current turn completes (max 10) -- **pendingApprovals**: tool-use approval requests waiting for user input - ---- - -## 5. Message/Event Type Definitions - -### `AgentChatEvent` (27 event types) -| Type | Key Fields | Description | -|------|-----------|-------------| -| `user_message` | text, attachments?, turnId? | User sent a message | -| `text` | text, messageId?, turnId? | Assistant text delta (streaming) | -| `tool_call` | tool, args, itemId, turnId? | Tool invocation started | -| `tool_result` | tool, result, itemId, status? | Tool completed/failed | -| `file_change` | path, diff, kind, itemId, status? | File was created/modified/deleted | -| `command` | command, cwd, output, itemId, status | Shell command execution | -| `plan` | steps[], explanation? | Plan outline | -| `reasoning` | text, turnId?, summaryIndex? | Model reasoning/thinking | -| `approval_request` | itemId, kind, description | Tool use needs approval | -| `status` | turnStatus, turnId?, message? | Turn lifecycle event | -| `delegation_state` | contract, message? | Delegation/handoff state | -| `error` | message, errorInfo? | Error occurred | -| `done` | turnId, status, model?, usage? | Turn completed/interrupted/failed | -| `activity` | activity, detail? | Current activity indicator | -| `step_boundary` | stepNumber | Step separator | -| `todo_update` | items[] | Todo list changes | -| `subagent_started` | taskId, description | Subagent spawned | -| `subagent_progress` | taskId, summary, usage? | Subagent progress | -| `subagent_result` | taskId, status, summary | Subagent completed | -| `structured_question` | question, options?, itemId | Question for the user | -| `tool_use_summary` | summary, toolUseIds | Summarized tool uses | -| `context_compact` | trigger | Context was compacted | -| `system_notice` | noticeKind, message, detail? | System notification | -| `completion_report` | report | Session completion summary | -| `web_search` | query, itemId, status | Web search event | -| `auto_approval_review` | targetItemId, reviewStatus | Auto-approval decision | -| `prompt_suggestion` | suggestion | Suggested follow-up prompt | -| `plan_text` | text | Plan text content | - -### `AgentChatEventEnvelope` -```typescript -{ - sessionId: string; - timestamp: string; - event: AgentChatEvent; - sequence?: number; - provenance?: { - messageId?: string; - threadId?: string | null; - role?: "user" | "orchestrator" | "worker" | "agent" | null; - // ... more fields - }; -} -``` - -### `PendingInputRequest` (approval/question data) -```typescript -{ - requestId: string; - itemId?: string; - source: "claude" | "codex" | "unified" | "mission" | "ade"; - kind: "approval" | "question" | "structured_question" | "permissions" | "plan_approval"; - title?: string | null; - description?: string | null; - questions: PendingInputQuestion[]; - allowsFreeform: boolean; - blocking: boolean; - canProceedWithoutAnswer: boolean; - options?: PendingInputOption[]; - turnId?: string | null; -} -``` - ---- - -## 6. WebSocket Protocol Details - -### Connection Flow -1. Client opens WebSocket to `ws://:8787` -2. Client sends `hello` or `pairing_request` envelope -3. Desktop validates auth (bootstrap token or paired device credentials) -4. Desktop sends `hello_ok` with features list (including `chatStreaming` and all supported command actions) -5. Authenticated peer can send commands, subscribe to terminals, and, when `chatStreaming.enabled` is true, subscribe to chat events. - -### Envelope Format -```typescript -{ - version: 1, // Protocol version - type: string, // Message type - requestId?: string | null, // For request/response correlation - compression: "none" | "gzip", // Payload compression - payloadEncoding: "json" | "base64", // Encoding - payload: unknown, // The actual data - uncompressedBytes?: number, // Original size if compressed -} -``` - -### Authentication -Two auth methods: -1. **Bootstrap token**: Shared secret stored at `.ade/secrets/sync-bootstrap-token` -2. **Paired device**: Device-specific credentials via QR code pairing flow - -### Command Protocol -``` -Client → command { commandId, action, args } -Desktop → command_ack { commandId, accepted, status, message } -Desktop → command_result { commandId, ok, result?, error? } -``` - ---- - -## 7. Connection Management - -- **No rate limiting** on commands or connections -- **No max peer limit** — peers are tracked in a `Set` -- **Heartbeat**: 30s interval, unanswered heartbeat → close with code 4001 -- **mDNS**: Host published via Bonjour (`ade-sync` service type) -- **Max WebSocket payload**: 25 MB -- **Compression threshold**: 4 KB (payloads ≥4KB are gzip compressed) - ---- - -## 8. Recommendations for Backend Changes - -### Critical (required for basic mobile chat) - -1. **Chat streaming is already wired through sync envelopes**: `chat_subscribe` and `chat_unsubscribe` gate `chat_event` pushes, and the capability should be checked via `hello_ok.features.chatStreaming.enabled` before subscribing. - -2. **Remaining chat commands for mobile**: - - `chat.handoff` → `agentChatService.handoffSession()` - - `chat.getCapabilities` → `agentChatService.getSessionCapabilities()` - - `chat.listSubagents` → `agentChatService.listSubagents()` - - `chat.slashCommands` → `agentChatService.getSlashCommands()` - - `chat.fileSearch` → `agentChatService.codexFuzzyFileSearch()` - - `chat.warmupModel` → `agentChatService.warmupModel()` - -### High Priority - -3. **Consider whether mobile needs the remaining read-only commands immediately**: `chat.getCapabilities`, `chat.listSubagents`, and `chat.slashCommands` are the most likely next additions. - -### Nice to Have - -4. **Consider connection-level rate limiting**: Currently there's no protection against command flooding from peers. - -### What Mobile Can Do with Existing APIs - -With the 13 existing chat commands, mobile can already: -- ✅ List chat sessions per lane -- ✅ Get session summaries -- ✅ Read chat transcripts (polling) -- ✅ Create new chat sessions -- ✅ Send initial messages -- ✅ Interrupt, steer, approve, and answer input requests in real time -- ✅ Resume, update, and dispose chat sessions -- ✅ List available models -- ✅ Receive real-time chat events after `chat_subscribe` -- ❌ Cannot hand off sessions to a different model -- ❌ Cannot query capabilities, subagents, slash commands, file search, or model warmup - -### Implementation Approach - -The most impactful single change is adding **chat event streaming** via `chat_subscribe`/`chat_event` envelopes and gating it behind `hello_ok.features.chatStreaming.enabled`. The additional command registrations are straightforward - they just wire existing service methods through the existing command dispatch pattern. diff --git a/apps/desktop/build/icon.alpha.icns b/apps/desktop/build/icon.alpha.icns new file mode 100644 index 000000000..639d1840a Binary files /dev/null and b/apps/desktop/build/icon.alpha.icns differ diff --git a/apps/desktop/build/icon.beta.icns b/apps/desktop/build/icon.beta.icns new file mode 100644 index 000000000..d8d0408ea Binary files /dev/null and b/apps/desktop/build/icon.beta.icns differ diff --git a/apps/desktop/package-lock.json b/apps/desktop/package-lock.json index ca61b2249..571ce7528 100644 --- a/apps/desktop/package-lock.json +++ b/apps/desktop/package-lock.json @@ -29,6 +29,7 @@ "@radix-ui/react-tabs": "^1.1.13", "@tanstack/react-virtual": "^3.13.21", "@types/canvas-confetti": "^1.9.0", + "@types/ssh2": "^1.15.5", "@wize-logic/nodejs-rfb": "^4.2.0", "@xterm/addon-fit": "^0.11.0", "@xterm/addon-webgl": "^0.19.0", @@ -52,7 +53,6 @@ "node-pty": "^1.1.0", "onnxruntime-node": "^1.24.3", "path-browserify": "^1.0.1", - "qrcode": "^1.5.4", "react": "^18.3.1", "react-dom": "^18.3.1", "react-markdown": "^10.1.0", @@ -63,6 +63,7 @@ "remark-gfm": "^4.0.1", "shiki": "^4.0.2", "sql.js": "^1.13.0", + "ssh2": "^1.17.0", "tailwind-merge": "^3.4.0", "ws": "^8.19.0", "yaml": "^2.8.2", @@ -81,7 +82,6 @@ "@types/node": "^20.11.30", "@types/node-cron": "^3.0.11", "@types/path-browserify": "^1.0.3", - "@types/qrcode": "^1.5.6", "@types/react": "^18.2.74", "@types/react-dom": "^18.2.24", "@types/sql.js": "^1.4.9", @@ -7041,16 +7041,6 @@ "dev": true, "license": "MIT" }, - "node_modules/@types/qrcode": { - "version": "1.5.6", - "resolved": "https://registry.npmjs.org/@types/qrcode/-/qrcode-1.5.6.tgz", - "integrity": "sha512-te7NQcV2BOvdj2b1hCAHzAoMNuj65kNBMz0KBaxM6c3VGBOhU0dURQKOtH8CFNI/dsKkwlv32p26qYQTWoB5bw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/node": "*" - } - }, "node_modules/@types/react": { "version": "18.3.28", "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.28.tgz", @@ -7100,6 +7090,30 @@ "@types/node": "*" } }, + "node_modules/@types/ssh2": { + "version": "1.15.5", + "resolved": "https://registry.npmjs.org/@types/ssh2/-/ssh2-1.15.5.tgz", + "integrity": "sha512-N1ASjp/nXH3ovBHddRJpli4ozpk6UdDYIX4RJWFa9L1YKnzdhTlVmiGHm4DZnj/jLbqZpes4aeR30EFGQtvhQQ==", + "license": "MIT", + "dependencies": { + "@types/node": "^18.11.18" + } + }, + "node_modules/@types/ssh2/node_modules/@types/node": { + "version": "18.19.130", + "resolved": "https://registry.npmjs.org/@types/node/-/node-18.19.130.tgz", + "integrity": "sha512-GRaXQx6jGfL8sKfaIDD6OupbIHBr9jv7Jnaml9tB7l4v068PAOXqfcujMMo5PhbIs6ggR1XODELqahT2R8v0fg==", + "license": "MIT", + "dependencies": { + "undici-types": "~5.26.4" + } + }, + "node_modules/@types/ssh2/node_modules/undici-types": { + "version": "5.26.5", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-5.26.5.tgz", + "integrity": "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==", + "license": "MIT" + }, "node_modules/@types/trusted-types": { "version": "2.0.7", "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz", @@ -7858,6 +7872,7 @@ "version": "5.0.1", "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "devOptional": true, "license": "MIT", "engines": { "node": ">=8" @@ -7867,6 +7882,7 @@ "version": "4.3.0", "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, "license": "MIT", "dependencies": { "color-convert": "^2.0.1" @@ -8254,6 +8270,15 @@ "node": ">=8" } }, + "node_modules/asn1": { + "version": "0.2.6", + "resolved": "https://registry.npmjs.org/asn1/-/asn1-0.2.6.tgz", + "integrity": "sha512-ix/FxPn0MDjeyJ7i/yoHGFt/EX6LyNbxSEhPPXODPL+KB0VPk86UYfL0lMdy+KCnv+fmvIzySwaK5COwqVbWTQ==", + "license": "MIT", + "dependencies": { + "safer-buffer": "~2.1.0" + } + }, "node_modules/assert-plus": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/assert-plus/-/assert-plus-1.0.0.tgz", @@ -8461,6 +8486,15 @@ "node": ">=6.0.0" } }, + "node_modules/bcrypt-pbkdf": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/bcrypt-pbkdf/-/bcrypt-pbkdf-1.0.2.tgz", + "integrity": "sha512-qeFIXtP4MSoi6NLqO12WfqARWWuCKi2Rn/9hJLEmtB5yTNr9DqFWkJRCf2qShWzPeAMRnOgCrq0sg/KLv5ES9w==", + "license": "BSD-3-Clause", + "dependencies": { + "tweetnacl": "^0.14.3" + } + }, "node_modules/bindings": { "version": "1.5.0", "resolved": "https://registry.npmjs.org/bindings/-/bindings-1.5.0.tgz", @@ -8646,6 +8680,15 @@ "dev": true, "license": "MIT" }, + "node_modules/buildcheck": { + "version": "0.0.7", + "resolved": "https://registry.npmjs.org/buildcheck/-/buildcheck-0.0.7.tgz", + "integrity": "sha512-lHblz4ahamxpTmnsk+MNTRWsjYKv965MwOrSJyeD588rR3Jcu7swE+0wN5F+PbL5cjgu/9ObkhfzEPuofEMwLA==", + "optional": true, + "engines": { + "node": ">=10.0.0" + } + }, "node_modules/builder-util": { "version": "26.8.1", "resolved": "https://registry.npmjs.org/builder-util/-/builder-util-26.8.1.tgz", @@ -8893,15 +8936,6 @@ "node": ">=6" } }, - "node_modules/camelcase": { - "version": "5.3.1", - "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz", - "integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==", - "license": "MIT", - "engines": { - "node": ">=6" - } - }, "node_modules/caniuse-lite": { "version": "1.0.30001779", "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001779.tgz", @@ -9263,6 +9297,7 @@ "version": "2.0.1", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, "license": "MIT", "dependencies": { "color-name": "~1.1.4" @@ -9275,6 +9310,7 @@ "version": "1.1.4", "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true, "license": "MIT" }, "node_modules/color-support": { @@ -9503,6 +9539,20 @@ "node": ">= 6" } }, + "node_modules/cpu-features": { + "version": "0.0.10", + "resolved": "https://registry.npmjs.org/cpu-features/-/cpu-features-0.0.10.tgz", + "integrity": "sha512-9IkYqtX3YHPCzoVg1Py+o9057a3i0fp7S530UWokCSaFVTc7CwXPRiOjRjBQQ18ZCNafx78YfnG+HALxtVmOGA==", + "hasInstallScript": true, + "optional": true, + "dependencies": { + "buildcheck": "~0.0.6", + "nan": "^2.19.0" + }, + "engines": { + "node": ">=10.0.0" + } + }, "node_modules/crc": { "version": "3.8.0", "resolved": "https://registry.npmjs.org/crc/-/crc-3.8.0.tgz", @@ -10122,15 +10172,6 @@ } } }, - "node_modules/decamelize": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/decamelize/-/decamelize-1.2.0.tgz", - "integrity": "sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==", - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/decimal.js": { "version": "10.6.0", "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz", @@ -10380,12 +10421,6 @@ "node": "^14.15.0 || ^16.10.0 || >=18.0.0" } }, - "node_modules/dijkstrajs": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/dijkstrajs/-/dijkstrajs-1.0.3.tgz", - "integrity": "sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA==", - "license": "MIT" - }, "node_modules/dir-compare": { "version": "4.2.0", "resolved": "https://registry.npmjs.org/dir-compare/-/dir-compare-4.2.0.tgz", @@ -10892,6 +10927,7 @@ "version": "8.0.0", "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "devOptional": true, "license": "MIT" }, "node_modules/encodeurl": { @@ -12140,6 +12176,7 @@ "version": "2.0.5", "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", + "dev": true, "license": "ISC", "engines": { "node": "6.* || 8.* || >= 10.*" @@ -13245,6 +13282,7 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "devOptional": true, "license": "MIT", "engines": { "node": ">=8" @@ -15996,6 +16034,13 @@ "thenify-all": "^1.0.0" } }, + "node_modules/nan": { + "version": "2.26.2", + "resolved": "https://registry.npmjs.org/nan/-/nan-2.26.2.tgz", + "integrity": "sha512-0tTvBTYkt3tdGw22nrAy50x7gpbGCCFH3AFcyS5WiUu7Eu4vWlri1woE6qHBSfy11vksDqkiwjOnlR7WV8G1Hw==", + "license": "MIT", + "optional": true + }, "node_modules/nanoid": { "version": "3.3.11", "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", @@ -16452,15 +16497,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/p-try": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz", - "integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==", - "license": "MIT", - "engines": { - "node": ">=6" - } - }, "node_modules/package-json-from-dist": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz", @@ -16566,6 +16602,7 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "dev": true, "license": "MIT", "engines": { "node": ">=8" @@ -16747,15 +16784,6 @@ "node": ">=10.4.0" } }, - "node_modules/pngjs": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/pngjs/-/pngjs-5.0.0.tgz", - "integrity": "sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw==", - "license": "MIT", - "engines": { - "node": ">=10.13.0" - } - }, "node_modules/points-on-curve": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/points-on-curve/-/points-on-curve-0.2.0.tgz", @@ -17097,141 +17125,6 @@ "node": ">=6" } }, - "node_modules/qrcode": { - "version": "1.5.4", - "resolved": "https://registry.npmjs.org/qrcode/-/qrcode-1.5.4.tgz", - "integrity": "sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg==", - "license": "MIT", - "dependencies": { - "dijkstrajs": "^1.0.1", - "pngjs": "^5.0.0", - "yargs": "^15.3.1" - }, - "bin": { - "qrcode": "bin/qrcode" - }, - "engines": { - "node": ">=10.13.0" - } - }, - "node_modules/qrcode/node_modules/cliui": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/cliui/-/cliui-6.0.0.tgz", - "integrity": "sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ==", - "license": "ISC", - "dependencies": { - "string-width": "^4.2.0", - "strip-ansi": "^6.0.0", - "wrap-ansi": "^6.2.0" - } - }, - "node_modules/qrcode/node_modules/find-up": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz", - "integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==", - "license": "MIT", - "dependencies": { - "locate-path": "^5.0.0", - "path-exists": "^4.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/qrcode/node_modules/locate-path": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz", - "integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==", - "license": "MIT", - "dependencies": { - "p-locate": "^4.1.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/qrcode/node_modules/p-limit": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz", - "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==", - "license": "MIT", - "dependencies": { - "p-try": "^2.0.0" - }, - "engines": { - "node": ">=6" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/qrcode/node_modules/p-locate": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz", - "integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==", - "license": "MIT", - "dependencies": { - "p-limit": "^2.2.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/qrcode/node_modules/wrap-ansi": { - "version": "6.2.0", - "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz", - "integrity": "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==", - "license": "MIT", - "dependencies": { - "ansi-styles": "^4.0.0", - "string-width": "^4.1.0", - "strip-ansi": "^6.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/qrcode/node_modules/y18n": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/y18n/-/y18n-4.0.3.tgz", - "integrity": "sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==", - "license": "ISC" - }, - "node_modules/qrcode/node_modules/yargs": { - "version": "15.4.1", - "resolved": "https://registry.npmjs.org/yargs/-/yargs-15.4.1.tgz", - "integrity": "sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A==", - "license": "MIT", - "dependencies": { - "cliui": "^6.0.0", - "decamelize": "^1.2.0", - "find-up": "^4.1.0", - "get-caller-file": "^2.0.1", - "require-directory": "^2.1.1", - "require-main-filename": "^2.0.0", - "set-blocking": "^2.0.0", - "string-width": "^4.2.0", - "which-module": "^2.0.0", - "y18n": "^4.0.0", - "yargs-parser": "^18.1.2" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/qrcode/node_modules/yargs-parser": { - "version": "18.1.3", - "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-18.1.3.tgz", - "integrity": "sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==", - "license": "ISC", - "dependencies": { - "camelcase": "^5.0.0", - "decamelize": "^1.2.0" - }, - "engines": { - "node": ">=6" - } - }, "node_modules/qs": { "version": "6.15.1", "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.1.tgz", @@ -18333,6 +18226,7 @@ "version": "2.1.1", "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", + "dev": true, "license": "MIT", "engines": { "node": ">=0.10.0" @@ -18347,12 +18241,6 @@ "node": ">=0.10.0" } }, - "node_modules/require-main-filename": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/require-main-filename/-/require-main-filename-2.0.0.tgz", - "integrity": "sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==", - "license": "ISC" - }, "node_modules/requires-port": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/requires-port/-/requires-port-1.0.0.tgz", @@ -18857,7 +18745,8 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz", "integrity": "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==", - "license": "ISC" + "license": "ISC", + "optional": true }, "node_modules/set-cookie-parser": { "version": "2.7.2", @@ -19739,6 +19628,23 @@ "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", "license": "ISC" }, + "node_modules/ssh2": { + "version": "1.17.0", + "resolved": "https://registry.npmjs.org/ssh2/-/ssh2-1.17.0.tgz", + "integrity": "sha512-wPldCk3asibAjQ/kziWQQt1Wh3PgDFpC0XpwclzKcdT1vql6KeYxf5LIt4nlFkUeR8WuphYMKqUA56X4rjbfgQ==", + "hasInstallScript": true, + "dependencies": { + "asn1": "^0.2.6", + "bcrypt-pbkdf": "^1.0.2" + }, + "engines": { + "node": ">=10.16.0" + }, + "optionalDependencies": { + "cpu-features": "~0.0.10", + "nan": "^2.23.0" + } + }, "node_modules/ssri": { "version": "12.0.0", "resolved": "https://registry.npmjs.org/ssri/-/ssri-12.0.0.tgz", @@ -19804,6 +19710,7 @@ "version": "4.2.3", "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "devOptional": true, "license": "MIT", "dependencies": { "emoji-regex": "^8.0.0", @@ -19848,6 +19755,7 @@ "version": "6.0.1", "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "devOptional": true, "license": "MIT", "dependencies": { "ansi-regex": "^5.0.1" @@ -20581,6 +20489,12 @@ "node": "*" } }, + "node_modules/tweetnacl": { + "version": "0.14.5", + "resolved": "https://registry.npmjs.org/tweetnacl/-/tweetnacl-0.14.5.tgz", + "integrity": "sha512-KXXFFdAbFXY4geFIwoyNK+f5Z1b7swfXABfL7HXCmoIWMKU3dmS26672A4EeQtDzLKy7SXmfBu51JolvEKwtGA==", + "license": "Unlicense" + }, "node_modules/type-check": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", @@ -22702,12 +22616,6 @@ "node": ">= 8" } }, - "node_modules/which-module": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/which-module/-/which-module-2.0.1.tgz", - "integrity": "sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ==", - "license": "ISC" - }, "node_modules/why-is-node-running": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", diff --git a/apps/desktop/package.json b/apps/desktop/package.json index a832f329e..171589b77 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -15,15 +15,17 @@ "dev:vite": "vite --port 5173 --strictPort", "export:browser-mock-ade": "node ./scripts/export-browser-mock-ade-snapshot.mjs", "build": "tsup && vite build", - "dist:win": "npm run validate:win:artifacts && npm run build && electron-builder --win --x64 --publish never && npm run validate:win:release", - "dist:mac": "npm run build && electron-builder --mac --publish never", - "dist:mac:dir": "npm run build && electron-builder --dir --mac --publish never -c.mac.identity=null -c.mac.notarize=false", - "dist:mac:signed": "node ./scripts/require-macos-release-secrets.cjs && npm run build && electron-builder --mac --publish never", + "dist:win": "npm run materialize:runtime-resources && npm run validate:runtime-resources && npm run validate:win:artifacts && npm run build && electron-builder --win --x64 --publish never && npm run validate:win:release", + "dist:mac": "npm run materialize:runtime-resources && npm run validate:runtime-resources && npm run build && electron-builder --mac --publish never", + "dist:mac:dir": "npm run materialize:runtime-resources && npm run validate:runtime-resources && npm run build && electron-builder --dir --mac --publish never -c.mac.identity=null -c.mac.notarize=false", + "dist:mac:signed": "node ./scripts/require-macos-release-secrets.cjs && npm run materialize:runtime-resources && npm run validate:runtime-resources && npm run build && electron-builder --mac --publish never", "prepare:mac:universal": "node ./scripts/prepare-universal-mac-inputs.mjs", - "dist:mac:universal:signed": "node ./scripts/require-macos-release-secrets.cjs && npm run build && electron-builder --mac --universal --publish never", - "dist:mac:universal:signed:zip": "node ./scripts/require-macos-release-secrets.cjs && npm run build && electron-builder --mac zip --universal --publish never", + "dist:mac:universal:signed": "node ./scripts/require-macos-release-secrets.cjs && npm run materialize:runtime-resources && npm run validate:runtime-resources && npm run build && electron-builder --mac --universal --publish never", + "dist:mac:universal:signed:zip": "node ./scripts/require-macos-release-secrets.cjs && npm run materialize:runtime-resources && npm run validate:runtime-resources && npm run build && electron-builder --mac zip --universal --publish never", "notarize:mac:dmg": "node ./scripts/notarize-mac-dmg.mjs", "validate:mac:artifacts": "node ./scripts/validate-mac-artifacts.mjs", + "materialize:runtime-resources": "node ./scripts/materialize-runtime-resources.mjs", + "validate:runtime-resources": "node ./scripts/validate-runtime-resources.mjs", "validate:win:artifacts": "node ./scripts/validate-win-artifacts.mjs --mode=preflight", "validate:win:release": "node ./scripts/validate-win-artifacts.mjs --mode=release", "release:mac:local": "node ./scripts/release-mac-local.mjs", @@ -67,6 +69,7 @@ "@radix-ui/react-tabs": "^1.1.13", "@tanstack/react-virtual": "^3.13.21", "@types/canvas-confetti": "^1.9.0", + "@types/ssh2": "^1.15.5", "@wize-logic/nodejs-rfb": "^4.2.0", "@xterm/addon-fit": "^0.11.0", "@xterm/addon-webgl": "^0.19.0", @@ -90,7 +93,6 @@ "node-pty": "^1.1.0", "onnxruntime-node": "^1.24.3", "path-browserify": "^1.0.1", - "qrcode": "^1.5.4", "react": "^18.3.1", "react-dom": "^18.3.1", "react-markdown": "^10.1.0", @@ -101,6 +103,7 @@ "remark-gfm": "^4.0.1", "shiki": "^4.0.2", "sql.js": "^1.13.0", + "ssh2": "^1.17.0", "tailwind-merge": "^3.4.0", "ws": "^8.19.0", "yaml": "^2.8.2", @@ -119,7 +122,6 @@ "@types/node": "^20.11.30", "@types/node-cron": "^3.0.11", "@types/path-browserify": "^1.0.3", - "@types/qrcode": "^1.5.6", "@types/react": "^18.2.74", "@types/react-dom": "^18.2.24", "@types/sql.js": "^1.4.9", @@ -175,6 +177,18 @@ "from": "../ade-cli/dist/cli.cjs", "to": "ade-cli/cli.cjs" }, + { + "from": "../ade-cli/dist/bootstrap.cjs", + "to": "ade-cli/bootstrap.cjs" + }, + { + "from": "../ade-cli/dist/adeRpcServer.cjs", + "to": "ade-cli/adeRpcServer.cjs" + }, + { + "from": "../ade-cli/dist/tuiClient", + "to": "ade-cli/tuiClient" + }, { "from": "scripts/ade-cli-macos-wrapper.sh", "to": "ade-cli/bin/ade" @@ -190,6 +204,13 @@ { "from": "scripts/ade-cli-install-path.cmd", "to": "ade-cli/install-path.cmd" + }, + { + "from": "resources/runtime", + "to": "runtime", + "filter": [ + "**/*" + ] } ], "afterPack": "./scripts/after-pack-runtime-fixes.cjs", diff --git a/apps/desktop/resources/runtime/.gitkeep b/apps/desktop/resources/runtime/.gitkeep new file mode 100644 index 000000000..8b1378917 --- /dev/null +++ b/apps/desktop/resources/runtime/.gitkeep @@ -0,0 +1 @@ + diff --git a/apps/desktop/scripts/ade-cli-install-path.sh b/apps/desktop/scripts/ade-cli-install-path.sh index 84d159b06..a5a5ab3bc 100755 --- a/apps/desktop/scripts/ade-cli-install-path.sh +++ b/apps/desktop/scripts/ade-cli-install-path.sh @@ -12,8 +12,19 @@ while [ -L "$SOURCE" ]; do done SCRIPT_DIR=$(CDPATH= cd -P -- "$(dirname -- "$SOURCE")" && pwd) -ADE_BIN=${ADE_BIN:-"$SCRIPT_DIR/bin/ade"} -TARGET_PATH=${1:-"$HOME/.local/bin/ade"} + +CLI_NAME=${ADE_CLI_INSTALL_NAME:-} +if [ -z "$CLI_NAME" ] && [ -f "$SCRIPT_DIR/channel" ]; then + CHANNEL=$(tr -d '[:space:]' < "$SCRIPT_DIR/channel") + case "$CHANNEL" in + alpha) CLI_NAME=ade-alpha ;; + beta) CLI_NAME=ade-beta ;; + esac +fi +CLI_NAME=${CLI_NAME:-ade} + +ADE_BIN=${ADE_BIN:-"$SCRIPT_DIR/bin/$CLI_NAME"} +TARGET_PATH=${1:-"$HOME/.local/bin/$CLI_NAME"} TARGET_DIR=$(dirname -- "$TARGET_PATH") if [ ! -x "$ADE_BIN" ]; then @@ -24,5 +35,5 @@ fi mkdir -p "$TARGET_DIR" ln -sf "$ADE_BIN" "$TARGET_PATH" -echo "Installed ade -> $ADE_BIN" -echo "Ensure $TARGET_DIR is on PATH, then run: ade doctor" +echo "Installed $CLI_NAME -> $ADE_BIN" +echo "Ensure $TARGET_DIR is on PATH, then run: $CLI_NAME doctor" diff --git a/apps/desktop/scripts/ade-cli-macos-wrapper.sh b/apps/desktop/scripts/ade-cli-macos-wrapper.sh index 279057e63..276b36ec2 100755 --- a/apps/desktop/scripts/ade-cli-macos-wrapper.sh +++ b/apps/desktop/scripts/ade-cli-macos-wrapper.sh @@ -13,6 +13,26 @@ done SCRIPT_DIR=$(CDPATH= cd -P -- "$(dirname -- "$SOURCE")" && pwd) CLI_JS=${ADE_CLI_JS:-"$SCRIPT_DIR/../cli.cjs"} +CLI_NAME=$(basename -- "$SOURCE") +CHANNEL=${ADE_PACKAGE_CHANNEL:-} +if [ -z "$CHANNEL" ] && [ -f "$SCRIPT_DIR/../channel" ]; then + CHANNEL=$(tr -d '[:space:]' < "$SCRIPT_DIR/../channel") +fi + +case "$CLI_NAME:$CHANNEL" in + ade-alpha:*|ade:alpha) + export ADE_PACKAGE_CHANNEL=${ADE_PACKAGE_CHANNEL:-alpha} + export ADE_HOME=${ADE_HOME:-"$HOME/.ade-alpha"} + export ADE_DESKTOP_APP_NAME=${ADE_DESKTOP_APP_NAME:-"ADE Alpha"} + export ADE_DISABLE_RUNTIME_SERVICE_INSTALL=${ADE_DISABLE_RUNTIME_SERVICE_INSTALL:-1} + ;; + ade-beta:*|ade:beta) + export ADE_PACKAGE_CHANNEL=${ADE_PACKAGE_CHANNEL:-beta} + export ADE_HOME=${ADE_HOME:-"$HOME/.ade-beta"} + export ADE_DESKTOP_APP_NAME=${ADE_DESKTOP_APP_NAME:-"ADE Beta"} + export ADE_DISABLE_RUNTIME_SERVICE_INSTALL=${ADE_DISABLE_RUNTIME_SERVICE_INSTALL:-1} + ;; +esac if [ -n "${ADE_CLI_NODE:-}" ]; then exec "$ADE_CLI_NODE" "$CLI_JS" "$@" diff --git a/apps/desktop/scripts/after-pack-runtime-fixes.cjs b/apps/desktop/scripts/after-pack-runtime-fixes.cjs index 81adb715a..b3136c41e 100644 --- a/apps/desktop/scripts/after-pack-runtime-fixes.cjs +++ b/apps/desktop/scripts/after-pack-runtime-fixes.cjs @@ -33,6 +33,47 @@ function requireFile(filePath, label) { } } +function normalizePackageChannel(value) { + const normalized = String(value || "").trim().toLowerCase(); + return normalized === "alpha" || normalized === "beta" ? normalized : null; +} + +function resolvePackageChannel(context) { + const explicit = normalizePackageChannel(process.env.ADE_PACKAGE_CHANNEL); + if (explicit) return explicit; + const appInfo = context?.packager?.appInfo; + const candidates = [ + appInfo?.productName, + appInfo?.productFilename, + appInfo?.id, + ]; + for (const candidate of candidates) { + const text = String(candidate || "").toLowerCase(); + if (text.includes("alpha")) return "alpha"; + if (text.includes("beta")) return "beta"; + } + return null; +} + +function channelCliName(channel) { + if (channel === "alpha") return "ade-alpha"; + if (channel === "beta") return "ade-beta"; + return "ade"; +} + +function materializeChannelCliWrapper(resourcesRoot, channel) { + if (!channel) return null; + const cliRoot = path.join(resourcesRoot, "ade-cli"); + const binRoot = path.join(cliRoot, "bin"); + const sourcePath = path.join(binRoot, "ade"); + const targetPath = path.join(binRoot, channelCliName(channel)); + requireFile(sourcePath, "bundled ADE CLI wrapper"); + fs.copyFileSync(sourcePath, targetPath); + fs.chmodSync(targetPath, 0o755); + fs.writeFileSync(path.join(cliRoot, "channel"), `${channel}\n`); + return targetPath; +} + function removeIfPresent(rootPath, relativePath) { const targetPath = path.join(rootPath, relativePath); if (!fs.existsSync(targetPath)) return false; @@ -111,6 +152,7 @@ function pruneUnneededRuntimePayload(runtimeRoot, platform) { module.exports = async function afterPack(context) { const platform = context?.electronPlatformName; + const packageChannel = resolvePackageChannel(context); const { runtimeRoot, appBundlePath } = resolveUnpackedRuntimeRoot(context); if (!fs.existsSync(runtimeRoot)) { throw new Error(`[afterPack] Missing unpacked runtime payload: ${runtimeRoot}`); @@ -118,7 +160,13 @@ module.exports = async function afterPack(context) { const resourcesRoot = resolveExtraResourcesRoot(context, appBundlePath); const bundledCliPath = path.join(resourcesRoot, "ade-cli", "cli.cjs"); + const bundledCliBootstrapPath = path.join(resourcesRoot, "ade-cli", "bootstrap.cjs"); + const bundledCliRpcPath = path.join(resourcesRoot, "ade-cli", "adeRpcServer.cjs"); + const bundledCliTuiPath = path.join(resourcesRoot, "ade-cli", "tuiClient", "cli.mjs"); requireFile(bundledCliPath, "bundled ADE CLI entry"); + requireFile(bundledCliBootstrapPath, "bundled ADE CLI bootstrap entry"); + requireFile(bundledCliRpcPath, "bundled ADE CLI RPC entry"); + requireFile(bundledCliTuiPath, "bundled ADE CLI TUI entry"); if (platform === "darwin") { const bundledCliBinPath = path.join(resourcesRoot, "ade-cli", "bin", "ade"); @@ -133,6 +181,10 @@ module.exports = async function afterPack(context) { fs.chmodSync(bundledCliBinPath, 0o755); fs.chmodSync(bundledCliInstallerPath, 0o755); fs.chmodSync(iosSimHelperBuildScript, 0o755); + const channelWrapperPath = materializeChannelCliWrapper(resourcesRoot, packageChannel); + if (channelWrapperPath) { + console.log(`[afterPack] Added channel CLI wrapper: ${path.basename(channelWrapperPath)}`); + } } else if (platform === "win32") { requireFile(path.join(resourcesRoot, "ade-cli", "bin", "ade.cmd"), "bundled ADE CLI Windows wrapper"); requireFile(path.join(resourcesRoot, "ade-cli", "install-path.cmd"), "bundled ADE CLI Windows PATH installer"); diff --git a/apps/desktop/scripts/materialize-runtime-resources.mjs b/apps/desktop/scripts/materialize-runtime-resources.mjs new file mode 100644 index 000000000..e7f1effbf --- /dev/null +++ b/apps/desktop/scripts/materialize-runtime-resources.mjs @@ -0,0 +1,349 @@ +import { execFile } from "node:child_process"; +import { createWriteStream } from "node:fs"; +import fs from "node:fs/promises"; +import https from "node:https"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { promisify } from "node:util"; + +const execFileAsync = promisify(execFile); + +const scriptDir = path.dirname(fileURLToPath(import.meta.url)); +const desktopRoot = path.resolve(scriptDir, ".."); +const repoRoot = path.resolve(desktopRoot, "..", ".."); +const cliRoot = path.join(repoRoot, "apps", "ade-cli"); +const runtimeRoot = path.join(desktopRoot, "resources", "runtime"); +const cliDistStaticRoot = path.join(cliRoot, "dist-static"); +const targets = ["darwin-arm64", "darwin-x64", "linux-arm64", "linux-x64"]; +const seaFuse = "NODE_SEA_FUSE_fce680ab2cc467b6e072b8b5df1996b2"; +const allowHostOnlyRuntimeResources = process.env.ADE_RUNTIME_RESOURCES_ALLOW_HOST_ONLY === "1"; + +function currentTarget() { + const platform = process.platform === "darwin" ? "darwin" : process.platform === "linux" ? "linux" : process.platform; + const arch = process.arch === "x64" ? "x64" : process.arch === "arm64" ? "arm64" : process.arch; + return `${platform}-${arch}`; +} + +function npmCommand() { + return process.platform === "win32" ? "npm.cmd" : "npm"; +} + +function artifactNamesForTarget(target) { + return [`ade-${target}`, `ade-${target}.native.tar.gz`]; +} + +function isRuntimeBinaryName(name) { + return targets.some((target) => name === `ade-${target}`); +} + +function isRuntimeArtifactName(name) { + return targets.some((target) => artifactNamesForTarget(target).includes(name)); +} + +function uniquePaths(paths) { + const seen = new Set(); + const result = []; + for (const entry of paths) { + if (!entry) continue; + const resolved = path.resolve(entry); + if (seen.has(resolved)) continue; + seen.add(resolved); + result.push(resolved); + } + return result; +} + +async function pathExists(targetPath) { + try { + await fs.access(targetPath); + return true; + } catch { + return false; + } +} + +async function isSeaCapableNodeBinary(binaryPath) { + try { + const contents = await fs.readFile(binaryPath); + return contents.includes(Buffer.from(seaFuse)); + } catch { + return false; + } +} + +function nodeArchiveCandidates(version, target) { + return [ + { + name: `node-${version}-${target}.tar.gz`, + tarFlag: "-xzf", + }, + { + name: `node-${version}-${target}.tar.xz`, + tarFlag: "-xJf", + }, + ]; +} + +async function downloadFile(url, destinationPath) { + await fs.mkdir(path.dirname(destinationPath), { recursive: true }); + await new Promise((resolve, reject) => { + const request = https.get(url, (response) => { + if ( + response.statusCode && + response.statusCode >= 300 && + response.statusCode < 400 && + response.headers.location + ) { + response.resume(); + downloadFile(new URL(response.headers.location, url).toString(), destinationPath).then(resolve, reject); + return; + } + if (response.statusCode !== 200) { + response.resume(); + reject(new Error(`HTTP ${response.statusCode ?? "unknown"}`)); + return; + } + const output = createWriteStream(destinationPath, { mode: 0o644 }); + response.pipe(output); + output.once("finish", () => { + output.close(resolve); + }); + output.once("error", reject); + }); + request.once("error", reject); + }); +} + +async function downloadOfficialNodeBinary(target) { + const version = (process.env.ADE_STATIC_NODE_VERSION?.trim() || process.version).replace(/^([^v])/, "v$1"); + const cacheRoot = path.resolve( + process.env.ADE_STATIC_NODE_CACHE_DIR?.trim() || path.join(desktopRoot, ".cache", "runtime-node") + ); + const extractRoot = path.join(cacheRoot, version, target); + const binaryPath = path.join(extractRoot, `node-${version}-${target}`, "bin", "node"); + if (await isSeaCapableNodeBinary(binaryPath)) { + return binaryPath; + } + + await fs.rm(extractRoot, { recursive: true, force: true }); + await fs.mkdir(extractRoot, { recursive: true }); + + let lastError = null; + for (const candidate of nodeArchiveCandidates(version, target)) { + const archivePath = path.join(cacheRoot, version, candidate.name); + const url = `https://nodejs.org/dist/${version}/${candidate.name}`; + try { + console.log(`[runtime-resources] Downloading official Node ${version} for ${target}: ${url}`); + await downloadFile(url, archivePath); + await execFileAsync("tar", [candidate.tarFlag, archivePath, "-C", extractRoot], { + cwd: desktopRoot, + maxBuffer: 20 * 1024 * 1024, + }); + if (await isSeaCapableNodeBinary(binaryPath)) { + await fs.chmod(binaryPath, 0o755); + return binaryPath; + } + throw new Error(`Downloaded Node binary is not SEA-capable: ${binaryPath}`); + } catch (error) { + lastError = error instanceof Error ? error : new Error(String(error)); + await fs.rm(extractRoot, { recursive: true, force: true }); + await fs.mkdir(extractRoot, { recursive: true }); + } + } + + throw lastError ?? new Error(`Unable to download official Node ${version} for ${target}.`); +} + +async function resolveSeaNodeBinaryForHostBuild(target) { + const explicit = process.env.ADE_STATIC_NODE_BINARY?.trim(); + if (explicit) { + if (await isSeaCapableNodeBinary(explicit)) return explicit; + throw new Error(`ADE_STATIC_NODE_BINARY is not SEA-capable: ${explicit}`); + } + if (await isSeaCapableNodeBinary(process.execPath)) return null; + if (process.env.ADE_RUNTIME_DISABLE_NODE_DOWNLOAD === "1") { + throw new Error( + "This local source build needs to create ADE's bundled runtime helper, but this machine's Node install " + + `cannot build it (${process.execPath}) and automatic helper-tool download is disabled. ` + + "This does not affect people downloading ADE releases; release downloads already include the helper." + ); + } + return downloadOfficialNodeBinary(target); +} + +async function walkFiles(rootPath, files = []) { + let entries; + try { + entries = await fs.readdir(rootPath, { withFileTypes: true }); + } catch (error) { + if (error?.code === "ENOENT") return files; + throw error; + } + + for (const entry of entries) { + const entryPath = path.join(rootPath, entry.name); + if (entry.isDirectory()) { + await walkFiles(entryPath, files); + } else if (entry.isFile()) { + files.push(entryPath); + } + } + + return files; +} + +async function collectArtifacts(rootPath) { + const files = await walkFiles(rootPath); + const matches = new Map(); + for (const filePath of files.sort((a, b) => a.localeCompare(b))) { + const name = path.basename(filePath); + if (!isRuntimeArtifactName(name) || matches.has(name)) continue; + matches.set(name, filePath); + } + return matches; +} + +async function copyArtifactsFrom(rootPath) { + if (!(await pathExists(rootPath))) { + console.log(`[runtime-resources] Artifact source missing, skipping: ${rootPath}`); + return 0; + } + + const artifacts = await collectArtifacts(rootPath); + let copied = 0; + await fs.mkdir(runtimeRoot, { recursive: true }); + + for (const [name, sourcePath] of artifacts) { + const destinationPath = path.join(runtimeRoot, name); + if (path.resolve(sourcePath) !== path.resolve(destinationPath)) { + await fs.copyFile(sourcePath, destinationPath); + copied += 1; + } + if (isRuntimeBinaryName(name)) { + await fs.chmod(destinationPath, 0o755); + } + } + + if (artifacts.size > 0) { + console.log(`[runtime-resources] Materialized ${artifacts.size} artifact(s) from ${rootPath}.`); + } + return copied; +} + +async function missingArtifactsForTarget(target) { + const missing = []; + for (const name of artifactNamesForTarget(target)) { + const artifactPath = path.join(runtimeRoot, name); + if (!(await pathExists(artifactPath))) { + missing.push(name); + } + } + return missing; +} + +async function missingArtifacts() { + const missing = []; + for (const target of targets) { + missing.push(...await missingArtifactsForTarget(target)); + } + return missing; +} + +async function buildHostArtifactsIfNeeded() { + const target = currentTarget(); + if (!targets.includes(target)) return false; + + const missingHostArtifacts = await missingArtifactsForTarget(target); + if (missingHostArtifacts.length === 0) return false; + + console.log( + `[runtime-resources] Missing host runtime artifact(s) for ${target}; building ${missingHostArtifacts.join(", ")}.` + ); + const env = { ...process.env }; + const seaNodeBinary = await resolveSeaNodeBinaryForHostBuild(target); + if (seaNodeBinary) { + env.ADE_STATIC_NODE_BINARY = seaNodeBinary; + console.log(`[runtime-resources] Using SEA-capable Node binary for ${target}: ${seaNodeBinary}`); + } + let stdout = ""; + let stderr = ""; + try { + const result = await execFileAsync(npmCommand(), [ + "--prefix", + cliRoot, + "run", + "build:static", + "--", + "--target", + target, + "--out-dir", + runtimeRoot, + ], { + cwd: desktopRoot, + env, + maxBuffer: 100 * 1024 * 1024, + }); + stdout = result.stdout; + stderr = result.stderr; + } catch (error) { + stdout = typeof error?.stdout === "string" ? error.stdout : ""; + stderr = typeof error?.stderr === "string" ? error.stderr : ""; + if (stdout) process.stdout.write(stdout); + if (stderr) process.stderr.write(stderr); + const message = error instanceof Error ? error.message : String(error); + throw new Error( + `[runtime-resources] Failed to build host runtime artifacts for ${target}. ` + + "This only affects local source builds; ADE release downloads already include these files. " + + "Provide prebuilt runtime artifacts, or allow the script to download the official build helper. " + + `Original error: ${message}` + ); + } + if (stdout) process.stdout.write(stdout); + if (stderr) process.stderr.write(stderr); + return true; +} + +function formatMissing(missing) { + return missing.map((name) => ` - ${name}`).join("\n"); +} + +async function main() { + await fs.mkdir(runtimeRoot, { recursive: true }); + const artifactRoots = uniquePaths([ + process.env.ADE_RUNTIME_ARTIFACTS_DIR?.trim() || null, + cliDistStaticRoot, + ]); + + for (const artifactRoot of artifactRoots) { + await copyArtifactsFrom(artifactRoot); + } + + await buildHostArtifactsIfNeeded(); + + const missing = await missingArtifacts(); + if (missing.length > 0) { + if (allowHostOnlyRuntimeResources) { + console.warn( + "[runtime-resources] Host-only local package mode is enabled; missing remote runtime artifact(s):\n" + + `${formatMissing(missing)}\n` + + "\nRemote runtime bootstrap to those targets will be unavailable in this local package. " + + "Release builds still require the full runtime artifact set." + ); + return; + } + throw new Error( + "[runtime-resources] Missing remote ADE runtime artifact(s):\n" + + `${formatMissing(missing)}\n` + + "\nThis only affects local source builds; ADE release downloads already include these files. " + + "For local packaging, point ADE_RUNTIME_ARTIFACTS_DIR at the CI runtime artifacts, or run the " + + "runtime-binary workflow and download the `ade-runtime-*` artifacts before packaging." + ); + } + + console.log(`[runtime-resources] Materialized runtime resources for ${targets.length} target(s).`); +} + +main().catch((error) => { + console.error(error instanceof Error ? error.message : String(error)); + process.exit(1); +}); diff --git a/apps/desktop/scripts/set-ci-version.mjs b/apps/desktop/scripts/set-ci-version.mjs index 1ed27d460..f5781359f 100644 --- a/apps/desktop/scripts/set-ci-version.mjs +++ b/apps/desktop/scripts/set-ci-version.mjs @@ -4,7 +4,11 @@ import { fileURLToPath } from "node:url"; const scriptDir = path.dirname(fileURLToPath(import.meta.url)); const appDir = path.resolve(scriptDir, ".."); -const packageJsonPath = path.join(appDir, "package.json"); +const repoRoot = path.resolve(appDir, "..", ".."); +const packageJsonPaths = [ + path.join(appDir, "package.json"), + path.join(repoRoot, "apps", "ade-cli", "package.json"), +]; const buildNumberRaw = process.env.ADE_BUILD_NUMBER ?? process.env.GITHUB_RUN_NUMBER; if (!buildNumberRaw) { @@ -16,10 +20,13 @@ if (!Number.isFinite(buildNumber) || buildNumber <= 0) { throw new Error(`Invalid build number: ${buildNumberRaw}`); } -const packageJson = JSON.parse(await readFile(packageJsonPath, "utf8")); +const packageJson = JSON.parse(await readFile(packageJsonPaths[0], "utf8")); const [major = "1", minor = "0"] = String(packageJson.version ?? "1.0.0").split("."); packageJson.version = `${major}.${minor}.${buildNumber}`; -await writeFile(packageJsonPath, `${JSON.stringify(packageJson, null, 2)}\n`); +for (const packageJsonPath of packageJsonPaths) { + const nextPackageJson = JSON.parse(await readFile(packageJsonPath, "utf8")); + nextPackageJson.version = packageJson.version; + await writeFile(packageJsonPath, `${JSON.stringify(nextPackageJson, null, 2)}\n`); +} process.stdout.write(`${packageJson.version}\n`); - diff --git a/apps/desktop/scripts/set-release-version.mjs b/apps/desktop/scripts/set-release-version.mjs index 53fa10d3d..5a9b2e6a8 100644 --- a/apps/desktop/scripts/set-release-version.mjs +++ b/apps/desktop/scripts/set-release-version.mjs @@ -4,7 +4,11 @@ import { fileURLToPath } from "node:url"; const scriptDir = path.dirname(fileURLToPath(import.meta.url)); const appDir = path.resolve(scriptDir, ".."); -const packageJsonPath = path.join(appDir, "package.json"); +const repoRoot = path.resolve(appDir, "..", ".."); +const packageJsonPaths = [ + path.join(appDir, "package.json"), + path.join(repoRoot, "apps", "ade-cli", "package.json"), +]; const releaseTag = (process.env.ADE_RELEASE_TAG ?? process.env.GITHUB_REF_NAME ?? "").trim(); if (!releaseTag) { @@ -20,8 +24,11 @@ if (!/^\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)*$/.test(version)) { throw new Error(`Release tag must contain a semver-compatible version, received: ${releaseTag}`); } -const packageJson = JSON.parse(await readFile(packageJsonPath, "utf8")); -packageJson.version = version; +for (const packageJsonPath of packageJsonPaths) { + const packageJson = JSON.parse(await readFile(packageJsonPath, "utf8")); + packageJson.version = version; -await writeFile(packageJsonPath, `${JSON.stringify(packageJson, null, 2)}\n`); -process.stdout.write(`${packageJson.version}\n`); + await writeFile(packageJsonPath, `${JSON.stringify(packageJson, null, 2)}\n`); +} + +process.stdout.write(`${version}\n`); diff --git a/apps/desktop/scripts/validate-mac-artifacts.mjs b/apps/desktop/scripts/validate-mac-artifacts.mjs index b26588fb2..c5361b6df 100644 --- a/apps/desktop/scripts/validate-mac-artifacts.mjs +++ b/apps/desktop/scripts/validate-mac-artifacts.mjs @@ -14,6 +14,7 @@ const appDir = path.resolve(scriptDir, ".."); const releaseDir = path.join(appDir, "release"); const DEFAULT_MAX_APP_ASAR_BYTES = 900 * 1024 * 1024; const DEFAULT_MAX_UNPACKED_BYTES = 600 * 1024 * 1024; +const REMOTE_RUNTIME_TARGETS = ["darwin-arm64", "darwin-x64", "linux-arm64", "linux-x64"]; function readFlag(name) { const prefix = `${name}=`; @@ -151,6 +152,22 @@ async function assertExecutable(targetPath, description) { } } +async function assertRemoteRuntimeBundle(resourcesPath, description) { + const runtimeRoot = path.join(resourcesPath, "runtime"); + await assertPathExists(runtimeRoot, `remote runtime bundle directory for ${description}`); + for (const target of REMOTE_RUNTIME_TARGETS) { + const binaryPath = path.join(runtimeRoot, `ade-${target}`); + const nativeArchivePath = path.join(runtimeRoot, `ade-${target}.native.tar.gz`); + await assertPathExists(binaryPath, `remote runtime binary ${target} for ${description}`); + await assertExecutable(binaryPath, `remote runtime binary ${target}`); + await assertPathExists(nativeArchivePath, `remote runtime native dependency archive ${target} for ${description}`); + const { stdout } = await execFileAsync("tar", ["-tzf", nativeArchivePath]); + if (!stdout.split(/\r?\n/).some((entry) => entry.startsWith("./node_modules/"))) { + throw new Error(`[release:mac] Remote runtime native archive for ${target} does not contain ./node_modules/: ${nativeArchivePath}`); + } + } +} + async function findFirstNodeAddon(rootPath) { const entries = await fs.readdir(rootPath, { withFileTypes: true }); @@ -300,6 +317,9 @@ async function validatePackagedRuntime(appPath, description) { const appAsarPath = path.join(resourcesPath, "app.asar"); const unpackedPath = await resolveRuntimeUnpackedPath(resourcesPath); const adeCliPath = path.join(resourcesPath, "ade-cli", "cli.cjs"); + const adeCliBootstrapPath = path.join(resourcesPath, "ade-cli", "bootstrap.cjs"); + const adeCliRpcPath = path.join(resourcesPath, "ade-cli", "adeRpcServer.cjs"); + const adeCliTuiPath = path.join(resourcesPath, "ade-cli", "tuiClient", "cli.mjs"); const adeCliBinPath = path.join(resourcesPath, "ade-cli", "bin", "ade"); const adeCliInstallerPath = path.join(resourcesPath, "ade-cli", "install-path.sh"); const iosSimHelperRoot = path.join(resourcesPath, "native", "ios-sim-helpers"); @@ -313,6 +333,9 @@ async function validatePackagedRuntime(appPath, description) { await assertPathExists(appAsarPath, "app.asar payload"); await assertPathExists(unpackedPath, "unpacked runtime payload"); await assertPathExists(adeCliPath, "bundled ADE CLI entry"); + await assertPathExists(adeCliBootstrapPath, "bundled ADE CLI bootstrap entry"); + await assertPathExists(adeCliRpcPath, "bundled ADE CLI RPC entry"); + await assertPathExists(adeCliTuiPath, "bundled ADE CLI TUI entry"); await assertPathExists(adeCliBinPath, "bundled ADE CLI wrapper"); await assertPathExists(adeCliInstallerPath, "bundled ADE CLI PATH installer"); await assertPathExists(iosSimHelperBuildScript, "bundled iOS simulator helper build script"); @@ -324,6 +347,7 @@ async function validatePackagedRuntime(appPath, description) { await assertExecutable(iosSimHelperBuildScript, "bundled iOS simulator helper build script"); await assertPathExists(nodePtyModulePath, "unpacked node-pty module"); await assertPathExists(smokeScriptPath, "unpacked packaged runtime smoke script"); + await assertRemoteRuntimeBundle(resourcesPath, description); await validatePackageHygiene(appPath, description); const nodePtyAddon = await findNodePtyAddon(nodePtyModulePath); diff --git a/apps/desktop/scripts/validate-runtime-resources.mjs b/apps/desktop/scripts/validate-runtime-resources.mjs new file mode 100644 index 000000000..de783c309 --- /dev/null +++ b/apps/desktop/scripts/validate-runtime-resources.mjs @@ -0,0 +1,73 @@ +import fs from "node:fs/promises"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +const scriptDir = path.dirname(fileURLToPath(import.meta.url)); +const desktopRoot = path.resolve(scriptDir, ".."); +const runtimeRoot = path.join(desktopRoot, "resources", "runtime"); +const allTargets = ["darwin-arm64", "darwin-x64", "linux-arm64", "linux-x64"]; + +function currentTarget() { + const platform = process.platform === "darwin" ? "darwin" : process.platform === "linux" ? "linux" : process.platform; + const arch = process.arch === "x64" ? "x64" : process.arch === "arm64" ? "arm64" : process.arch; + return `${platform}-${arch}`; +} + +const targets = process.env.ADE_RUNTIME_RESOURCES_ALLOW_HOST_ONLY === "1" + ? [currentTarget()] + : allTargets; + +function fail(message) { + throw new Error(`[runtime-resources] ${message}`); +} + +async function statFile(filePath, label) { + let stat; + try { + stat = await fs.stat(filePath); + } catch { + fail(`Missing ${label}: ${filePath}`); + } + if (!stat.isFile()) { + fail(`Expected ${label} to be a file: ${filePath}`); + } + if (stat.size <= 0) { + fail(`Expected ${label} to be non-empty: ${filePath}`); + } + return stat; +} + +async function validateExecutable(filePath, label) { + const stat = await statFile(filePath, label); + if (process.platform !== "win32" && (stat.mode & 0o111) === 0) { + fail(`Expected ${label} to be executable: ${filePath}`); + } +} + +async function main() { + for (const target of targets) { + await validateExecutable(path.join(runtimeRoot, `ade-${target}`), `remote ADE service binary ${target}`); + await statFile( + path.join(runtimeRoot, `ade-${target}.native.tar.gz`), + `remote ADE service native dependency archive ${target}`, + ); + } + + const mode = process.env.ADE_RUNTIME_RESOURCES_ALLOW_HOST_ONLY === "1" ? "host-only local" : "full"; + console.log(`[runtime-resources] Found ${targets.length} ${mode} ADE service binaries and native archives.`); +} + +main().catch((error) => { + const message = error instanceof Error ? error.message : String(error); + console.error(message); + console.error( + "[runtime-resources] Populate apps/desktop/resources/runtime with every " + + "`ade-{darwin,linux}-{arm64,x64}` binary and matching `.native.tar.gz` archive. " + + "Run `npm --prefix apps/desktop run materialize:runtime-resources` to copy downloaded artifacts " + + "or build the local host target. For a direct local same-platform build, run " + + "`npm --prefix apps/ade-cli run build:static -- --target --out-dir ../desktop/resources/runtime`; " + + "release CI uses the artifact download step. Local channel packages may set " + + "ADE_RUNTIME_RESOURCES_ALLOW_HOST_ONLY=1 to validate only the host target.", + ); + process.exit(1); +}); diff --git a/apps/desktop/scripts/validate-win-artifacts.mjs b/apps/desktop/scripts/validate-win-artifacts.mjs index b8864e4a7..96450589d 100644 --- a/apps/desktop/scripts/validate-win-artifacts.mjs +++ b/apps/desktop/scripts/validate-win-artifacts.mjs @@ -14,6 +14,7 @@ const pkg = JSON.parse(fs.readFileSync(packageJsonPath, "utf8")); const productName = pkg.build?.productName ?? pkg.productName ?? "ADE"; const DEFAULT_MAX_APP_ASAR_BYTES = 900 * 1024 * 1024; const DEFAULT_MAX_UNPACKED_BYTES = 600 * 1024 * 1024; +const REMOTE_RUNTIME_TARGETS = ["darwin-arm64", "darwin-x64", "linux-arm64", "linux-x64"]; function readFlag(name) { const prefix = `${name}=`; @@ -120,6 +121,16 @@ async function assertPathMissing(targetPath, description) { fail(`Unexpected ${description}: ${targetPath}`); } +async function assertExecutable(targetPath, description) { + if (process.platform === "win32") { + return; + } + const stat = await fsp.stat(targetPath); + if ((stat.mode & 0o111) !== 0o111) { + fail(`Expected ${description} to be executable: ${targetPath}`); + } +} + function requireFile(relativePath, label) { const absolutePath = path.join(desktopRoot, relativePath); if (!fs.existsSync(absolutePath)) { @@ -164,6 +175,15 @@ function validatePreflight() { if (!hasExtraResource("ade-cli/bin/ade.cmd")) { fail("package.json build.extraResources must ship ade-cli/bin/ade.cmd"); } + if (!hasExtraResource("ade-cli/bootstrap.cjs")) { + fail("package.json build.extraResources must ship ade-cli/bootstrap.cjs"); + } + if (!hasExtraResource("ade-cli/adeRpcServer.cjs")) { + fail("package.json build.extraResources must ship ade-cli/adeRpcServer.cjs"); + } + if (!hasExtraResource("ade-cli/tuiClient")) { + fail("package.json build.extraResources must ship ade-cli/tuiClient"); + } if (!hasExtraResource("ade-cli/install-path.cmd")) { fail("package.json build.extraResources must ship ade-cli/install-path.cmd"); } @@ -312,6 +332,22 @@ function runCommand(command, args, options = {}) { }); } +async function assertRemoteRuntimeBundle(resourcesPath) { + const runtimeRoot = path.join(resourcesPath, "runtime"); + await assertPathExists(runtimeRoot, "remote runtime bundle directory"); + for (const target of REMOTE_RUNTIME_TARGETS) { + const binaryPath = path.join(runtimeRoot, `ade-${target}`); + const nativeArchivePath = path.join(runtimeRoot, `ade-${target}.native.tar.gz`); + await assertPathExists(binaryPath, `remote runtime binary ${target}`); + await assertExecutable(binaryPath, `remote runtime binary ${target}`); + await assertPathExists(nativeArchivePath, `remote runtime native dependency archive ${target}`); + const { stdout } = await runCommand("tar", ["-tzf", nativeArchivePath]); + if (!stdout.split(/\r?\n/).some((entry) => entry.startsWith("./node_modules/"))) { + fail(`Remote runtime native archive for ${target} does not contain ./node_modules/: ${nativeArchivePath}`); + } + } +} + async function findFirstNodeAddon(rootPath) { const entries = await fsp.readdir(rootPath, { withFileTypes: true }); @@ -415,6 +451,9 @@ async function validatePackagedRuntime(appDir) { const appAsarPath = path.join(resourcesPath, "app.asar"); const unpackedPath = path.join(resourcesPath, "app.asar.unpacked"); const adeCliPath = path.join(resourcesPath, "ade-cli", "cli.cjs"); + const adeCliBootstrapPath = path.join(resourcesPath, "ade-cli", "bootstrap.cjs"); + const adeCliRpcPath = path.join(resourcesPath, "ade-cli", "adeRpcServer.cjs"); + const adeCliTuiPath = path.join(resourcesPath, "ade-cli", "tuiClient", "cli.mjs"); const adeCliBinPath = path.join(resourcesPath, "ade-cli", "bin", "ade.cmd"); const adeCliInstallerPath = path.join(resourcesPath, "ade-cli", "install-path.cmd"); const nodeModulesPath = path.join(unpackedPath, "node_modules"); @@ -438,6 +477,9 @@ async function validatePackagedRuntime(appDir) { await assertPathExists(appAsarPath, "app.asar payload"); await assertPathExists(unpackedPath, "app.asar.unpacked runtime payload"); await assertPathExists(adeCliPath, "bundled ADE CLI entry"); + await assertPathExists(adeCliBootstrapPath, "bundled ADE CLI bootstrap entry"); + await assertPathExists(adeCliRpcPath, "bundled ADE CLI RPC entry"); + await assertPathExists(adeCliTuiPath, "bundled ADE CLI TUI entry"); await assertPathExists(adeCliBinPath, "bundled ADE CLI wrapper"); await assertPathExists(adeCliInstallerPath, "bundled ADE CLI PATH installer"); await assertPathExists(nodePtyModulePath, "unpacked node-pty module"); @@ -447,6 +489,7 @@ async function validatePackagedRuntime(appDir) { await assertPathExists(path.join(onnxRuntimeWinPath, "DirectML.dll"), "Windows DirectML DLL"); await assertPathExists(smokeScriptPath, "unpacked packaged runtime smoke script"); await assertPathExists(crsqliteDllPath, "unpacked Windows cr-sqlite extension"); + await assertRemoteRuntimeBundle(resourcesPath); await validatePackageHygiene(resourcesPath); const nodePtyAddon = await findNodePtyAddon(nodePtyModulePath); diff --git a/apps/desktop/src/main/main.ts b/apps/desktop/src/main/main.ts index 7f2afede4..ee474840a 100644 --- a/apps/desktop/src/main/main.ts +++ b/apps/desktop/src/main/main.ts @@ -61,7 +61,16 @@ import { createAdeProjectService } from "./services/projects/adeProjectService"; import { createConfigReloadService } from "./services/projects/configReloadService"; import { IPC } from "../shared/ipc"; import { resolveAdeLayout } from "../shared/adeLayout"; -import type { PortLease, ProjectInfo, SyncMobileProjectSummary, SyncProjectConnectionPayload, SyncProjectSwitchRequestPayload, SyncProjectSwitchResultPayload } from "../shared/types"; +import type { + OpenProjectBinding, + PortLease, + PrEventPayload, + ProjectInfo, + SyncMobileProjectSummary, + SyncProjectConnectionPayload, + SyncProjectSwitchRequestPayload, + SyncProjectSwitchResultPayload, +} from "../shared/types"; import type { AutomationTriggerType } from "../shared/types/config"; import type { AutomationTriggerLinearIssueContext } from "../shared/types/automations"; import type { LinearIngressEventRecord } from "../shared/types/linearSync"; @@ -76,6 +85,7 @@ import { type AdeRuntimePaths, } from "../../../ade-cli/src/bootstrap"; import { startJsonRpcServer, type JsonRpcTransport } from "../../../ade-cli/src/jsonrpc"; +import { resolveMachineAdeLayout } from "../../../ade-cli/src/services/projects/machineLayout"; import { createKeybindingsService } from "./services/keybindings/keybindingsService"; import { createAgentToolsService } from "./services/agentTools/agentToolsService"; import { createAdeCliService } from "./services/cli/adeCliService"; @@ -96,6 +106,7 @@ import { } from "./services/adeActions/registry"; import { createUsageTrackingService } from "./services/usage/usageTrackingService"; import { createBudgetCapService } from "./services/usage/budgetCapService"; +import { markMachineStateMigrationComplete, runMachineStateMigration } from "./services/runtime/machineStateMigration"; import { createRebaseSuggestionService } from "./services/lanes/rebaseSuggestionService"; import { createAutoRebaseService } from "./services/lanes/autoRebaseService"; import { createMissionService } from "./services/missions/missionService"; @@ -136,7 +147,6 @@ import { createLinearCloseoutService } from "./services/cto/linearCloseoutServic import { createLinearDispatcherService } from "./services/cto/linearDispatcherService"; import { createLinearIngressService } from "./services/cto/linearIngressService"; import { createLinearSyncService } from "./services/cto/linearSyncService"; -import { createOpenclawBridgeService } from "./services/cto/openclawBridgeService"; import { createOrchestratorService } from "./services/orchestrator/orchestratorService"; import { createAiOrchestratorService } from "./services/orchestrator/aiOrchestratorService"; import { createMissionBudgetService } from "./services/orchestrator/missionBudgetService"; @@ -147,6 +157,7 @@ import { createAppControlService } from "./services/appControl/appControlService import { createBuiltInBrowserService } from "./services/builtInBrowser/builtInBrowserService"; import { createMacosVmService } from "./services/macosVm/macosVmService"; import { configureBuiltInBrowserWebAuthn } from "./services/builtInBrowser/builtInBrowserWebAuthn"; +import { LocalRuntimeConnectionPool } from "./services/localRuntime/localRuntimeConnectionPool"; import { createSyncService } from "./services/sync/syncService"; import { ApnsService, ApnsKeyStore } from "./services/notifications/apnsService"; import { @@ -162,6 +173,49 @@ import type { Logger } from "./services/logging/logger"; const AUTO_UPDATER_CACHE_DIR_NAME = "ade-desktop-updater"; const ADE_BROWSER_WEBVIEW_PARTITION = "persist:ade-browser"; +type AdePackageChannel = "alpha" | "beta"; + +function normalizeAdePackageChannel(value: unknown): AdePackageChannel | null { + const normalized = typeof value === "string" ? value.trim().toLowerCase() : ""; + return normalized === "alpha" || normalized === "beta" ? normalized : null; +} + +function readBundledAdePackageChannel(): AdePackageChannel | null { + const envChannel = normalizeAdePackageChannel(process.env.ADE_PACKAGE_CHANNEL); + if (envChannel) return envChannel; + + try { + const packageJsonPath = path.join(app.getAppPath(), "package.json"); + const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, "utf8")) as { + adePackageChannel?: unknown; + productName?: unknown; + }; + const packageChannel = normalizeAdePackageChannel(packageJson.adePackageChannel); + if (packageChannel) return packageChannel; + const productName = typeof packageJson.productName === "string" ? packageJson.productName : ""; + if (/\balpha\b/i.test(productName)) return "alpha"; + if (/\bbeta\b/i.test(productName)) return "beta"; + } catch { + // Dev builds and older packaged apps do not need channel metadata. + } + + const appName = app.getName(); + if (/\balpha\b/i.test(appName)) return "alpha"; + if (/\bbeta\b/i.test(appName)) return "beta"; + return null; +} + +function applyPackagedChannelDefaults(): void { + const channel = readBundledAdePackageChannel(); + if (!channel) return; + + process.env.ADE_PACKAGE_CHANNEL = process.env.ADE_PACKAGE_CHANNEL || channel; + process.env.ADE_DESKTOP_APP_NAME = process.env.ADE_DESKTOP_APP_NAME || (channel === "alpha" ? "ADE Alpha" : "ADE Beta"); + process.env.ADE_HOME = process.env.ADE_HOME || path.join(os.homedir(), `.ade-${channel}`); + process.env.ADE_DISABLE_RUNTIME_SERVICE_INSTALL = process.env.ADE_DISABLE_RUNTIME_SERVICE_INSTALL || "1"; +} + +applyPackagedChannelDefaults(); function resolveAutoUpdaterCacheDir(): string { const homeDir = os.homedir(); @@ -203,6 +257,27 @@ function fixElectronShellPath(): void { // Must run before any service or child process is created. fixElectronShellPath(); +function installAdeCliForTerminalInBackground( + adeCliService: ReturnType, + logger: Logger, +): void { + if (process.env.ADE_DISABLE_CLI_AUTO_INSTALL === "1") return; + void adeCliService.installForUser() + .then((result) => { + logger.info("ade_cli.auto_install", { + ok: result.ok, + command: result.status.command, + installTargetPath: result.status.installTargetPath, + message: result.message, + }); + }) + .catch((error) => { + logger.warn("ade_cli.auto_install_failed", { + error: error instanceof Error ? error.message : String(error), + }); + }); +} + const disableHardwareAcceleration = process.env.ADE_DISABLE_HARDWARE_ACCEL === "1"; if (disableHardwareAcceleration) { @@ -944,6 +1019,19 @@ app.whenReady().then(async () => { writeGlobalState(globalStatePath, normalizedState); } + const machineAdeLayout = resolveMachineAdeLayout(); + const machineStateMigration = runMachineStateMigration({ + layout: machineAdeLayout, + recentProjects: cleanedRecentProjects, + }); + const shouldAttemptRuntimeServiceInstall = + machineStateMigration.didRun + && app.isPackaged + && process.env.NODE_ENV !== "test" + && process.env.ADE_DISABLE_RUNTIME_SERVICE_INSTALL !== "1"; + const shouldShowRuntimeMigrationNotice = + shouldAttemptRuntimeServiceInstall && machineStateMigration.shouldShowNotice; + const envRoot = process.env.ADE_PROJECT_ROOT; const pendingStartupProjectRoot = pendingProjectOpenFiles @@ -993,12 +1081,37 @@ app.whenReady().then(async () => { const projectInitPromises = new Map>(); const closeContextPromises = new Map>(); const windowProjectRoots = new Map(); + const windowProjectBindings = new Map(); const ipcWindowScope = new AsyncLocalStorage(); const rpcSocketCleanupByRoot = new Map void>(); const projectLastActivatedAt = new Map(); const mobileSyncHandoffLeases = new Map(); const mobileSyncHandoffLeaseTimers = new Map>(); const mobileSyncPreparationPromises = new Map>(); + const localRuntimeLogger = createFileLogger(path.join(app.getPath("userData"), "local-runtime.jsonl")); + const localRuntimePool = new LocalRuntimeConnectionPool(app.getVersion(), localRuntimeLogger); + if (shouldAttemptRuntimeServiceInstall) { + void localRuntimePool.installServiceBestEffort() + .then(() => { + const status = localRuntimePool.getStatus().serviceInstall; + if (status.state === "installed") { + markMachineStateMigrationComplete({ layout: machineAdeLayout }); + } + }) + .catch((error) => { + localRuntimeLogger.warn("local_runtime.service_install_failed", { + error: error instanceof Error ? error.message : String(error), + }); + }); + } else if (!machineStateMigration.didRun) { + localRuntimePool.noteServiceInstallSkipped("Background service migration already completed."); + } else if (process.env.ADE_DISABLE_RUNTIME_SERVICE_INSTALL === "1") { + localRuntimePool.noteServiceInstallSkipped("Background service installation is disabled by ADE_DISABLE_RUNTIME_SERVICE_INSTALL."); + } else if (!app.isPackaged) { + localRuntimePool.noteServiceInstallSkipped("Background service installation is skipped in dev builds."); + } else if (process.env.NODE_ENV === "test") { + localRuntimePool.noteServiceInstallSkipped("Background service installation is skipped in tests."); + } const MAX_WARM_IDLE_PROJECT_CONTEXTS = 1; const MOBILE_SYNC_HANDOFF_LEASE_MS = 60_000; let activeProjectRoot: string | null = null; @@ -1010,11 +1123,27 @@ app.whenReady().then(async () => { const currentIpcWindowId = (): number | null => ipcWindowScope.getStore() ?? null; + const useInProcessProjectRuntime = (): boolean => + process.env.NODE_ENV === "test" + || process.env.ADE_ENABLE_DESKTOP_IN_PROCESS_RUNTIME === "1" + || process.env.ADE_DISABLE_LOCAL_RUNTIME_DAEMON === "1" + || process.env.ADE_LOCAL_RUNTIME_FALLBACK === "1"; + const projectForRoot = (projectRoot: string | null): ProjectInfo | null => { if (!projectRoot) return null; return projectContexts.get(projectRoot)?.project ?? null; }; + const bindingForLocalProject = (project: ProjectInfo | null): OpenProjectBinding | null => + project + ? { + kind: "local", + key: `local:${project.rootPath}`, + rootPath: project.rootPath, + displayName: project.displayName, + } + : null; + const rootsBoundToWindows = (): Set => { const roots = new Set(); for (const root of windowProjectRoots.values()) { @@ -1036,6 +1165,19 @@ app.whenReady().then(async () => { } }; + const emitProjectBindingChangedToWindow = ( + windowId: number | null, + binding: OpenProjectBinding | null, + ): void => { + const win = windowId == null ? null : BrowserWindow.fromId(windowId); + if (!win || win.isDestroyed()) return; + try { + win.webContents.send(IPC.appProjectBindingChanged, binding); + } catch { + // ignore + } + }; + const firstAvailableRecentProjectRoot = (): string | null => { const recentProjects = readGlobalState(globalStatePath).recentProjects ?? []; for (const project of recentProjects) { @@ -1046,10 +1188,16 @@ app.whenReady().then(async () => { return null; }; + const isDesktopSyncHostEnabled = (): boolean => + process.env.ADE_ENABLE_DESKTOP_SYNC_HOST === "1" + && process.env.ADE_DISABLE_SYNC_HOST !== "1"; + const getMobileSyncHostRoot = (): string | null => - mobileSyncSelectedRoot - ?? activeProjectRoot - ?? firstAvailableRecentProjectRoot(); + isDesktopSyncHostEnabled() + ? mobileSyncSelectedRoot + ?? activeProjectRoot + ?? firstAvailableRecentProjectRoot() + : null; const getMobileSyncService = (): ReturnType | null => { const hostRoot = getMobileSyncHostRoot(); @@ -1116,6 +1264,7 @@ app.whenReady().then(async () => { const normalizedRoot = projectRoot ? normalizeProjectRoot(projectRoot) : null; if (windowId != null) { windowProjectRoots.set(windowId, normalizedRoot); + windowProjectBindings.delete(windowId); } if (options.foreground ?? true) { setForegroundProject(normalizedRoot); @@ -1126,10 +1275,33 @@ app.whenReady().then(async () => { if (ctx) { persistRecentProject(ctx.project, { recordLastProject: false, preserveRecentOrder: true }); } + if (process.env.NODE_ENV !== "test" && process.env.ADE_DISABLE_LOCAL_RUNTIME_DAEMON !== "1") { + void localRuntimePool.ensureProject(normalizedRoot).catch((error) => { + localRuntimeLogger.warn("local_runtime.project_registration_failed", { + rootPath: normalizedRoot, + error: error instanceof Error ? error.message : String(error), + }); + }); + } } if (options.emit !== false) { - emitProjectChangedToWindow(windowId, projectForRoot(normalizedRoot)); + const project = projectForRoot(normalizedRoot); + emitProjectChangedToWindow(windowId, project); + emitProjectBindingChangedToWindow(windowId, bindingForLocalProject(project)); + } + }; + + const bindWindowToRemoteProject = ( + windowId: number | null, + binding: OpenProjectBinding & { kind: "remote" }, + ): void => { + if (windowId != null) { + windowProjectRoots.set(windowId, null); + windowProjectBindings.set(windowId, binding); } + setForegroundProject(null); + emitProjectChangedToWindow(windowId, null); + emitProjectBindingChangedToWindow(windowId, binding); }; const getActiveContext = (): AppContext => { @@ -1184,7 +1356,7 @@ app.whenReady().then(async () => { }; try { - if (ctx.sessionService.list({ status: "running", limit: 1 }).length > 0) { + if (ctx.sessionService?.list({ status: "running", limit: 1 }).length > 0) { return true; } } catch (error) { @@ -1192,7 +1364,7 @@ app.whenReady().then(async () => { } try { - if (ctx.missionService.list({ status: "active", limit: 1 }).length > 0) { + if (ctx.missionService?.list({ status: "active", limit: 1 }).length > 0) { return true; } } catch (error) { @@ -1200,7 +1372,7 @@ app.whenReady().then(async () => { } try { - if (ctx.testService.hasActiveRuns()) { + if (ctx.testService?.hasActiveRuns()) { return true; } } catch (error) { @@ -1208,20 +1380,22 @@ app.whenReady().then(async () => { } try { - const lanes = await ctx.laneService.list({ - includeArchived: false, - includeStatus: false, - }); - for (const lane of lanes) { - if ( - ctx.processService.listRuntime(lane.id).some((runtime) => - runtime.status === "starting" - || runtime.status === "running" - || runtime.status === "degraded" - || runtime.status === "stopping" - ) - ) { - return true; + if (ctx.laneService && ctx.processService) { + const lanes = await ctx.laneService.list({ + includeArchived: false, + includeStatus: false, + }); + for (const lane of lanes) { + if ( + ctx.processService.listRuntime(lane.id).some((runtime) => + runtime.status === "starting" + || runtime.status === "running" + || runtime.status === "degraded" + || runtime.status === "stopping" + ) + ) { + return true; + } } } } catch (error) { @@ -1471,6 +1645,7 @@ app.whenReady().then(async () => { logger, }); adeCliService.applyToProcessEnv(); + installAdeCliForTerminalInBackground(adeCliService, logger); const devToolsService = createDevToolsService({ logger }); const project = toProjectInfo(projectRoot, baseRef); @@ -1789,6 +1964,7 @@ app.whenReady().then(async () => { projectRoot, aiIntegrationService, githubService, + onSubmissionUpdated: (event) => broadcast(IPC.feedbackOnUpdate, event), }); const conflictService = createConflictService({ @@ -1958,13 +2134,23 @@ app.whenReady().then(async () => { registry?.invalidateApnsToken?.(deviceToken); }); + const rpcEventBuffer = createEventBuffer(); + const emitPrEvent = (event: PrEventPayload): void => { + emitProjectEvent(projectRoot, IPC.prsEvent, event); + rpcEventBuffer.push({ + timestamp: new Date().toISOString(), + category: "runtime", + payload: { type: "pr_event", event }, + }); + }; + const prPollingService = createPrPollingService({ logger, prService, projectConfigService, db, notificationEventBus, - onEvent: (event) => emitProjectEvent(projectRoot, IPC.prsEvent, event), + onEvent: emitPrEvent, onPullRequestsChanged: async ({ changedPrs, changes }) => { if (changedPrs.length > 0) { prService.markHotRefresh(changedPrs.map((pr) => pr.id)); @@ -2007,9 +2193,6 @@ app.whenReady().then(async () => { let linearDispatcherServiceRef: ReturnType< typeof createLinearDispatcherService > | null = null; - let openclawBridgeServiceRef: ReturnType< - typeof createOpenclawBridgeService - > | null = null; let linearSyncServiceRef: ReturnType< typeof createLinearSyncService > | null = null; @@ -2025,7 +2208,7 @@ app.whenReady().then(async () => { prService, laneService, conflictService, - emitEvent: (event) => emitProjectEvent(projectRoot, IPC.prsEvent, event), + emitEvent: emitPrEvent, onStateChanged: (state) => { const hotPrIds = new Set(); const currentEntry = state.entries[state.currentPosition]; @@ -2534,7 +2717,6 @@ app.whenReady().then(async () => { getAdeCliAgentEnv: adeCliService.agentEnv, onEvent: (event) => { aiOrchestratorServiceRef?.onAgentChatEvent(event); - openclawBridgeServiceRef?.onAgentChatEvent(event); emitProjectEvent(projectRoot, IPC.agentChatEvent, event); // Capture agent session errors as failure gotchas for the memory system @@ -2629,7 +2811,6 @@ app.whenReady().then(async () => { laneService, projectConfigService, broadcastEvent: (ev) => { - openclawBridgeServiceRef?.onTestEvent(ev); emitProjectEvent(projectRoot, IPC.testsEvent, ev); }, }); @@ -2713,7 +2894,6 @@ app.whenReady().then(async () => { .catch(() => {}); }, onEvent: (event) => { - openclawBridgeServiceRef?.onMissionEvent(event); emitProjectEvent(projectRoot, IPC.missionsEvent, event); if (event.missionId) { automationService?.onMissionUpdated({ missionId: event.missionId }); @@ -2868,32 +3048,6 @@ app.whenReady().then(async () => { "ADE_ENABLE_PORT_ALLOCATION_RECOVERY", ); - const openclawBridgeService = createOpenclawBridgeService({ - projectRoot, - adeDir: adePaths.adeDir, - laneService, - agentChatService, - ctoStateService, - workerAgentService, - missionService, - logger, - appVersion: app.getVersion(), - onStatusChange: (status) => - emitProjectEvent(projectRoot, IPC.openclawConnectionStatus, status), - }); - openclawBridgeServiceRef = openclawBridgeService; - scheduleBackgroundProjectTask( - "openclaw_bridge.start", - () => openclawBridgeService.start(), - (error) => { - logger.warn("openclaw_bridge.start_failed", { - error: error instanceof Error ? error.message : String(error), - }); - }, - 0, - "ADE_ENABLE_OPENCLAW", - ); - const orchestratorService = createOrchestratorService({ db, projectId, @@ -2912,7 +3066,6 @@ app.whenReady().then(async () => { knowledgeCaptureService, onEvent: (event) => { aiOrchestratorServiceRef?.onOrchestratorRuntimeEvent(event); - openclawBridgeServiceRef?.onOrchestratorEvent(event); emitProjectEvent(projectRoot, IPC.orchestratorEvent, event); }, }); @@ -3096,21 +3249,22 @@ app.whenReady().then(async () => { emitProjectEvent(projectRoot, IPC.orchestratorDagMutation, event), }); aiOrchestratorServiceRef = aiOrchestratorService; - // Phone sync is an app-level feature. A single project context still backs - // the project-scoped data stream, but the backing context is selected by the - // app-level sync host root rather than the visible project tab alone. - // ADE_DISABLE_SYNC_HOST=1 is a global kill switch for tests / CI. + // Phone sync is owned by the per-machine ADE service. The desktop + // keeps a non-host sync service for legacy viewer state and explicit + // diagnostics only; ADE_ENABLE_DESKTOP_SYNC_HOST=1 re-enables the old + // in-process host path while debugging migrations. const mobileSyncHostRoot = getMobileSyncHostRoot(); const isMobileSyncHostContext = mobileSyncHostRoot != null && normalizeProjectRoot(projectRoot) === mobileSyncHostRoot; - const syncHostAutoStart = - process.env.ADE_DISABLE_SYNC_HOST !== "1" && isMobileSyncHostContext; + const syncHostAutoStart = isMobileSyncHostContext; const syncService = createSyncService({ db, logger, projectRoot, - localDeviceIdPath: path.join(app.getPath("userData"), "sync-device-id"), + projectId, + appVersion: app.getVersion(), + localDeviceIdPath: path.join(machineAdeLayout.secretsDir, "sync-device-id"), fileService, laneService, gitService, @@ -3143,7 +3297,7 @@ app.whenReady().then(async () => { getLinearSyncService: () => linearSyncServiceRef, processService, hostStartupEnabled: syncHostAutoStart, - phonePairingStateDir: path.join(app.getPath("userData"), "phone-sync"), + phonePairingStateDir: machineAdeLayout.secretsDir, hostDiscoveryEnabled: isMobileSyncHostContext, forceHostRole: true, notificationEventBus, @@ -3678,7 +3832,6 @@ app.whenReady().then(async () => { writeGlobalState(globalStatePath, state); // ── ADE RPC Socket Server (embedded mode) ───────────────────── - const rpcEventBuffer = createEventBuffer(); const rpcRuntime: AdeRuntime = { projectRoot, workspaceRoot: projectRoot, @@ -3726,7 +3879,6 @@ app.whenReady().then(async () => { workerHeartbeatService, workerTaskSessionService, linearCredentialService, - openclawBridgeService, flowPolicyService, linearDispatcherService, linearIssueTracker, @@ -3766,7 +3918,7 @@ app.whenReady().then(async () => { return { ok: false, mode: "unavailable" as const, - message: "No ADE desktop window is available for this project.", + message: "No ADE window is available for this project.", }; } if (targetWindow.isMinimized()) targetWindow.restore(); @@ -3785,17 +3937,9 @@ app.whenReady().then(async () => { dispose: () => {}, // desktop manages service lifecycle }; - // When ADE_RPC_SOCKET_PATH is set, derive a per-project socket path from - // the override so each project context gets its own socket and avoids - // EADDRINUSE. The first context uses the env path as-is for compatibility; - // subsequent contexts append a project-root hash suffix. - const envSocketOverride = process.env.ADE_RPC_SOCKET_PATH?.trim(); - const rpcSocketPath = envSocketOverride - ? projectContexts.size === 0 - ? envSocketOverride - : `${envSocketOverride}.${Buffer.from(normalizeProjectRoot(projectRoot)).toString("base64url").slice(0, 8)}` - : adePaths.socketPath; const activeRpcConnections = new Set(); + let rpcSocketServer: net.Server | undefined; + let rpcSocketPath: string | undefined; const destroyActiveRpcConnections = (): void => { for (const conn of activeRpcConnections) { @@ -3812,73 +3956,93 @@ app.whenReady().then(async () => { destroyActiveRpcConnections, ); - if (!isAdeMcpNamedPipePath(rpcSocketPath)) { - try { - fs.unlinkSync(rpcSocketPath); - } catch {} - } + if (process.env.ADE_ENABLE_DESKTOP_RPC_SOCKET === "1") { + // Legacy compatibility: the ADE service owns ADE RPC by default. + // When explicitly enabled, derive a per-project socket path so multiple + // desktop project contexts do not collide on the same override. + const envSocketOverride = process.env.ADE_RPC_SOCKET_PATH?.trim(); + rpcSocketPath = envSocketOverride + ? projectContexts.size === 0 + ? envSocketOverride + : `${envSocketOverride}.${Buffer.from(normalizeProjectRoot(projectRoot)).toString("base64url").slice(0, 8)}` + : adePaths.socketPath; + + if (!isAdeMcpNamedPipePath(rpcSocketPath)) { + try { + fs.unlinkSync(rpcSocketPath); + } catch {} + } - const rpcSocketServer = net.createServer((conn) => { - activeRpcConnections.add(conn); - let stopped = false; - const transport: JsonRpcTransport = { - onData(callback) { - conn.on("data", callback); - }, - write(data) { - conn.write(data); - }, - close() { - if (!conn.destroyed) conn.destroy(); - }, - }; - let stop: ReturnType | null = null; - const rpcHandler = createAdeRpcRequestHandler({ - runtime: rpcRuntime, - serverVersion: app.getVersion(), - onActionsListChanged: () => { - stop?.notify("ade/actions/list_changed", {}); - }, - }); - stop = startJsonRpcServer(rpcHandler, transport, { nonFatal: true }); - const unsubscribeChatEvents = rpcRuntime.agentChatService?.subscribeToEvents((event) => { - stop?.notify("chat/event", event); - }) ?? (() => {}); - let removedConnection = false; - const removeConnection = (): void => { - if (removedConnection) return; - removedConnection = true; - activeRpcConnections.delete(conn); - unsubscribeChatEvents(); - }; - conn.once("close", removeConnection); - conn.once("end", removeConnection); - conn.once("error", removeConnection); - conn.on("close", () => { - if (!stopped) { - stopped = true; - stop?.(); - } - rpcHandler.dispose(); - }); - conn.on("error", () => {}); // ignore connection errors - }); - await measureProjectInitStep("rpc.socket_server_start", () => - new Promise((resolve, reject) => { - const handleListening = () => { - rpcSocketServer.off("error", handleError); - resolve(); + const server = net.createServer((conn) => { + activeRpcConnections.add(conn); + let stopped = false; + const transport: JsonRpcTransport = { + onData(callback) { + conn.on("data", callback); + }, + write(data) { + conn.write(data); + }, + close() { + if (!conn.destroyed) conn.destroy(); + }, }; - const handleError = (error: Error) => { - rpcSocketServer.off("listening", handleListening); - reject(error); + let stop: ReturnType | null = null; + const rpcHandler = createAdeRpcRequestHandler({ + runtime: rpcRuntime, + serverVersion: app.getVersion(), + onActionsListChanged: () => { + stop?.notify("ade/actions/list_changed", {}); + }, + }); + stop = startJsonRpcServer(rpcHandler, transport, { nonFatal: true }); + const unsubscribeChatEvents = rpcRuntime.agentChatService?.subscribeToEvents((event) => { + stop?.notify("chat/event", event); + }) ?? (() => {}); + let removedConnection = false; + const removeConnection = (): void => { + if (removedConnection) return; + removedConnection = true; + activeRpcConnections.delete(conn); + unsubscribeChatEvents(); }; - rpcSocketServer.once("listening", handleListening); - rpcSocketServer.once("error", handleError); - rpcSocketServer.listen(rpcSocketPath); - }), - ); - logger.info("rpc.socket_server_started", { socketPath: rpcSocketPath }); + conn.once("close", removeConnection); + conn.once("end", removeConnection); + conn.once("error", removeConnection); + conn.on("close", () => { + if (!stopped) { + stopped = true; + stop?.(); + } + rpcHandler.dispose(); + }); + conn.on("error", () => {}); // ignore connection errors + }); + rpcSocketServer = server; + await measureProjectInitStep("rpc.socket_server_start", () => + new Promise((resolve, reject) => { + const handleListening = () => { + server.off("error", handleError); + resolve(); + }; + const handleError = (error: Error) => { + server.off("listening", handleListening); + reject(error); + }; + server.once("listening", handleListening); + server.once("error", handleError); + server.listen(rpcSocketPath); + }), + ); + logger.warn("rpc.socket_server_started", { + socketPath: rpcSocketPath, + mode: "legacy_desktop", + }); + } else { + logger.info("rpc.socket_server_skipped", { + reason: "runtime_daemon_owns_rpc", + }); + } // Wire the automation runtime into the shared ADE-action registry so // that `ade-action` automation steps can invoke the same domain services @@ -4035,7 +4199,6 @@ app.whenReady().then(async () => { embeddingService, embeddingWorkerService, ctoStateService, - openclawBridgeService, workerAgentService, adeProjectService, workerRevisionService, @@ -4054,6 +4217,37 @@ app.whenReady().then(async () => { }; }; + const initRuntimeBackedProjectContext = async ({ + projectRoot, + baseRef, + userSelectedProject, + }: { + projectRoot: string; + baseRef: string; + userSelectedProject: boolean; + }): Promise => { + const adePaths = ensureAdeDirs(projectRoot); + const logger = createFileLogger(path.join(adePaths.logsDir, "main.jsonl")); + const project = toProjectInfo(projectRoot, baseRef); + const runtimeProject = await localRuntimePool.ensureProject(projectRoot); + const shellContext = createDormantProjectContext(projectRoot); + logger.info("project.runtime_bound", { + projectRoot, + projectId: runtimeProject.projectId, + mode: "local_runtime_daemon", + }); + return { + ...shellContext, + logger, + project, + projectId: runtimeProject.projectId, + adeDir: adePaths.adeDir, + hasUserSelectedProject: userSelectedProject, + adeCliService: shellContext.adeCliService, + builtInBrowserService, + } as AppContext; + }; + const createDormantProjectContext = (projectRoot = ""): AppContext => { const rootIsDefined = typeof projectRoot === "string" && projectRoot.trim().length > 0; @@ -4079,6 +4273,15 @@ app.whenReady().then(async () => { logger, githubService: dormantGithubService, }); + const adeCliService = createAdeCliService({ + isPackaged: app.isPackaged, + resourcesPath: process.resourcesPath, + userDataPath: app.getPath("userData"), + appExecutablePath: process.execPath, + logger, + }); + adeCliService.applyToProcessEnv(); + installAdeCliForTerminalInBackground(adeCliService, logger); return { db: null, logger, @@ -4090,13 +4293,7 @@ app.whenReady().then(async () => { disposeHeadWatcher: () => {}, keybindingsService: null, agentToolsService: null, - adeCliService: createAdeCliService({ - isPackaged: app.isPackaged, - resourcesPath: process.resourcesPath, - userDataPath: app.getPath("userData"), - appExecutablePath: process.execPath, - logger, - }), + adeCliService, devToolsService: null, onboardingService: null, laneService: null, @@ -4162,7 +4359,6 @@ app.whenReady().then(async () => { proceduralLearningService: null, skillRegistryService: null, ctoStateService: null, - openclawBridgeService: null, workerAgentService: null, adeProjectService: null, workerRevisionService: null, @@ -4295,11 +4491,6 @@ app.whenReady().then(async () => { } catch { // ignore } - try { - await ctx.openclawBridgeService?.stop?.(); - } catch { - // ignore - } try { await ctx.skillRegistryService?.dispose?.(); } catch { @@ -4514,7 +4705,7 @@ app.whenReady().then(async () => { const existing = projectContexts.get(normalizedRoot); if (existing) return existing; if (!fs.existsSync(normalizedRoot)) { - throw new Error("Project is no longer available on this desktop."); + throw new Error("Project is no longer available on this machine."); } let initPromise = projectInitPromises.get(normalizedRoot); @@ -4572,14 +4763,14 @@ app.whenReady().then(async () => { if (!catalogEntry || !catalogEntry.isAvailable) { return { ok: false, - message: "That project is not available from this desktop.", + message: "That project is not available from this machine.", }; } const targetRoot = catalogEntry.rootPath ? normalizeProjectRoot(catalogEntry.rootPath) : null; if (!targetRoot) { return { ok: false, - message: "Choose a desktop project first.", + message: "Choose a machine project first.", }; } @@ -4767,18 +4958,25 @@ app.whenReady().then(async () => { durationMs: Date.now() - baseRefStartedAt, }); const initStartedAt = Date.now(); - const ctx = await initContextForProjectRoot({ - projectRoot: repoRoot!, - baseRef, - ensureExclude: true, - recordLastProject: true, - recordRecent: true, - preserveRecentOrder: isKnownRecentProject, - userSelectedProject: true, - }); + const ctx = useInProcessProjectRuntime() + ? await initContextForProjectRoot({ + projectRoot: repoRoot!, + baseRef, + ensureExclude: true, + recordLastProject: true, + recordRecent: true, + preserveRecentOrder: isKnownRecentProject, + userSelectedProject: true, + }) + : await initRuntimeBackedProjectContext({ + projectRoot: repoRoot!, + baseRef, + userSelectedProject: true, + }); projectOpenLogger.info("project.open.context_initialized", { selectedPath, repoRoot, + mode: useInProcessProjectRuntime() ? "in_process" : "local_runtime_daemon", durationMs: Date.now() - initStartedAt, }); projectContexts.set(repoRoot!, ctx); @@ -4824,7 +5022,9 @@ app.whenReady().then(async () => { for (const [windowId, root] of windowProjectRoots) { if (root === normalizedRoot) { windowProjectRoots.set(windowId, null); + windowProjectBindings.delete(windowId); emitProjectChangedToWindow(windowId, null); + emitProjectBindingChangedToWindow(windowId, null); } } await closeProjectContext(normalizedRoot); @@ -5058,7 +5258,7 @@ app.whenReady().then(async () => { title: "Quit ADE?", message: "Save your work before closing ADE.", detail: - "Quitting ADE will end any running agents and stop background processes started by ADE, including OpenCode servers, terminal sessions, and test runs.", + "Quitting ADE will end agents and background processes owned by this desktop session, including OpenCode servers, terminal sessions, and test runs. The ADE service login item keeps running separately when it is installed.", rememberQuitAcknowledgement: true, }); @@ -5187,6 +5387,7 @@ app.whenReady().then(async () => { const registerWindowSession = (win: BrowserWindow, projectRoot: string | null = null): void => { windowProjectRoots.set(win.id, projectRoot ? normalizeProjectRoot(projectRoot) : null); + windowProjectBindings.delete(win.id); win.on("focus", () => { setForegroundProject(windowProjectRoots.get(win.id) ?? null); builtInBrowserService.attachToWindow(win); @@ -5194,6 +5395,7 @@ app.whenReady().then(async () => { win.on("closed", () => { const previousRoot = windowProjectRoots.get(win.id) ?? null; windowProjectRoots.delete(win.id); + windowProjectBindings.delete(win.id); if (activeProjectRoot === previousRoot) { setForegroundProject(firstOpenWindowProjectRoot()); } @@ -5201,13 +5403,18 @@ app.whenReady().then(async () => { }); }; - const getWindowSession = (windowId: number | null): { windowId: number | null; project: ProjectInfo | null } => { + const getWindowSession = (windowId: number | null): { windowId: number | null; project: ProjectInfo | null; binding: OpenProjectBinding | null } => { if (windowId == null) { - return { windowId: null, project: projectForRoot(activeProjectRoot) }; + const project = projectForRoot(activeProjectRoot); + return { windowId: null, project, binding: bindingForLocalProject(project) }; } + const remoteBinding = windowProjectBindings.get(windowId) ?? null; + if (remoteBinding) return { windowId, project: null, binding: remoteBinding }; + const project = projectForRoot(windowProjectRoots.get(windowId) ?? null); return { windowId, - project: projectForRoot(windowProjectRoots.get(windowId) ?? null), + project, + binding: bindingForLocalProject(project), }; }; @@ -5226,6 +5433,7 @@ app.whenReady().then(async () => { }); } else { emitProjectChangedToWindow(win.id, null); + emitProjectBindingChangedToWindow(win.id, null); } return getWindowSession(win.id); }; @@ -5355,6 +5563,8 @@ app.whenReady().then(async () => { runWithIpcWindow: (event, fn) => ipcWindowScope.run(BrowserWindow.fromWebContents(event.sender)?.id ?? null, fn), getWindowSession, + bindRemoteProject: bindWindowToRemoteProject, + localRuntimeConnectionPool: localRuntimePool, createWindow: openAdeWindow, closeWindow: closeAdeWindow, switchProjectFromDialog, @@ -5383,6 +5593,19 @@ app.whenReady().then(async () => { onCloseRequested: handleMainWindowCloseRequested, }); builtInBrowserService.attachToWindow(initialWindow); + if (shouldShowRuntimeMigrationNotice && process.env.NODE_ENV !== "test") { + void dialog.showMessageBox(initialWindow, { + type: "info", + buttons: ["Got it"], + defaultId: 0, + title: "ADE now runs in the background", + message: "ADE now runs in the background", + detail: [ + "Your machine can stay available for mobile pairing and agent work after the app window closes.", + "You can remove the background service by running `ade serve --uninstall-service`.", + ].join("\n\n"), + }).catch(() => {}); + } app.on("activate", async () => { if (BrowserWindow.getAllWindows().length === 0) { diff --git a/apps/desktop/src/main/services/adeActions/registry.test.ts b/apps/desktop/src/main/services/adeActions/registry.test.ts index 9b4605abf..137a16944 100644 --- a/apps/desktop/src/main/services/adeActions/registry.test.ts +++ b/apps/desktop/src/main/services/adeActions/registry.test.ts @@ -1,6 +1,8 @@ -import { describe, expect, it } from "vitest"; +import { describe, expect, it, vi } from "vitest"; +import type { LaneListSnapshot, LaneSummary, TerminalSessionSummary } from "../../../shared/types"; import { ADE_ACTION_ALLOWLIST, + getAdeActionDomainServices, isCtoOnlyAdeAction, isAllowedAdeAction, listAllowedAdeActionNames, @@ -104,6 +106,13 @@ describe("isCtoOnlyAdeAction", () => { expect(isCtoOnlyAdeAction("path_to_merge", "startPathToMerge")).toBe(true); expect(isCtoOnlyAdeAction("path_to_merge", "stopPathToMerge")).toBe(true); }); + + it("keeps AI credential mutations CTO-only", () => { + expect(isCtoOnlyAdeAction("ai", "storeApiKey")).toBe(true); + expect(isCtoOnlyAdeAction("ai", "deleteApiKey")).toBe(true); + expect(isCtoOnlyAdeAction("ai", "listApiKeys")).toBe(false); + }); + }); describe("ADE_ACTION_ALLOWLIST shape", () => { @@ -142,6 +151,46 @@ describe("ADE_ACTION_ALLOWLIST shape", () => { } }); + it("exposes lane.listSnapshots for runtime-backed lane snapshot parity", () => { + const actions = ADE_ACTION_ALLOWLIST.lane ?? []; + expect(actions).toContain("listSnapshots"); + }); + + it("exposes ade_project.clearLocalData for runtime-backed cleanup", () => { + const actions = ADE_ACTION_ALLOWLIST.ade_project ?? []; + expect(actions).toContain("clearLocalData"); + }); + + it("exposes session.getDelta for runtime-backed session delta reads", () => { + const actions = ADE_ACTION_ALLOWLIST.session ?? []; + expect(actions).toContain("getDelta"); + }); + + it("exposes computer_use_artifacts.readArtifactPreview for runtime-backed proof previews", () => { + const actions = ADE_ACTION_ALLOWLIST.computer_use_artifacts ?? []; + expect(actions).toContain("readArtifactPreview"); + }); + + it("exposes Linear issue tracker composite reads for runtime-backed CTO views", () => { + const actions = ADE_ACTION_ALLOWLIST.linear_issue_tracker ?? []; + expect(actions).toContain("getWorkflowCatalog"); + expect(actions).toContain("getIssuePickerData"); + expect(actions).toContain("getConnectionStatus"); + expect(actions).toContain("getQuickView"); + expect(ADE_ACTION_ALLOWLIST.linear_routing ?? []).toContain("simulateRoute"); + expect(ADE_ACTION_ALLOWLIST.linear_oauth ?? []).toEqual(expect.arrayContaining([ + "getSession", + "startSession", + ])); + }); + + it("exposes CTO identity session and scan wrappers for runtime-backed CTO views", () => { + const chatActions = ADE_ACTION_ALLOWLIST.chat ?? []; + expect(chatActions).toContain("ensureCtoSession"); + expect(chatActions).toContain("ensureAgentIdentitySession"); + expect(ADE_ACTION_ALLOWLIST.cto_state ?? []).toContain("runProjectScan"); + }); + it("exposes the browser panel and tab control surface", () => { const actions = ADE_ACTION_ALLOWLIST.built_in_browser ?? []; for (const name of ["showPanel", "navigate", "createTab", "switchTab", "closeTab"]) { @@ -156,3 +205,666 @@ describe("ADE_ACTION_ALLOWLIST shape", () => { } }); }); + +describe("runtime Linear issue tracker actions", () => { + it("builds catalog and picker payloads from tracker reads", async () => { + const projects = [{ id: "project-1", name: "ADE" }]; + const users = [{ id: "user-1", name: "Arul" }]; + const labels = [{ id: "label-1", name: "Bug" }]; + const states = [{ id: "state-1", name: "Todo" }]; + const tracker = { + listProjects: vi.fn(async () => projects), + listUsers: vi.fn(async () => users), + listLabels: vi.fn(async () => labels), + listWorkflowStates: vi.fn(async () => states), + }; + const runtime = { + linearIssueTracker: tracker, + } as unknown as Parameters[0]; + const service = getAdeActionDomainServices(runtime).linear_issue_tracker as { + getWorkflowCatalog: () => Promise; + getIssuePickerData: () => Promise; + } & Record; + + expect(listAllowedAdeActionNames("linear_issue_tracker", service)).toContain("getWorkflowCatalog"); + expect(listAllowedAdeActionNames("linear_issue_tracker", service)).toContain("getIssuePickerData"); + await expect(service.getWorkflowCatalog()).resolves.toEqual({ users, labels, states }); + await expect(service.getIssuePickerData()).resolves.toEqual({ projects, users, states }); + }); +}); + +describe("runtime Linear OAuth actions", () => { + it("adds connection status to completed OAuth sessions", async () => { + const start = { sessionId: "linear-oauth-1", authUrl: "https://linear.app/oauth/authorize", redirectUri: "http://127.0.0.1:19836/oauth/callback" }; + const runtime = { + linearOAuthService: { + startSession: vi.fn(async () => start), + getSession: vi.fn(() => ({ status: "completed" })), + }, + linearCredentialService: { + getStatus: vi.fn(() => ({ + tokenStored: true, + authMode: "oauth", + oauthConfigured: true, + tokenExpiresAt: "2026-05-10T00:00:00.000Z", + })), + }, + linearIssueTracker: { + getConnectionStatus: vi.fn(async () => ({ + connected: true, + viewerId: "user-1", + viewerName: "Arul", + message: null, + })), + }, + } as unknown as Parameters[0]; + const service = getAdeActionDomainServices(runtime).linear_oauth as { + startSession: () => Promise; + getSession: (sessionId: string) => Promise; + } & Record; + + expect(listAllowedAdeActionNames("linear_oauth", service)).toEqual(["getSession", "startSession"]); + await expect(service.startSession()).resolves.toEqual(start); + await expect(service.getSession("linear-oauth-1")).resolves.toMatchObject({ + status: "completed", + connection: { + tokenStored: true, + connected: true, + viewerId: "user-1", + viewerName: "Arul", + authMode: "oauth", + oauthAvailable: true, + }, + }); + }); +}); + +describe("runtime session actions", () => { + it("adds getDelta from the runtime session delta service", () => { + const delta = { sessionId: "session-1", filesChanged: 2 }; + const runtime = { + sessionService: { + get: vi.fn(), + list: vi.fn(), + readTranscriptTail: vi.fn(), + }, + sessionDeltaService: { + getSessionDelta: vi.fn(() => delta), + }, + } as unknown as Parameters[0]; + const sessionService = getAdeActionDomainServices(runtime).session as { + getDelta: (args: { sessionId: string }) => unknown; + } & Record; + + expect(listAllowedAdeActionNames("session", sessionService)).toContain("getDelta"); + expect(sessionService.getDelta({ sessionId: "session-1" })).toEqual(delta); + expect(runtime.sessionDeltaService?.getSessionDelta).toHaveBeenCalledWith("session-1"); + }); +}); + +describe("runtime computer-use artifact actions", () => { + it("exposes artifact preview reads from the broker", async () => { + const broker = { + getBackendStatus: vi.fn(), + ingest: vi.fn(), + listArtifacts: vi.fn(), + readArtifactPreview: vi.fn(async () => "data:image/png;base64,AAAA"), + routeArtifact: vi.fn(), + updateArtifactReview: vi.fn(), + }; + const runtime = { + computerUseArtifactBrokerService: broker, + } as unknown as Parameters[0]; + const artifactService = getAdeActionDomainServices(runtime).computer_use_artifacts as { + readArtifactPreview: (args: { uri: string }) => Promise; + } & Record; + + expect(listAllowedAdeActionNames("computer_use_artifacts", artifactService)).toContain("readArtifactPreview"); + await expect(artifactService.readArtifactPreview({ uri: ".ade/artifacts/a.png" })).resolves.toBe("data:image/png;base64,AAAA"); + expect(broker.readArtifactPreview).toHaveBeenCalledWith({ uri: ".ade/artifacts/a.png" }); + }); +}); + +const TEST_NOW = "2026-05-10T00:00:00.000Z"; + +function makeLane(overrides: Partial & Pick): LaneSummary { + return { + description: null, + laneType: "worktree", + baseRef: "main", + branchRef: `feature/${overrides.id}`, + worktreePath: `/tmp/${overrides.id}`, + attachedRootPath: null, + parentLaneId: null, + childCount: 0, + stackDepth: 0, + parentStatus: null, + isEditProtected: false, + status: { + dirty: false, + ahead: 0, + behind: 0, + remoteBehind: 0, + rebaseInProgress: false, + }, + color: null, + icon: null, + tags: [], + folder: null, + missionId: null, + laneRole: null, + createdAt: TEST_NOW, + archivedAt: null, + ...overrides, + }; +} + +function makeSession( + overrides: Partial & Pick, +): TerminalSessionSummary { + return { + laneName: "Runtime lane", + ptyId: null, + tracked: true, + pinned: false, + goal: null, + toolType: "codex", + title: overrides.id, + status: "running", + startedAt: TEST_NOW, + endedAt: null, + exitCode: null, + transcriptPath: `/tmp/${overrides.id}.log`, + headShaStart: null, + headShaEnd: null, + lastOutputPreview: null, + summary: null, + runtimeState: "running", + resumeCommand: null, + ...overrides, + }; +} + +describe("runtime lane snapshot actions", () => { + it("builds rich lane.listSnapshots results from runtime services", async () => { + const lane = makeLane({ id: "lane-runtime", name: "Runtime lane" }); + const attachedLane = makeLane({ + id: "lane-attached", + name: "Attached lane", + laneType: "attached", + attachedRootPath: "/external/attached", + }); + const device = { deviceId: "ios-1", displayName: "iPhone", platform: "ios" }; + const rebaseSuggestion = { + laneId: lane.id, + parentLaneId: "parent-1", + parentHeadSha: "abc1234", + behindCount: 3, + baseLabel: "main", + groupContext: null, + lastSuggestedAt: TEST_NOW, + deferredUntil: null, + dismissedAt: null, + hasPr: true, + }; + const autoRebaseStatus = { + laneId: lane.id, + parentLaneId: "parent-1", + parentHeadSha: "def5678", + state: "rebaseConflict" as const, + updatedAt: TEST_NOW, + conflictCount: 2, + message: "Rebase needs attention.", + }; + const conflictStatus = { + laneId: lane.id, + status: "conflict-predicted" as const, + overlappingFileCount: 4, + peerConflictCount: 1, + lastPredictedAt: TEST_NOW, + }; + const stateSnapshot = { + laneId: lane.id, + agentSummary: { activeAgent: "codex" }, + missionSummary: { missionId: "mission-1" }, + updatedAt: TEST_NOW, + }; + const sessions = [ + makeSession({ + id: "running-terminal", + laneId: lane.id, + lastOutputPreview: "running tests", + }), + makeSession({ + id: "awaiting-chat", + laneId: lane.id, + toolType: "codex-chat", + lastOutputPreview: "thinking", + }), + makeSession({ + id: "ended-terminal", + laneId: lane.id, + status: "completed", + runtimeState: "exited", + endedAt: TEST_NOW, + exitCode: 0, + lastOutputPreview: "done", + }), + makeSession({ + id: "attached-running-terminal", + laneId: attachedLane.id, + }), + ]; + const list = vi.fn(() => [lane, attachedLane]); + const listStateSnapshots = vi.fn(() => [stateSnapshot]); + const runtime = { + laneService: { + list, + listStateSnapshots, + }, + sessionService: { + list: vi.fn(() => sessions), + }, + ptyService: { + enrichSessions: vi.fn((entries: TerminalSessionSummary[]) => entries), + }, + agentChatService: { + listSessions: vi.fn(async () => [ + { + sessionId: "awaiting-chat", + status: "active", + awaitingInput: true, + identityKey: null, + }, + ]), + }, + rebaseSuggestionService: { + listSuggestions: vi.fn(() => [rebaseSuggestion]), + }, + autoRebaseService: { + listStatuses: vi.fn(() => [autoRebaseStatus]), + }, + conflictService: { + getBatchAssessment: vi.fn(() => ({ lanes: [conflictStatus] })), + }, + syncService: { + getHostService: () => ({ + getLanePresenceSnapshot: () => [{ laneId: lane.id, devicesOpen: [device] }], + }), + }, + logger: { + info: vi.fn(), + warn: vi.fn(), + }, + } as unknown as Parameters[0]; + const laneService = getAdeActionDomainServices(runtime).lane as { + listSnapshots?: (args?: unknown) => Promise; + }; + + expect(laneService.listSnapshots).toEqual(expect.any(Function)); + expect(listAllowedAdeActionNames("lane", laneService as Record)).toContain("listSnapshots"); + + const snapshots = await laneService.listSnapshots?.({ + includeConflictStatus: true, + includeRebaseSuggestions: true, + includeAutoRebaseStatus: true, + }); + + expect(list).toHaveBeenCalledWith({ + includeArchived: false, + includeStatus: true, + }); + expect(snapshots).toEqual([ + { + lane: { + ...lane, + devicesOpen: [device], + }, + runtime: { + bucket: "awaiting-input", + runningCount: 1, + awaitingInputCount: 1, + endedCount: 1, + sessionCount: 3, + }, + rebaseSuggestion, + autoRebaseStatus, + conflictStatus, + stateSnapshot, + adoptableAttached: false, + }, + { + lane: attachedLane, + runtime: { + bucket: "running", + runningCount: 1, + awaitingInputCount: 0, + endedCount: 0, + sessionCount: 1, + }, + rebaseSuggestion: null, + autoRebaseStatus: null, + conflictStatus: null, + stateSnapshot: null, + adoptableAttached: true, + }, + ]); + }); +}); + +describe("runtime AI actions", () => { + it("exposes AI status and key storage actions through the allowlist", () => { + const runtime = { + aiIntegrationService: { + getStatus: vi.fn(), + getDailyUsageBatch: vi.fn(() => new Map()), + getFeatureFlag: vi.fn(), + getDailyBudgetLimit: vi.fn(), + verifyApiKeyConnection: vi.fn(), + storeApiKey: vi.fn(), + deleteApiKey: vi.fn(), + listApiKeys: vi.fn(), + }, + } as unknown as Parameters[0]; + const aiService = getAdeActionDomainServices(runtime).ai as Record; + + for (const action of ["getStatus", "storeApiKey", "deleteApiKey", "listApiKeys"]) { + expect(aiService[action]).toEqual(expect.any(Function)); + expect(listAllowedAdeActionNames("ai", aiService)).toContain(action); + } + }); + + it("returns IPC-shaped AI status rows from the runtime AI service", async () => { + const featureUsage = new Map([["narratives", 2]]); + const getStatus = vi.fn(async () => ({ + mode: "subscription", + availableProviders: { claude: true, codex: false, cursor: false, droid: false }, + models: { claude: [], codex: [], cursor: [], droid: [] }, + detectedAuth: [], + providerConnections: undefined, + runtimeConnections: {}, + availableModelIds: [], + opencodeBinaryInstalled: false, + opencodeBinarySource: "missing", + opencodeInventoryError: null, + opencodeProviders: [], + apiKeyStore: { + secureStorageAvailable: true, + legacyPlaintextDetected: false, + decryptionFailed: false, + }, + })); + const runtime = { + aiIntegrationService: { + getStatus, + getDailyUsageBatch: vi.fn(() => featureUsage), + getFeatureFlag: vi.fn((feature: string) => feature === "narratives"), + getDailyBudgetLimit: vi.fn((feature: string) => feature === "narratives" ? 5 : null), + }, + } as unknown as Parameters[0]; + const aiService = getAdeActionDomainServices(runtime).ai as { + getStatus(args?: { force?: boolean; refreshOpenCodeInventory?: boolean }): Promise<{ + features: Array<{ feature: string; enabled: boolean; dailyUsage: number; dailyLimit: number | null }>; + }>; + }; + + const status = await aiService.getStatus({ force: true, refreshOpenCodeInventory: true }); + + expect(getStatus).toHaveBeenCalledWith({ + force: true, + refreshOpenCodeInventory: true, + }); + expect(status.features).toContainEqual({ + feature: "narratives", + enabled: true, + dailyUsage: 2, + dailyLimit: 5, + }); + expect(status.features).toContainEqual({ + feature: "mission_planning", + enabled: false, + dailyUsage: 0, + dailyLimit: null, + }); + }); + + it("delegates AI key mutations to the runtime service", () => { + const storeApiKey = vi.fn(); + const deleteApiKey = vi.fn(); + const listApiKeys = vi.fn(() => ["cursor"]); + const runtime = { + aiIntegrationService: { + verifyApiKeyConnection: vi.fn(), + storeApiKey, + deleteApiKey, + listApiKeys, + }, + } as unknown as Parameters[0]; + const aiService = getAdeActionDomainServices(runtime).ai as { + storeApiKey(args?: { provider?: string; key?: string }): void; + deleteApiKey(args?: { provider?: string }): void; + listApiKeys(): string[]; + }; + + aiService.storeApiKey({ provider: " Cursor ", key: " key " }); + aiService.deleteApiKey({ provider: " Cursor " }); + + expect(aiService.listApiKeys()).toEqual(["cursor"]); + expect(storeApiKey).toHaveBeenCalledWith("Cursor", "key"); + expect(deleteApiKey).toHaveBeenCalledWith("Cursor"); + }); +}); + +describe("runtime GitHub actions", () => { + it("allowlists github.detectRepo when the runtime service exposes it", () => { + const runtime = { + githubService: { + getStatus: vi.fn(), + setToken: vi.fn(), + clearToken: vi.fn(), + getRepoOrThrow: vi.fn(), + detectRepo: vi.fn(), + listRepoLabels: vi.fn(), + listRepoCollaborators: vi.fn(), + publishCurrentProject: vi.fn(), + }, + } as unknown as Parameters[0]; + const githubService = getAdeActionDomainServices(runtime).github as Record; + + expect(githubService.detectRepo).toEqual(expect.any(Function)); + expect(listAllowedAdeActionNames("github", githubService)).toContain("detectRepo"); + expect(listAllowedAdeActionNames("github", githubService)).toEqual(expect.arrayContaining([ + "listRepoCollaborators", + "listRepoLabels", + "publishCurrentProject", + ])); + }); + + it("routes object-shaped GitHub repo picker args to the positional service methods", async () => { + const listRepoLabels = vi.fn(async () => [{ name: "bug" }]); + const listRepoCollaborators = vi.fn(async () => [{ login: "octocat" }]); + const runtime = { + githubService: { + getStatus: vi.fn(), + setToken: vi.fn(), + clearToken: vi.fn(), + getRepoOrThrow: vi.fn(), + detectRepo: vi.fn(), + listRepoLabels, + listRepoCollaborators, + }, + } as unknown as Parameters[0]; + const githubService = getAdeActionDomainServices(runtime).github as { + listRepoLabels(args?: { owner?: string; name?: string }): Promise; + listRepoCollaborators(args?: { owner?: string; name?: string }): Promise; + }; + + await expect(githubService.listRepoLabels({ owner: " acme ", name: " ade " })).resolves.toEqual([{ name: "bug" }]); + await expect(githubService.listRepoCollaborators({ owner: " acme ", name: " ade " })).resolves.toEqual([{ login: "octocat" }]); + + expect(listRepoLabels).toHaveBeenCalledWith("acme", "ade"); + expect(listRepoCollaborators).toHaveBeenCalledWith("acme", "ade"); + }); + + it("routes object-shaped publish args to the GitHub service", async () => { + const publishCurrentProject = vi.fn(async () => ({ + state: "pushed" as const, + htmlUrl: "https://github.com/acme/ade", + })); + const runtime = { + githubService: { + getStatus: vi.fn(), + setToken: vi.fn(), + clearToken: vi.fn(), + getRepoOrThrow: vi.fn(), + detectRepo: vi.fn(), + publishCurrentProject, + }, + } as unknown as Parameters[0]; + const githubService = getAdeActionDomainServices(runtime).github as { + publishCurrentProject(args?: { name?: string; description?: string; isPrivate?: boolean }): Promise; + }; + + await expect(githubService.publishCurrentProject({ + name: " ade ", + description: "Local-first agent desk", + isPrivate: true, + })).resolves.toEqual({ + state: "pushed", + htmlUrl: "https://github.com/acme/ade", + }); + await expect(githubService.publishCurrentProject({ name: "ade" })).rejects.toThrow("Expected 'isPrivate' to be a boolean."); + + expect(publishCurrentProject).toHaveBeenCalledWith({ + name: "ade", + description: "Local-first agent desk", + isPrivate: true, + }); + }); + + it("returns fresh GitHub status after token mutations", async () => { + let tokenStored = false; + const setToken = vi.fn((token: string) => { + tokenStored = token.length > 0; + }); + const clearToken = vi.fn(() => { + tokenStored = false; + }); + const runtime = { + githubService: { + getStatus: vi.fn(async () => ({ + tokenStored, + tokenDecryptionFailed: false, + storageScope: "app", + tokenType: tokenStored ? "classic" : "unknown", + repo: { owner: "ade", name: "runtime" }, + hasOrigin: true, + userLogin: null, + scopes: [], + checkedAt: tokenStored ? TEST_NOW : null, + repoAccessOk: tokenStored, + repoAccessError: tokenStored ? null : "GitHub token missing.", + connected: tokenStored, + })), + setToken, + clearToken, + }, + } as unknown as Parameters[0]; + + const githubService = getAdeActionDomainServices(runtime).github as { + setToken(token: string): Promise<{ + connected: boolean; + hasOrigin: boolean; + repoAccessError: string | null; + repoAccessOk: boolean | null; + tokenStored: boolean; + }>; + clearToken(): Promise<{ + connected: boolean; + hasOrigin: boolean; + repoAccessError: string | null; + repoAccessOk: boolean | null; + tokenStored: boolean; + }>; + }; + + await expect(githubService.setToken("ghp_test")).resolves.toMatchObject({ + connected: true, + hasOrigin: true, + repoAccessError: null, + repoAccessOk: true, + tokenStored: true, + }); + await expect(githubService.clearToken()).resolves.toMatchObject({ + connected: false, + hasOrigin: true, + repoAccessError: "GitHub token missing.", + repoAccessOk: false, + tokenStored: false, + }); + expect(setToken).toHaveBeenCalledWith("ghp_test"); + expect(clearToken).toHaveBeenCalled(); + }); +}); + +describe("runtime file actions", () => { + it("uses the runtime client id as the file watcher sender without leaking metadata to file args", async () => { + const pushedEvents: unknown[] = []; + const watchWorkspace = vi.fn(async (args, callback, senderId) => { + callback({ + workspaceId: "ws-1", + type: "modified", + path: "src/App.tsx", + ts: "2026-05-10T00:00:00.000Z", + }); + return { args, senderId }; + }); + const stopWatching = vi.fn(); + const runtime = { + fileService: { + watchWorkspace, + stopWatching, + }, + eventBuffer: { + push(event: unknown) { + pushedEvents.push(event); + }, + }, + } as unknown as Parameters[0]; + + const fileService = getAdeActionDomainServices(runtime).file as { + watchWorkspace(args?: unknown): Promise<{ ok: true }>; + stopWatching(args?: unknown): { ok: true }; + }; + + await fileService.watchWorkspace({ + workspaceId: "ws-1", + includeIgnored: true, + __adeRuntimeClientId: 42, + }); + fileService.stopWatching({ + workspaceId: "ws-1", + includeIgnored: true, + __adeRuntimeClientId: 42, + }); + + expect(watchWorkspace).toHaveBeenCalledWith( + { workspaceId: "ws-1", includeIgnored: true }, + expect.any(Function), + 42, + ); + expect(stopWatching).toHaveBeenCalledWith( + { workspaceId: "ws-1", includeIgnored: true }, + 42, + ); + expect(pushedEvents).toEqual([ + expect.objectContaining({ + category: "runtime", + payload: { + type: "file_change", + event: expect.objectContaining({ path: "src/App.tsx" }), + }, + }), + ]); + }); +}); diff --git a/apps/desktop/src/main/services/adeActions/registry.ts b/apps/desktop/src/main/services/adeActions/registry.ts index cb28bd55a..14c103da3 100644 --- a/apps/desktop/src/main/services/adeActions/registry.ts +++ b/apps/desktop/src/main/services/adeActions/registry.ts @@ -1,6 +1,11 @@ +import fs from "node:fs"; +import path from "node:path"; +import { randomUUID } from "node:crypto"; import type { AdeRuntime } from "../../../../../ade-cli/src/bootstrap"; import type { AutomationManualTriggerRequest, + AutomationIngressEventRecord, + AutomationIngressStatus, AutomationRun, AutomationRunDetail, AutomationRunListArgs, @@ -8,7 +13,72 @@ import type { AutomationSaveDraftRequest, AutomationSaveDraftResult, } from "../../../shared/types/automations"; +import type { ComputerUseOwnerSnapshotArgs } from "../../../shared/types/computerUseArtifacts"; +import type { + AgentChatFileSearchArgs, + AgentChatFileSearchResult, + AgentChatGetTurnFileDiffArgs, + AgentChatParallelLaunchState, + AgentChatSetParallelLaunchStateArgs, + AgentChatTurnFileDiff, +} from "../../../shared/types/chat"; import type { AutomationRule } from "../../../shared/types/config"; +import { buildPrAiResolutionContextKey } from "../../../shared/types"; +import type { + OrchestratorChatMessage, + OrchestratorRun, + OrchestratorRunGraph, +} from "../../../shared/types/orchestrator"; +import type { + AiConfig, + ApplyLaneTemplateArgs, + FileChangeEvent, + FilesWatchArgs, + LaneEnvInitConfig, + LaneEnvInitProgress, + LaneListSnapshot, + LaneOverlayOverrides, + LanePreviewInfo, + ListLanesArgs, + LaunchPrIssueResolutionFromThreadArgs, + PortLease, + PrAgentPermissionMode, + PrAiResolutionContext, + PrAiResolutionEventPayload, + PrAiResolutionGetSessionResult, + PrAiResolutionInputArgs, + PrAiResolutionSessionInfo, + PrAiResolutionSessionStatus, + PrAiResolutionStartArgs, + PrAiResolutionStartResult, + PrAiResolutionStopArgs, + PrIssueResolutionPromptPreviewArgs, + PrIssueResolutionStartArgs, + ProxyStatus, + RebaseResolutionStartArgs, + AiFeatureKey, + MemoryHealthStats, + AiSettingsStatus, + CtoRunProjectScanResult, + CtoLinearQuickView, + CtoSimulateFlowRouteArgs, + LinearConnectionStatus, + LinearRouteDecision, + NormalizedLinearIssue, + OnboardingDetectionResult, +} from "../../../shared/types"; +import { getModelById } from "../../../shared/modelRegistry"; +import { matchLaneOverlayPolicies } from "../config/laneOverlayMatcher"; +import { mergeAiConfig } from "../config/projectConfigService"; +import { appendDiffTruncationNotice, MAX_DIFF_SIDE_TEXT_BYTES } from "../diffs/diffService"; +import { runGit } from "../git/git"; +import { buildComputerUseOwnerSnapshot } from "../computerUse/controlPlane"; +import { buildLaneListSnapshots } from "../lanes/laneListSnapshotService"; +import { launchPrIssueResolutionChat, previewPrIssueResolutionPrompt } from "../prs/prIssueResolver"; +import { launchRebaseResolutionChat } from "../prs/prRebaseResolver"; +import { mapPermissionModeForModelFamily } from "../prs/resolverUtils"; +import { getErrorMessage, isRecord, nowIso, toMemoryEntryDto } from "../shared/utils"; +import { readCoordinatorCheckpoint } from "../orchestrator/missionStateDoc"; export const ADE_ACTION_DOMAIN_NAMES = [ "lane", @@ -19,21 +89,25 @@ export const ADE_ACTION_DOMAIN_NAMES = [ "tests", "chat", "keybindings", + "ai", "onboarding", "automation_planner", "mission", "orchestrator", "orchestrator_core", + "mission_budget", "memory", "cto_state", "worker_agent", "session", "operation", + "ade_project", "project_config", "issue_inventory", "path_to_merge", "flow_policy", "linear_credentials", + "linear_oauth", "linear_dispatcher", "linear_issue_tracker", "linear_sync", @@ -57,6 +131,7 @@ export const ADE_ACTION_DOMAIN_NAMES = [ "built_in_browser", "macos_vm", "automations", + "review", "issue", ] as const; @@ -79,11 +154,13 @@ export const ADE_ACTION_CTO_ONLY: Partial> = { lane: [ "adoptAttached", + "archive", "attach", + "cancelDelete", "create", + "createChild", "createFromUnstaged", + "deferRebaseSuggestion", "delete", + "deleteTemplate", + "diagnosticsActivateFallback", + "diagnosticsDeactivateFallback", + "diagnosticsGetLaneHealth", + "diagnosticsGetStatus", + "diagnosticsRunFullCheck", + "diagnosticsRunHealthCheck", + "dismissAutoRebaseStatus", + "dismissRebaseSuggestion", "getChildren", + "getDefaultTemplate", + "getDeleteRisk", + "getEnvStatus", + "getOverlay", "getStackChain", + "getTemplate", "importBranch", + "initEnv", + "listAutoRebaseStatuses", "list", + "listSnapshots", + "listRebaseSuggestions", + "listTemplates", "listUnregisteredWorktrees", + "oauthDecodeState", + "oauthEncodeState", + "oauthGenerateRedirectUris", + "oauthGetStatus", + "oauthListSessions", + "oauthUpdateConfig", + "portAcquire", + "portGetLease", + "portListConflicts", + "portListLeases", + "portRecoverOrphans", + "portRelease", + "previewBranchSwitch", + "proxyAddRoute", + "proxyGetPreviewInfo", + "proxyGetStatus", + "proxyRemoveRoute", + "proxyStart", + "proxyStop", "refreshSnapshots", + "rebaseAbort", + "rebasePush", + "rebaseRollback", + "rebaseStart", "rename", "reparent", + "applyTemplate", + "saveTemplate", + "setDefaultTemplate", + "switchBranch", + "unarchive", "updateAppearance", ], git: [ @@ -135,6 +263,9 @@ export const ADE_ACTION_ALLOWLIST: Partial; + getHistory(args: { id: string; limit?: number }): AutomationRun[]; listRuns(args?: AutomationRunListArgs): AutomationRun[]; getRunDetail(args: { runId: string }): Promise; + getIngressStatus(): AutomationIngressStatus; + listIngressEvents(args?: { limit?: number }): AutomationIngressEventRecord[]; }; function buildAutomationsDomainService(runtime: AdeRuntime): AutomationsDomainService | null { @@ -416,8 +827,11 @@ function buildAutomationsDomainService(runtime: AdeRuntime): AutomationsDomainSe deleteRule: ({ id }) => automationService.deleteRule({ id }), toggleRule: ({ id, enabled }) => automationService.toggle({ id, enabled }), triggerManually: (args) => automationService.triggerManually(args), + getHistory: (args) => automationService.getHistory(args), listRuns: (args = {}) => automationService.listRuns(args), getRunDetail: ({ runId }) => automationService.getRunDetail({ runId }), + getIngressStatus: () => automationService.getIngressStatus(), + listIngressEvents: (args = {}) => automationService.listIngressEvents(args.limit), }; } @@ -475,6 +889,1583 @@ function toService(value: unknown): OpaqueService | null { return (value ?? null) as OpaqueService | null; } +const MAX_TEMP_ATTACHMENT_BYTES = 10 * 1024 * 1024; + +function agentChatParallelLaunchStateKey(projectRoot: string, parentLaneId: string): string { + return `agent-chat-parallel-launch:${projectRoot}:${parentLaneId}`; +} + +function normalizeStringList(value: unknown): string[] { + if (!Array.isArray(value)) return []; + return value.filter((entry): entry is string => typeof entry === "string" && entry.trim().length > 0); +} + +function normalizeAgentChatParallelLaunchState( + raw: unknown, + parentLaneId: string, +): AgentChatParallelLaunchState | null { + if (!isRecord(raw)) return null; + const status = typeof raw.status === "string" ? raw.status : ""; + if (!["creating_lanes", "sending", "completed", "cleanup_pending"].includes(status)) return null; + return { + parentLaneId, + createdLaneIds: normalizeStringList(raw.createdLaneIds), + sentLaneIds: normalizeStringList(raw.sentLaneIds), + status: status as AgentChatParallelLaunchState["status"], + updatedAt: typeof raw.updatedAt === "string" && raw.updatedAt.trim().length + ? raw.updatedAt + : new Date().toISOString(), + lastError: typeof raw.lastError === "string" && raw.lastError.trim().length ? raw.lastError.trim() : null, + }; +} + +async function getTurnFileDiffFromGit( + projectRoot: string, + arg: AgentChatGetTurnFileDiffArgs, +): Promise { + const lang = arg.filePath.split(".").pop() ?? undefined; + const readSide = async (spec: string): Promise<{ + exists: boolean; + text: string; + isTruncated?: boolean; + isBinary?: boolean; + }> => { + const result = await runGit(["show", spec], { + cwd: projectRoot, + timeoutMs: 10_000, + maxOutputBytes: MAX_DIFF_SIDE_TEXT_BYTES + 64 * 1024, + }); + if (result.exitCode !== 0) return { exists: false, text: "" }; + const buf = Buffer.from(result.stdout, "utf8"); + if (buf.includes(0)) return { exists: true, text: "", isBinary: true }; + if (buf.length <= MAX_DIFF_SIDE_TEXT_BYTES) return { exists: true, text: result.stdout }; + return { + exists: true, + text: appendDiffTruncationNotice(buf.subarray(0, MAX_DIFF_SIDE_TEXT_BYTES).toString("utf8")), + isTruncated: true, + }; + }; + const origResult = await readSide(`${arg.beforeSha}:${arg.filePath}`); + const modResult = await readSide(`${arg.afterSha}:${arg.filePath}`); + return { + path: arg.filePath, + mode: "commit", + ...(lang ? { language: lang } : {}), + original: origResult, + modified: modResult, + ...(origResult.isBinary || modResult.isBinary ? { isBinary: true } : {}), + }; +} + +function saveAgentChatTempAttachment(projectRoot: string, arg: { data?: string; filename?: string }): { path: string } { + const maxEncodedLength = Math.ceil(MAX_TEMP_ATTACHMENT_BYTES / 3) * 4; + if (typeof arg.data !== "string") { + throw new Error("Temporary attachment data is required."); + } + if (arg.data.length > maxEncodedLength) { + throw new Error("Temporary attachments must be 10 MB or smaller."); + } + const content = Buffer.from(arg.data, "base64"); + if (content.byteLength > MAX_TEMP_ATTACHMENT_BYTES) { + throw new Error("Temporary attachments must be 10 MB or smaller."); + } + const baseDir = path.join(projectRoot, ".ade", "attachments"); + fs.mkdirSync(baseDir, { recursive: true }); + const filename = typeof arg.filename === "string" ? arg.filename : ""; + const ext = path.extname(filename) || ".png"; + const destPath = path.join(baseDir, `${randomUUID()}${ext}`); + fs.writeFileSync(destPath, content); + return { path: destPath }; +} + +function buildChatDomainService(runtime: AdeRuntime): OpaqueService | null { + const agentChatService = runtime.agentChatService; + if (!agentChatService) return null; + const base = agentChatService as unknown as OpaqueService; + return { + ...base, + ensureCtoSession: async (args?: { modelId?: string | null; reasoningEffort?: string | null }) => { + const laneId = await resolvePrimaryLaneId(runtime); + return agentChatService.ensureIdentitySession({ + identityKey: "cto", + laneId, + modelId: args?.modelId ?? null, + reasoningEffort: args?.reasoningEffort ?? null, + permissionMode: "full-auto", + }); + }, + ensureAgentIdentitySession: async (args?: { + agentId?: string; + modelId?: string | null; + reasoningEffort?: string | null; + }) => { + const agentId = requireNonEmptyString(args?.agentId, "agentId"); + const laneId = await resolvePrimaryLaneId(runtime); + return agentChatService.ensureIdentitySession({ + identityKey: `agent:${agentId}`, + laneId, + modelId: args?.modelId ?? null, + reasoningEffort: args?.reasoningEffort ?? null, + }); + }, + getParallelLaunchState: (args?: { parentLaneId?: string }) => { + const parentLaneId = requireNonEmptyString(args?.parentLaneId, "parentLaneId"); + const key = agentChatParallelLaunchStateKey(runtime.projectRoot, parentLaneId); + return normalizeAgentChatParallelLaunchState( + runtime.db.getJson(key), + parentLaneId, + ); + }, + setParallelLaunchState: (args?: AgentChatSetParallelLaunchStateArgs) => { + const parentLaneId = requireNonEmptyString(args?.parentLaneId, "parentLaneId"); + const key = agentChatParallelLaunchStateKey(runtime.projectRoot, parentLaneId); + runtime.db.setJson(key, normalizeAgentChatParallelLaunchState(args?.state ?? null, parentLaneId)); + }, + fileSearch: async (args?: AgentChatFileSearchArgs): Promise => { + const sessionId = requireNonEmptyString(args?.sessionId, "sessionId"); + const query = typeof args?.query === "string" ? args.query : ""; + const session = (await agentChatService.listSessions()).find((entry) => entry.sessionId === sessionId); + if (!session?.laneId || !runtime.fileService) return []; + const matches = await runtime.fileService.quickOpen({ + workspaceId: session.laneId, + query, + limit: 20, + }); + return matches.map((match) => ({ + path: match.path, + ...(typeof match.score === "number" ? { score: match.score } : {}), + })); + }, + getTurnFileDiff: (args?: AgentChatGetTurnFileDiffArgs) => { + if (!args) throw new Error("Turn file diff args are required."); + return getTurnFileDiffFromGit(runtime.projectRoot, args); + }, + saveTempAttachment: (args?: { data?: string; filename?: string }) => + saveAgentChatTempAttachment(runtime.projectRoot, args ?? {}), + }; +} + +async function resolvePrimaryLaneId(runtime: AdeRuntime): Promise { + const laneService = requireService(runtime.laneService, "Lane service not available."); + await laneService.ensurePrimaryLane(); + const lanes = await laneService.list(); + const primary = lanes.find((lane) => lane.laneType === "primary"); + if (!primary?.id) { + throw new Error("No primary lane is available to host the identity chat session."); + } + return primary.id; +} + +function summarizeProjectScan(result: OnboardingDetectionResult | null): Partial<{ + projectSummary: string; + criticalConventions: string[]; + activeFocus: string[]; + notes: string[]; +}> { + if (!result) return {}; + const projectTypes = result.projectTypes.filter((entry) => entry.trim().length > 0); + const signalFiles = result.indicators + .slice(0, 4) + .map((indicator) => indicator.file.trim()) + .filter((entry) => entry.length > 0); + const workflowPaths = result.suggestedWorkflows + .slice(0, 4) + .map((workflow) => workflow.path.trim()) + .filter((entry) => entry.length > 0); + + return { + projectSummary: `Detected ${projectTypes.join(", ") || "project"} setup from ${signalFiles.join(", ") || "repository signals"}.`, + criticalConventions: projectTypes.map((type) => `${type} conventions`), + activeFocus: projectTypes.length > 0 ? [`stabilize ${projectTypes[0]} workflows`] : [], + notes: workflowPaths.length > 0 ? workflowPaths.map((workflow) => `Detected workflow: ${workflow}`) : [], + }; +} + +function buildCtoStateDomainService(runtime: AdeRuntime): OpaqueService | null { + const ctoStateService = runtime.ctoStateService; + if (!ctoStateService) return null; + return { + ...(ctoStateService as unknown as OpaqueService), + runProjectScan: async (): Promise => { + const detection = await runtime.onboardingService?.detectDefaults().catch(() => null) ?? null; + const summary = summarizeProjectScan(detection); + const coreMemoryPatch = { + projectSummary: summary.projectSummary ?? "", + criticalConventions: summary.criticalConventions ?? [], + activeFocus: summary.activeFocus ?? [], + notes: summary.notes ?? [], + }; + + ctoStateService.updateCoreMemory(coreMemoryPatch); + + const createdMemoryIds: string[] = []; + if (runtime.memoryService) { + if (coreMemoryPatch.projectSummary) { + createdMemoryIds.push( + runtime.memoryService.addMemory({ + projectId: runtime.projectId, + scope: "project", + category: "fact", + content: coreMemoryPatch.projectSummary, + importance: "high", + }).id, + ); + } + for (const convention of coreMemoryPatch.criticalConventions) { + createdMemoryIds.push( + runtime.memoryService.addMemory({ + projectId: runtime.projectId, + scope: "project", + category: "convention", + content: convention, + importance: "medium", + }).id, + ); + } + } + + return { detection, coreMemoryPatch, createdMemoryIds }; + }, + }; +} + +function buildComputerUseArtifactsDomainService(runtime: AdeRuntime): OpaqueService | null { + const broker = runtime.computerUseArtifactBrokerService; + if (!broker) return null; + return { + ...(broker as unknown as OpaqueService), + getOwnerSnapshot: (args?: ComputerUseOwnerSnapshotArgs) => { + if (!args?.owner) throw new Error("owner is required."); + return buildComputerUseOwnerSnapshot({ + broker, + owner: args.owner, + ...(args.limit !== undefined ? { limit: args.limit } : {}), + }); + }, + }; +} + +function buildSessionDomainService(runtime: AdeRuntime): OpaqueService | null { + const sessionService = runtime.sessionService; + if (!sessionService) return null; + return { + ...(sessionService as unknown as OpaqueService), + getDelta: (args?: { sessionId?: string } | string) => { + const sessionId = typeof args === "string" + ? requireNonEmptyString(args, "sessionId") + : requireNonEmptyString(args?.sessionId, "sessionId"); + return runtime.sessionDeltaService?.getSessionDelta(sessionId) ?? null; + }, + }; +} + +function buildWorkerAgentDomainService(runtime: AdeRuntime): OpaqueService | null { + const workerAgentService = runtime.workerAgentService; + if (!workerAgentService) return null; + return { + ...(workerAgentService as unknown as OpaqueService), + saveAgent: (args?: { agent?: unknown; actor?: string }) => + requireService(runtime.workerRevisionService, "Worker revision service not available.").saveAgent( + args?.agent as never, + args?.actor ?? "user", + ), + removeAgent: (args?: { agentId?: string }) => { + workerAgentService.removeAgent(requireNonEmptyString(args?.agentId, "agentId")); + runtime.workerHeartbeatService?.syncFromConfig(); + }, + setAgentStatus: (args?: { agentId?: string; status?: string }) => { + workerAgentService.setAgentStatus(requireNonEmptyString(args?.agentId, "agentId"), args?.status as never); + runtime.workerHeartbeatService?.syncFromConfig(); + }, + listAgentRevisions: (args?: { agentId?: string; limit?: number }) => + requireService(runtime.workerRevisionService, "Worker revision service not available.").listAgentRevisions( + requireNonEmptyString(args?.agentId, "agentId"), + args?.limit ?? 20, + ), + rollbackAgentRevision: (args?: { agentId?: string; revisionId?: string; actor?: string }) => + requireService(runtime.workerRevisionService, "Worker revision service not available.").rollbackAgentRevision( + requireNonEmptyString(args?.agentId, "agentId"), + requireNonEmptyString(args?.revisionId, "revisionId"), + args?.actor ?? "user", + ), + getBudgetSnapshot: (args?: { monthKey?: string }) => + requireService(runtime.workerBudgetService, "Worker budget service not available.").getBudgetSnapshot( + args?.monthKey ? { monthKey: args.monthKey } : {}, + ), + triggerWakeup: (args?: unknown) => + requireService(runtime.workerHeartbeatService, "Worker heartbeat service not available.").triggerWakeup(args as never), + listAgentRuns: (args?: unknown) => + requireService(runtime.workerHeartbeatService, "Worker heartbeat service not available.").listRuns(args as never), + clearAgentTaskSession: (args?: unknown) => + requireService(runtime.workerTaskSessionService, "Worker task session service not available.").clearAgentTaskSession(args as never), + listAgentTaskSessions: (args?: { agentId?: string; limit?: number }) => + requireService(runtime.workerTaskSessionService, "Worker task session service not available.").listAgentTaskSessions( + requireNonEmptyString(args?.agentId, "agentId"), + args?.limit ?? 40, + ), + }; +} + +type MemoryWriteScope = "user" | "project" | "lane" | "mission"; +type MemoryScope = "project" | "agent" | "mission"; + +type MemoryRuntimeExtraService = + | "missionMemoryLifecycleService" + | "proceduralLearningService" + | "skillRegistryService" + | "humanWorkDigestService" + | "embeddingService" + | "embeddingWorkerService" + | "memoryLifecycleService" + | "batchConsolidationService"; + +type MemoryHealthCountRow = { + scope: string | null; + tier: number | null; + status: string | null; + count: number | null; +}; + +type MemorySweepLogRow = { + sweep_id: string; + project_id: string; + trigger_reason: string | null; + started_at: string; + completed_at: string; + entries_decayed: number | null; + entries_demoted: number | null; + entries_promoted: number | null; + entries_archived: number | null; + entries_orphaned: number | null; + duration_ms: number | null; +}; + +type MemoryConsolidationLogRow = { + consolidation_id: string; + project_id: string; + trigger_reason: string | null; + started_at: string; + completed_at: string; + clusters_found: number | null; + entries_merged: number | null; + entries_created: number | null; + tokens_used: number | null; + duration_ms: number | null; +}; + +const MEMORY_HEALTH_SCOPES = ["project", "agent", "mission"] as const; +const MEMORY_HEALTH_LIMITS: Record<(typeof MEMORY_HEALTH_SCOPES)[number], number> = { + project: 2000, + agent: 500, + mission: 200, +}; + +function normalizeMemoryWriteScope(rawScope: unknown): MemoryWriteScope | undefined { + const trimmed = typeof rawScope === "string" ? rawScope.trim() : ""; + if (trimmed === "agent") return "user"; + if (trimmed === "user" || trimmed === "project" || trimmed === "lane" || trimmed === "mission") return trimmed; + return undefined; +} + +function normalizeMemoryScope(rawScope: unknown): MemoryScope | undefined { + const trimmed = typeof rawScope === "string" ? rawScope.trim() : ""; + if (trimmed === "project") return "project"; + if (trimmed === "agent" || trimmed === "user") return "agent"; + if (trimmed === "mission" || trimmed === "lane") return "mission"; + return undefined; +} + +function normalizeMemoryHealthScope(rawScope: unknown): (typeof MEMORY_HEALTH_SCOPES)[number] | null { + const scope = normalizeMemoryScope(rawScope); + return scope ?? null; +} + +function getMemoryExtraService(runtime: AdeRuntime, key: MemoryRuntimeExtraService): OpaqueService | null { + return toService((runtime as unknown as Record)[key]); +} + +function createEmptyRuntimeMemoryHealthStats(): MemoryHealthStats { + const model: MemoryHealthStats["embeddings"]["model"] = { + modelId: "Xenova/all-MiniLM-L6-v2", + state: "idle", + activity: "idle", + installState: "missing", + cacheDir: null, + installPath: null, + progress: null, + loaded: null, + total: null, + file: null, + error: null, + }; + + return { + scopes: MEMORY_HEALTH_SCOPES.map((scope) => ({ + scope, + current: 0, + max: MEMORY_HEALTH_LIMITS[scope], + counts: { + tier1: 0, + tier2: 0, + tier3: 0, + archived: 0, + }, + })), + lastSweep: null, + lastConsolidation: null, + embeddings: { + entriesEmbedded: 0, + entriesTotal: 0, + queueDepth: 0, + processing: false, + lastBatchProcessedAt: null, + cacheEntries: 0, + cacheHits: 0, + cacheMisses: 0, + cacheHitRate: 0, + model, + }, + }; +} + +function numberOrZero(value: unknown): number { + const next = Number(value ?? 0); + return Number.isFinite(next) ? next : 0; +} + +function getRuntimeMemoryHealthStats(runtime: AdeRuntime): MemoryHealthStats { + const stats = createEmptyRuntimeMemoryHealthStats(); + const scopes = new Map(stats.scopes.map((entry) => [entry.scope, entry] as const)); + + const rows = runtime.db.all( + ` + SELECT scope, tier, status, COUNT(*) AS count + FROM unified_memories + WHERE project_id = ? + GROUP BY scope, tier, status + `, + [runtime.projectId], + ); + + for (const row of rows) { + const scope = normalizeMemoryHealthScope(row.scope); + if (!scope) continue; + const target = scopes.get(scope); + if (!target) continue; + const count = numberOrZero(row.count); + if (count <= 0) continue; + + if (String(row.status ?? "").trim() === "archived") { + target.counts.archived += count; + continue; + } + + const tier = Number(row.tier ?? 0); + if (tier === 1) target.counts.tier1 += count; + else if (tier === 2) target.counts.tier2 += count; + else target.counts.tier3 += count; + } + + for (const scope of stats.scopes) { + scope.current = scope.counts.tier1 + scope.counts.tier2 + scope.counts.tier3; + } + + const embeddingService = getMemoryExtraService(runtime, "embeddingService"); + const embeddingWorkerService = getMemoryExtraService(runtime, "embeddingWorkerService"); + const getEmbeddingStatus = embeddingService?.getStatus; + const getEmbeddingWorkerStatus = embeddingWorkerService?.getStatus; + const embeddingStatus = typeof getEmbeddingStatus === "function" + ? asActionRecord(getEmbeddingStatus.call(embeddingService)) + : {}; + const embeddingWorkerStatus = typeof getEmbeddingWorkerStatus === "function" + ? asActionRecord(getEmbeddingWorkerStatus.call(embeddingWorkerService)) + : {}; + const embeddedCountRow = runtime.db.get<{ count: number | null }>( + ` + SELECT COUNT(*) AS count + FROM unified_memories m + WHERE m.project_id = ? + AND m.status != 'archived' + AND EXISTS ( + SELECT 1 + FROM unified_memory_embeddings e + WHERE e.memory_id = m.id + ) + `, + [runtime.projectId], + ); + const entriesEmbedded = numberOrZero(embeddedCountRow?.count); + const entriesTotal = stats.scopes.reduce((total, scope) => total + scope.current, 0); + const cacheHits = numberOrZero(embeddingStatus.cacheHits); + const cacheMisses = numberOrZero(embeddingStatus.cacheMisses); + const cacheTotal = cacheHits + cacheMisses; + + stats.embeddings = { + entriesEmbedded, + entriesTotal, + queueDepth: numberOrZero(embeddingWorkerStatus.queueDepth), + processing: embeddingWorkerStatus.processing === true, + lastBatchProcessedAt: typeof embeddingWorkerStatus.lastProcessedAt === "string" + ? embeddingWorkerStatus.lastProcessedAt + : null, + cacheEntries: numberOrZero(embeddingStatus.cacheEntries), + cacheHits, + cacheMisses, + cacheHitRate: cacheTotal > 0 ? cacheHits / cacheTotal : 0, + model: { + modelId: typeof embeddingStatus.modelId === "string" ? embeddingStatus.modelId : "Xenova/all-MiniLM-L6-v2", + state: typeof embeddingStatus.state === "string" ? embeddingStatus.state as never : "idle", + activity: typeof embeddingStatus.activity === "string" ? embeddingStatus.activity as never : "idle", + installState: typeof embeddingStatus.installState === "string" ? embeddingStatus.installState as never : "missing", + cacheDir: typeof embeddingStatus.cacheDir === "string" ? embeddingStatus.cacheDir : null, + installPath: typeof embeddingStatus.installPath === "string" ? embeddingStatus.installPath : null, + progress: typeof embeddingStatus.progress === "number" ? embeddingStatus.progress : null, + loaded: typeof embeddingStatus.loaded === "number" ? embeddingStatus.loaded : null, + total: typeof embeddingStatus.total === "number" ? embeddingStatus.total : null, + file: typeof embeddingStatus.file === "string" ? embeddingStatus.file : null, + error: typeof embeddingStatus.error === "string" ? embeddingStatus.error : null, + }, + }; + + const lastSweep = runtime.db.get( + ` + SELECT sweep_id, project_id, trigger_reason, started_at, completed_at, + entries_decayed, entries_demoted, entries_promoted, entries_archived, + entries_orphaned, duration_ms + FROM memory_sweep_log + WHERE project_id = ? + ORDER BY completed_at DESC + LIMIT 1 + `, + [runtime.projectId], + ); + if (lastSweep) { + stats.lastSweep = { + sweepId: lastSweep.sweep_id, + projectId: lastSweep.project_id, + reason: lastSweep.trigger_reason === "startup" ? "startup" : "manual", + startedAt: lastSweep.started_at, + completedAt: lastSweep.completed_at, + entriesDecayed: numberOrZero(lastSweep.entries_decayed), + entriesDemoted: numberOrZero(lastSweep.entries_demoted), + entriesPromoted: numberOrZero(lastSweep.entries_promoted), + entriesArchived: numberOrZero(lastSweep.entries_archived), + entriesOrphaned: numberOrZero(lastSweep.entries_orphaned), + durationMs: numberOrZero(lastSweep.duration_ms), + }; + } + + const lastConsolidation = runtime.db.get( + ` + SELECT consolidation_id, project_id, trigger_reason, started_at, completed_at, + clusters_found, entries_merged, entries_created, tokens_used, duration_ms + FROM memory_consolidation_log + WHERE project_id = ? + ORDER BY completed_at DESC + LIMIT 1 + `, + [runtime.projectId], + ); + if (lastConsolidation) { + stats.lastConsolidation = { + consolidationId: lastConsolidation.consolidation_id, + projectId: lastConsolidation.project_id, + reason: lastConsolidation.trigger_reason === "auto" ? "auto" : "manual", + startedAt: lastConsolidation.started_at, + completedAt: lastConsolidation.completed_at, + clustersFound: numberOrZero(lastConsolidation.clusters_found), + entriesMerged: numberOrZero(lastConsolidation.entries_merged), + entriesCreated: numberOrZero(lastConsolidation.entries_created), + tokensUsed: numberOrZero(lastConsolidation.tokens_used), + durationMs: numberOrZero(lastConsolidation.duration_ms), + }; + } + + return stats; +} + +function buildMemoryDomainService(runtime: AdeRuntime): OpaqueService | null { + const memoryService = runtime.memoryService; + if (!memoryService || runtime.capabilities?.memory === false) return null; + + return { + ...(memoryService as unknown as OpaqueService), + add: (args?: unknown) => { + const record = asActionRecord(args); + const projectId = typeof record.projectId === "string" && record.projectId.trim() + ? record.projectId.trim() + : runtime.projectId; + const scope = normalizeMemoryWriteScope(record.scope) ?? "project"; + const content = typeof record.content === "string" ? record.content.trim() : ""; + if (!content) { + throw new Error("memory.add requires non-empty content."); + } + const category = typeof record.category === "string" && record.category.trim() + ? record.category.trim() + : ""; + if (!category) { + throw new Error("memory.add requires category."); + } + const importance = record.importance === "low" || record.importance === "medium" || record.importance === "high" + ? record.importance + : "medium"; + const sourceRunId = typeof record.sourceRunId === "string" && record.sourceRunId.trim() + ? record.sourceRunId.trim() + : undefined; + const scopeOwnerIdRaw = typeof record.scopeOwnerId === "string" ? record.scopeOwnerId.trim() : ""; + const scopeOwnerId = scopeOwnerIdRaw || (scope === "mission" && sourceRunId ? sourceRunId : undefined); + return memoryService.addMemory({ + projectId, + scope, + ...(scopeOwnerId ? { scopeOwnerId } : {}), + category: category as never, + content, + importance, + ...(sourceRunId ? { sourceRunId } : {}), + }); + }, + pin: (args?: { id?: string }) => { + memoryService.pinMemory(requireNonEmptyString(args?.id, "id")); + }, + getBudget: (args?: unknown) => { + const record = asActionRecord(args); + const projectId = typeof record.projectId === "string" && record.projectId.trim() + ? record.projectId.trim() + : runtime.projectId; + const level = record.level === "lite" || record.level === "standard" || record.level === "deep" + ? record.level + : "standard"; + const scope = normalizeMemoryScope(record.scope); + const scopeOwnerId = typeof record.scopeOwnerId === "string" && record.scopeOwnerId.trim() + ? record.scopeOwnerId.trim() + : undefined; + return memoryService.getMemoryBudget(projectId, level, { + ...(scope ? { scope } : {}), + ...(scopeOwnerId ? { scopeOwnerId } : {}), + }).map((memory) => toMemoryEntryDto(memory)); + }, + getCandidates: (args?: { projectId?: string; limit?: number }) => { + const projectId = typeof args?.projectId === "string" && args.projectId.trim() + ? args.projectId.trim() + : runtime.projectId; + return memoryService.getCandidateMemories(projectId, args?.limit ?? 20) + .map((memory) => toMemoryEntryDto(memory)); + }, + promote: (args?: { id?: string }) => { + memoryService.promoteMemory(requireNonEmptyString(args?.id, "id")); + }, + promoteMissionEntry: async (args?: { id?: string; missionId?: string; runId?: string | null }) => { + const service = getMemoryExtraService(runtime, "missionMemoryLifecycleService"); + const promoteMissionMemoryEntry = service?.promoteMissionMemoryEntry; + if (typeof promoteMissionMemoryEntry !== "function") return null; + const result = await promoteMissionMemoryEntry.call(service, { + memoryId: requireNonEmptyString(args?.id, "id"), + missionId: requireNonEmptyString(args?.missionId, "missionId"), + ...(args?.runId ? { runId: args.runId } : {}), + }); + return result ? toMemoryEntryDto(result as { embedded?: boolean }) : null; + }, + archive: (args?: { id?: string }) => { + memoryService.archiveMemory(requireNonEmptyString(args?.id, "id")); + }, + search: async (args?: unknown) => { + const record = asActionRecord(args); + const query = typeof record.query === "string" ? record.query : ""; + const projectId = typeof record.projectId === "string" && record.projectId.trim() + ? record.projectId.trim() + : runtime.projectId; + const scope = normalizeMemoryScope(record.scope); + const scopeOwnerId = typeof record.scopeOwnerId === "string" && record.scopeOwnerId.trim() + ? record.scopeOwnerId.trim() + : undefined; + const status = record.status === "all" + ? (["promoted", "candidate", "archived"] as const) + : record.status === "promoted" || record.status === "candidate" || record.status === "archived" + ? record.status + : "promoted"; + const memories = await memoryService.searchMemories( + query, + projectId, + scope, + typeof record.limit === "number" ? record.limit : 10, + status, + scopeOwnerId, + record.mode === "lexical" ? "lexical" : "hybrid", + ); + return memories.map((memory) => toMemoryEntryDto(memory)); + }, + list: (args?: unknown) => { + const record = asActionRecord(args); + const scope = normalizeMemoryScope(record.scope); + const status = record.status === "all" + ? (["promoted", "candidate", "archived"] as const) + : record.status === "candidate" || record.status === "promoted" || record.status === "archived" + ? record.status + : undefined; + const tier = record.tier === 1 || record.tier === 2 || record.tier === 3 ? record.tier : undefined; + const tiers = tier ? [tier] as const : undefined; + + return memoryService.listMemories({ + projectId: runtime.projectId, + ...(scope ? { scope } : {}), + ...(status ? { status } : {}), + ...(tiers ? { tiers } : {}), + limit: Math.max(1, Math.min(200, Math.floor(typeof record.limit === "number" ? record.limit : 100))), + }).map((memory) => toMemoryEntryDto(memory)); + }, + listMissionEntries: (args?: { missionId?: string; runId?: string | null; status?: string }) => { + const service = getMemoryExtraService(runtime, "missionMemoryLifecycleService"); + const listMissionEntries = service?.listMissionEntries; + if (typeof listMissionEntries !== "function") return []; + const status = args?.status ?? "all"; + return (listMissionEntries.call(service, { + projectId: runtime.projectId, + missionId: requireNonEmptyString(args?.missionId, "missionId"), + runId: args?.runId, + status, + }) as Array<{ embedded?: boolean }>).map((memory) => toMemoryEntryDto(memory)); + }, + listProcedures: (args?: unknown) => { + const service = getMemoryExtraService(runtime, "proceduralLearningService"); + const listProcedures = service?.listProcedures; + return typeof listProcedures === "function" ? listProcedures.call(service, asActionRecord(args)) : []; + }, + getProcedureDetail: (args?: { id?: string }) => { + const service = getMemoryExtraService(runtime, "proceduralLearningService"); + const getProcedureDetail = service?.getProcedureDetail; + return typeof getProcedureDetail === "function" + ? getProcedureDetail.call(service, requireNonEmptyString(args?.id, "id")) + : null; + }, + exportProcedureSkill: (args?: unknown) => { + const service = getMemoryExtraService(runtime, "skillRegistryService"); + const exportProcedureSkill = service?.exportProcedureSkill; + return typeof exportProcedureSkill === "function" + ? exportProcedureSkill.call(service, asActionRecord(args)) + : null; + }, + listIndexedSkills: () => { + const service = getMemoryExtraService(runtime, "skillRegistryService"); + const listIndexedSkills = service?.listIndexedSkills; + return typeof listIndexedSkills === "function" ? listIndexedSkills.call(service) : []; + }, + reindexSkills: (args?: unknown) => { + const service = getMemoryExtraService(runtime, "skillRegistryService"); + const reindexSkills = service?.reindexSkills; + return typeof reindexSkills === "function" ? reindexSkills.call(service, asActionRecord(args)) : []; + }, + syncKnowledge: () => { + const service = getMemoryExtraService(runtime, "humanWorkDigestService"); + const syncKnowledge = service?.syncKnowledge; + return typeof syncKnowledge === "function" ? syncKnowledge.call(service) : null; + }, + getKnowledgeSyncStatus: () => { + const service = getMemoryExtraService(runtime, "humanWorkDigestService"); + const getKnowledgeSyncStatus = service?.getKnowledgeSyncStatus; + return typeof getKnowledgeSyncStatus === "function" + ? getKnowledgeSyncStatus.call(service) + : { + syncing: false, + lastSeenHeadSha: null, + currentHeadSha: null, + diverged: false, + lastDigestAt: null, + lastDigestMemoryId: null, + lastError: null, + }; + }, + getHealthStats: () => getRuntimeMemoryHealthStats(runtime), + healthStats: () => getRuntimeMemoryHealthStats(runtime), + downloadEmbeddingModel: async () => { + const service = requireService(getMemoryExtraService(runtime, "embeddingService"), "Embedding service is not available."); + const preload = service.preload; + if (typeof preload !== "function") { + throw new Error("Embedding service is not available."); + } + const getStatus = service.getStatus; + const status = typeof getStatus === "function" ? asActionRecord(getStatus.call(service)) : {}; + const localFilesOnly = status.installState === "installed" && status.state !== "unavailable"; + if (!localFilesOnly && status.installState !== "missing" && typeof service.clearCache === "function") { + await service.clearCache.call(service); + } + void Promise.resolve(preload.call(service, { forceRetry: true, localFilesOnly })).catch(() => { + // Health polling will surface the unavailable state. + }); + return getRuntimeMemoryHealthStats(runtime); + }, + runSweep: () => { + const service = getMemoryExtraService(runtime, "memoryLifecycleService"); + const runSweep = service?.runSweep; + if (typeof runSweep !== "function") { + throw new Error("Memory lifecycle service is not available."); + } + return runSweep.call(service, { reason: "manual" }); + }, + runConsolidation: () => { + const service = getMemoryExtraService(runtime, "batchConsolidationService"); + const runConsolidation = service?.runConsolidation; + if (typeof runConsolidation !== "function") { + throw new Error("Batch consolidation service is not available."); + } + return runConsolidation.call(service, { reason: "manual" }); + }, + }; +} + +const RUNTIME_CURSOR_DOC_REF_TRANSPORT_LIMIT = 12; +const PAYLOAD_DOC_REF_TRANSPORT_LIMIT = 12; +const RUN_GRAPH_CONTEXT_SNAPSHOT_TRANSPORT_LIMIT = 5; +const CHAT_TOOL_RESULT_STRING_LIMIT = 1_200; +const CHAT_TOOL_RESULT_ARRAY_PREVIEW_LIMIT = 5; +const CHAT_TOOL_RESULT_KEY_PREVIEW_LIMIT = 12; + +function isAdeInternalDocPath(value: unknown): boolean { + if (typeof value !== "string") return false; + const normalized = value.replace(/\\/g, "/"); + return normalized === ".ade" || normalized.startsWith(".ade/") || normalized.includes("/.ade/"); +} + +function compactRuntimeCursorForTransport(value: unknown): unknown { + if (!isRecord(value)) return value; + const rawDocs = Array.isArray(value.docs) ? value.docs : []; + const docs = rawDocs + .filter((entry) => !isAdeInternalDocPath(isRecord(entry) ? entry.path : null)) + .slice(0, RUNTIME_CURSOR_DOC_REF_TRANSPORT_LIMIT) + .map((entry) => { + if (!isRecord(entry)) return entry; + return { + path: typeof entry.path === "string" ? entry.path : "", + bytes: typeof entry.bytes === "number" ? entry.bytes : 0, + sha256: typeof entry.sha256 === "string" ? entry.sha256 : "", + truncated: entry.truncated === true, + mode: typeof entry.mode === "string" ? entry.mode : undefined, + }; + }); + return { + ...value, + docs, + docsOmittedCount: Math.max(0, rawDocs.length - docs.length), + }; +} + +function compactDocRefsArrayForTransport(rawDocs: unknown[], limit: number): unknown[] { + return rawDocs + .filter((entry) => !isAdeInternalDocPath(isRecord(entry) ? entry.path : null)) + .slice(0, limit); +} + +function compactPayloadForTransport(payload: Record | null): Record | null { + if (!payload) return payload; + const next: Record = { ...payload }; + if (Array.isArray(next.docsRefs)) { + const rawDocsRefs = next.docsRefs; + const docsRefs = compactDocRefsArrayForTransport(rawDocsRefs, PAYLOAD_DOC_REF_TRANSPORT_LIMIT); + next.docsRefs = docsRefs; + next.docsRefsOmittedCount = Math.max(0, rawDocsRefs.length - docsRefs.length); + } + return next; +} + +function compactChatToolValueForTransport(value: unknown): unknown { + if (value == null || typeof value === "boolean" || typeof value === "number") return value; + if (typeof value === "string") { + if (value.length <= CHAT_TOOL_RESULT_STRING_LIMIT) return value; + return { + preview: value.slice(0, CHAT_TOOL_RESULT_STRING_LIMIT), + omittedChars: value.length - CHAT_TOOL_RESULT_STRING_LIMIT, + }; + } + if (Array.isArray(value)) { + return { + type: "array", + length: value.length, + preview: value + .slice(0, CHAT_TOOL_RESULT_ARRAY_PREVIEW_LIMIT) + .map((entry) => compactChatToolValueForTransport(entry)), + omittedItems: Math.max(0, value.length - CHAT_TOOL_RESULT_ARRAY_PREVIEW_LIMIT), + }; + } + if (!isRecord(value)) return value; + + const safeKeys = [ + "ok", + "status", + "outcome", + "summary", + "message", + "error", + "workerId", + "stepId", + "stepKey", + "runId", + "missionId", + "filesChanged", + "testsRun", + "artifacts", + ]; + const next: Record = {}; + for (const key of safeKeys) { + if (Object.prototype.hasOwnProperty.call(value, key)) { + next[key] = compactChatToolValueForTransport(value[key]); + } + } + const keys = Object.keys(value); + next.__adeTransportCompact = true; + next.keys = keys.slice(0, CHAT_TOOL_RESULT_KEY_PREVIEW_LIMIT); + next.omittedKeys = Math.max(0, keys.length - CHAT_TOOL_RESULT_KEY_PREVIEW_LIMIT); + return next; +} + +function compactChatMessageMetadataForTransport(metadata: OrchestratorChatMessage["metadata"]): OrchestratorChatMessage["metadata"] { + if (!isRecord(metadata)) return metadata; + const structuredStream = isRecord(metadata.structuredStream) ? metadata.structuredStream : null; + if (!structuredStream) return metadata; + const nextStructured = { ...structuredStream }; + if (Object.prototype.hasOwnProperty.call(nextStructured, "result")) { + nextStructured.result = compactChatToolValueForTransport(nextStructured.result); + } + return { + ...metadata, + structuredStream: nextStructured, + }; +} + +function compactChatMessageForTransport(message: OrchestratorChatMessage): OrchestratorChatMessage { + return { + ...message, + metadata: compactChatMessageMetadataForTransport(message.metadata), + }; +} + +function compactRunMetadataForTransport(metadata: OrchestratorRun["metadata"]): OrchestratorRun["metadata"] { + if (!isRecord(metadata)) return metadata; + const next: Record = { ...metadata }; + if (isRecord(next.runtimeCursor)) { + next.runtimeCursor = compactRuntimeCursorForTransport(next.runtimeCursor); + } + return next; +} + +function compactRunForTransport(run: OrchestratorRun): OrchestratorRun { + return { + ...run, + metadata: compactRunMetadataForTransport(run.metadata), + }; +} + +function compactRunGraphForTransport(graph: OrchestratorRunGraph): OrchestratorRunGraph { + return { + ...graph, + run: compactRunForTransport(graph.run), + contextSnapshots: graph.contextSnapshots + .slice(0, RUN_GRAPH_CONTEXT_SNAPSHOT_TRANSPORT_LIMIT) + .map((snapshot) => ({ + ...snapshot, + cursor: compactRuntimeCursorForTransport(snapshot.cursor) as typeof snapshot.cursor, + })), + handoffs: graph.handoffs.map((handoff) => ({ + ...handoff, + payload: compactPayloadForTransport(handoff.payload) ?? {}, + })), + timeline: graph.timeline.map((event) => ({ + ...event, + detail: compactPayloadForTransport(event.detail), + })), + runtimeEvents: graph.runtimeEvents?.map((event) => ({ + ...event, + payload: compactPayloadForTransport(event.payload), + })), + }; +} + +function buildMissionDomainService(runtime: AdeRuntime): OpaqueService | null { + const service = runtime.missionService; + if (!service) return null; + return { + ...(service as OpaqueService), + async getFullMissionView(args?: unknown): Promise> { + const request = asActionRecord(args); + const missionId = typeof request.missionId === "string" ? request.missionId.trim() : ""; + if (!missionId) { + return { mission: null, runGraph: null, artifacts: [], checkpoints: [], dashboard: null }; + } + + let dashboard: unknown = null; + try { + dashboard = service.getDashboard(); + } catch { + // Dashboard is supplemental for this composed view. + } + + const mission = await service.get(missionId); + let runGraph: unknown = null; + let artifacts: unknown[] = []; + let checkpoints: unknown[] = []; + + const orchestratorService = requireService(runtime.orchestratorService, "Orchestrator service not available."); + const aiOrchestratorService = requireService(runtime.aiOrchestratorService, "AI orchestrator service not available."); + const runs = await orchestratorService.listRuns({ missionId, limit: 20 }); + const activeStatuses = new Set(["active", "bootstrapping", "queued", "paused"]); + const preferredRun = runs.find((entry) => activeStatuses.has(entry.status)) ?? runs[0]; + if (preferredRun) { + const [graph, arts, cps] = await Promise.all([ + Promise.resolve(orchestratorService.getRunGraph({ runId: preferredRun.id, timelineLimit: 120 })), + Promise.resolve(aiOrchestratorService.listArtifacts({ missionId, runId: preferredRun.id })).catch(() => []), + Promise.resolve(aiOrchestratorService.listWorkerCheckpoints({ missionId, runId: preferredRun.id })).catch(() => []), + ]); + runGraph = compactRunGraphForTransport(graph); + artifacts = Array.isArray(arts) ? arts : []; + checkpoints = Array.isArray(cps) ? cps : []; + } + + return { mission, runGraph, artifacts, checkpoints, dashboard }; + }, + preflight(args?: unknown): Promise { + const missionPreflightService = requireService(runtime.missionPreflightService, "Mission preflight service not available."); + return missionPreflightService.runPreflight(asActionRecord(args) as never); + }, + getRunView(args?: unknown): Promise { + const aiOrchestratorService = requireService(runtime.aiOrchestratorService, "AI orchestrator service not available."); + return aiOrchestratorService.getRunView(asActionRecord(args) as never); + }, + }; +} + +function buildOrchestratorCoreDomainService(runtime: AdeRuntime): OpaqueService | null { + const service = runtime.orchestratorService; + if (!service) return null; + return { + ...(service as OpaqueService), + listRuns: (args?: Parameters[0]) => + service.listRuns(args).map(compactRunForTransport), + getRunGraph: (args: Parameters[0]) => + compactRunGraphForTransport(service.getRunGraph(args)), + startRun: (args: Parameters[0]) => { + const started = service.startRun(args); + return { ...started, run: compactRunForTransport(started.run) }; + }, + tick: (args: Parameters[0]) => + compactRunForTransport(service.tick(args)), + pauseRun: (args: Parameters[0]) => + compactRunForTransport(service.pauseRun(args)), + resumeRun: (args: Parameters[0]) => + compactRunForTransport(service.resumeRun(args)), + finalizeRun: (args: Parameters[0]) => + service.finalizeRun(args), + }; +} + +function buildAiOrchestratorDomainService(runtime: AdeRuntime): OpaqueService | null { + const service = runtime.aiOrchestratorService; + if (!service) return null; + return { + ...(service as OpaqueService), + sendChat: async (args: Parameters[0]) => + compactChatMessageForTransport(await service.sendChat(args)), + getChat: (args: Parameters[0]) => + service.getChat(args).map(compactChatMessageForTransport), + getThreadMessages: (args: Parameters[0]) => + service.getThreadMessages(args).map(compactChatMessageForTransport), + sendThreadMessage: async (args: Parameters[0]) => + compactChatMessageForTransport(await service.sendThreadMessage(args)), + cancelRunGracefully: async (args: Parameters[0]) => + compactRunForTransport(await service.cancelRunGracefully(args)), + resumeRun: async (args: Parameters[0]) => + compactRunForTransport(await service.resumeRun(args)), + startMissionRun: async (args: Parameters[0]) => { + const result = await service.startMissionRun(args); + return result?.started + ? { ...result, started: { ...result.started, run: compactRunForTransport(result.started.run) } } + : result; + }, + getGlobalChat: (args: Parameters[0]) => + service.getGlobalChat(args).map(compactChatMessageForTransport), + sendAgentMessage: async (args: Parameters[0]) => + compactChatMessageForTransport(await service.sendAgentMessage(args)), + async getCheckpointStatus(args?: unknown): Promise | null> { + const runId = typeof asActionRecord(args).runId === "string" + ? String(asActionRecord(args).runId).trim() + : ""; + if (!runId) return null; + const checkpoint = await readCoordinatorCheckpoint(runtime.projectRoot, runId); + if (!checkpoint) return null; + return { + savedAt: checkpoint.savedAt, + turnCount: checkpoint.turnCount, + compactionCount: checkpoint.compactionCount, + }; + }, + }; +} + +function mergeLaneDockerConfig( + current: { composePath?: string; services?: string[]; projectPrefix?: string } | undefined, + next: { composePath?: string; services?: string[]; projectPrefix?: string } | undefined, +) { + if (!current && !next) return undefined; + if (!current) return next ? { ...next, ...(next.services ? { services: [...next.services] } : {}) } : undefined; + if (!next) return { ...current, ...(current.services ? { services: [...current.services] } : {}) }; + return { + ...current, + ...next, + ...(next.services != null + ? { services: [...next.services] } + : current.services != null + ? { services: [...current.services] } + : {}), + }; +} + +function mergeLaneEnvInitConfig( + current: LaneEnvInitConfig | undefined, + next: LaneEnvInitConfig | undefined, +): LaneEnvInitConfig | undefined { + if (!current && !next) return undefined; + if (!current) { + return next + ? { + ...(next.envFiles ? { envFiles: [...next.envFiles] } : {}), + ...(mergeLaneDockerConfig(undefined, next.docker) ? { docker: mergeLaneDockerConfig(undefined, next.docker) } : {}), + ...(next.dependencies ? { dependencies: [...next.dependencies] } : {}), + ...(next.mountPoints ? { mountPoints: [...next.mountPoints] } : {}), + ...(next.copyPaths ? { copyPaths: [...next.copyPaths] } : {}), + } + : undefined; + } + if (!next) { + return { + ...(current.envFiles ? { envFiles: [...current.envFiles] } : {}), + ...(mergeLaneDockerConfig(undefined, current.docker) ? { docker: mergeLaneDockerConfig(undefined, current.docker) } : {}), + ...(current.dependencies ? { dependencies: [...current.dependencies] } : {}), + ...(current.mountPoints ? { mountPoints: [...current.mountPoints] } : {}), + ...(current.copyPaths ? { copyPaths: [...current.copyPaths] } : {}), + }; + } + return { + envFiles: [...(current.envFiles ?? []), ...(next.envFiles ?? [])], + ...(mergeLaneDockerConfig(current.docker, next.docker) ? { docker: mergeLaneDockerConfig(current.docker, next.docker) } : {}), + dependencies: [...(current.dependencies ?? []), ...(next.dependencies ?? [])], + mountPoints: [...(current.mountPoints ?? []), ...(next.mountPoints ?? [])], + copyPaths: [...(current.copyPaths ?? []), ...(next.copyPaths ?? [])], + }; +} + +function mergeLaneOverrides(base: LaneOverlayOverrides, next: Partial): LaneOverlayOverrides { + return { + ...base, + ...next, + ...(base.env || next.env ? { env: { ...(base.env ?? {}), ...(next.env ?? {}) } } : {}), + ...(base.processIds || next.processIds ? { processIds: [...(next.processIds ?? base.processIds ?? [])] } : {}), + ...(base.testSuiteIds || next.testSuiteIds ? { testSuiteIds: [...(next.testSuiteIds ?? base.testSuiteIds ?? [])] } : {}), + ...(mergeLaneEnvInitConfig(base.envInit, next.envInit) ? { envInit: mergeLaneEnvInitConfig(base.envInit, next.envInit) } : {}), + }; +} + +function applyLeaseToOverrides(overrides: LaneOverlayOverrides, lease: PortLease | null): LaneOverlayOverrides { + if (!lease || lease.status !== "active" || overrides.portRange) { + return { ...overrides }; + } + return { + ...overrides, + portRange: { start: lease.rangeStart, end: lease.rangeEnd }, + }; +} + +function requireService(service: T | null | undefined, message: string): T { + if (!service) throw new Error(message); + return service; +} + +async function resolveLane(runtime: AdeRuntime, laneId: string) { + const lanes = await runtime.laneService.list({ includeArchived: true, includeStatus: false }); + const lane = lanes.find((entry) => entry.id === laneId); + if (!lane) throw new Error(`Lane not found: ${laneId}`); + return lane; +} + +async function resolveActiveLaneIds(runtime: AdeRuntime): Promise { + const lanes = await runtime.laneService.list({ includeArchived: false, includeStatus: false }); + return lanes.map((lane) => lane.id); +} + +async function resolveLaneOverlayContext(runtime: AdeRuntime, laneId: string) { + const lane = await resolveLane(runtime, laneId); + const config = runtime.projectConfigService.getEffective(); + const overlayOverrides = matchLaneOverlayPolicies(lane, config.laneOverlayPolicies ?? []); + const lease = runtime.portAllocationService?.getLease(lane.id) ?? null; + const overrides = applyLeaseToOverrides(overlayOverrides, lease); + const envInitConfig = runtime.laneEnvironmentService?.resolveEnvInitConfig(config.laneEnvInit, overrides); + return { + lane, + overrides, + envInitConfig, + lease, + }; +} + +async function ensureLanePortLease(runtime: AdeRuntime, laneId: string): Promise { + await resolveLane(runtime, laneId); + const portAllocationService = runtime.portAllocationService; + if (!portAllocationService) return null; + return portAllocationService.getLease(laneId) ?? portAllocationService.acquire(laneId); +} + +async function ensureLanePreviewInfo(runtime: AdeRuntime, laneId: string): Promise { + const laneProxyService = runtime.laneProxyService; + const portAllocationService = runtime.portAllocationService; + if (!laneProxyService || !portAllocationService) return null; + + const lane = await resolveLane(runtime, laneId).catch(() => null); + if (!lane || lane.archivedAt != null) { + laneProxyService.removeRoute(laneId); + return null; + } + + const lease = portAllocationService.getLease(laneId) ?? portAllocationService.acquire(laneId); + if (lease.status !== "active") { + laneProxyService.removeRoute(laneId); + return null; + } + + if (!laneProxyService.getStatus().running) { + await laneProxyService.start().catch((error: unknown) => { + runtime.logger.warn("lane_proxy.preview_start_failed", { + laneId, + error: error instanceof Error ? error.message : String(error), + }); + }); + } + if (!laneProxyService.getStatus().running) return null; + + const expectedHostname = laneProxyService.generateHostname(laneId, lane.name); + const health = runtime.runtimeDiagnosticsService + ? await runtime.runtimeDiagnosticsService.checkLaneHealth(laneId).catch(() => null) + : null; + const respondingPort = Number.isInteger(health?.respondingPort) + && (health?.respondingPort as number) >= lease.rangeStart + && (health?.respondingPort as number) <= lease.rangeEnd + ? (health?.respondingPort as number) + : null; + const targetPort = respondingPort ?? lease.rangeStart; + const currentRoute = laneProxyService.getRoute(laneId); + if ( + !currentRoute || + currentRoute.targetPort !== targetPort || + currentRoute.hostname !== expectedHostname || + currentRoute.status !== "active" + ) { + laneProxyService.addRoute(laneId, targetPort, lane.name); + } + return laneProxyService.getPreviewInfo(laneId); +} + +function buildLaneDomainService(runtime: AdeRuntime): OpaqueService { + const laneService = runtime.laneService as unknown as OpaqueService; + return { + ...laneService, + listSnapshots: async (args?: ListLanesArgs): Promise => { + const lanes = await runtime.laneService.list({ + includeArchived: Boolean(args?.includeArchived), + includeStatus: args?.includeStatus !== false, + }); + return buildLaneListSnapshots( + { + laneService: runtime.laneService, + sessionService: runtime.sessionService, + ptyService: runtime.ptyService, + agentChatService: runtime.agentChatService ?? null, + rebaseSuggestionService: runtime.rebaseSuggestionService ?? null, + autoRebaseService: runtime.autoRebaseService ?? null, + conflictService: runtime.conflictService ?? null, + syncService: runtime.syncService ?? null, + logger: runtime.logger, + }, + lanes, + { + includeConflictStatus: args?.includeConflictStatus !== false, + includeRebaseSuggestions: args?.includeRebaseSuggestions !== false, + includeAutoRebaseStatus: args?.includeAutoRebaseStatus !== false, + }, + ); + }, + listRebaseSuggestions: () => runtime.rebaseSuggestionService?.listSuggestions() ?? [], + dismissRebaseSuggestion: async (args?: { laneId?: string }) => { + const laneId = requireNonEmptyString(args?.laneId, "laneId"); + runtime.conflictService?.dismissRebase(laneId); + await runtime.rebaseSuggestionService?.dismiss({ laneId }); + }, + deferRebaseSuggestion: async (args?: { laneId?: string; minutes?: number }) => { + const laneId = requireNonEmptyString(args?.laneId, "laneId"); + const minutes = Math.max(5, Math.min(7 * 24 * 60, Math.floor(args?.minutes ?? 60))); + const until = new Date(Date.now() + minutes * 60_000).toISOString(); + runtime.conflictService?.deferRebase(laneId, until); + await runtime.rebaseSuggestionService?.defer({ laneId, minutes }); + }, + listAutoRebaseStatuses: () => runtime.autoRebaseService?.listStatuses() ?? [], + dismissAutoRebaseStatus: async (args?: { laneId?: string }) => { + const laneId = requireNonEmptyString(args?.laneId, "laneId"); + await runtime.autoRebaseService?.dismissStatus({ laneId }); + }, + initEnv: async (args?: { laneId?: string }): Promise => { + const laneEnvironmentService = requireService(runtime.laneEnvironmentService, "Lane environment service not available."); + const laneId = requireNonEmptyString(args?.laneId, "laneId"); + const context = await resolveLaneOverlayContext(runtime, laneId); + if (!context.envInitConfig) { + const now = new Date().toISOString(); + return { laneId, steps: [], startedAt: now, completedAt: now, overallStatus: "completed" }; + } + return laneEnvironmentService.initLaneEnvironment(context.lane, context.envInitConfig, context.overrides); + }, + getEnvStatus: (args?: { laneId?: string }) => + runtime.laneEnvironmentService?.getProgress(requireNonEmptyString(args?.laneId, "laneId")) ?? null, + getOverlay: async (args?: { laneId?: string }) => { + const context = await resolveLaneOverlayContext(runtime, requireNonEmptyString(args?.laneId, "laneId")); + return context.overrides; + }, + listTemplates: () => runtime.laneTemplateService?.listTemplates() ?? [], + getTemplate: (args?: { templateId?: string }) => + runtime.laneTemplateService?.getTemplate(requireNonEmptyString(args?.templateId, "templateId")) ?? null, + getDefaultTemplate: () => runtime.laneTemplateService?.getDefaultTemplateId() ?? null, + setDefaultTemplate: (args?: { templateId?: string | null }) => { + requireService(runtime.laneTemplateService, "Lane template service not available.").setDefaultTemplateId(args?.templateId ?? null); + }, + applyTemplate: async (args?: ApplyLaneTemplateArgs): Promise => { + const laneTemplateService = requireService(runtime.laneTemplateService, "Lane template service not available."); + const laneEnvironmentService = requireService(runtime.laneEnvironmentService, "Lane environment service not available."); + const laneId = requireNonEmptyString(args?.laneId, "laneId"); + const templateId = requireNonEmptyString(args?.templateId, "templateId"); + const context = await resolveLaneOverlayContext(runtime, laneId); + const template = laneTemplateService.getTemplate(templateId); + if (!template) throw new Error(`Template not found: ${templateId}`); + const templateEnvInit = laneTemplateService.resolveTemplateAsEnvInit(template); + const mergedOverrides = mergeLaneOverrides(context.overrides, { + ...(template.envVars ? { env: template.envVars } : {}), + ...(!context.overrides.portRange && template.portRange ? { portRange: template.portRange } : {}), + envInit: templateEnvInit, + }); + const mergedEnvInitConfig = mergeLaneEnvInitConfig(context.envInitConfig, templateEnvInit) ?? templateEnvInit; + return laneEnvironmentService.initLaneEnvironment(context.lane, mergedEnvInitConfig, mergedOverrides); + }, + saveTemplate: (args?: { template?: unknown }) => { + const template = args?.template; + if (!template || typeof template !== "object" || Array.isArray(template)) { + throw new Error("Lane template payload is required."); + } + requireService(runtime.laneTemplateService, "Lane template service not available.").saveTemplate(template as Parameters["saveTemplate"]>[0]); + }, + deleteTemplate: (args?: { templateId?: string }) => { + requireService(runtime.laneTemplateService, "Lane template service not available.").deleteTemplate(requireNonEmptyString(args?.templateId, "templateId")); + }, + portGetLease: async (args?: { laneId?: string }) => { + const laneId = requireNonEmptyString(args?.laneId, "laneId"); + await ensureLanePortLease(runtime, laneId); + return runtime.portAllocationService?.getLease(laneId) ?? null; + }, + portListLeases: () => runtime.portAllocationService?.listLeases() ?? [], + portAcquire: async (args?: { laneId?: string }) => { + const lease = await ensureLanePortLease(runtime, requireNonEmptyString(args?.laneId, "laneId")); + if (!lease) throw new Error("Port allocation service not available."); + return lease; + }, + portRelease: async (args?: { laneId?: string }) => { + const laneId = requireNonEmptyString(args?.laneId, "laneId"); + await resolveLane(runtime, laneId); + runtime.portAllocationService?.release(laneId); + }, + portListConflicts: () => runtime.portAllocationService?.listConflicts() ?? [], + portRecoverOrphans: async () => { + if (!runtime.portAllocationService) return []; + const validIds = new Set(await resolveActiveLaneIds(runtime)); + return runtime.portAllocationService.recoverOrphans(validIds); + }, + proxyGetStatus: (): ProxyStatus => runtime.laneProxyService?.getStatus() ?? { running: false, proxyPort: 8080, routes: [] }, + proxyStart: (args?: { port?: number }) => requireService(runtime.laneProxyService, "Proxy service not available.").start(args?.port), + proxyStop: async () => { + await runtime.laneProxyService?.stop(); + }, + proxyAddRoute: async (args?: { laneId?: string; targetPort?: number }) => { + const laneId = requireNonEmptyString(args?.laneId, "laneId"); + const targetPort = args?.targetPort; + if (!Number.isInteger(targetPort) || Number(targetPort) <= 0) { + throw new Error("targetPort must be a positive integer."); + } + const lane = await resolveLane(runtime, laneId); + return requireService(runtime.laneProxyService, "Proxy service not available.").addRoute(laneId, Number(targetPort), lane.name); + }, + proxyRemoveRoute: (args?: { laneId?: string }) => + runtime.laneProxyService?.removeRoute(requireNonEmptyString(args?.laneId, "laneId")), + proxyGetPreviewInfo: (args?: { laneId?: string }) => + ensureLanePreviewInfo(runtime, requireNonEmptyString(args?.laneId, "laneId")), + oauthGetStatus: () => runtime.oauthRedirectService?.getStatus() ?? { enabled: false, routingMode: "state-parameter", activeSessions: [], callbackPaths: [] }, + oauthUpdateConfig: (args?: Record) => { + requireService(runtime.oauthRedirectService, "OAuth redirect service not available.").updateConfig(args ?? {}); + }, + oauthGenerateRedirectUris: (args?: { provider?: string }) => + runtime.oauthRedirectService?.generateRedirectUris(args?.provider) ?? [], + oauthEncodeState: (args?: { laneId?: string; originalState?: string }) => + requireService(runtime.oauthRedirectService, "OAuth redirect service not available.").encodeState( + requireNonEmptyString(args?.laneId, "laneId"), + typeof args?.originalState === "string" ? args.originalState : "", + ), + oauthDecodeState: (args?: { encodedState?: string }) => + runtime.oauthRedirectService?.decodeState(requireNonEmptyString(args?.encodedState, "encodedState")) ?? null, + oauthListSessions: () => runtime.oauthRedirectService?.listSessions() ?? [], + diagnosticsGetStatus: async () => { + const laneIds = await resolveActiveLaneIds(runtime); + return runtime.runtimeDiagnosticsService?.getStatus(laneIds) ?? { + lanes: [], + proxyRunning: false, + proxyPort: runtime.laneProxyService?.getStatus().proxyPort ?? 0, + totalRoutes: 0, + activeConflicts: 0, + fallbackLanes: [], + }; + }, + diagnosticsGetLaneHealth: (args?: { laneId?: string }) => + runtime.runtimeDiagnosticsService?.getLaneHealth(requireNonEmptyString(args?.laneId, "laneId")) ?? null, + diagnosticsRunHealthCheck: async (args?: { laneId?: string }) => { + const laneId = requireNonEmptyString(args?.laneId, "laneId"); + await resolveLane(runtime, laneId); + return requireService(runtime.runtimeDiagnosticsService, "Runtime diagnostics service not available.").checkLaneHealth(laneId); + }, + diagnosticsRunFullCheck: async () => { + const laneIds = await resolveActiveLaneIds(runtime); + return runtime.runtimeDiagnosticsService?.checkAllLanes(laneIds) ?? []; + }, + diagnosticsActivateFallback: async (args?: { laneId?: string }) => { + const laneId = requireNonEmptyString(args?.laneId, "laneId"); + await resolveLane(runtime, laneId); + runtime.runtimeDiagnosticsService?.activateFallback(laneId); + }, + diagnosticsDeactivateFallback: async (args?: { laneId?: string }) => { + const laneId = requireNonEmptyString(args?.laneId, "laneId"); + await resolveLane(runtime, laneId); + runtime.runtimeDiagnosticsService?.deactivateFallback(laneId); + }, + }; +} + +function buildAiDomainService(runtime: AdeRuntime): OpaqueService | null { + const aiIntegrationService = runtime.aiIntegrationService; + if (!aiIntegrationService) return null; + return { + getStatus: (args?: { force?: boolean; refreshOpenCodeInventory?: boolean }) => + buildAiSettingsStatus(aiIntegrationService, args), + verifyApiKeyConnection: (args?: { provider?: string }) => + aiIntegrationService.verifyApiKeyConnection(requireNonEmptyString(args?.provider, "provider")), + storeApiKey: (args?: { provider?: string; key?: string }) => + aiIntegrationService.storeApiKey( + requireNonEmptyString(args?.provider, "provider"), + requireNonEmptyString(args?.key, "key"), + ), + deleteApiKey: (args?: { provider?: string }) => + aiIntegrationService.deleteApiKey(requireNonEmptyString(args?.provider, "provider")), + listApiKeys: () => aiIntegrationService.listApiKeys(), + updateConfig: (partial?: Partial) => { + const projectConfigService = requireService(runtime.projectConfigService, "Project config service not available."); + const snapshot = projectConfigService.get(); + const currentAi = snapshot.shared?.ai ?? {}; + const merged = mergeAiConfig(currentAi, partial ?? {}) ?? {}; + projectConfigService.save({ + shared: { ...snapshot.shared, ai: merged }, + local: snapshot.local ?? {}, + }); + }, + listCursorCloudRepositories: () => aiIntegrationService.listCursorCloudRepositories(), + listCursorCloudAgents: (args?: { includeArchived?: boolean; limit?: number; cursor?: string | null }) => + aiIntegrationService.listCursorCloudAgents(args ?? {}), + listCursorCloudRuns: (args?: { agentId?: string; limit?: number; cursor?: string | null }) => + aiIntegrationService.listCursorCloudRuns({ + agentId: requireNonEmptyString(args?.agentId, "agentId"), + ...(args?.limit !== undefined ? { limit: args.limit } : {}), + ...(args?.cursor !== undefined ? { cursor: args.cursor } : {}), + }), + createCursorCloudRun: (args: Parameters[0]) => + aiIntegrationService.createCursorCloudRun(args), + archiveCursorCloudAgent: (args?: { agentId?: string }) => + aiIntegrationService.archiveCursorCloudAgent(requireNonEmptyString(args?.agentId, "agentId")), + unarchiveCursorCloudAgent: (args?: { agentId?: string }) => + aiIntegrationService.unarchiveCursorCloudAgent(requireNonEmptyString(args?.agentId, "agentId")), + deleteCursorCloudAgent: (args?: { agentId?: string }) => + aiIntegrationService.deleteCursorCloudAgent(requireNonEmptyString(args?.agentId, "agentId")), + getCursorCloudAgent: (args?: { agentId?: string }) => + aiIntegrationService.getCursorCloudAgent(requireNonEmptyString(args?.agentId, "agentId")), + listCursorCloudArtifacts: async (args?: { agentId?: string }) => { + const items = await aiIntegrationService.listCursorCloudArtifacts(requireNonEmptyString(args?.agentId, "agentId")); + return items.map((entry) => ({ + path: entry.path, + ...(typeof entry.sizeBytes === "number" ? { sizeBytes: entry.sizeBytes } : {}), + ...(entry.updatedAt !== undefined ? { updatedAt: entry.updatedAt } : {}), + ...(entry.mimeType !== undefined ? { mimeType: entry.mimeType } : {}), + })); + }, + downloadCursorCloudArtifact: (args?: { agentId?: string; path?: string }) => + aiIntegrationService.downloadCursorCloudArtifact({ + agentId: requireNonEmptyString(args?.agentId, "agentId"), + path: requireNonEmptyString(args?.path, "path"), + }), + cancelCursorCloudRun: (args?: { agentId?: string; runId?: string }) => + requireService(runtime.agentChatService, "Agent chat service not available.").cancelCursorCloudRun({ + agentId: requireNonEmptyString(args?.agentId, "agentId"), + runId: requireNonEmptyString(args?.runId, "runId"), + }), + cursorCloudFollowUp: (args?: { agentId?: string; prompt?: string; modelId?: string | null }) => + requireService(runtime.agentChatService, "Agent chat service not available.").cursorCloudFollowUp({ + agentId: requireNonEmptyString(args?.agentId, "agentId"), + prompt: requireNonEmptyString(args?.prompt, "prompt"), + ...(args?.modelId !== undefined ? { modelId: args.modelId } : {}), + }), + openCursorCloudChat: (args?: { cloudAgentId?: string; laneId?: string }) => + requireService(runtime.agentChatService, "Agent chat service not available.").openCursorCloudChat({ + cloudAgentId: requireNonEmptyString(args?.cloudAgentId, "cloudAgentId"), + laneId: requireNonEmptyString(args?.laneId, "laneId"), + }), + }; +} + +const AI_SETTINGS_FEATURE_KEYS: AiFeatureKey[] = [ + "narratives", + "conflict_proposals", + "commit_messages", + "pr_descriptions", + "terminal_summaries", + "memory_consolidation", + "mission_planning", + "orchestrator", + "initial_context", +]; + +async function buildAiSettingsStatus( + aiIntegrationService: NonNullable, + options?: { force?: boolean; refreshOpenCodeInventory?: boolean }, +): Promise { + const status = await aiIntegrationService.getStatus({ + force: options?.force === true, + refreshOpenCodeInventory: options?.refreshOpenCodeInventory === true, + }); + const usageBatch = aiIntegrationService.getDailyUsageBatch(AI_SETTINGS_FEATURE_KEYS); + return { + mode: status.mode, + availableProviders: status.availableProviders, + models: status.models, + detectedAuth: status.detectedAuth, + providerConnections: status.providerConnections, + runtimeConnections: status.runtimeConnections, + availableModelIds: status.availableModelIds, + opencodeBinaryInstalled: status.opencodeBinaryInstalled, + opencodeBinarySource: status.opencodeBinarySource, + opencodeInventoryError: status.opencodeInventoryError, + opencodeProviders: status.opencodeProviders, + apiKeyStore: status.apiKeyStore, + features: AI_SETTINGS_FEATURE_KEYS.map((feature) => ({ + feature, + enabled: aiIntegrationService.getFeatureFlag(feature), + dailyUsage: usageBatch.get(feature) ?? 0, + dailyLimit: aiIntegrationService.getDailyBudgetLimit(feature), + })), + }; +} + function requireNonEmptyString(value: unknown, field: string): string { if (typeof value !== "string") { throw new Error(`Expected '${field}' to be a non-empty string.`); @@ -592,6 +2583,864 @@ type TerminalDomainService = { activeForChat(args?: unknown): unknown; }; +const RUNTIME_FILE_WATCH_CLIENT_ID_FIELD = "__adeRuntimeClientId"; +const RUNTIME_FILE_WATCH_DEFAULT_SENDER_ID = 1; + +function asActionRecord(value: unknown): Record { + return value && typeof value === "object" && !Array.isArray(value) + ? value as Record + : {}; +} + +function readRuntimeFileWatchSenderId(args: Record): number { + const raw = args[RUNTIME_FILE_WATCH_CLIENT_ID_FIELD]; + const numeric = typeof raw === "number" + ? raw + : typeof raw === "string" + ? Number.parseInt(raw, 10) + : NaN; + if (Number.isSafeInteger(numeric) && numeric > 0) { + return numeric; + } + return RUNTIME_FILE_WATCH_DEFAULT_SENDER_ID; +} + +function toRuntimeFileWatchArgs(args: Record): FilesWatchArgs { + const { [RUNTIME_FILE_WATCH_CLIENT_ID_FIELD]: _clientId, ...watchArgs } = args; + return watchArgs as unknown as FilesWatchArgs; +} + +function readStringActionArg(value: unknown, field: string): string { + if (typeof value === "string") { + return requireNonEmptyString(value, field); + } + return requireNonEmptyString(asActionRecord(value)[field], field); +} + +function buildIssueResolutionInstructionsFromThread(arg: LaunchPrIssueResolutionFromThreadArgs): string { + const lines = [`Focus on review thread ${arg.threadId} on PR ${arg.prId}.`]; + if (arg.commentId) { + lines.push(`The relevant comment id is ${arg.commentId}.`); + } + const fileContext = arg.fileContext; + if (fileContext?.path) { + const lineNumber = fileContext.startLine ?? fileContext.line ?? null; + lines.push( + lineNumber != null + ? `Start by inspecting ${fileContext.path}:${lineNumber}.` + : `Start by inspecting ${fileContext.path}.`, + ); + } + if (arg.additionalInstructions) { + lines.push("", arg.additionalInstructions); + } + return lines.join("\n"); +} + +function buildPrIssueResolutionDeps(runtime: AdeRuntime) { + return { + prService: requireService(runtime.prService, "PR service not available."), + laneService: runtime.laneService, + agentChatService: requireService(runtime.agentChatService, "Agent chat service not available."), + sessionService: runtime.sessionService, + issueInventoryService: runtime.issueInventoryService, + laneWorktreeLockService: runtime.laneWorktreeLockService ?? null, + }; +} + +type PrAiRuntimeSession = { + sessionId: string; + ptyId: string | null; + runId: string; + provider: "codex" | "claude"; + contextKey: string; + context: PrAiResolutionContext; + modelId: string; + reasoning: string | null; + permissionMode: PrAgentPermissionMode; + pollTimer: ReturnType | null; + finalizing: boolean; +}; + +type PrAiRuntimeBridge = { + getSession(args?: unknown): Promise; + start(args?: unknown): Promise; + input(args?: unknown): Promise; + stop(args?: unknown): Promise; +}; + +const prAiRuntimeBridges = new WeakMap(); + +function inferPrAiProvider(modelId: string): "codex" | "claude" { + const descriptor = getModelById(modelId); + return descriptor?.family === "anthropic" ? "claude" : "codex"; +} + +function collectPrAiSourceLaneIds(context: PrAiResolutionContext): string[] { + const sourceLaneIds = new Set(); + const add = (value: string | null | undefined) => { + const normalized = typeof value === "string" ? value.trim() : ""; + if (normalized) sourceLaneIds.add(normalized); + }; + for (const laneId of context.sourceLaneIds ?? []) { + add(laneId); + } + add(context.sourceLaneId ?? null); + if (context.sourceTab !== "integration") { + add(context.laneId ?? null); + } + return Array.from(sourceLaneIds); +} + +function mapExternalResolverStatusToPrAi(status: string): PrAiResolutionSessionStatus { + if (status === "completed") return "completed"; + if (status === "failed" || status === "blocked") return "failed"; + if (status === "canceled") return "cancelled"; + return "running"; +} + +function buildPrAiDisplayText(context: PrAiResolutionContext): string { + if (context.sourceTab === "rebase") return "Resolve this rebase with AI."; + if (context.sourceTab === "queue") return "Resolve this queued PR with AI."; + if (context.sourceTab === "integration") { + return context.proposalId + ? "Resolve this integration proposal with AI." + : "Resolve this integration PR with AI."; + } + return "Resolve this PR with AI."; +} + +function emitPrAiResolutionRuntimeEvent(runtime: AdeRuntime, payload: PrAiResolutionEventPayload): void { + runtime.eventBuffer.push({ + timestamp: nowIso(), + category: "runtime", + payload: { type: "pr_ai_resolution_event", event: payload }, + }); +} + +function readSummaryPermissionMode(summary: unknown): PrAgentPermissionMode | null { + const record = asActionRecord(summary); + return typeof record.permissionMode === "string" + ? record.permissionMode as PrAgentPermissionMode + : null; +} + +function buildPrAiSessionInfo(args: { + context: PrAiResolutionContext; + contextKey: string; + sessionId: string; + provider: "codex" | "claude"; + model: string | null; + modelId: string | null; + reasoning: string | null; + permissionMode: PrAgentPermissionMode | null; + status: PrAiResolutionSessionStatus; +}): PrAiResolutionSessionInfo { + return { + contextKey: args.contextKey, + sessionId: args.sessionId, + provider: args.provider, + model: args.model, + modelId: args.modelId, + reasoning: args.reasoning, + permissionMode: args.permissionMode, + context: args.context, + status: args.status, + }; +} + +function getPrAiRuntimeBridge(runtime: AdeRuntime): PrAiRuntimeBridge { + const existing = prAiRuntimeBridges.get(runtime); + if (existing) return existing; + + const prAiSessions = new Map(); + const prAiSessionsByContextKey = new Map(); + + const clearSession = (sessionId: string): void => { + const session = prAiSessions.get(sessionId); + if (!session) return; + if (session.pollTimer) clearInterval(session.pollTimer); + if (prAiSessionsByContextKey.get(session.contextKey) === sessionId) { + prAiSessionsByContextKey.delete(session.contextKey); + } + prAiSessions.delete(sessionId); + }; + + const finalize = async ( + sessionId: string, + opts: { forceStatus?: "cancelled" | "completed" | "failed"; message?: string } = {}, + ): Promise => { + const session = prAiSessions.get(sessionId); + if (!session || session.finalizing) return; + session.finalizing = true; + try { + const detail = runtime.sessionService.get(sessionId); + const derivedExitCode = opts.forceStatus === "cancelled" + ? 130 + : (detail?.exitCode ?? (detail?.status === "completed" ? 0 : 1)); + try { + await runtime.conflictService.finalizeResolverSession({ + runId: session.runId, + exitCode: derivedExitCode, + }); + } catch (error) { + runtime.logger.debug("ade_actions.prs_ai_resolution_finalize_failed", { + sessionId, + runId: session.runId, + error: getErrorMessage(error), + }); + } + + const status = opts.forceStatus + ?? (detail?.status === "disposed" + ? "cancelled" + : derivedExitCode === 0 + ? "completed" + : "failed"); + emitPrAiResolutionRuntimeEvent(runtime, { + sessionId, + status, + message: opts.message ?? null, + timestamp: nowIso(), + }); + } finally { + clearSession(sessionId); + } + }; + + const bridge: PrAiRuntimeBridge = { + async getSession(args?: unknown): Promise { + const context = (asActionRecord(args).context ?? {}) as PrAiResolutionContext; + const contextKey = buildPrAiResolutionContextKey(context); + const liveSessionId = prAiSessionsByContextKey.get(contextKey); + const agentChatService = requireService(runtime.agentChatService, "Agent chat service not available."); + const sessionSummaries = await agentChatService.listSessions(); + + if (liveSessionId) { + const liveSession = prAiSessions.get(liveSessionId); + if (liveSession) { + const summary = sessionSummaries.find((entry) => entry.sessionId === liveSessionId) ?? null; + const summaryRecord = asActionRecord(summary); + return buildPrAiSessionInfo({ + context: liveSession.context, + contextKey, + sessionId: liveSessionId, + provider: liveSession.provider, + model: typeof summaryRecord.model === "string" ? summaryRecord.model : liveSession.modelId, + modelId: typeof summaryRecord.modelId === "string" ? summaryRecord.modelId : liveSession.modelId, + reasoning: typeof summaryRecord.reasoningEffort === "string" ? summaryRecord.reasoningEffort : liveSession.reasoning, + permissionMode: readSummaryPermissionMode(summary) ?? liveSession.permissionMode, + status: "running", + }); + } + prAiSessionsByContextKey.delete(contextKey); + } + + const persistedRun = runtime.conflictService + .listExternalResolverRuns({ limit: 200 }) + .find((entry) => entry.resolverContextKey === contextKey && entry.sessionId); + if (!persistedRun?.sessionId) return null; + + const summary = sessionSummaries.find((entry) => entry.sessionId === persistedRun.sessionId) ?? null; + const summaryRecord = asActionRecord(summary); + return buildPrAiSessionInfo({ + context, + contextKey, + sessionId: persistedRun.sessionId, + provider: persistedRun.provider === "claude" ? "claude" : "codex", + model: typeof summaryRecord.model === "string" ? summaryRecord.model : persistedRun.model ?? null, + modelId: typeof summaryRecord.modelId === "string" ? summaryRecord.modelId : persistedRun.model ?? null, + reasoning: typeof summaryRecord.reasoningEffort === "string" ? summaryRecord.reasoningEffort : persistedRun.reasoningEffort ?? null, + permissionMode: readSummaryPermissionMode(summary) ?? persistedRun.permissionMode ?? null, + status: mapExternalResolverStatusToPrAi(persistedRun.status), + }); + }, + async start(args?: unknown): Promise { + const startArgs = asActionRecord(args) as unknown as PrAiResolutionStartArgs; + const context = (startArgs.context ?? {}) as PrAiResolutionContext; + const model = typeof startArgs.model === "string" ? startArgs.model.trim() : ""; + const targetLaneId = typeof context.targetLaneId === "string" ? context.targetLaneId.trim() : ""; + const sourceLaneIds = collectPrAiSourceLaneIds(context); + const permissionMode: PrAgentPermissionMode = startArgs.permissionMode ?? "default"; + const reasoning = typeof startArgs.reasoning === "string" && startArgs.reasoning.trim().length > 0 + ? startArgs.reasoning.trim() + : null; + const additionalInstructions = typeof startArgs.additionalInstructions === "string" && startArgs.additionalInstructions.trim().length > 0 + ? startArgs.additionalInstructions.trim() + : null; + let runId = ""; + + if (!model) { + const sessionId = randomUUID(); + const error = "Model is required to start AI resolution."; + emitPrAiResolutionRuntimeEvent(runtime, { sessionId, status: "failed", message: error, timestamp: nowIso() }); + return { sessionId, provider: "codex", ptyId: null, status: "failed", error, context }; + } + if (!targetLaneId) { + const sessionId = randomUUID(); + const error = "Target lane is required to start AI resolution."; + emitPrAiResolutionRuntimeEvent(runtime, { sessionId, status: "failed", message: error, timestamp: nowIso() }); + return { sessionId, provider: inferPrAiProvider(model), ptyId: null, status: "failed", error, context }; + } + if (sourceLaneIds.length === 0) { + const sessionId = randomUUID(); + const error = "At least one source lane is required to start AI resolution."; + emitPrAiResolutionRuntimeEvent(runtime, { sessionId, status: "failed", message: error, timestamp: nowIso() }); + return { sessionId, provider: inferPrAiProvider(model), ptyId: null, status: "failed", error, context }; + } + + try { + const provider = inferPrAiProvider(model); + const modelDescriptor = getModelById(model); + const prep = await runtime.conflictService.prepareResolverSession({ + provider, + targetLaneId, + sourceLaneIds, + cwdLaneId: typeof context.integrationLaneId === "string" && context.integrationLaneId.trim().length > 0 + ? context.integrationLaneId.trim() + : (typeof context.laneId === "string" && context.laneId.trim().length > 0 ? context.laneId.trim() : undefined), + proposalId: typeof context.proposalId === "string" && context.proposalId.trim().length > 0 + ? context.proposalId.trim() + : undefined, + sourceTab: context.sourceTab, + scenario: context.scenario ?? (sourceLaneIds.length > 1 ? "integration-merge" : "single-merge"), + model, + reasoningEffort: reasoning, + permissionMode, + additionalInstructions, + originSurface: context.sourceTab === "integration" || context.sourceTab === "rebase" ? context.sourceTab : "manual", + }); + runId = prep.runId; + if (prep.status === "blocked") { + const sessionId = randomUUID(); + const reason = prep.contextGaps.length + ? prep.contextGaps.map((gap) => gap.message).join(", ") + : "Resolver session blocked due to insufficient context."; + emitPrAiResolutionRuntimeEvent(runtime, { sessionId, status: "failed", message: reason, timestamp: nowIso() }); + return { sessionId, provider, ptyId: null, status: "failed", error: reason, context }; + } + + const agentChatService = requireService(runtime.agentChatService, "Agent chat service not available."); + const session = await agentChatService.createSession({ + laneId: prep.cwdLaneId, + provider, + model: modelDescriptor?.shortId ?? model, + ...(modelDescriptor?.id ? { modelId: modelDescriptor.id } : {}), + ...(reasoning ? { reasoningEffort: reasoning } : {}), + permissionMode: mapPermissionModeForModelFamily(permissionMode, modelDescriptor?.family), + }); + const promptText = fs.readFileSync(prep.promptFilePath, "utf8"); + const runtimeContext: PrAiResolutionContext = { + ...context, + laneId: prep.cwdLaneId, + targetLaneId, + sourceLaneId: sourceLaneIds[0] ?? context.sourceLaneId ?? context.laneId ?? null, + sourceLaneIds, + integrationLaneId: prep.integrationLaneId ?? context.integrationLaneId ?? null, + }; + const contextKey = buildPrAiResolutionContextKey(runtimeContext); + const runtimeSession: PrAiRuntimeSession = { + sessionId: session.id, + ptyId: null, + runId: prep.runId, + provider, + contextKey, + context: runtimeContext, + modelId: model, + reasoning, + permissionMode, + pollTimer: null, + finalizing: false, + }; + await runtime.conflictService.attachResolverSession({ + runId: prep.runId, + ptyId: null, + sessionId: session.id, + command: [], + }); + runtimeSession.pollTimer = setInterval(() => { + const current = prAiSessions.get(runtimeSession.sessionId); + if (!current || current.finalizing) return; + const detail = runtime.sessionService.get(runtimeSession.sessionId); + if (!detail || detail.status === "running") return; + void finalize(runtimeSession.sessionId); + }, 1_000); + prAiSessions.set(runtimeSession.sessionId, runtimeSession); + prAiSessionsByContextKey.set(contextKey, runtimeSession.sessionId); + emitPrAiResolutionRuntimeEvent(runtime, { + sessionId: runtimeSession.sessionId, + status: "running", + message: null, + timestamp: nowIso(), + }); + void agentChatService.sendMessage({ + sessionId: runtimeSession.sessionId, + text: promptText, + displayText: buildPrAiDisplayText(runtimeContext), + ...(reasoning ? { reasoningEffort: reasoning } : {}), + }).catch(async (error: unknown) => { + runtime.logger.warn("ade_actions.prs_ai_resolution_send_failed", { + sessionId: runtimeSession.sessionId, + runId: prep.runId, + error: getErrorMessage(error), + }); + await finalize(runtimeSession.sessionId, { forceStatus: "failed", message: getErrorMessage(error) }); + }); + return { + sessionId: runtimeSession.sessionId, + provider, + ptyId: null, + status: "started", + error: null, + context: runtimeContext, + }; + } catch (error) { + if (runId) { + try { + await runtime.conflictService.finalizeResolverSession({ runId, exitCode: 1 }); + } catch { + // Preserve the original error. + } + } + const sessionId = randomUUID(); + const message = getErrorMessage(error); + emitPrAiResolutionRuntimeEvent(runtime, { sessionId, status: "failed", message, timestamp: nowIso() }); + return { sessionId, provider: inferPrAiProvider(model), ptyId: null, status: "failed", error: message, context }; + } + }, + async input(args?: unknown): Promise { + const inputArgs = asActionRecord(args) as unknown as PrAiResolutionInputArgs; + const sessionId = typeof inputArgs.sessionId === "string" ? inputArgs.sessionId.trim() : ""; + const text = typeof inputArgs.text === "string" ? inputArgs.text : ""; + if (!sessionId || !text.length) return; + if (!prAiSessions.has(sessionId)) throw new Error(`AI resolution session not found: ${sessionId}`); + const agentChatService = requireService(runtime.agentChatService, "Agent chat service not available."); + const sessionDetail = runtime.sessionService.get(sessionId); + if (sessionDetail?.status === "running") { + await agentChatService.steer({ sessionId, text }); + return; + } + await agentChatService.sendMessage({ sessionId, text }); + }, + async stop(args?: unknown): Promise { + const stopArgs = asActionRecord(args) as unknown as PrAiResolutionStopArgs; + const sessionId = typeof stopArgs.sessionId === "string" ? stopArgs.sessionId.trim() : ""; + if (!sessionId) return; + if (!prAiSessions.has(sessionId)) return; + const agentChatService = requireService(runtime.agentChatService, "Agent chat service not available."); + await agentChatService.interrupt({ sessionId }); + await finalize(sessionId, { forceStatus: "cancelled", message: "AI resolution stopped by user." }); + }, + }; + + prAiRuntimeBridges.set(runtime, bridge); + return bridge; +} + +async function persistIssueResolutionRuntime( + runtime: AdeRuntime, + args: PrIssueResolutionStartArgs, + result: { sessionId: string; laneId: string; href: string }, +): Promise { + try { + const status = runtime.issueInventoryService.getConvergenceStatus(args.prId); + runtime.issueInventoryService.saveConvergenceRuntime(args.prId, { + currentRound: status.currentRound, + status: "running", + pollerStatus: "idle", + activeSessionId: result.sessionId, + activeLaneId: result.laneId, + activeHref: result.href, + lastStartedAt: nowIso(), + errorMessage: null, + pauseReason: null, + }); + } catch (error) { + runtime.logger.warn("ade_actions.pr_issue_resolution_convergence_persist_failed", { + prId: args.prId, + sessionId: result.sessionId, + laneId: result.laneId, + href: result.href, + error: getErrorMessage(error), + }); + } +} + +function buildPrDomainService(runtime: AdeRuntime): OpaqueService | null { + const prService = runtime.prService; + if (!prService) return null; + const queueLandingService = runtime.queueLandingService ?? null; + const prSummaryService = runtime.prSummaryService ?? null; + + return { + ...(prService as unknown as OpaqueService), + aiResolutionGetSession(args?: unknown) { + return getPrAiRuntimeBridge(runtime).getSession(args); + }, + aiResolutionStart(args?: unknown) { + return getPrAiRuntimeBridge(runtime).start(args); + }, + aiResolutionInput(args?: unknown) { + return getPrAiRuntimeBridge(runtime).input(args); + }, + aiResolutionStop(args?: unknown) { + return getPrAiRuntimeBridge(runtime).stop(args); + }, + ...(queueLandingService + ? { + async startQueueAutomation(args?: unknown) { + return await queueLandingService.startQueue(asActionRecord(args) as Parameters[0]); + }, + pauseQueueAutomation(args?: unknown) { + return queueLandingService.pauseQueue(readStringActionArg(args, "queueId")); + }, + resumeQueueAutomation(args?: unknown) { + return queueLandingService.resumeQueue(asActionRecord(args) as Parameters[0]); + }, + cancelQueueAutomation(args?: unknown) { + return queueLandingService.cancelQueue(readStringActionArg(args, "queueId")); + }, + getQueueState(args?: unknown) { + return queueLandingService.getQueueStateByGroup(readStringActionArg(args, "groupId")); + }, + listQueueStates(args?: unknown) { + return queueLandingService.listQueueStates(asActionRecord(args) as Parameters[0]); + }, + } + : {}), + ...(prSummaryService + ? { + getAiSummary(prId: unknown) { + return prSummaryService.getSummary(readStringActionArg(prId, "prId")); + }, + regenerateAiSummary(prId: unknown) { + return prSummaryService.regenerateSummary(readStringActionArg(prId, "prId")); + }, + } + : {}), + async issueResolutionStart(args?: unknown) { + const startArgs = asActionRecord(args) as unknown as PrIssueResolutionStartArgs; + const result = await launchPrIssueResolutionChat(buildPrIssueResolutionDeps(runtime), startArgs); + await persistIssueResolutionRuntime(runtime, startArgs, result); + return result; + }, + issueResolutionPreviewPrompt(args?: unknown) { + return previewPrIssueResolutionPrompt( + buildPrIssueResolutionDeps(runtime), + asActionRecord(args) as unknown as PrIssueResolutionPromptPreviewArgs, + ); + }, + rebaseResolutionStart(args?: unknown) { + return launchRebaseResolutionChat( + { + laneService: runtime.laneService, + agentChatService: requireService(runtime.agentChatService, "Agent chat service not available."), + sessionService: runtime.sessionService, + conflictService: runtime.conflictService, + }, + asActionRecord(args) as unknown as RebaseResolutionStartArgs, + ); + }, + async launchIssueResolutionFromThread(args?: unknown) { + const threadArgs = asActionRecord(args) as unknown as LaunchPrIssueResolutionFromThreadArgs; + if (!threadArgs.modelId) { + throw new Error("modelId is required for launchIssueResolutionFromThread."); + } + const startArgs: PrIssueResolutionStartArgs = { + prId: threadArgs.prId, + scope: "comments", + modelId: threadArgs.modelId, + reasoning: threadArgs.reasoning ?? null, + permissionMode: threadArgs.permissionMode, + additionalInstructions: buildIssueResolutionInstructionsFromThread(threadArgs), + }; + const result = await launchPrIssueResolutionChat(buildPrIssueResolutionDeps(runtime), startArgs); + await persistIssueResolutionRuntime(runtime, startArgs, result); + return result; + }, + }; +} + +function buildGithubDomainService(runtime: AdeRuntime): OpaqueService | null { + const githubService = runtime.githubService; + if (!githubService) return null; + return { + ...(githubService as unknown as OpaqueService), + async listRepoLabels(args?: unknown) { + const actionArgs = asActionRecord(args); + return githubService.listRepoLabels( + requireNonEmptyString(actionArgs.owner, "owner"), + requireNonEmptyString(actionArgs.name, "name"), + ); + }, + async listRepoCollaborators(args?: unknown) { + const actionArgs = asActionRecord(args); + return githubService.listRepoCollaborators( + requireNonEmptyString(actionArgs.owner, "owner"), + requireNonEmptyString(actionArgs.name, "name"), + ); + }, + async publishCurrentProject(args?: unknown) { + const actionArgs = asActionRecord(args); + const isPrivate = actionArgs.isPrivate; + if (typeof isPrivate !== "boolean") { + throw new Error("Expected 'isPrivate' to be a boolean."); + } + const description = typeof actionArgs.description === "string" + ? actionArgs.description + : undefined; + return githubService.publishCurrentProject({ + name: requireNonEmptyString(actionArgs.name, "name"), + description, + isPrivate, + }); + }, + async setToken(args?: unknown) { + githubService.setToken(readStringActionArg(args, "token")); + return githubService.getStatus(); + }, + async clearToken() { + githubService.clearToken(); + return githubService.getStatus(); + }, + }; +} + +function buildLinearIssueTrackerDomainService(runtime: AdeRuntime): OpaqueService | null { + const tracker = runtime.linearIssueTracker; + if (!tracker) return null; + return { + ...(tracker as unknown as OpaqueService), + async getConnectionStatus() { + return buildRuntimeLinearConnectionStatus(runtime); + }, + async getQuickView(connection?: LinearConnectionStatus): Promise { + const nextConnection = connection ?? await buildRuntimeLinearConnectionStatus(runtime); + if (!nextConnection.connected) return createEmptyLinearQuickView(nextConnection); + try { + return await tracker.getQuickView(nextConnection); + } catch (error) { + return createEmptyLinearQuickView({ + ...nextConnection, + connected: false, + viewerId: null, + viewerName: null, + checkedAt: nowIso(), + message: getErrorMessage(error) || "Linear tracker error", + }); + } + }, + async getWorkflowCatalog() { + const [users, labels, states] = await Promise.all([ + tracker.listUsers(), + tracker.listLabels(), + tracker.listWorkflowStates(), + ]); + return { users, labels, states }; + }, + async getIssuePickerData() { + const [projects, users, states] = await Promise.all([ + tracker.listProjects().catch(() => []), + tracker.listUsers().catch(() => []), + tracker.listWorkflowStates().catch(() => []), + ]); + return { projects, users, states }; + }, + }; +} + +async function buildRuntimeLinearConnectionStatus(runtime: AdeRuntime): Promise { + const credentialStatus = runtime.linearCredentialService?.getStatus() ?? { + tokenStored: false, + authMode: null, + oauthConfigured: false, + tokenExpiresAt: null, + }; + const tokenStored = Boolean(credentialStatus.tokenStored); + if (!runtime.linearIssueTracker || !tokenStored) { + return { + tokenStored, + connected: false, + viewerId: null, + viewerName: null, + checkedAt: nowIso(), + authMode: credentialStatus.authMode, + oauthAvailable: credentialStatus.oauthConfigured, + tokenExpiresAt: credentialStatus.tokenExpiresAt, + message: tokenStored ? "Linear tracker service unavailable." : "Linear token not configured.", + }; + } + try { + const status = await runtime.linearIssueTracker.getConnectionStatus(); + return { + tokenStored, + connected: status.connected, + viewerId: status.viewerId, + viewerName: status.viewerName, + organizationId: status.organizationId ?? null, + organizationName: status.organizationName ?? null, + organizationUrlKey: status.organizationUrlKey ?? null, + organizationLogoUrl: status.organizationLogoUrl ?? null, + checkedAt: nowIso(), + authMode: credentialStatus.authMode, + oauthAvailable: credentialStatus.oauthConfigured, + tokenExpiresAt: credentialStatus.tokenExpiresAt, + message: formatLinearConnectionMessage(status.message, credentialStatus.authMode), + }; + } catch (error) { + return { + tokenStored, + connected: false, + viewerId: null, + viewerName: null, + checkedAt: nowIso(), + authMode: credentialStatus.authMode, + oauthAvailable: credentialStatus.oauthConfigured, + tokenExpiresAt: credentialStatus.tokenExpiresAt, + message: formatLinearConnectionMessage( + getErrorMessage(error) || "Linear connection check failed.", + credentialStatus.authMode, + ), + }; + } +} + +function formatLinearConnectionMessage( + message: string | null | undefined, + authMode: "manual" | "oauth" | null | undefined, +): string | null { + const trimmed = message?.trim(); + if ( + authMode === "manual" + && trimmed + && /authentication required|not authenticated/i.test(trimmed) + ) { + return "Linear rejected the API key. Paste a Linear personal API key from linear.app/settings/api; it should start with lin_api_."; + } + return trimmed || null; +} + +function buildLinearOAuthDomainService(runtime: AdeRuntime): OpaqueService | null { + const service = runtime.linearOAuthService; + if (!service) return null; + return { + async startSession() { + return service.startSession(); + }, + async getSession(args?: unknown) { + const session = service.getSession(readStringActionArg(args, "sessionId")); + if (session.status !== "completed") { + return session; + } + return { + ...session, + connection: await buildRuntimeLinearConnectionStatus(runtime), + }; + }, + }; +} + +function createEmptyLinearQuickView(connection: LinearConnectionStatus): CtoLinearQuickView { + return { + connection, + organization: null, + viewer: null, + projects: [], + teams: [], + assignedIssues: [], + recentIssues: [], + fetchedAt: nowIso(), + sdk: { + packageName: "@linear/sdk", + surfaces: [], + }, + }; +} + +function normalizeSimulatedLinearIssue(runtime: AdeRuntime, args?: CtoSimulateFlowRouteArgs): NormalizedLinearIssue { + const issueInput = args?.issue; + if (!issueInput?.title?.trim()) { + throw new Error("issue.title is required."); + } + const policy = runtime.flowPolicyService?.getPolicy(); + const defaultProjectSlug = + policy?.workflows.flatMap((workflow) => workflow.triggers.projectSlugs ?? []).find(Boolean) + ?? policy?.legacyConfig?.projects?.[0]?.slug + ?? "sim-project"; + const now = nowIso(); + return { + id: issueInput.id ?? `sim-${randomUUID()}`, + identifier: issueInput.identifier ?? "SIM-1", + title: issueInput.title, + description: issueInput.description ?? "", + url: issueInput.url ?? null, + projectId: issueInput.projectId ?? "sim-project", + projectSlug: issueInput.projectSlug ?? defaultProjectSlug, + teamId: issueInput.teamId ?? "sim-team", + teamKey: issueInput.teamKey ?? "SIM", + stateId: issueInput.stateId ?? "sim-state", + stateName: issueInput.stateName ?? "Todo", + stateType: issueInput.stateType ?? "unstarted", + priority: Number.isFinite(Number(issueInput.priority)) ? Number(issueInput.priority) : 3, + priorityLabel: issueInput.priorityLabel ?? "normal", + labels: Array.isArray(issueInput.labels) ? issueInput.labels : [], + metadataTags: Array.isArray(issueInput.metadataTags) ? issueInput.metadataTags : [], + assigneeId: issueInput.assigneeId ?? null, + assigneeName: issueInput.assigneeName ?? null, + ownerId: issueInput.ownerId ?? null, + creatorId: issueInput.creatorId ?? null, + creatorName: issueInput.creatorName ?? null, + blockerIssueIds: Array.isArray(issueInput.blockerIssueIds) ? issueInput.blockerIssueIds : [], + hasOpenBlockers: Boolean(issueInput.hasOpenBlockers), + createdAt: issueInput.createdAt ?? now, + updatedAt: issueInput.updatedAt ?? now, + raw: isRecord(issueInput.raw) ? issueInput.raw : {}, + }; +} + +function buildLinearRoutingDomainService(runtime: AdeRuntime): OpaqueService | null { + const routingService = runtime.linearRoutingService; + if (!routingService) return null; + return { + ...(routingService as unknown as OpaqueService), + simulateRoute: (args?: CtoSimulateFlowRouteArgs): Promise => + routingService.simulateRoute({ issue: normalizeSimulatedLinearIssue(runtime, args) }), + }; +} + +function buildFileDomainService(runtime: AdeRuntime): OpaqueService | null { + const fileService = runtime.fileService; + if (!fileService) return null; + return { + ...(fileService as unknown as OpaqueService), + async watchWorkspace(args?: unknown): Promise<{ ok: true }> { + const actionArgs = asActionRecord(args); + const senderId = readRuntimeFileWatchSenderId(actionArgs); + await fileService.watchWorkspace( + toRuntimeFileWatchArgs(actionArgs), + (event: FileChangeEvent) => { + runtime.eventBuffer.push({ + timestamp: new Date().toISOString(), + category: "runtime", + payload: { type: "file_change", event }, + }); + }, + senderId, + ); + return { ok: true }; + }, + stopWatching(args?: unknown): { ok: true } { + const actionArgs = asActionRecord(args); + const senderId = readRuntimeFileWatchSenderId(actionArgs); + fileService.stopWatching( + toRuntimeFileWatchArgs(actionArgs), + senderId, + ); + return { ok: true }; + }, + }; +} + function buildTerminalDomainService(runtime: AdeRuntime): TerminalDomainService | null { if (!runtime.ptyService) return null; return { @@ -617,52 +3466,57 @@ export function getAdeActionDomainServices( runtime: AdeRuntime, ): Partial> { return { - lane: toService(runtime.laneService), + lane: toService(buildLaneDomainService(runtime)), git: toService(runtime.gitService), diff: toService(runtime.diffService), conflicts: toService(runtime.conflictService), - pr: toService(runtime.prService), + pr: toService(buildPrDomainService(runtime)), tests: toService(runtime.testService), - chat: toService(runtime.agentChatService), + chat: toService(buildChatDomainService(runtime)), keybindings: toService(runtime.keybindingsService), + ai: toService(buildAiDomainService(runtime)), onboarding: toService(runtime.onboardingService), automation_planner: toService(runtime.automationPlannerService), - mission: toService(runtime.missionService), - orchestrator: toService(runtime.aiOrchestratorService), - orchestrator_core: toService(runtime.orchestratorService), - memory: toService(runtime.memoryService), - cto_state: toService(runtime.ctoStateService), - worker_agent: toService(runtime.workerAgentService), - session: toService(runtime.sessionService), + mission: toService(buildMissionDomainService(runtime)), + orchestrator: toService(buildAiOrchestratorDomainService(runtime)), + orchestrator_core: toService(buildOrchestratorCoreDomainService(runtime)), + mission_budget: toService(runtime.missionBudgetService), + memory: toService(buildMemoryDomainService(runtime)), + cto_state: toService(buildCtoStateDomainService(runtime)), + worker_agent: toService(buildWorkerAgentDomainService(runtime)), + session: toService(buildSessionDomainService(runtime)), operation: toService(runtime.operationService), + ade_project: toService(runtime.adeProjectService), project_config: toService(runtime.projectConfigService), issue_inventory: toService(runtime.issueInventoryService), path_to_merge: toService(runtime.pathToMergeOrchestrator), flow_policy: toService(runtime.flowPolicyService), linear_credentials: toService(runtime.linearCredentialService), + linear_oauth: buildLinearOAuthDomainService(runtime), linear_dispatcher: toService(runtime.linearDispatcherService), - linear_issue_tracker: toService(runtime.linearIssueTracker), + linear_issue_tracker: toService(buildLinearIssueTrackerDomainService(runtime)), linear_sync: toService(runtime.linearSyncService), linear_ingress: toService(runtime.linearIngressService), - linear_routing: toService(runtime.linearRoutingService), - github: toService(runtime.githubService), + linear_routing: toService(buildLinearRoutingDomainService(runtime)), + github: buildGithubDomainService(runtime), feedback: toService(runtime.feedbackReporterService), usage: toService(runtime.usageTrackingService), budget: toService(runtime.budgetCapService), update: toService(runtime.autoUpdateService), - file: toService(runtime.fileService), + file: toService(buildFileDomainService(runtime)), process: toService(runtime.processService), pty: toService(runtime.ptyService), terminal: toService(buildTerminalDomainService(runtime)), layout: toService(buildLayoutDomainService(runtime)), tiling_tree: toService(buildTilingTreeDomainService(runtime)), graph_state: toService(buildGraphStateDomainService(runtime)), - computer_use_artifacts: toService(runtime.computerUseArtifactBrokerService), + computer_use_artifacts: toService(buildComputerUseArtifactsDomainService(runtime)), ios_simulator: toService(runtime.iosSimulatorService), app_control: toService(runtime.appControlService), built_in_browser: toService(runtime.builtInBrowserService), macos_vm: toService(runtime.macosVmService), automations: toService(buildAutomationsDomainService(runtime)), + review: toService(runtime.reviewService), issue: toService(buildIssueDomainService(runtime)), }; } diff --git a/apps/desktop/src/main/services/ai/aiIntegrationService.ts b/apps/desktop/src/main/services/ai/aiIntegrationService.ts index 09844a925..1b75e65bb 100644 --- a/apps/desktop/src/main/services/ai/aiIntegrationService.ts +++ b/apps/desktop/src/main/services/ai/aiIntegrationService.ts @@ -51,7 +51,13 @@ import { initialize as initModelsDevService } from "./modelsDevService"; import { updateModelPricing } from "../../../shared/modelProfiles"; import { isRecord } from "../shared/utils"; import { parseStructuredOutput } from "./utils"; -import { getAllApiKeys, getApiKeyStoreStatus } from "./apiKeyStore"; +import { + deleteApiKey as deleteStoredApiKey, + getAllApiKeys, + getApiKeyStoreStatus, + listStoredProviders, + storeApiKey as storeStoredApiKey, +} from "./apiKeyStore"; import type { createMemoryService } from "../memory/memoryService"; import { inspectLocalProvider } from "./localModelDiscovery"; import { @@ -1798,6 +1804,17 @@ export function createAiIntegrationService(args: { getAvailability: getAvailabilitySync, verifyApiKeyConnection, + storeApiKey(provider: string, key: string): void { + storeStoredApiKey(provider, key); + invalidateProviderReadinessCaches(); + }, + deleteApiKey(provider: string): void { + deleteStoredApiKey(provider); + invalidateProviderReadinessCaches(); + }, + listApiKeys(): string[] { + return listStoredProviders(); + }, listCursorCloudRepositories, listCursorCloudAgents, listCursorCloudRuns, diff --git a/apps/desktop/src/main/services/ai/apiKeyStore.test.ts b/apps/desktop/src/main/services/ai/apiKeyStore.test.ts index 843384733..324e1faee 100644 --- a/apps/desktop/src/main/services/ai/apiKeyStore.test.ts +++ b/apps/desktop/src/main/services/ai/apiKeyStore.test.ts @@ -106,6 +106,34 @@ async function loadStoreModule() { return mod; } +class MemoryCredentialStore { + readonly values = new Map(); + + async get(key: string): Promise { + return this.getSync(key); + } + + async set(key: string, value: string): Promise { + this.setSync(key, value); + } + + async delete(key: string): Promise { + this.deleteSync(key); + } + + getSync(key: string): string | null { + return this.values.get(key) ?? null; + } + + setSync(key: string, value: string): void { + this.values.set(key, value); + } + + deleteSync(key: string): void { + this.values.delete(key); + } +} + describe("apiKeyStore", () => { let tempRoot: string; let keychain: Map; @@ -276,4 +304,70 @@ describe("apiKeyStore", () => { ]); expect(securityAccountsFor("add-generic-password")).toContain("__ade_provider_index__"); }); + + it("stores, lists, returns, and deletes API keys through a provided credential store", async () => { + delete process.env.OPENAI_API_KEY; + const credentialStore = new MemoryCredentialStore(); + const store = await loadStoreModule(); + store.initApiKeyStore(tempRoot, { credentialStore }); + + store.storeApiKey(" OpenAI ", " sk-test-key "); + store.storeApiKey("CURSOR", " crsr_test_key "); + + expect(store.getApiKey("openai")).toBe("sk-test-key"); + expect(store.getAllApiKeys()).toEqual({ + cursor: "crsr_test_key", + openai: "sk-test-key", + }); + expect(store.listStoredProviders().sort()).toEqual(["cursor", "openai"]); + expect(credentialStore.values.get("ai.api_key.openai.v1")).toBe("sk-test-key"); + expect(credentialStore.values.get("ai.api_key.cursor.v1")).toBe("crsr_test_key"); + expect(JSON.parse(credentialStore.values.get("ai.api_key.index.v1") ?? "[]")).toEqual(["cursor", "openai"]); + expect(keychain.size).toBe(0); + + store.deleteApiKey("OPENAI"); + + expect(store.getApiKey("openai")).toBeNull(); + expect(store.getAllApiKeys()).toEqual({ cursor: "crsr_test_key" }); + expect(store.listStoredProviders()).toEqual(["cursor"]); + expect(credentialStore.values.has("ai.api_key.openai.v1")).toBe(false); + expect(JSON.parse(credentialStore.values.get("ai.api_key.index.v1") ?? "[]")).toEqual(["cursor"]); + }); + + it("reads an unindexed credential-store provider on demand and updates the index", async () => { + const credentialStore = new MemoryCredentialStore(); + credentialStore.setSync("ai.api_key.openai.v1", "sk-unindexed-key"); + const store = await loadStoreModule(); + store.initApiKeyStore(tempRoot, { credentialStore }); + + expect(store.listStoredProviders()).toEqual([]); + expect(store.getApiKey("OPENAI")).toBe("sk-unindexed-key"); + + expect(store.listStoredProviders()).toEqual(["openai"]); + expect(JSON.parse(credentialStore.values.get("ai.api_key.index.v1") ?? "[]")).toEqual(["openai"]); + }); + + it("can use the ADE CLI encrypted credential store without persisting the raw key", async () => { + process.env.ADE_API_KEY_STORE_DISABLE_KEYCHAIN = "1"; + const credentialsPath = path.join(tempRoot, "credentials.json.enc"); + const machineKeyPath = path.join(tempRoot, ".machine-key"); + const { EncryptedFileCredentialStore } = await import("../../../../../ade-cli/src/services/credentials/credentialStore"); + const credentialStore = new EncryptedFileCredentialStore({ credentialsPath, machineKeyPath }); + const store = await loadStoreModule(); + store.initApiKeyStore(tempRoot, { credentialStore }); + + store.storeApiKey("OpenAI", "sk-raw-secret-value"); + + expect(store.getApiKey("openai")).toBe("sk-raw-secret-value"); + expect(store.listStoredProviders()).toEqual(["openai"]); + const persisted = fs.readFileSync(credentialsPath, "utf8"); + expect(persisted).toContain("ciphertext"); + expect(persisted).not.toContain("sk-raw-secret-value"); + expect(fs.existsSync(path.join(tempRoot, ".ade", "secrets", "api-keys.v1.bin"))).toBe(false); + expect(store.getApiKeyStoreStatus()).toMatchObject({ + secureStorageAvailable: true, + encryptedStorePath: null, + decryptionFailed: false, + }); + }); }); diff --git a/apps/desktop/src/main/services/ai/apiKeyStore.ts b/apps/desktop/src/main/services/ai/apiKeyStore.ts index 801eaf6bf..f5c79afa3 100644 --- a/apps/desktop/src/main/services/ai/apiKeyStore.ts +++ b/apps/desktop/src/main/services/ai/apiKeyStore.ts @@ -21,6 +21,19 @@ try { type StoredKeys = Record; +export type ApiKeyCredentialStore = { + get?: (key: string) => Promise | string | null; + set?: (key: string, value: string) => Promise | void; + delete?: (key: string) => Promise | void; + getSync?: (key: string) => string | null; + setSync?: (key: string, value: string) => void; + deleteSync?: (key: string) => void; +}; + +export type InitApiKeyStoreOptions = { + credentialStore?: ApiKeyCredentialStore | null; +}; + export type ApiKeyStoreStatus = { secureStorageAvailable: boolean; macosKeychainAvailable: boolean; @@ -54,13 +67,16 @@ const MACOS_KEYCHAIN_MISSING_PATTERNS = [ /the specified item could not be found/i, ]; const SECURITY_TIMEOUT_MS = 5_000; +const CREDENTIAL_PROVIDER_INDEX_KEY = "ai.api_key.index.v1"; let storePath: string | null = null; let legacyStorePath: string | null = null; +let credentialStore: ApiKeyCredentialStore | null = null; let cache: StoredKeys | null = null; let decryptionFailed = false; let macosKeychainError: string | null = null; let missingMacosKeychainProviders = new Set(); +let missingCredentialProviders = new Set(); export function __setSafeStorageForTests(next: SafeStorage | null): void { safeStorage = next; @@ -79,15 +95,20 @@ function isMacosKeychainAvailable(): boolean { } function isPersistentSecureStorageAvailable(): boolean { + if (credentialStore) return true; return isMacosKeychainAvailable() || isSecureStorageAvailable(); } +function normalizeProvider(provider: string): string { + return provider.trim().toLowerCase(); +} + function normalizeStoredKeys(value: unknown): StoredKeys { if (!value || typeof value !== "object" || Array.isArray(value)) return {}; const out: StoredKeys = {}; for (const [provider, rawValue] of Object.entries(value as Record)) { if (typeof rawValue !== "string") continue; - const normalizedProvider = provider.trim().toLowerCase(); + const normalizedProvider = normalizeProvider(provider); const normalizedKey = rawValue.trim(); if (!normalizedProvider.length || !normalizedKey.length) continue; out[normalizedProvider] = normalizedKey; @@ -213,6 +234,77 @@ function normalizeProviderList(value: unknown): string[] { return Array.from(providers).sort(); } +function credentialProviderKey(provider: string): string { + return `ai.api_key.${provider}.v1`; +} + +function getSyncCredentialStore(): Required> | null { + if (!credentialStore) return null; + if ( + typeof credentialStore.getSync === "function" + && typeof credentialStore.setSync === "function" + && typeof credentialStore.deleteSync === "function" + ) { + return credentialStore as Required>; + } + throw new Error("API key credentialStore must provide getSync, setSync, and deleteSync."); +} + +function readCredentialSecret(key: string): string | null { + const store = getSyncCredentialStore(); + if (!store) return null; + try { + const value = store.getSync(key); + decryptionFailed = false; + if (typeof value !== "string") return null; + const trimmed = value.trim(); + return trimmed.length ? trimmed : null; + } catch { + decryptionFailed = true; + return null; + } +} + +function writeCredentialSecret(key: string, value: string): void { + const store = getSyncCredentialStore(); + if (!store) return; + store.setSync(key, value); + decryptionFailed = false; +} + +function deleteCredentialSecret(key: string): void { + const store = getSyncCredentialStore(); + if (!store) return; + store.deleteSync(key); + decryptionFailed = false; +} + +function readCredentialProviderIndex(): { exists: boolean; providers: string[] } { + const raw = readCredentialSecret(CREDENTIAL_PROVIDER_INDEX_KEY); + if (!raw) return { exists: false, providers: [] }; + try { + return { exists: true, providers: normalizeProviderList(JSON.parse(raw)) }; + } catch { + decryptionFailed = true; + return { exists: true, providers: [] }; + } +} + +function writeCredentialProviderIndex(providers: Iterable): void { + writeCredentialSecret(CREDENTIAL_PROVIDER_INDEX_KEY, JSON.stringify(normalizeProviderList(Array.from(providers)))); +} + +function readCredentialStore(providerCandidates: Iterable): StoredKeys { + const out: StoredKeys = {}; + for (const provider of providerCandidates) { + const normalizedProvider = normalizeProvider(provider); + if (!normalizedProvider.length) continue; + const value = readCredentialSecret(credentialProviderKey(normalizedProvider)); + if (value) out[normalizedProvider] = value; + } + return out; +} + function readMacosKeychainProviderIndex(): { exists: boolean; providers: string[] } { const raw = readMacosKeychainSecret(MACOS_KEYCHAIN_PROVIDER_INDEX_ACCOUNT); if (!raw) return { exists: false, providers: [] }; @@ -308,6 +400,12 @@ function ensureStore(): StoredKeys { if (cache) return cache; ensureInitialized(); + if (credentialStore) { + const index = readCredentialProviderIndex(); + cache = index.exists ? readCredentialStore(index.providers) : {}; + return cache; + } + const encryptedStore = loadEncryptedStore(); if (isMacosKeychainAvailable()) { const indexBeforeMigration = readMacosKeychainProviderIndex(); @@ -352,14 +450,16 @@ function persistEncryptedStore(nextStore: StoredKeys = cache ?? {}): void { } } -export function initApiKeyStore(projectRoot: string): void { +export function initApiKeyStore(projectRoot: string, options: InitApiKeyStoreOptions = {}): void { const layout = resolveAdeLayout(projectRoot); storePath = layout.apiKeysPath; legacyStorePath = layout.legacyApiKeysPath; + credentialStore = options.credentialStore ?? null; cache = null; decryptionFailed = false; macosKeychainError = null; missingMacosKeychainProviders = new Set(); + missingCredentialProviders = new Set(); } export function getApiKeyStoreStatus(): ApiKeyStoreStatus { @@ -380,7 +480,7 @@ export function getApiKeyStoreStatus(): ApiKeyStoreStatus { macosKeychainAvailable: isMacosKeychainAvailable(), macosKeychainService: isMacosKeychainAvailable() ? MACOS_KEYCHAIN_SERVICE : null, macosKeychainError, - encryptedStorePath: storePath, + encryptedStorePath: credentialStore ? null : storePath, legacyPlaintextDetected: Boolean(legacyStorePath && fs.existsSync(legacyStorePath)), legacyPlaintextPath: legacyStorePath && fs.existsSync(legacyStorePath) ? legacyStorePath : null, decryptionFailed, @@ -388,12 +488,20 @@ export function getApiKeyStoreStatus(): ApiKeyStoreStatus { } export function storeApiKey(provider: string, key: string): void { - const normalizedProvider = provider.trim().toLowerCase(); + const normalizedProvider = normalizeProvider(provider); const normalizedKey = key.trim(); if (!normalizedProvider.length || !normalizedKey.length) { throw new Error("Provider and key are required."); } const store = ensureStore(); + if (credentialStore) { + writeCredentialSecret(credentialProviderKey(normalizedProvider), normalizedKey); + store[normalizedProvider] = normalizedKey; + missingCredentialProviders.delete(normalizedProvider); + const index = readCredentialProviderIndex(); + writeCredentialProviderIndex(new Set([...index.providers, normalizedProvider])); + return; + } if (isMacosKeychainAvailable()) { writeMacosKeychainSecret(normalizedProvider, normalizedKey); store[normalizedProvider] = normalizedKey; @@ -408,11 +516,21 @@ export function storeApiKey(provider: string, key: string): void { } export function getApiKey(provider: string): string | null { - const normalizedProvider = provider.trim().toLowerCase(); + const normalizedProvider = normalizeProvider(provider); if (!normalizedProvider.length) return null; const store = ensureStore(); const stored = store[normalizedProvider]; if (stored) return stored; + if (credentialStore && !missingCredentialProviders.has(normalizedProvider)) { + const credentialValue = readCredentialSecret(credentialProviderKey(normalizedProvider)); + if (credentialValue) { + store[normalizedProvider] = credentialValue; + const index = readCredentialProviderIndex(); + writeCredentialProviderIndex(new Set([...index.providers, normalizedProvider])); + return credentialValue; + } + missingCredentialProviders.add(normalizedProvider); + } if (isMacosKeychainAvailable() && !missingMacosKeychainProviders.has(normalizedProvider)) { const keychainValue = readMacosKeychainSecret(normalizedProvider); if (keychainValue) { @@ -432,9 +550,17 @@ export function getApiKey(provider: string): string | null { } export function deleteApiKey(provider: string): void { - const normalizedProvider = provider.trim().toLowerCase(); + const normalizedProvider = normalizeProvider(provider); if (!normalizedProvider.length) return; const store = ensureStore(); + if (credentialStore) { + deleteCredentialSecret(credentialProviderKey(normalizedProvider)); + delete store[normalizedProvider]; + missingCredentialProviders.add(normalizedProvider); + const index = readCredentialProviderIndex(); + writeCredentialProviderIndex(index.providers.filter((entry) => entry !== normalizedProvider)); + return; + } if (isMacosKeychainAvailable()) { deleteMacosKeychainSecret(normalizedProvider); delete store[normalizedProvider]; diff --git a/apps/desktop/src/main/services/ai/tools/ctoOperatorTools.ts b/apps/desktop/src/main/services/ai/tools/ctoOperatorTools.ts index 08d42df6e..39658e723 100644 --- a/apps/desktop/src/main/services/ai/tools/ctoOperatorTools.ts +++ b/apps/desktop/src/main/services/ai/tools/ctoOperatorTools.ts @@ -1254,7 +1254,7 @@ export function createCtoOperatorTools(deps: CtoOperatorToolDeps): Record { const persisted = readPersistedChatState(session.id); writePersistedChatState(session.id, { ...persisted, - continuitySummary: "- Keep the OpenClaw bridge runtime state in machine-local cache.", + continuitySummary: "- Keep runtime cache state machine-local.", continuitySummaryUpdatedAt: new Date().toISOString(), recentConversationEntries: [ { role: "user", text: "What lane should frontend use?" }, @@ -2471,7 +2471,7 @@ describe("createAgentChatService", () => { expect(result.sessionId).toBe(session.id); expect(send).toHaveBeenCalledTimes(1); expect(send).toHaveBeenCalledWith(expect.stringContaining("Continuity Summary")); - expect(send).toHaveBeenCalledWith(expect.stringContaining("Keep the OpenClaw bridge runtime state in machine-local cache.")); + expect(send).toHaveBeenCalledWith(expect.stringContaining("Keep runtime cache state machine-local.")); expect(send).toHaveBeenCalledWith(expect.stringContaining("User: What lane should frontend use?")); expect(send).toHaveBeenCalledWith(expect.stringContaining("Assistant: Use the primary-hosted coordinator first.")); }); @@ -2727,7 +2727,7 @@ describe("createAgentChatService", () => { const result = await service.runSessionTurn({ sessionId: session.id, - text: "Please keep the OpenClaw bridge state private.", + text: "Please keep the runtime bridge state private.", timeoutMs: 15_000, }); await new Promise((resolve) => setTimeout(resolve, 25)); @@ -2736,7 +2736,7 @@ describe("createAgentChatService", () => { expect(result.outputText).toContain("Partial answer"); expect(persisted.sdkSessionId).toBe("sdk-session-2"); expect(persisted.continuitySummary).toContain("Recent continuity snapshot:"); - expect(persisted.continuitySummary).toContain("User: Please keep the OpenClaw bridge state private."); + expect(persisted.continuitySummary).toContain("User: Please keep the runtime bridge state private."); expect(persisted.continuitySummary).toContain("Assistant: Partial answer"); expect(unstable_v2_createSession).toHaveBeenCalledTimes(2); expect(recoverySend).toHaveBeenCalledWith("System initialization check. Respond with only the word READY."); diff --git a/apps/desktop/src/main/services/chat/agentChatService.ts b/apps/desktop/src/main/services/chat/agentChatService.ts index 57d315d12..ade7e5d28 100644 --- a/apps/desktop/src/main/services/chat/agentChatService.ts +++ b/apps/desktop/src/main/services/chat/agentChatService.ts @@ -31,6 +31,7 @@ type ClaudeV2Session = { import { buildClaudeV2Message, inferAttachmentMediaType } from "./buildClaudeV2Message"; import { discoverClaudeSlashCommands, resolveClaudeSlashCommandInvocation } from "./claudeSlashCommandDiscovery"; import { discoverCodexSlashCommands, resolveCodexSlashCommandInvocation } from "./codexSlashCommandDiscovery"; +import { classifyAgentCliError } from "../../../../../ade-cli/src/services/agentRegistry"; import type { RuntimeFilePart as FilePart, RuntimeImagePart as ImagePart, @@ -6721,21 +6722,49 @@ export function createAgentChatService(args: { setSessionPreview(managed, event.text); }; + const decorateAgentCliError = ( + managed: ManagedChatSession, + event: Extract, + ): Extract => { + const existingInfo = typeof event.errorInfo === "object" && event.errorInfo ? event.errorInfo : null; + if (existingInfo?.agentCli) return event; + + const match = classifyAgentCliError(`${event.message}\n${event.detail ?? ""}`, managed.session.provider); + if (!match) return event; + + return { + ...event, + errorInfo: { + category: match.category === "missing" ? "agent_cli_missing" : "agent_cli_auth", + ...(existingInfo?.provider ? { provider: existingInfo.provider } : { provider: match.displayName }), + ...(existingInfo?.model ? { model: existingInfo.model } : {}), + agentCli: { + agent: match.agent, + displayName: match.displayName, + category: match.category, + installCommand: match.installCommand, + authCommand: match.authCommand, + }, + }, + }; + }; + const commitChatEvent = (managed: ManagedChatSession, event: AgentChatEvent): void => { + const storedEvent = event.type === "error" ? decorateAgentCliError(managed, event) : event; managed.session.lastActivityAt = nowIso(); - trackSubagentEvent(managed, event); - appendRecentConversationEntry(managed, event); + trackSubagentEvent(managed, storedEvent); + appendRecentConversationEntry(managed, storedEvent); - if (event.type === "text") { - updatePreviewFromText(managed, event); - } else if (event.type === "command") { - setSessionPreview(managed, event.output); - } else if (event.type === "error") { - setSessionPreview(managed, event.message); - } else if (event.type === "completion_report") { - managed.session.completion = event.report; - if (event.report.summary.trim().length > 0) { - setSessionPreview(managed, event.report.summary); + if (storedEvent.type === "text") { + updatePreviewFromText(managed, storedEvent); + } else if (storedEvent.type === "command") { + setSessionPreview(managed, storedEvent.output); + } else if (storedEvent.type === "error") { + setSessionPreview(managed, storedEvent.message); + } else if (storedEvent.type === "completion_report") { + managed.session.completion = storedEvent.report; + if (storedEvent.report.summary.trim().length > 0) { + setSessionPreview(managed, storedEvent.report.summary); } } @@ -6745,7 +6774,7 @@ export function createAgentChatService(args: { const envelope: AgentChatEventEnvelope = { sessionId: managed.session.id, timestamp: nowIso(), - event, + event: storedEvent, sequence: ++managed.eventSequence, }; @@ -6766,24 +6795,24 @@ export function createAgentChatService(args: { const collector = sessionTurnCollectors.get(managed.session.id); if (!collector) return; - if (event.type === "text") { - collector.outputText += event.text; + if (storedEvent.type === "text") { + collector.outputText += storedEvent.text; return; } - if (event.type === "error") { - collector.lastError = event.message; + if (storedEvent.type === "error") { + collector.lastError = storedEvent.message; return; } - if (event.type === "status" && event.turnStatus === "failed" && event.message) { - collector.lastError = event.message; + if (storedEvent.type === "status" && storedEvent.turnStatus === "failed" && storedEvent.message) { + collector.lastError = storedEvent.message; return; } - if (event.type !== "done") return; + if (storedEvent.type !== "done") return; - collector.usage = event.usage; + collector.usage = storedEvent.usage; if (collector.timeout) { clearTimeout(collector.timeout); } @@ -6795,7 +6824,7 @@ export function createAgentChatService(args: { ...(managed.session.modelId ? { modelId: managed.session.modelId } : {}), outputText: collector.outputText.trim() || managed.preview?.trim() || "", ...(collector.usage ? { usage: collector.usage } : {}), - ...(event.turnId ? { turnId: event.turnId } : {}), + ...(storedEvent.turnId ? { turnId: storedEvent.turnId } : {}), ...(managed.session.threadId ? { threadId: managed.session.threadId } : {}), ...(managed.runtime?.kind === "claude" ? { sdkSessionId: managed.runtime.sdkSessionId ?? null } : {}), }); @@ -17066,7 +17095,7 @@ export function createAgentChatService(args: { const providerFromPreference: AgentChatProvider = (() => { if (workerIdentity?.adapterType === "claude-local") return "claude"; if (workerIdentity?.adapterType === "codex-local") return "codex"; - if (workerIdentity?.adapterType === "openclaw-webhook" || workerIdentity?.adapterType === "process") return "opencode"; + if (workerIdentity?.adapterType === "process") return "opencode"; if (preferredProviderRaw.includes("codex") || preferredProviderRaw.includes("openai")) return "codex"; if (preferredProviderRaw.includes("claude") || preferredProviderRaw.includes("anthropic")) return "claude"; if (preferredProviderRaw.includes("droid") || preferredProviderRaw.includes("factory")) return "droid"; diff --git a/apps/desktop/src/main/services/cli/adeCliService.test.ts b/apps/desktop/src/main/services/cli/adeCliService.test.ts index bed3ce13d..2c402ffce 100644 --- a/apps/desktop/src/main/services/cli/adeCliService.test.ts +++ b/apps/desktop/src/main/services/cli/adeCliService.test.ts @@ -80,6 +80,32 @@ describe("createAdeCliService", () => { expect(service.agentEnv({ PATH: "/usr/bin:/bin" }).PATH?.split(path.delimiter)[0]).toBe(packagedBinDir); }); + it("uses channel-specific packaged CLI commands and install targets", async () => { + const root = makeTempRoot(); + const home = path.join(root, "home"); + const resourcesPath = path.join(root, "Resources"); + const packagedBinDir = path.join(resourcesPath, "ade-cli", "bin"); + const packagedCommandPath = path.join(packagedBinDir, "ade-alpha"); + writeExecutable(packagedCommandPath); + writeExecutable(path.join(resourcesPath, "ade-cli", "install-path.sh")); + fs.writeFileSync(path.join(resourcesPath, "ade-cli", "cli.cjs"), "console.log('ade')\n"); + + const service = createAdeCliService({ + isPackaged: true, + resourcesPath, + userDataPath: path.join(root, "user-data"), + appExecutablePath: path.join(root, "ADE Alpha.app", "Contents", "MacOS", "ADE Alpha"), + env: { ADE_PACKAGE_CHANNEL: "alpha", HOME: home, PATH: "/usr/bin:/bin" }, + logger: logger() as any, + }); + + const status = await service.getStatus(); + expect(service.resolved.commandPath).toBe(packagedCommandPath); + expect(status.command).toBe("ade-alpha"); + expect(status.installTargetPath).toBe(path.join(home, ".local", "bin", "ade-alpha")); + expect(status.nextAction).toBe("Install the ade-alpha command for Terminal access."); + }); + it("uses packaged Windows cmd wrappers and Path casing", async () => { setPlatform("win32"); const root = makeTempRoot(); @@ -442,7 +468,7 @@ describe("createAdeCliService", () => { logger: logger() as any, }); - const shimPath = path.join(userDataPath, "ade-cli", "bin", "ade"); + const shimPath = path.join(userDataPath, "ade-cli", "bin", "ade-dev"); expect(service.resolved.source).toBe("dev"); expect(service.resolved.commandPath).toBe(shimPath); expect(fs.existsSync(shimPath)).toBe(true); @@ -471,7 +497,7 @@ describe("createAdeCliService", () => { logger: logger() as any, }); - const shimPath = path.join(userDataPath, "ade-cli", "bin", "ade.cmd"); + const shimPath = path.join(userDataPath, "ade-cli", "bin", "ade-dev.cmd"); const script = fs.readFileSync(shimPath, "utf8"); expect(service.resolved.source).toBe("dev"); @@ -503,7 +529,7 @@ describe("createAdeCliService", () => { logger: logger() as any, }); - const shimPath = path.join(userDataPath, "ade-cli", "bin", "ade"); + const shimPath = path.join(userDataPath, "ade-cli", "bin", "ade-dev"); const shimScript = fs.readFileSync(shimPath, "utf8"); expect(service.resolved.source).toBe("dev"); @@ -547,7 +573,7 @@ describe("createAdeCliService", () => { expect(service.resolved.cliJsPath).toBe(sourceCliPath); }); - it("does not run a global installer from dev builds", async () => { + it("installs the dev CLI command separately from prod", async () => { const root = makeTempRoot(); vi.spyOn(process, "cwd").mockReturnValue(root); @@ -556,11 +582,13 @@ describe("createAdeCliService", () => { resourcesPath: path.join(root, "missing-resources"), userDataPath: path.join(root, "user-data"), appExecutablePath: "/Applications/ADE.app/Contents/MacOS/ADE", + env: { HOME: path.join(root, "home"), PATH: "/usr/bin:/bin" }, logger: logger() as any, }); const result = await service.installForUser(); - expect(result.ok).toBe(false); - expect(result.message).toContain("local development"); + expect(result.ok).toBe(true); + expect(result.status.command).toBe("ade-dev"); + expect(fs.existsSync(path.join(root, "home", ".local", "bin", "ade-dev"))).toBe(true); }); }); diff --git a/apps/desktop/src/main/services/cli/adeCliService.ts b/apps/desktop/src/main/services/cli/adeCliService.ts index c0779cbf0..479248707 100644 --- a/apps/desktop/src/main/services/cli/adeCliService.ts +++ b/apps/desktop/src/main/services/cli/adeCliService.ts @@ -34,6 +34,7 @@ type DevCliEntry = { }; const PATH_DELIMITER = path.delimiter; +const VALID_COMMAND_NAME = /^ade(?:-[a-z0-9][a-z0-9-]*)?$/; function shellQuote(value: string): string { return `'${value.replace(/'/g, "'\\''")}'`; @@ -43,8 +44,8 @@ function pathDelimiter(): string { return process.platform === "win32" ? ";" : PATH_DELIMITER; } -function commandFileName(): "ade" | "ade.cmd" { - return process.platform === "win32" ? "ade.cmd" : "ade"; +function commandFileName(commandName: string): string { + return process.platform === "win32" ? `${commandName}.cmd` : commandName; } function installerFileName(): "install-path.sh" | "install-path.cmd" { @@ -64,6 +65,24 @@ function isExecutable(filePath: string | null | undefined): boolean { } } +function normalizePackageChannel(value: unknown): "alpha" | "beta" | null { + const normalized = typeof value === "string" ? value.trim().toLowerCase() : ""; + return normalized === "alpha" || normalized === "beta" ? normalized : null; +} + +function sanitizeCommandName(value: unknown): string | null { + const normalized = typeof value === "string" ? value.trim() : ""; + return VALID_COMMAND_NAME.test(normalized) ? normalized : null; +} + +function resolveCommandName(args: CreateAdeCliServiceArgs): string { + const explicit = sanitizeCommandName(args.env?.ADE_CLI_INSTALL_NAME ?? process.env.ADE_CLI_INSTALL_NAME); + if (explicit) return explicit; + const channel = normalizePackageChannel(args.env?.ADE_PACKAGE_CHANNEL ?? process.env.ADE_PACKAGE_CHANNEL); + if (channel) return `ade-${channel}`; + return args.isPackaged ? "ade" : "ade-dev"; +} + function splitPathEntries(value: string | null | undefined): string[] { return (value ?? "").split(pathDelimiter()).map((entry) => entry.trim()).filter(Boolean); } @@ -268,6 +287,7 @@ function resolveDevCliEntry(devRepoRoot?: string | null): DevCliEntry | null { } function writeDevShim(args: { + commandName: string; cliJsPath: string; entryKind: "built" | "source"; tsxBinPath: string | null; @@ -277,7 +297,7 @@ function writeDevShim(args: { logger: Logger; }): { commandPath: string; binDir: string } | null { const binDir = path.join(args.userDataPath, "ade-cli", "bin"); - const commandPath = path.join(binDir, commandFileName()); + const commandPath = path.join(binDir, commandFileName(args.commandName)); const script = process.platform === "win32" ? createWindowsShimScript(args) : [ "#!/bin/sh", "set -eu", @@ -348,16 +368,17 @@ function writeDevShim(args: { } } -function resolveCliPaths(args: CreateAdeCliServiceArgs): ResolvedCliPaths { +function resolveCliPaths(args: CreateAdeCliServiceArgs, commandName: string): ResolvedCliPaths { const resourcesPath = args.resourcesPath ? path.resolve(args.resourcesPath) : null; const packagedBinDir = resourcesPath ? path.join(resourcesPath, "ade-cli", "bin") : null; - const packagedCommandPath = packagedBinDir ? path.join(packagedBinDir, commandFileName()) : null; + const packagedCommandPath = packagedBinDir ? path.join(packagedBinDir, commandFileName(commandName)) : null; + const fallbackPackagedCommandPath = packagedBinDir && commandName !== "ade" ? path.join(packagedBinDir, commandFileName("ade")) : null; const packagedCliJsPath = resourcesPath ? path.join(resourcesPath, "ade-cli", "cli.cjs") : null; const packagedInstallerPath = resourcesPath ? path.join(resourcesPath, "ade-cli", installerFileName()) : null; - if (args.isPackaged && isExecutable(packagedCommandPath)) { + if (args.isPackaged && (isExecutable(packagedCommandPath) || isExecutable(fallbackPackagedCommandPath))) { return { - commandPath: packagedCommandPath, + commandPath: isExecutable(packagedCommandPath) ? packagedCommandPath : fallbackPackagedCommandPath, binDir: packagedBinDir, installerPath: isExecutable(packagedInstallerPath) ? packagedInstallerPath : null, cliJsPath: fs.existsSync(packagedCliJsPath ?? "") ? packagedCliJsPath : null, @@ -368,6 +389,7 @@ function resolveCliPaths(args: CreateAdeCliServiceArgs): ResolvedCliPaths { const devCli = resolveDevCliEntry(args.devRepoRoot); if (devCli) { const shim = writeDevShim({ + commandName, cliJsPath: devCli.cliPath, entryKind: devCli.entryKind, tsxBinPath: path.join(devCli.repoRoot, "apps", "ade-cli", "node_modules", ".bin", process.platform === "win32" ? "tsx.cmd" : "tsx"), @@ -403,12 +425,12 @@ function homeDir(env: NodeJS.ProcessEnv = process.env): string { return env.HOME?.trim() || os.homedir(); } -function installTargetPath(env: NodeJS.ProcessEnv = process.env): string { +function installTargetPath(commandName: string, env: NodeJS.ProcessEnv = process.env): string { if (process.platform === "win32") { const localAppData = env.LOCALAPPDATA?.trim() || path.join(homeDir(env), "AppData", "Local"); - return path.join(localAppData, "ADE", "bin", "ade.cmd"); + return path.join(localAppData, "ADE", "bin", `${commandName}.cmd`); } - return path.join(homeDir(env), ".local", "bin", "ade"); + return path.join(homeDir(env), ".local", "bin", commandName); } type ShellProfile = { path: string; flavor: "posix" | "fish" }; @@ -454,6 +476,7 @@ function ensureUserBinOnShellPath( } function statusMessage(args: { + commandName: string; terminalInstalled: boolean; bundledAvailable: boolean; agentPathReady: boolean; @@ -462,27 +485,27 @@ function statusMessage(args: { }): { message: string; nextAction: string | null } { if (args.terminalInstalled && args.agentPathReady) { return { - message: "The ade command is available to Terminal and ADE-launched agents.", + message: `The ${args.commandName} command is available to Terminal and ADE-launched agents.`, nextAction: null, }; } if (args.agentPathReady && args.bundledAvailable) { return { - message: "ADE-launched agents can use ade. Terminal access is not installed yet.", + message: `ADE-launched agents can use ${args.commandName}. Terminal access is not installed yet.`, nextAction: args.installAvailable - ? "Install the ade command for Terminal access." + ? `Install the ${args.commandName} command for Terminal access.` : "Run npm link in apps/ade-cli for local development.", }; } if (args.bundledAvailable) { return { - message: "The bundled ade command is present, but it is not on the agent PATH yet.", + message: `The bundled ${args.commandName} command is present, but it is not on the agent PATH yet.`, nextAction: "Restart ADE so new agent sessions receive the bundled CLI path.", }; } return { message: args.isPackaged - ? "The bundled ade command is missing from this app build." + ? `The bundled ${args.commandName} command is missing from this app build.` : "The local ADE CLI build was not found.", nextAction: args.isPackaged ? "Reinstall or update ADE." @@ -491,7 +514,8 @@ function statusMessage(args: { } export function createAdeCliService(args: CreateAdeCliServiceArgs) { - const resolved = resolveCliPaths(args); + const commandName = resolveCommandName(args); + const resolved = resolveCliPaths(args, commandName); const envSnapshot = args.env ?? process.env; const hostPathSnapshot = getPathEnvValue(envSnapshot); @@ -513,16 +537,18 @@ export function createAdeCliService(args: CreateAdeCliServiceArgs) { }; const getStatus = async (): Promise => { - const terminalCommandPath = resolveCommandOnPath("ade", hostPathSnapshot, envSnapshot); - const targetPath = installTargetPath(envSnapshot); + const terminalCommandPath = resolveCommandOnPath(commandName, hostPathSnapshot, envSnapshot); + const targetPath = installTargetPath(commandName, envSnapshot); const targetDir = path.dirname(targetPath); const terminalInstalled = Boolean(terminalCommandPath); const bundledAvailable = Boolean(resolved.commandPath && isExecutable(resolved.commandPath)); const hostPathEnv: NodeJS.ProcessEnv = {}; if (hostPathSnapshot) setPathEnvValue(hostPathEnv, hostPathSnapshot); const agentPathReady = bundledAvailable && pathContainsDir(getPathEnvValue(agentEnv(hostPathEnv)), resolved.binDir); - const installAvailable = resolved.source === "packaged" && isExecutable(resolved.installerPath); + const packagedInstallAvailable = resolved.source === "packaged" && isExecutable(resolved.installerPath); + const installAvailable = packagedInstallAvailable || (resolved.source === "dev" && bundledAvailable); const message = statusMessage({ + commandName, terminalInstalled, bundledAvailable, agentPathReady, @@ -531,7 +557,7 @@ export function createAdeCliService(args: CreateAdeCliServiceArgs) { }); return { - command: "ade", + command: commandName, platform: process.platform, isPackaged: args.isPackaged, bundledAvailable, @@ -550,36 +576,44 @@ export function createAdeCliService(args: CreateAdeCliServiceArgs) { }; const installForUser = async (): Promise => { - if (!isExecutable(resolved.installerPath)) { + const installDevCommand = resolved.source === "dev" && isExecutable(resolved.commandPath); + if (!isExecutable(resolved.installerPath) && !installDevCommand) { const status = await getStatus(); return { ok: false, message: args.isPackaged ? "The ADE CLI installer is missing from this app build." - : "Terminal install is available from packaged ADE builds. For local development, run npm link in apps/ade-cli.", + : "The local ADE CLI build was not found.", status, }; } try { - const result = await spawnAsync(resolved.installerPath!, []); - if (result.status !== 0) { - throw new Error(result.stderr.trim() || result.stdout.trim() || "ADE CLI installer failed."); + if (installDevCommand) { + const targetPath = installTargetPath(commandName, envSnapshot); + fs.mkdirSync(path.dirname(targetPath), { recursive: true }); + fs.rmSync(targetPath, { force: true }); + fs.symlinkSync(resolved.commandPath!, targetPath); + } else { + const result = await spawnAsync(resolved.installerPath!, []); + if (result.status !== 0) { + throw new Error(result.stderr.trim() || result.stdout.trim() || "ADE CLI installer failed."); + } } - const targetDir = path.dirname(installTargetPath(envSnapshot)); + const targetDir = path.dirname(installTargetPath(commandName, envSnapshot)); const profileResult = ensureUserBinOnShellPath(targetDir, envSnapshot); const status = await getStatus(); return { ok: true, message: process.platform === "win32" - ? `Installed ade for Terminal access and added ${targetDir} to the user PATH if it was missing. Open a new terminal, then run: ade doctor.` + ? `Installed ${commandName} for Terminal access and added ${targetDir} to the user PATH if it was missing. Open a new terminal, then run: ${commandName} doctor.` : profileResult ? profileResult.modified - ? `Installed ade for Terminal access and added ${targetDir} to ${profileResult.profilePath}. Open a new terminal or source that file.` - : `Installed ade for Terminal access. PATH entry already present in ${profileResult.profilePath}; open a new terminal or source that file.` + ? `Installed ${commandName} for Terminal access and added ${targetDir} to ${profileResult.profilePath}. Open a new terminal or source that file.` + : `Installed ${commandName} for Terminal access. PATH entry already present in ${profileResult.profilePath}; open a new terminal or source that file.` : status.installTargetDirOnPath - ? "Installed ade for Terminal access." - : `Installed ade at ${status.installTargetPath}. Add ${path.dirname(status.installTargetPath)} to PATH if your shell cannot find it.`, + ? `Installed ${commandName} for Terminal access.` + : `Installed ${commandName} at ${status.installTargetPath}. Add ${path.dirname(status.installTargetPath)} to PATH if your shell cannot find it.`, status, }; } catch (error) { diff --git a/apps/desktop/src/main/services/computerUse/computerUseArtifactBrokerService.test.ts b/apps/desktop/src/main/services/computerUse/computerUseArtifactBrokerService.test.ts index c67598580..9e70c10ad 100644 --- a/apps/desktop/src/main/services/computerUse/computerUseArtifactBrokerService.test.ts +++ b/apps/desktop/src/main/services/computerUse/computerUseArtifactBrokerService.test.ts @@ -117,6 +117,32 @@ describe("computerUseArtifactBrokerService", () => { ]); }); + it("reads image previews only from the project artifact directory", async () => { + const missionService = { addArtifact: vi.fn() } as any; + const orchestratorService = { registerArtifact: vi.fn() } as any; + const broker = createComputerUseArtifactBrokerService({ + db, + projectId: "project-1", + projectRoot, + missionService, + orchestratorService, + logger: createLogger(), + }); + const artifactDir = path.join(projectRoot, ".ade", "artifacts", "computer-use"); + fs.mkdirSync(artifactDir, { recursive: true }); + const artifactPath = path.join(artifactDir, "preview.png"); + const bytes = Buffer.from([0x89, 0x50, 0x4e, 0x47]); + fs.writeFileSync(artifactPath, bytes); + + await expect(broker.readArtifactPreview({ + uri: "ade-artifact://project/.ade/artifacts/computer-use/preview.png", + })).resolves.toBe(`data:image/png;base64,${bytes.toString("base64")}`); + + const outsidePath = path.join(projectRoot, "outside.png"); + fs.writeFileSync(outsidePath, bytes); + await expect(broker.readArtifactPreview({ uri: outsidePath })).resolves.toBeNull(); + }); + it("rejects local file imports outside allowed artifact roots", () => { const missionService = { addArtifact: vi.fn() } as any; const orchestratorService = { registerArtifact: vi.fn() } as any; diff --git a/apps/desktop/src/main/services/computerUse/computerUseArtifactBrokerService.ts b/apps/desktop/src/main/services/computerUse/computerUseArtifactBrokerService.ts index 3e27fd1ef..dfd123f84 100644 --- a/apps/desktop/src/main/services/computerUse/computerUseArtifactBrokerService.ts +++ b/apps/desktop/src/main/services/computerUse/computerUseArtifactBrokerService.ts @@ -2,6 +2,7 @@ import { randomUUID } from "node:crypto"; import fs from "node:fs"; import os from "node:os"; import path from "node:path"; +import { fileURLToPath } from "node:url"; import type { ComputerUseArtifactIngestionRequest, ComputerUseArtifactIngestionResult, @@ -60,6 +61,16 @@ type StoredArtifactRow = { const DEFAULT_REVIEW_STATE: ComputerUseArtifactReviewState = "accepted"; const DEFAULT_WORKFLOW_STATE: ComputerUseArtifactWorkflowState = "evidence_only"; +const ARTIFACT_PREVIEW_SIZE_CAP = 10 * 1024 * 1024; +const ARTIFACT_PREVIEW_MIME_BY_EXTENSION: Record = { + bmp: "image/bmp", + gif: "image/gif", + jpeg: "image/jpeg", + jpg: "image/jpeg", + png: "image/png", + svg: "image/svg+xml", + webp: "image/webp", +}; type StoredLinkRow = { id: string; @@ -89,6 +100,50 @@ function isAllowedExternalArtifactSource( }); } +function resolveRendererArtifactPath(rawPath: string, projectRoot: string): string { + let inputPath = rawPath; + if (/^ade-artifact:\/\/project(?:\/|$)/i.test(inputPath)) { + const parsed = new URL(inputPath); + inputPath = decodeURIComponent(parsed.pathname.replace(/^\/+/, "")); + } + if (/^file:\/\//i.test(inputPath)) { + try { + inputPath = fileURLToPath(inputPath); + } catch { + inputPath = decodeURIComponent(inputPath.replace(/^file:\/\//i, "")); + } + } + return path.resolve(path.isAbsolute(inputPath) ? inputPath : path.join(projectRoot, inputPath)); +} + +async function readArtifactPreviewDataUrl(args: { + uri?: string; + projectRoot: string; + artifactsDir: string; +}): Promise { + const uri = typeof args.uri === "string" ? args.uri.trim() : ""; + if (!uri) return null; + const filePath = resolveRendererArtifactPath(uri, args.projectRoot); + const canonical = path.normalize(path.resolve(filePath)); + try { + resolvePathWithinRoot(args.artifactsDir, canonical); + } catch { + return null; + } + + try { + const stat = await fs.promises.stat(canonical); + if (!stat.isFile() || stat.size > ARTIFACT_PREVIEW_SIZE_CAP) return null; + const ext = path.extname(canonical).replace(/^\./, "").toLowerCase(); + const mime = ARTIFACT_PREVIEW_MIME_BY_EXTENSION[ext]; + if (!mime) return null; + const buf = await fs.promises.readFile(canonical); + return `data:${mime};base64,${buf.toString("base64")}`; + } catch { + return null; + } +} + function secureCopyFromDescriptor(sourcePath: string, targetPath: string): void { const sourceFlags = fs.constants.O_RDONLY | (typeof fs.constants.O_NOFOLLOW === "number" ? fs.constants.O_NOFOLLOW : 0); const sourceFd = fs.openSync(sourcePath, sourceFlags); @@ -688,6 +743,14 @@ export function createComputerUseArtifactBrokerService(args: { return updated; }, + readArtifactPreview(args: { uri?: string }): Promise { + return readArtifactPreviewDataUrl({ + uri: args?.uri, + projectRoot, + artifactsDir: layout.artifactsDir, + }); + }, + getBackendStatus, }; } diff --git a/apps/desktop/src/main/services/cto/ctoState.test.ts b/apps/desktop/src/main/services/cto/ctoState.test.ts index aa961aa3c..285b4e184 100644 --- a/apps/desktop/src/main/services/cto/ctoState.test.ts +++ b/apps/desktop/src/main/services/cto/ctoState.test.ts @@ -61,7 +61,6 @@ describe("ctoStateService", () => { expect(buildAdeGitignore()).toContain("!cto/identity.yaml"); expect(buildAdeGitignore()).not.toContain("cto/core-memory.json"); expect(buildAdeGitignore()).not.toContain("cto/CURRENT.md"); - expect(buildAdeGitignore()).not.toContain("cto/openclaw-history.json"); fixture.db.close(); }); diff --git a/apps/desktop/src/main/services/cto/ctoStateService.ts b/apps/desktop/src/main/services/cto/ctoStateService.ts index 507100430..024a507f3 100644 --- a/apps/desktop/src/main/services/cto/ctoStateService.ts +++ b/apps/desktop/src/main/services/cto/ctoStateService.ts @@ -5,7 +5,6 @@ import YAML from "yaml"; import type { CtoCoreMemory, CtoIdentity, - OpenclawContextPolicy, CtoOnboardingState, CtoSessionLogEntry, CtoSubordinateActivityEntry, @@ -548,7 +547,6 @@ function normalizeIdentity(input: unknown): CtoIdentity | null { source.communicationStyle && typeof source.communicationStyle === "object" ? (source.communicationStyle as Record) : {}; - const openclawContextPolicy = normalizeOpenclawContextPolicy(source.openclawContextPolicy); const onboardingState = normalizeOnboardingState(source.onboardingState); const personality = normalizePersonalityPreset(source.personality); const customPersonality = @@ -616,24 +614,11 @@ function normalizeIdentity(input: unknown): CtoIdentity | null { ? Math.max(1, Math.floor(Number(memoryPolicyRaw.temporalDecayHalfLifeDays))) : 30, }, - ...(openclawContextPolicy ? { openclawContextPolicy } : {}), ...(onboardingState ? { onboardingState } : {}), updatedAt, }; } -function normalizeOpenclawContextPolicy(value: unknown): OpenclawContextPolicy | undefined { - if (!value || typeof value !== "object") return undefined; - const source = value as Record; - const blockedCategories = Array.isArray(source.blockedCategories) - ? [...new Set(source.blockedCategories.map((entry) => String(entry ?? "").trim()).filter((entry) => entry.length > 0))] - : []; - return { - shareMode: source.shareMode === "full" ? "full" : "filtered", - blockedCategories, - }; -} - function squishText(value: string): string { return String(value ?? "").replace(/\s+/g, " ").trim(); } @@ -752,10 +737,6 @@ function makeDefaultIdentity(): CtoIdentity { preCompactionFlush: true, temporalDecayHalfLifeDays: 30, }, - openclawContextPolicy: { - shareMode: "filtered", - blockedCategories: ["secret", "token", "system_prompt"], - }, updatedAt: timestamp, }; } @@ -1367,7 +1348,6 @@ export function createCtoStateService(args: CtoStateServiceArgs) { ...patch, modelPreferences: { ...current.modelPreferences, ...(patch.modelPreferences ?? {}) }, memoryPolicy: { ...current.memoryPolicy, ...(patch.memoryPolicy ?? {}) }, - openclawContextPolicy: normalizeOpenclawContextPolicy(patch.openclawContextPolicy) ?? current.openclawContextPolicy, version: current.version + 1, updatedAt: timestamp, }; diff --git a/apps/desktop/src/main/services/cto/ctoWorkerLifecycle.test.ts b/apps/desktop/src/main/services/cto/ctoWorkerLifecycle.test.ts index 76ce6d38f..d4d32eeab 100644 --- a/apps/desktop/src/main/services/cto/ctoWorkerLifecycle.test.ts +++ b/apps/desktop/src/main/services/cto/ctoWorkerLifecycle.test.ts @@ -1,11 +1,9 @@ -import YAML from "yaml"; import fs from "node:fs"; import os from "node:os"; import path from "node:path"; import type { AgentIdentity, WorkerAgentRunStatus, WorkerAgentWakeupReason } from "../../../shared/types"; import { EventEmitter } from "node:events"; import { afterEach, describe, expect, it, vi } from "vitest"; -import { createOpenclawBridgeService } from "./openclawBridgeService"; import { createWorkerAdapterRuntimeService } from "./workerAdapterRuntimeService"; import { createWorkerAgentService } from "./workerAgentService"; import { createWorkerBudgetService } from "./workerBudgetService"; @@ -1096,36 +1094,6 @@ describe("workerAdapterRuntimeService (file group)", () => { }); }); - it("sends openclaw-webhook request with resolved env header", async () => { - process.env.OPENCLAW_WEBHOOK_TOKEN = "secret-token"; - const fetchMock = vi.fn(async (_url: string, init?: RequestInit) => { - return { - ok: true, - status: 200, - text: async () => JSON.stringify({ output: "webhook-ok" }), - } as any; - }); - const service = createWorkerAdapterRuntimeService({ fetchImpl: fetchMock as any }); - const result = await service.run({ - agent: makeAgent({ - adapterType: "openclaw-webhook", - adapterConfig: { - url: "https://example.com/hook", - headers: { - Authorization: "Bearer ${env:OPENCLAW_WEBHOOK_TOKEN}", - }, - }, - }), - prompt: "run remote", - }); - - expect(fetchMock).toHaveBeenCalledTimes(1); - const [, init] = fetchMock.mock.calls[0] as [string, RequestInit]; - expect((init.headers as Record).Authorization).toBe("Bearer secret-token"); - expect(result.ok).toBe(true); - expect(result.outputText).toBe("webhook-ok"); - }); - it("runs process adapter and blocks unsafe commands", async () => { const { spawn } = createSpawnStub("process-output"); const service = createWorkerAdapterRuntimeService({ spawnImpl: spawn as any }); @@ -1310,12 +1278,10 @@ describe("workerAgentService (file group)", () => { fixture.service.saveAgent({ name: "Remote", role: "researcher", - adapterType: "openclaw-webhook", + adapterType: "process", adapterConfig: { - url: "https://example.com/hook", - headers: { - Authorization: "Bearer sk-secret-value", - }, + command: "echo", + env: { API_TOKEN: "Bearer sk-secret-value" }, }, }) ).toThrow(/raw secret-like value/i); @@ -1323,12 +1289,10 @@ describe("workerAgentService (file group)", () => { const ok = fixture.service.saveAgent({ name: "Remote 2", role: "researcher", - adapterType: "openclaw-webhook", + adapterType: "process", adapterConfig: { - url: "https://example.com/hook", - headers: { - Authorization: "Bearer ${env:OPENCLAW_WEBHOOK_TOKEN}", - }, + command: "echo", + env: { API_TOKEN: "${env:PROCESS_ADAPTER_TOKEN}" }, }, }); expect(ok.id).toBeTruthy(); @@ -1659,10 +1623,10 @@ describe("workerRevisionService (file group)", () => { { name: "Redacted Worker", role: "researcher", - adapterType: "openclaw-webhook", + adapterType: "process", adapterConfig: { - url: "https://example.com", - headers: { Authorization: "${env:OPENCLAW_WEBHOOK_TOKEN}" }, + command: "echo", + env: { API_TOKEN: "${env:PROCESS_ADAPTER_TOKEN}" }, }, }, "tester" @@ -1681,7 +1645,7 @@ describe("workerRevisionService (file group)", () => { created.id, JSON.stringify({ ...created, name: "__REDACTED__" }), JSON.stringify(created), - JSON.stringify(["adapterConfig.headers.Authorization"]), + JSON.stringify(["adapterConfig.env.API_TOKEN"]), 1, "tester", new Date().toISOString(), @@ -1862,477 +1826,3 @@ describe("workerTaskSessionService (file group)", () => { }); }); - -describe("openclawBridgeService (file group)", () => { - - function writeOpenclawConfig(adeDir: string, patch: Record): void { - fs.mkdirSync(adeDir, { recursive: true }); - fs.writeFileSync( - path.join(adeDir, "local.secret.yaml"), - YAML.stringify({ - openclaw: { - bridgePort: 0, - hooksToken: "test-hook-token", - ...patch, - }, - }), - "utf8", - ); - } - - describe("openclawBridgeService", () => { - const services: Array> = []; - - afterEach(async () => { - while (services.length) { - const service = services.pop(); - await service?.stop(); - } - }); - - it("handles synchronous query replies end to end", async () => { - const adeDir = fs.mkdtempSync(path.join(os.tmpdir(), "ade-openclaw-query-")); - writeOpenclawConfig(adeDir, { enabled: false }); - - let service!: ReturnType; - const sentMessages: Array<{ sessionId: string; text: string; displayText?: string }> = []; - const agentChatService = { - listSessions: vi.fn(async () => []), - ensureIdentitySession: vi.fn(async () => ({ id: "session-cto", laneId: "lane-1" })), - sendMessage: vi.fn(async ({ sessionId, text, displayText }: { sessionId: string; text: string; displayText?: string }) => { - sentMessages.push({ sessionId, text, displayText }); - const turnId = "turn-1"; - queueMicrotask(() => { - service.onAgentChatEvent({ - sessionId, - timestamp: new Date().toISOString(), - event: { type: "user_message", text: displayText ?? text, turnId }, - }); - service.onAgentChatEvent({ - sessionId, - timestamp: new Date().toISOString(), - event: { type: "text", text: "CTO reply from ADE", turnId }, - }); - service.onAgentChatEvent({ - sessionId, - timestamp: new Date().toISOString(), - event: { type: "done", turnId, status: "completed" }, - }); - }); - }), - } as any; - - service = createOpenclawBridgeService({ - projectRoot: "/tmp/project", - adeDir, - laneService: { - ensurePrimaryLane: vi.fn(async () => {}), - list: vi.fn(async () => [ - { id: "lane-2", laneType: "feature" }, - { id: "lane-1", laneType: "primary" }, - ]), - } as any, - agentChatService, - ctoStateService: { - getIdentity: vi.fn(() => ({ - openclawContextPolicy: { shareMode: "filtered", blockedCategories: ["secret"] }, - })), - } as any, - }); - services.push(service); - await service.start(); - - const state = service.getState(); - const res = await fetch(state.endpoints.queryUrl!, { - method: "POST", - headers: { - "content-type": "application/json", - authorization: "Bearer test-hook-token", - }, - body: JSON.stringify({ - requestId: "req-query-1", - agentId: "discord-cto", - sessionKey: "discord:thread:123", - message: "What changed?", - context: { channel: "discord", secret: "redact-me" }, - }), - }); - - expect(res.status).toBe(200); - const body = await res.json(); - expect(body.reply).toBe("CTO reply from ADE"); - expect(agentChatService.ensureIdentitySession).toHaveBeenCalledWith( - expect.objectContaining({ identityKey: "cto", laneId: "lane-1" }), - ); - expect(sentMessages[0]?.text).toContain("Treat this routing context as turn-scoped bridge metadata only."); - expect(sentMessages[0]?.text).toContain("What changed?"); - }); - - it("routes worker targets by slug and falls back unknown targets to CTO", async () => { - const adeDir = fs.mkdtempSync(path.join(os.tmpdir(), "ade-openclaw-target-")); - writeOpenclawConfig(adeDir, { enabled: false, allowEmployeeTargets: true }); - - let service!: ReturnType; - const ensureIdentitySession = vi.fn(async ({ identityKey }: { identityKey: string }) => ({ - id: identityKey === "cto" ? "session-cto" : "session-worker", - laneId: "lane-1", - })); - const sendMessage = vi.fn(async ({ sessionId, text, displayText }: { sessionId: string; text: string; displayText?: string }) => { - const turnId = sessionId === "session-worker" ? "turn-worker" : "turn-cto"; - queueMicrotask(() => { - service.onAgentChatEvent({ - sessionId, - timestamp: new Date().toISOString(), - event: { type: "user_message", text: displayText ?? text, turnId }, - }); - service.onAgentChatEvent({ - sessionId, - timestamp: new Date().toISOString(), - event: { type: "text", text: sessionId === "session-worker" ? "worker reply" : "cto fallback reply", turnId }, - }); - service.onAgentChatEvent({ - sessionId, - timestamp: new Date().toISOString(), - event: { type: "done", turnId, status: "completed" }, - }); - }); - }); - - service = createOpenclawBridgeService({ - projectRoot: "/tmp/project", - adeDir, - laneService: { - ensurePrimaryLane: vi.fn(async () => {}), - list: vi.fn(async () => [{ id: "lane-1", laneType: "primary" }]), - } as any, - agentChatService: { - listSessions: vi.fn(async () => []), - ensureIdentitySession, - sendMessage, - } as any, - workerAgentService: { - listAgents: vi.fn(() => [ - { id: "worker-1", slug: "frontend", status: "active", deletedAt: null }, - ]), - } as any, - ctoStateService: { - getIdentity: vi.fn(() => ({ - openclawContextPolicy: { shareMode: "filtered", blockedCategories: [] }, - })), - } as any, - }); - services.push(service); - await service.start(); - - const state = service.getState(); - const good = await fetch(state.endpoints.queryUrl!, { - method: "POST", - headers: { - "content-type": "application/json", - authorization: "Bearer test-hook-token", - }, - body: JSON.stringify({ - requestId: "req-good-target", - message: "Ping frontend worker", - targetHint: "agent:frontend", - }), - }); - expect(good.status).toBe(200); - await expect(good.json()).resolves.toEqual(expect.objectContaining({ - accepted: true, - async: true, - status: "working", - routeTarget: "agent:frontend", - })); - expect(ensureIdentitySession).toHaveBeenCalledWith(expect.objectContaining({ identityKey: "agent:worker-1" })); - - const fallback = await fetch(state.endpoints.queryUrl!, { - method: "POST", - headers: { - "content-type": "application/json", - authorization: "Bearer test-hook-token", - }, - body: JSON.stringify({ - requestId: "req-bad-target", - message: "Ping unknown worker", - targetHint: "agent:ghost", - }), - }); - expect(fallback.status).toBe(200); - const latestInbound = service.listMessages(4).find((entry) => entry.requestId === "req-bad-target" && entry.direction === "inbound"); - expect(latestInbound?.resolvedTarget).toBe("cto"); - expect(latestInbound?.metadata).toEqual(expect.objectContaining({ - fallbackReason: expect.stringContaining("ghost"), - })); - }); - - it("deduplicates async hook requests by idempotency key", async () => { - const adeDir = fs.mkdtempSync(path.join(os.tmpdir(), "ade-openclaw-hook-")); - writeOpenclawConfig(adeDir, { enabled: false }); - - let service!: ReturnType; - const sendMessage = vi.fn(async ({ sessionId, text, displayText }: { sessionId: string; text: string; displayText?: string }) => { - queueMicrotask(() => { - service.onAgentChatEvent({ - sessionId, - timestamp: new Date().toISOString(), - event: { type: "user_message", text: displayText ?? text, turnId: "turn-hook" }, - }); - }); - }); - - service = createOpenclawBridgeService({ - projectRoot: "/tmp/project", - adeDir, - laneService: { - ensurePrimaryLane: vi.fn(async () => {}), - list: vi.fn(async () => [{ id: "lane-1", laneType: "primary" }]), - } as any, - agentChatService: { - listSessions: vi.fn(async () => []), - ensureIdentitySession: vi.fn(async () => ({ id: "session-cto", laneId: "lane-1" })), - sendMessage, - } as any, - ctoStateService: { - getIdentity: vi.fn(() => ({ - openclawContextPolicy: { shareMode: "filtered", blockedCategories: [] }, - })), - } as any, - }); - services.push(service); - await service.start(); - - const state = service.getState(); - const request = { - requestId: "dup-key-1", - message: "Fire and forget", - }; - const first = await fetch(state.endpoints.hookUrl!, { - method: "POST", - headers: { - "content-type": "application/json", - authorization: "Bearer test-hook-token", - }, - body: JSON.stringify(request), - }); - const second = await fetch(state.endpoints.hookUrl!, { - method: "POST", - headers: { - "content-type": "application/json", - authorization: "Bearer test-hook-token", - }, - body: JSON.stringify(request), - }); - - expect(first.status).toBe(202); - expect(second.status).toBe(202); - expect(sendMessage).toHaveBeenCalledTimes(1); - expect(await second.json()).toEqual(expect.objectContaining({ duplicate: true })); - }); - - it("queues outbound messages when the operator socket is unavailable", async () => { - const adeDir = fs.mkdtempSync(path.join(os.tmpdir(), "ade-openclaw-outbox-")); - writeOpenclawConfig(adeDir, { enabled: false }); - - const service = createOpenclawBridgeService({ - projectRoot: "/tmp/project", - adeDir, - laneService: { - ensurePrimaryLane: vi.fn(async () => {}), - list: vi.fn(async () => [{ id: "lane-1", laneType: "primary" }]), - } as any, - agentChatService: { - listSessions: vi.fn(async () => []), - ensureIdentitySession: vi.fn(async () => ({ id: "session-cto", laneId: "lane-1" })), - sendMessage: vi.fn(async () => {}), - } as any, - ctoStateService: { - getIdentity: vi.fn(() => ({ - openclawContextPolicy: { shareMode: "filtered", blockedCategories: ["secret"] }, - })), - } as any, - }); - services.push(service); - await service.start(); - - const record = await service.sendMessage({ - requestId: "queued-message-1", - agentId: "discord-cto", - message: "Mission finished", - context: { secret: "hide-me", lane: "lane-1" }, - }); - - expect(record.status).toBe("queued"); - expect(service.getState().status.queuedMessages).toBe(1); - expect(record.context).toEqual({ lane: "lane-1" }); - }); - - it("recursively redacts inbound bridge context before prompting and persistence", async () => { - const adeDir = fs.mkdtempSync(path.join(os.tmpdir(), "ade-openclaw-redact-")); - writeOpenclawConfig(adeDir, { enabled: false }); - - let service!: ReturnType; - const sentMessages: Array<{ text: string }> = []; - service = createOpenclawBridgeService({ - projectRoot: "/tmp/project", - adeDir, - laneService: { - ensurePrimaryLane: vi.fn(async () => {}), - list: vi.fn(async () => [{ id: "lane-1", laneType: "primary" }]), - } as any, - agentChatService: { - listSessions: vi.fn(async () => []), - ensureIdentitySession: vi.fn(async () => ({ id: "session-cto", laneId: "lane-1" })), - sendMessage: vi.fn(async ({ sessionId, text, displayText }: { sessionId: string; text: string; displayText?: string }) => { - sentMessages.push({ text }); - queueMicrotask(() => { - service.onAgentChatEvent({ - sessionId, - timestamp: new Date().toISOString(), - event: { type: "user_message", text: displayText ?? text, turnId: "turn-1" }, - }); - service.onAgentChatEvent({ - sessionId, - timestamp: new Date().toISOString(), - event: { type: "text", text: "redacted", turnId: "turn-1" }, - }); - service.onAgentChatEvent({ - sessionId, - timestamp: new Date().toISOString(), - event: { type: "done", turnId: "turn-1", status: "completed" }, - }); - }); - }), - } as any, - ctoStateService: { - getIdentity: vi.fn(() => ({ - openclawContextPolicy: { shareMode: "filtered", blockedCategories: ["secret"] }, - })), - } as any, - }); - services.push(service); - await service.start(); - - const res = await fetch(service.getState().endpoints.queryUrl!, { - method: "POST", - headers: { - "content-type": "application/json", - authorization: "Bearer test-hook-token", - }, - body: JSON.stringify({ - requestId: "req-redact-1", - message: "Review this", - context: { - nested: { - apiKey: "test-api-key-placeholder", - note: "safe", - }, - secret: "remove-me", - }, - }), - }); - - expect(res.status).toBe(200); - expect(sentMessages[0]?.text).toContain("\"apiKey\": \"[REDACTED]\""); - expect(sentMessages[0]?.text).toContain("\"note\": \"safe\""); - expect(sentMessages[0]?.text).not.toContain("remove-me"); - const inbound = service.listMessages(10).find((entry) => entry.requestId === "req-redact-1" && entry.direction === "inbound"); - expect(inbound?.context).toEqual({ - nested: { - apiKey: "[REDACTED]", - note: "safe", - }, - }); - }); - - it("keeps shareMode full while still redacting sensitive values", async () => { - const adeDir = fs.mkdtempSync(path.join(os.tmpdir(), "ade-openclaw-full-share-")); - writeOpenclawConfig(adeDir, { enabled: false }); - - const service = createOpenclawBridgeService({ - projectRoot: "/tmp/project", - adeDir, - laneService: { - ensurePrimaryLane: vi.fn(async () => {}), - list: vi.fn(async () => [{ id: "lane-1", laneType: "primary" }]), - } as any, - agentChatService: { - listSessions: vi.fn(async () => []), - ensureIdentitySession: vi.fn(async () => ({ id: "session-cto", laneId: "lane-1" })), - sendMessage: vi.fn(async () => {}), - } as any, - ctoStateService: { - getIdentity: vi.fn(() => ({ - openclawContextPolicy: { shareMode: "full", blockedCategories: ["secret"] }, - })), - } as any, - }); - services.push(service); - await service.start(); - - const record = await service.sendMessage({ - requestId: "queued-message-2", - agentId: "discord-cto", - message: "Mission finished", - context: { - secret: "Bearer very-secret-token-value", - lane: "lane-1", - }, - }); - - expect(record.context).toEqual({ - secret: "[REDACTED]", - lane: "lane-1", - }); - }); - - it("migrates legacy runtime files into cache and removes repo-visible copies", async () => { - const adeDir = fs.mkdtempSync(path.join(os.tmpdir(), "ade-openclaw-migrate-")); - writeOpenclawConfig(adeDir, { enabled: false }); - fs.mkdirSync(path.join(adeDir, "cto"), { recursive: true }); - fs.writeFileSync( - path.join(adeDir, "cto", "openclaw-history.json"), - JSON.stringify([{ - id: "legacy-1", - requestId: "legacy-request", - direction: "inbound", - mode: "hook", - status: "received", - body: "Legacy body", - summary: "Legacy summary", - context: { - apiKey: "test-api-key-placeholder", - }, - createdAt: new Date().toISOString(), - }], null, 2), - "utf8", - ); - - const service = createOpenclawBridgeService({ - projectRoot: "/tmp/project", - adeDir, - laneService: { - ensurePrimaryLane: vi.fn(async () => {}), - list: vi.fn(async () => [{ id: "lane-1", laneType: "primary" }]), - } as any, - agentChatService: { - listSessions: vi.fn(async () => []), - ensureIdentitySession: vi.fn(async () => ({ id: "session-cto", laneId: "lane-1" })), - sendMessage: vi.fn(async () => {}), - } as any, - ctoStateService: { - getIdentity: vi.fn(() => ({ - openclawContextPolicy: { shareMode: "filtered", blockedCategories: [] }, - })), - } as any, - }); - services.push(service); - await service.start(); - - expect(fs.existsSync(path.join(adeDir, "cto", "openclaw-history.json"))).toBe(false); - expect(fs.existsSync(path.join(adeDir, "cache", "openclaw", "openclaw-history.json"))).toBe(true); - expect(service.listMessages(10)[0]?.context).toEqual({ apiKey: "[REDACTED]" }); - }); - }); - -}); diff --git a/apps/desktop/src/main/services/cto/openclawBridgeService.ts b/apps/desktop/src/main/services/cto/openclawBridgeService.ts deleted file mode 100644 index af049b729..000000000 --- a/apps/desktop/src/main/services/cto/openclawBridgeService.ts +++ /dev/null @@ -1,1689 +0,0 @@ -import crypto, { randomUUID } from "node:crypto"; -import fs from "node:fs"; -import http, { type IncomingMessage, type ServerResponse } from "node:http"; -import path from "node:path"; -import YAML from "yaml"; -import { WebSocket, type RawData } from "ws"; -import type { Logger } from "../logging/logger"; -import type { createAgentChatService } from "../chat/agentChatService"; -import type { createLaneService } from "../lanes/laneService"; -import type { createCtoStateService } from "./ctoStateService"; -import type { createWorkerAgentService } from "./workerAgentService"; -import type { createMissionService } from "../missions/missionService"; -import type { - AgentChatEventEnvelope, - MissionsEventPayload, - OpenclawBridgeConfig, - OpenclawBridgeState, - OpenclawBridgeStatus, - OpenclawContextPolicy, - OpenclawInboundEnvelope, - OpenclawMessageRecord, - OpenclawNotificationRoute, - OpenclawNotificationType, - OpenclawOutboundEnvelope, - OpenclawTargetHint, - TestEvent, - OrchestratorRuntimeEvent, -} from "../../../shared/types"; -import { - clipText, - getErrorMessage, - isRecord, - nowIso, - parseIsoToEpoch, - sanitizeStructuredData, - toBase64Url, - writeTextAtomic, -} from "../shared/utils"; - -const DEFAULT_BRIDGE_PORT = 18791; -const HTTP_BODY_LIMIT_BYTES = 1_000_000; -const IDEMPOTENCY_TTL_MS = 24 * 60 * 60 * 1000; -const ROUTE_TTL_MS = 60 * 60 * 1000; -const HISTORY_CAP = 400; -const MAX_OUTBOX_ATTEMPTS = 10; -const MAX_RECONNECT_BACKOFF_MS = 30_000; -const CONNECT_CHALLENGE_TIMEOUT_MS = 2_000; -const TICK_WATCH_FLOOR_MS = 1_000; -const DEFAULT_TICK_INTERVAL_MS = 30_000; -const ED25519_SPKI_PREFIX = Buffer.from("302a300506032b6570032100", "hex"); -const BRIDGE_CONTEXT_MAX_STRING_LENGTH = 4_000; -const BRIDGE_CONTEXT_MAX_OBJECT_ENTRIES = 50; -const BRIDGE_CONTEXT_MAX_ARRAY_ENTRIES = 50; -const HISTORY_BODY_MAX_LENGTH = 1_200; -const HISTORY_ERROR_MAX_LENGTH = 400; -const HISTORY_SUMMARY_MAX_LENGTH = 160; -const OPENCLAW_HISTORY_FILE = "openclaw-history.json"; -const OPENCLAW_OUTBOX_FILE = "openclaw-outbox.json"; -const OPENCLAW_IDEMPOTENCY_FILE = "openclaw-idempotency.json"; -const OPENCLAW_ROUTES_FILE = "openclaw-routes.json"; - -type DeviceIdentity = { - deviceId: string; - publicKeyPem: string; - privateKeyPem: string; -}; - -type OpenclawRequestFrame = { - type: "req"; - id: string; - method: string; - params: Record; -}; - -type OpenclawResponseFrame = { - type: "res"; - id: string; - ok: boolean; - payload?: Record; - error?: { message?: string }; -}; - -type OpenclawEventFrame = { - type: "evt"; - event: string; - seq?: number; - payload?: Record; -}; - -type PersistedIdempotencyState = Record; - -type PersistedRouteCacheEntry = { - agentId?: string | null; - sessionKey?: string | null; - channel?: string | null; - replyChannel?: string | null; - accountId?: string | null; - replyAccountId?: string | null; - threadId?: string | null; - updatedAt: string; - expiresAt: number; -}; - -type PersistedRouteCache = { - byAgentId: Record; -}; - -type OutboxEntry = { - id: string; - envelope: OpenclawOutboundEnvelope; - queuedAt: string; - attempts: number; - lastAttemptAt?: string | null; - lastError?: string | null; -}; - -type PendingWsRequest = { - resolve: (value: Record) => void; - reject: (error: Error) => void; - expectFinal: boolean; -}; - -type ConversationRoute = PersistedRouteCacheEntry & { - sessionId?: string | null; - targetHint?: OpenclawTargetHint | null; -}; - -type PendingBridgeTurn = { - requestId: string; - mode: "hook" | "query" | "ambient"; - route: ConversationRoute; - sessionId: string; - displayText: string; - createdAt: string; - turnId?: string; - chunks: string[]; - outputSent: boolean; - resolve?: (value: { reply: string; sessionId: string; route: ConversationRoute }) => void; - reject?: (error: Error) => void; - timeoutHandle?: ReturnType; -}; - -type OpenclawBridgeServiceArgs = { - projectRoot: string; - adeDir: string; - laneService: ReturnType; - agentChatService: ReturnType; - ctoStateService?: ReturnType | null; - workerAgentService?: ReturnType | null; - missionService?: ReturnType | null; - logger?: Logger | null; - appVersion?: string; - onStatusChange?: (status: OpenclawBridgeStatus) => void; -}; - -function trimToNull(value: unknown): string | null { - const trimmed = typeof value === "string" ? value.trim() : ""; - return trimmed.length ? trimmed : null; -} - -function summarizeMessage(text: string, maxLength = 120): string { - const normalized = text.replace(/\s+/g, " ").trim(); - if (normalized.length <= maxLength) return normalized; - return `${normalized.slice(0, Math.max(0, maxLength - 1)).trimEnd()}…`; -} - -function sanitizeContext( - context: unknown, - options?: { blockedTopLevelKeys?: Iterable }, -): Record | null { - return sanitizeStructuredData(context, { - blockedTopLevelKeys: options?.blockedTopLevelKeys, - maxStringLength: BRIDGE_CONTEXT_MAX_STRING_LENGTH, - maxObjectEntries: BRIDGE_CONTEXT_MAX_OBJECT_ENTRIES, - maxArrayEntries: BRIDGE_CONTEXT_MAX_ARRAY_ENTRIES, - }); -} - -function buildDeviceAuthPayloadV3(params: { - deviceId: string; - clientId: string; - clientMode: string; - role: string; - scopes: string[]; - signedAtMs: number; - token: string | null; - nonce: string; - platform: string; - deviceFamily: string; -}): string { - return [ - "v3", - params.deviceId, - params.clientId, - params.clientMode, - params.role, - params.scopes.join(","), - String(params.signedAtMs), - params.token ?? "", - params.nonce, - params.platform, - params.deviceFamily, - ].join("|"); -} - -function derivePublicKeyRaw(publicKeyPem: string): Buffer { - const spki = crypto.createPublicKey(publicKeyPem).export({ - type: "spki", - format: "der", - }); - if (spki.length === ED25519_SPKI_PREFIX.length + 32 - && spki.subarray(0, ED25519_SPKI_PREFIX.length).equals(ED25519_SPKI_PREFIX)) { - return spki.subarray(ED25519_SPKI_PREFIX.length); - } - return spki; -} - -function fingerprintPublicKey(publicKeyPem: string): string { - return crypto.createHash("sha256").update(derivePublicKeyRaw(publicKeyPem)).digest("hex"); -} - -function generateDeviceIdentity(): DeviceIdentity { - const { publicKey, privateKey } = crypto.generateKeyPairSync("ed25519"); - const publicKeyPem = publicKey.export({ type: "spki", format: "pem" }).toString(); - const privateKeyPem = privateKey.export({ type: "pkcs8", format: "pem" }).toString(); - return { - deviceId: fingerprintPublicKey(publicKeyPem), - publicKeyPem, - privateKeyPem, - }; -} - -function loadOrCreateDeviceIdentity(filePath: string): DeviceIdentity { - try { - if (fs.existsSync(filePath)) { - const parsed = JSON.parse(fs.readFileSync(filePath, "utf8")) as Record; - if ( - parsed.version === 1 - && typeof parsed.deviceId === "string" - && typeof parsed.publicKeyPem === "string" - && typeof parsed.privateKeyPem === "string" - ) { - return { - deviceId: parsed.deviceId, - publicKeyPem: parsed.publicKeyPem, - privateKeyPem: parsed.privateKeyPem, - }; - } - } - } catch { - // fall through to regeneration - } - const identity = generateDeviceIdentity(); - fs.mkdirSync(path.dirname(filePath), { recursive: true }); - fs.writeFileSync(filePath, `${JSON.stringify({ version: 1, ...identity }, null, 2)}\n`, { mode: 0o600 }); - try { - fs.chmodSync(filePath, 0o600); - } catch { - // best effort - } - return identity; -} - -function signDevicePayload(privateKeyPem: string, payload: string): string { - return toBase64Url(crypto.sign(null, Buffer.from(payload, "utf8"), crypto.createPrivateKey(privateKeyPem))); -} - -function publicKeyRawBase64UrlFromPem(publicKeyPem: string): string { - return toBase64Url(derivePublicKeyRaw(publicKeyPem)); -} - -function normalizeNotificationRoute(value: unknown): OpenclawNotificationRoute | null { - if (!isRecord(value)) return null; - const notificationType = trimToNull(value.notificationType); - if (notificationType !== "mission_complete" && notificationType !== "ci_broken" && notificationType !== "blocked_run") { - return null; - } - return { - notificationType, - agentId: trimToNull(value.agentId), - sessionKey: trimToNull(value.sessionKey), - enabled: value.enabled !== false, - }; -} - -function normalizeTargetHint(value: unknown, fallback: OpenclawTargetHint = "cto"): OpenclawTargetHint { - const trimmed = trimToNull(value); - if (trimmed === "cto") return "cto"; - if (trimmed?.startsWith("agent:")) return trimmed as OpenclawTargetHint; - return fallback; -} - -function normalizeConfig(value: unknown): OpenclawBridgeConfig { - const source = isRecord(value) ? value : {}; - const allowedAgentIds = Array.isArray(source.allowedAgentIds) - ? [...new Set(source.allowedAgentIds.map((entry) => String(entry ?? "").trim()).filter((entry) => entry.length > 0))] - : []; - const notificationRoutes = Array.isArray(source.notificationRoutes) - ? source.notificationRoutes.map(normalizeNotificationRoute).filter((entry): entry is OpenclawNotificationRoute => entry != null) - : []; - const bridgePort = Number(source.bridgePort); - return { - enabled: source.enabled === true, - bridgePort: Number.isFinite(bridgePort) ? Math.max(0, Math.floor(bridgePort)) : DEFAULT_BRIDGE_PORT, - gatewayUrl: trimToNull(source.gatewayUrl), - gatewayToken: trimToNull(source.gatewayToken), - deviceToken: trimToNull(source.deviceToken), - hooksToken: trimToNull(source.hooksToken), - allowedAgentIds, - defaultTarget: normalizeTargetHint(source.defaultTarget, "cto"), - allowEmployeeTargets: source.allowEmployeeTargets !== false, - notificationRoutes, - }; -} - -function normalizeContextPolicy(value: OpenclawContextPolicy | undefined | null): OpenclawContextPolicy { - return { - shareMode: value?.shareMode === "full" ? "full" : "filtered", - blockedCategories: Array.isArray(value?.blockedCategories) - ? [...new Set(value.blockedCategories.map((entry) => String(entry ?? "").trim()).filter((entry) => entry.length > 0))] - : [], - }; -} - -async function readBody(req: IncomingMessage): Promise { - return await new Promise((resolve, reject) => { - const chunks: Buffer[] = []; - let received = 0; - req.on("data", (chunk) => { - const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk); - received += buffer.length; - if (received > HTTP_BODY_LIMIT_BYTES) { - reject(new Error("Request body exceeded the 1MB limit.")); - req.destroy(); - return; - } - chunks.push(buffer); - }); - req.on("end", () => resolve(Buffer.concat(chunks).toString("utf8"))); - req.on("error", reject); - }); -} - -function jsonResponse(res: ServerResponse, statusCode: number, payload: Record): void { - const body = JSON.stringify(payload); - res.writeHead(statusCode, { - "content-type": "application/json; charset=utf-8", - "content-length": Buffer.byteLength(body), - "cache-control": "no-store", - }); - res.end(body); -} - -function parseJsonBody(raw: string): unknown { - try { - return JSON.parse(raw); - } catch (error) { - throw new Error(`Invalid JSON body: ${getErrorMessage(error)}`); - } -} - -function isResponseFrame(value: unknown): value is OpenclawResponseFrame { - return isRecord(value) && value.type === "res" && typeof value.id === "string"; -} - -function isEventFrame(value: unknown): value is OpenclawEventFrame { - return isRecord(value) && value.type === "evt" && typeof value.event === "string"; -} - -function getRequestToken(req: IncomingMessage): string | null { - const authHeader = req.headers.authorization; - if (typeof authHeader === "string" && authHeader.toLowerCase().startsWith("bearer ")) { - return authHeader.slice("bearer ".length).trim(); - } - const header = req.headers["x-openclaw-hook-token"]; - if (typeof header === "string") return header.trim(); - if (Array.isArray(header) && header[0]) return header[0].trim(); - return null; -} - -function createInitialStatus(config: OpenclawBridgeConfig, deviceId: string | null): OpenclawBridgeStatus { - return { - state: config.enabled ? "disconnected" : "disabled", - enabled: config.enabled, - fallbackMode: !config.gatewayUrl, - httpListening: false, - bridgePort: config.bridgePort, - gatewayUrl: config.gatewayUrl, - deviceId, - paired: Boolean(config.deviceToken), - deviceTokenStored: Boolean(config.deviceToken), - lastConnectedAt: null, - lastEventAt: null, - lastMessageAt: null, - lastError: null, - queuedMessages: 0, - }; -} - -export function createOpenclawBridgeService(args: OpenclawBridgeServiceArgs) { - const logger = args.logger ?? null; - const secretPath = path.join(args.adeDir, "local.secret.yaml"); - const ctoDir = path.join(args.adeDir, "cto"); - const cacheDir = path.join(args.adeDir, "cache", "openclaw"); - const devicePath = path.join(ctoDir, "openclaw-device.json"); - const historyPath = path.join(cacheDir, OPENCLAW_HISTORY_FILE); - const outboxPath = path.join(cacheDir, OPENCLAW_OUTBOX_FILE); - const idempotencyPath = path.join(cacheDir, OPENCLAW_IDEMPOTENCY_FILE); - const routeCachePath = path.join(cacheDir, OPENCLAW_ROUTES_FILE); - fs.mkdirSync(ctoDir, { recursive: true }); - fs.mkdirSync(cacheDir, { recursive: true }); - - const migrateLegacyRuntimeFile = (legacyFileName: string, nextPath: string): void => { - const legacyPath = path.join(ctoDir, legacyFileName); - if (!fs.existsSync(legacyPath)) return; - let copied = false; - try { - if (!fs.existsSync(nextPath)) { - writeTextAtomic(nextPath, fs.readFileSync(legacyPath, "utf8")); - copied = true; - } - } catch (error) { - logger?.warn("openclaw.runtime_state_migration_failed", { - legacyPath, - nextPath, - error: getErrorMessage(error), - }); - return; - } - if (!copied) return; - try { - fs.unlinkSync(legacyPath); - } catch (error) { - logger?.warn("openclaw.runtime_state_cleanup_failed", { - legacyPath, - error: getErrorMessage(error), - }); - } - }; - - migrateLegacyRuntimeFile(OPENCLAW_HISTORY_FILE, historyPath); - migrateLegacyRuntimeFile(OPENCLAW_OUTBOX_FILE, outboxPath); - migrateLegacyRuntimeFile(OPENCLAW_IDEMPOTENCY_FILE, idempotencyPath); - migrateLegacyRuntimeFile(OPENCLAW_ROUTES_FILE, routeCachePath); - - const deviceIdentity = loadOrCreateDeviceIdentity(devicePath); - let config = readConfig(); - let history: OpenclawMessageRecord[] = readJsonFile(historyPath, []).map((record): OpenclawMessageRecord => ({ - ...record, - body: clipText(String(record.body ?? ""), HISTORY_BODY_MAX_LENGTH), - summary: summarizeMessage(String(record.summary ?? record.body ?? ""), HISTORY_SUMMARY_MAX_LENGTH), - ...(sanitizeContext(record.context) ? { context: sanitizeContext(record.context) } : { context: null }), - ...(sanitizeContext(record.metadata) ? { metadata: sanitizeContext(record.metadata) } : { metadata: null }), - ...(typeof record.error === "string" ? { error: clipText(record.error, HISTORY_ERROR_MAX_LENGTH) } : {}), - })); - let outbox: OutboxEntry[] = readJsonFile(outboxPath, []).map((entry): OutboxEntry => ({ - ...entry, - envelope: { - ...entry.envelope, - context: sanitizeContext(entry.envelope.context) ?? null, - }, - })); - let idempotencyState = pruneIdempotencyState(readJsonFile(idempotencyPath, {})); - let routeCache = readJsonFile(routeCachePath, { byAgentId: {} }); - - let httpServer: http.Server | null = null; - let currentHttpPort = Number.isFinite(config.bridgePort) ? config.bridgePort : DEFAULT_BRIDGE_PORT; - let ws: WebSocket | null = null; - let wsConnectNonce: string | null = null; - let wsConnectTimer: ReturnType | null = null; - let reconnectTimer: ReturnType | null = null; - let reconnectAttempt = 0; - let tickTimer: ReturnType | null = null; - let lastTickAt: number | null = null; - let requestedStop = false; - const pendingWsRequests = new Map(); - const pendingTurnsBySession = new Map(); - const turnBindings = new Map(); - const activeSessionRoutes = new Map(); - let status = createInitialStatus(config, deviceIdentity.deviceId); - - function readJsonFile(filePath: string, fallback: T): T { - try { - if (!fs.existsSync(filePath)) return fallback; - return JSON.parse(fs.readFileSync(filePath, "utf8")) as T; - } catch { - return fallback; - } - } - - function writeJsonFile(filePath: string, payload: unknown): void { - writeTextAtomic(filePath, `${JSON.stringify(payload, null, 2)}\n`); - } - - function readSecretDocument(): Record { - try { - if (!fs.existsSync(secretPath)) return {}; - const parsed = YAML.parse(fs.readFileSync(secretPath, "utf8")); - return isRecord(parsed) ? parsed : {}; - } catch { - return {}; - } - } - - function writeSecretDocument(doc: Record): void { - writeTextAtomic(secretPath, YAML.stringify(doc, { indent: 2 })); - } - - function readConfig(): OpenclawBridgeConfig { - const doc = readSecretDocument(); - return normalizeConfig(doc.openclaw); - } - - function persistConfig(next: OpenclawBridgeConfig): void { - const doc = readSecretDocument(); - doc.openclaw = { - enabled: next.enabled, - bridgePort: next.bridgePort, - gatewayUrl: next.gatewayUrl ?? null, - gatewayToken: next.gatewayToken ?? null, - deviceToken: next.deviceToken ?? null, - hooksToken: next.hooksToken ?? null, - allowedAgentIds: next.allowedAgentIds, - defaultTarget: next.defaultTarget, - allowEmployeeTargets: next.allowEmployeeTargets, - notificationRoutes: next.notificationRoutes, - }; - writeSecretDocument(doc); - } - - function pruneIdempotencyState(raw: PersistedIdempotencyState): PersistedIdempotencyState { - const now = Date.now(); - return Object.fromEntries( - Object.entries(raw).filter(([, expiresAt]) => Number.isFinite(expiresAt) && expiresAt > now), - ); - } - - function persistRuntimeState(): void { - writeJsonFile(historyPath, history.slice(-HISTORY_CAP)); - writeJsonFile(outboxPath, outbox); - writeJsonFile(idempotencyPath, idempotencyState); - writeJsonFile(routeCachePath, routeCache); - } - - function setStatus(patch: Partial): void { - status = { - ...status, - ...patch, - enabled: config.enabled, - fallbackMode: !config.gatewayUrl, - bridgePort: currentHttpPort, - gatewayUrl: config.gatewayUrl, - paired: Boolean(config.deviceToken), - deviceTokenStored: Boolean(config.deviceToken), - queuedMessages: outbox.length, - deviceId: deviceIdentity.deviceId, - }; - args.onStatusChange?.(status); - } - - function endpoints() { - const base = status.httpListening ? `http://127.0.0.1:${currentHttpPort}` : null; - return { - healthUrl: base ? `${base}/openclaw/health` : null, - hookUrl: base ? `${base}/openclaw/hook` : null, - queryUrl: base ? `${base}/openclaw/query` : null, - }; - } - - function readBridgeState(): OpenclawBridgeState { - return { - config, - status, - endpoints: endpoints(), - }; - } - - function saveHistoryRecord(record: OpenclawMessageRecord): OpenclawMessageRecord { - const sanitizedRecord: OpenclawMessageRecord = { - ...record, - body: clipText(record.body, HISTORY_BODY_MAX_LENGTH), - summary: summarizeMessage(record.summary || record.body, HISTORY_SUMMARY_MAX_LENGTH), - context: sanitizeContext(record.context), - ...(record.error ? { error: clipText(record.error, HISTORY_ERROR_MAX_LENGTH) } : {}), - ...(record.metadata ? { metadata: sanitizeContext(record.metadata) } : {}), - }; - history = [...history.filter((entry) => entry.id !== sanitizedRecord.id), sanitizedRecord] - .sort((a, b) => parseIsoToEpoch(a.createdAt) - parseIsoToEpoch(b.createdAt)) - .slice(-HISTORY_CAP); - persistRuntimeState(); - setStatus({ lastMessageAt: sanitizedRecord.createdAt }); - return sanitizedRecord; - } - - function getHistoryMessages(limit = 40): OpenclawMessageRecord[] { - return [...history] - .sort((a, b) => parseIsoToEpoch(b.createdAt) - parseIsoToEpoch(a.createdAt)) - .slice(0, Math.max(1, Math.min(200, Math.floor(limit)))); - } - - function rememberRoute(route: ConversationRoute): void { - const expiresAt = Date.now() + ROUTE_TTL_MS; - const stored: PersistedRouteCacheEntry = { - agentId: route.agentId ?? null, - sessionKey: route.sessionKey ?? null, - channel: route.channel ?? null, - replyChannel: route.replyChannel ?? null, - accountId: route.accountId ?? null, - replyAccountId: route.replyAccountId ?? null, - threadId: route.threadId ?? null, - updatedAt: nowIso(), - expiresAt, - }; - if (route.agentId) { - routeCache.byAgentId[route.agentId] = stored; - persistRuntimeState(); - } - } - - function pruneRouteCache(): void { - const now = Date.now(); - for (const [agentId, entry] of Object.entries(routeCache.byAgentId)) { - if ((entry?.expiresAt ?? 0) <= now) { - delete routeCache.byAgentId[agentId]; - } - } - } - - function markIdempotency(key: string): void { - idempotencyState[key] = Date.now() + IDEMPOTENCY_TTL_MS; - idempotencyState = pruneIdempotencyState(idempotencyState); - persistRuntimeState(); - } - - function hasSeenIdempotency(key: string): boolean { - idempotencyState = pruneIdempotencyState(idempotencyState); - return Number.isFinite(idempotencyState[key]); - } - - function buildReplyText(turn: PendingBridgeTurn, fallbackMessage?: string): string { - const text = turn.chunks.join("").trim(); - if (text.length) return text; - return fallbackMessage?.trim() || "No reply was generated."; - } - - async function resolvePrimaryLaneId(): Promise { - await args.laneService.ensurePrimaryLane().catch(() => {}); - const lanes = await args.laneService.list({ includeArchived: false, includeStatus: false }); - const preferred = lanes.find((entry) => entry.laneType === "primary") ?? lanes[0] ?? null; - if (!preferred?.id) { - throw new Error("No lane is available to host the OpenClaw bridge session."); - } - return preferred.id; - } - - function resolveTarget(targetHint?: OpenclawTargetHint | null): { identityKey: "cto" | `agent:${string}`; resolvedTarget: OpenclawTargetHint; fallbackReason?: string } { - const requestedTarget = normalizeTargetHint(targetHint, config.defaultTarget); - if (requestedTarget === "cto") { - return { identityKey: "cto", resolvedTarget: "cto" }; - } - if (!config.allowEmployeeTargets) { - return { - identityKey: "cto", - resolvedTarget: "cto", - fallbackReason: `Employee targets are disabled; routed ${requestedTarget} to CTO instead.`, - }; - } - const slug = requestedTarget.slice("agent:".length).trim().toLowerCase(); - const workers = args.workerAgentService?.listAgents({ includeDeleted: false }) ?? []; - const match = workers.find((agent) => agent.slug.toLowerCase() === slug && agent.deletedAt == null && agent.status !== "paused"); - if (!match) { - return { - identityKey: "cto", - resolvedTarget: "cto", - fallbackReason: `Unknown or unavailable worker '${slug}'; routed to CTO instead.`, - }; - } - return { - identityKey: `agent:${match.id}`, - resolvedTarget: `agent:${match.slug}`, - }; - } - - function applyContextPolicy(context: Record | null | undefined): Record | null { - const policy = normalizeContextPolicy(args.ctoStateService?.getIdentity().openclawContextPolicy); - return sanitizeContext(context, { - blockedTopLevelKeys: policy.shareMode === "full" ? [] : policy.blockedCategories, - }); - } - - function buildPromptFromInbound( - envelope: OpenclawInboundEnvelope, - requestId: string, - resolvedTarget: OpenclawTargetHint, - fallbackReason?: string, - ): string { - const sections = [ - "OpenClaw bridge request. Treat this routing context as turn-scoped bridge metadata only.", - "Do not automatically promote it to durable ADE memory.", - `Bridge request ID: ${requestId}`, - envelope.agentId ? `Origin agent ID: ${envelope.agentId}` : null, - envelope.sessionKey ? `Origin session key: ${envelope.sessionKey}` : null, - envelope.channel ? `Origin channel: ${envelope.channel}` : null, - envelope.threadId ? `Origin thread: ${envelope.threadId}` : null, - `Resolved target: ${resolvedTarget}`, - fallbackReason ? `Routing note: ${fallbackReason}` : null, - envelope.context ? `Structured bridge context:\n${JSON.stringify(envelope.context, null, 2)}` : null, - "", - "User message:", - envelope.message.trim(), - ].filter((entry): entry is string => Boolean(entry)); - return sections.join("\n"); - } - - async function ensureTargetSession(targetHint?: OpenclawTargetHint | null): Promise<{ - sessionId: string; - routeTarget: OpenclawTargetHint; - fallbackReason?: string; - }> { - const laneId = await resolvePrimaryLaneId(); - const resolved = resolveTarget(targetHint); - const session = await args.agentChatService.ensureIdentitySession({ - identityKey: resolved.identityKey, - laneId, - }); - return { - sessionId: session.id, - routeTarget: resolved.resolvedTarget, - fallbackReason: resolved.fallbackReason, - }; - } - - function queuePendingTurn(turn: PendingBridgeTurn): void { - const queue = pendingTurnsBySession.get(turn.sessionId) ?? []; - queue.push(turn); - pendingTurnsBySession.set(turn.sessionId, queue); - } - - function dequeuePendingTurn(turn: PendingBridgeTurn): void { - const queue = pendingTurnsBySession.get(turn.sessionId) ?? []; - const nextQueue = queue.filter((entry) => entry.requestId !== turn.requestId); - if (nextQueue.length) { - pendingTurnsBySession.set(turn.sessionId, nextQueue); - } else { - pendingTurnsBySession.delete(turn.sessionId); - } - if (turn.turnId) { - turnBindings.delete(turn.turnId); - } - if (turn.timeoutHandle) clearTimeout(turn.timeoutHandle); - } - - async function sendOutboundNow(envelope: OpenclawOutboundEnvelope): Promise { - const requestId = trimToNull(envelope.requestId) ?? randomUUID(); - const filteredContext = applyContextPolicy(envelope.context); - const message = filteredContext - ? `${envelope.message.trim()}\n\n[filtered_context]\n${JSON.stringify(filteredContext, null, 2)}` - : envelope.message.trim(); - const historyBody = envelope.message.trim(); - const recordBase: OpenclawMessageRecord = { - id: randomUUID(), - requestId, - direction: "outbound", - mode: envelope.notificationType ? "notification" : "manual", - status: "queued", - agentId: envelope.agentId ?? null, - sessionKey: envelope.sessionKey ?? null, - body: historyBody, - summary: summarizeMessage(historyBody), - context: filteredContext, - createdAt: nowIso(), - metadata: envelope.notificationType ? { notificationType: envelope.notificationType } : undefined, - }; - - if (!ws || ws.readyState !== WebSocket.OPEN || !config.enabled || !config.gatewayUrl) { - const queuedEnvelope = { ...envelope, requestId, context: filteredContext }; - outbox = [ - ...outbox.filter((entry) => entry.envelope.requestId !== requestId), - { - id: randomUUID(), - envelope: queuedEnvelope, - queuedAt: nowIso(), - attempts: 0, - }, - ]; - persistRuntimeState(); - setStatus({ queuedMessages: outbox.length }); - return saveHistoryRecord(recordBase); - } - - try { - if (envelope.sessionKey) { - await requestGateway("chat.send", { - sessionKey: envelope.sessionKey, - message, - deliver: envelope.deliver !== false, - attachments: [], - timeoutMs: envelope.timeoutMs ?? 60_000, - idempotencyKey: requestId, - }); - } else if (envelope.agentId) { - await requestGateway("agent", { - message, - agentId: envelope.agentId, - channel: envelope.channel ?? undefined, - replyChannel: envelope.replyChannel ?? undefined, - accountId: envelope.accountId ?? undefined, - replyAccountId: envelope.replyAccountId ?? undefined, - threadId: envelope.threadId ?? undefined, - deliver: envelope.deliver !== false, - bestEffortDeliver: envelope.bestEffort === true, - inputProvenance: { kind: "tool", sourceTool: "ade:openclaw-bridge" }, - idempotencyKey: requestId, - label: envelope.label ?? "ade-bridge", - }); - } else { - throw new Error("OpenClaw outbound envelope requires either sessionKey or agentId."); - } - return saveHistoryRecord({ - ...recordBase, - status: "sent", - }); - } catch (error) { - const failure = saveHistoryRecord({ - ...recordBase, - status: "failed", - error: getErrorMessage(error), - }); - if (envelope.bestEffort !== true) { - outbox = [ - ...outbox.filter((entry) => entry.envelope.requestId !== requestId), - { - id: randomUUID(), - envelope: { ...envelope, requestId, context: filteredContext }, - queuedAt: nowIso(), - attempts: 1, - lastAttemptAt: nowIso(), - lastError: getErrorMessage(error), - }, - ]; - persistRuntimeState(); - } - throw Object.assign(new Error(getErrorMessage(error)), { record: failure }); - } - } - - async function flushOutbox(): Promise { - if (!ws || ws.readyState !== WebSocket.OPEN || !config.enabled || !config.gatewayUrl) return; - const nextOutbox: OutboxEntry[] = []; - for (const entry of outbox) { - if (entry.attempts >= MAX_OUTBOX_ATTEMPTS) { - saveHistoryRecord({ - id: randomUUID(), - requestId: trimToNull(entry.envelope.requestId) ?? randomUUID(), - direction: "outbound", - mode: entry.envelope.notificationType ? "notification" : "manual", - status: "failed", - agentId: entry.envelope.agentId ?? null, - sessionKey: entry.envelope.sessionKey ?? null, - body: entry.envelope.message, - summary: summarizeMessage(entry.envelope.message), - context: applyContextPolicy(entry.envelope.context), - createdAt: nowIso(), - error: entry.lastError ?? "Outbox attempts exhausted.", - }); - continue; - } - try { - await sendOutboundNow({ - ...entry.envelope, - bestEffort: true, - }); - } catch (error) { - nextOutbox.push({ - ...entry, - attempts: entry.attempts + 1, - lastAttemptAt: nowIso(), - lastError: getErrorMessage(error), - }); - } - } - outbox = nextOutbox; - persistRuntimeState(); - setStatus({ queuedMessages: outbox.length }); - } - - async function finalizeTurn(turn: PendingBridgeTurn, outcome: "completed" | "failed" | "interrupted", fallbackMessage?: string): Promise { - if (turn.outputSent) return; - turn.outputSent = true; - const reply = buildReplyText(turn, fallbackMessage); - dequeuePendingTurn(turn); - if (turn.mode === "query") { - if (outcome === "failed") { - turn.reject?.(new Error(reply)); - } else { - turn.resolve?.({ reply, sessionId: turn.sessionId, route: turn.route }); - } - return; - } - if (outcome === "failed" && !reply.trim().length) { - saveHistoryRecord({ - id: randomUUID(), - requestId: turn.requestId, - direction: "outbound", - mode: "reply", - status: "failed", - agentId: turn.route.agentId ?? null, - sessionKey: turn.route.sessionKey ?? null, - body: reply, - summary: summarizeMessage(reply || fallbackMessage || "Bridge turn failed."), - context: null, - createdAt: nowIso(), - error: fallbackMessage ?? "Bridge turn failed.", - }); - return; - } - try { - await sendOutboundNow({ - requestId: turn.requestId, - sessionKey: turn.route.sessionKey ?? null, - agentId: turn.route.agentId ?? null, - channel: turn.route.channel ?? null, - replyChannel: turn.route.replyChannel ?? null, - accountId: turn.route.accountId ?? null, - replyAccountId: turn.route.replyAccountId ?? null, - threadId: turn.route.threadId ?? null, - message: reply, - bestEffort: true, - }); - } catch (error) { - logger?.warn("openclaw.reply_delivery_failed", { - requestId: turn.requestId, - error: getErrorMessage(error), - }); - } - } - - async function deliverNotification(type: OpenclawNotificationType, message: string, context?: Record | null): Promise { - pruneRouteCache(); - const routes = config.notificationRoutes.filter((route) => route.enabled !== false && route.notificationType === type); - for (const route of routes) { - const remembered = route.agentId ? routeCache.byAgentId[route.agentId] : null; - const sessionKey = trimToNull(route.sessionKey) ?? trimToNull(remembered?.sessionKey) ?? null; - const outbound: OpenclawOutboundEnvelope = { - requestId: randomUUID(), - agentId: route.agentId ?? remembered?.agentId ?? null, - sessionKey, - message, - context: context ?? null, - notificationType: type, - bestEffort: true, - }; - try { - await sendOutboundNow(outbound); - } catch { - // best effort queueing already handled in sendOutboundNow - } - } - } - - async function dispatchInbound( - mode: "hook" | "query", - envelope: OpenclawInboundEnvelope, - options?: { - onQueryResolved?: (value: { reply: string; sessionId: string; route: ConversationRoute }) => void; - onQueryRejected?: (error: Error) => void; - timeoutMs?: number; - }, - ): Promise<{ requestId: string; sessionId: string; routeTarget: OpenclawTargetHint; duplicate: boolean }> { - const message = trimToNull(envelope.message); - if (!message) { - throw new Error("OpenClaw inbound message is required."); - } - const requestId = trimToNull(envelope.requestId) ?? trimToNull(envelope.idempotencyKey) ?? randomUUID(); - const normalizedContext = applyContextPolicy(envelope.context); - if (hasSeenIdempotency(requestId)) { - saveHistoryRecord({ - id: randomUUID(), - requestId, - direction: "inbound", - mode, - status: "duplicate", - agentId: envelope.agentId ?? null, - sessionKey: envelope.sessionKey ?? null, - targetHint: envelope.targetHint ?? null, - body: message, - summary: summarizeMessage(message), - context: normalizedContext, - createdAt: nowIso(), - }); - return { requestId, sessionId: "", routeTarget: config.defaultTarget, duplicate: true }; - } - if (config.allowedAgentIds.length > 0) { - const agentId = trimToNull(envelope.agentId); - if (!agentId || !config.allowedAgentIds.includes(agentId)) { - throw new Error("OpenClaw agent is not allowed by this bridge configuration."); - } - } - - markIdempotency(requestId); - const targetSession = await ensureTargetSession(envelope.targetHint ?? config.defaultTarget); - const route: ConversationRoute = { - agentId: trimToNull(envelope.agentId), - sessionKey: trimToNull(envelope.sessionKey), - channel: trimToNull(envelope.channel), - replyChannel: trimToNull(envelope.replyChannel), - accountId: trimToNull(envelope.accountId), - replyAccountId: trimToNull(envelope.replyAccountId), - threadId: trimToNull(envelope.threadId), - updatedAt: nowIso(), - expiresAt: Date.now() + ROUTE_TTL_MS, - sessionId: targetSession.sessionId, - targetHint: targetSession.routeTarget, - }; - activeSessionRoutes.set(targetSession.sessionId, route); - rememberRoute(route); - - saveHistoryRecord({ - id: randomUUID(), - requestId, - direction: "inbound", - mode, - status: "received", - agentId: route.agentId ?? null, - sessionKey: route.sessionKey ?? null, - targetHint: envelope.targetHint ?? null, - resolvedTarget: targetSession.routeTarget, - body: message, - summary: summarizeMessage(message), - context: normalizedContext, - createdAt: nowIso(), - metadata: targetSession.fallbackReason ? { fallbackReason: targetSession.fallbackReason } : undefined, - }); - - const pendingTurn: PendingBridgeTurn = { - requestId, - mode, - route, - sessionId: targetSession.sessionId, - displayText: message, - createdAt: nowIso(), - chunks: [], - outputSent: false, - resolve: options?.onQueryResolved, - reject: options?.onQueryRejected, - timeoutHandle: mode === "query" && options?.timeoutMs - ? setTimeout(() => { - pendingTurn.reject?.(new Error("ADE timed out while waiting for the bridge reply.")); - }, options.timeoutMs) - : undefined, - }; - queuePendingTurn(pendingTurn); - - const promptText = buildPromptFromInbound( - { ...envelope, message, context: normalizedContext }, - requestId, - targetSession.routeTarget, - targetSession.fallbackReason, - ); - await args.agentChatService.sendMessage({ - sessionId: targetSession.sessionId, - text: promptText, - displayText: message, - }); - - return { - requestId, - sessionId: targetSession.sessionId, - routeTarget: targetSession.routeTarget, - duplicate: false, - }; - } - - function clearConnectTimer(): void { - if (wsConnectTimer) { - clearTimeout(wsConnectTimer); - wsConnectTimer = null; - } - } - - function clearTickTimer(): void { - if (tickTimer) { - clearInterval(tickTimer); - tickTimer = null; - } - } - - function clearReconnectTimer(): void { - if (reconnectTimer) { - clearTimeout(reconnectTimer); - reconnectTimer = null; - } - } - - function flushPendingWsErrors(error: Error): void { - for (const [, pending] of pendingWsRequests) pending.reject(error); - pendingWsRequests.clear(); - } - - function queueConnectTimeout(): void { - clearConnectTimer(); - wsConnectTimer = setTimeout(() => { - if (!ws || ws.readyState !== WebSocket.OPEN) return; - setStatus({ - state: "error", - lastError: "OpenClaw gateway connect challenge timed out.", - }); - ws.close(1008, "connect challenge timeout"); - }, CONNECT_CHALLENGE_TIMEOUT_MS); - } - - function startTickWatch(intervalMs: number): void { - clearTickTimer(); - tickTimer = setInterval(() => { - if (!lastTickAt || !ws) return; - if (Date.now() - lastTickAt > intervalMs * 2) { - ws.close(4000, "tick timeout"); - } - }, Math.max(intervalMs, TICK_WATCH_FLOOR_MS)); - } - - async function requestGateway( - method: string, - params: Record, - options?: { expectFinal?: boolean }, - ): Promise> { - if (!ws || ws.readyState !== WebSocket.OPEN) { - throw new Error("OpenClaw gateway is not connected."); - } - const id = randomUUID(); - const frame: OpenclawRequestFrame = { type: "req", id, method, params }; - const promise = new Promise>((resolve, reject) => { - pendingWsRequests.set(id, { - resolve, - reject, - expectFinal: options?.expectFinal === true, - }); - }); - ws.send(JSON.stringify(frame)); - return await promise; - } - - function sendConnectFrame(): void { - if (!ws || ws.readyState !== WebSocket.OPEN || !wsConnectNonce) return; - const authToken = trimToNull(config.gatewayToken); - const deviceToken = trimToNull(config.deviceToken); - const signedAtMs = Date.now(); - const scopes = ["operator.admin"]; - const payload = buildDeviceAuthPayloadV3({ - deviceId: deviceIdentity.deviceId, - clientId: "ade.openclaw.bridge", - clientMode: "backend", - role: "operator", - scopes, - signedAtMs, - token: authToken, - nonce: wsConnectNonce, - platform: process.platform, - deviceFamily: "ade", - }); - const params = { - minProtocol: 1, - maxProtocol: 1, - client: { - id: "ade.openclaw.bridge", - displayName: "ADE OpenClaw Bridge", - version: args.appVersion ?? "dev", - platform: process.platform, - deviceFamily: "ade", - mode: "backend", - }, - caps: [], - auth: authToken || deviceToken - ? { - ...(authToken ? { token: authToken } : {}), - ...(deviceToken ? { deviceToken } : {}), - } - : undefined, - role: "operator", - scopes, - device: { - id: deviceIdentity.deviceId, - publicKey: publicKeyRawBase64UrlFromPem(deviceIdentity.publicKeyPem), - signature: signDevicePayload(deviceIdentity.privateKeyPem, payload), - signedAt: signedAtMs, - nonce: wsConnectNonce, - }, - }; - void requestGateway("connect", params) - .then((hello) => { - const nextDeviceToken = trimToNull(isRecord(hello.auth) ? hello.auth.deviceToken : null); - if (nextDeviceToken && nextDeviceToken !== config.deviceToken) { - config = { ...config, deviceToken: nextDeviceToken }; - persistConfig(config); - } - reconnectAttempt = 0; - lastTickAt = Date.now(); - startTickWatch( - Number.isFinite(Number(isRecord(hello.policy) ? hello.policy.tickIntervalMs : null)) - ? Math.max(1_000, Number((hello.policy as Record).tickIntervalMs)) - : DEFAULT_TICK_INTERVAL_MS, - ); - setStatus({ - state: "connected", - lastConnectedAt: nowIso(), - lastError: null, - lastEventAt: nowIso(), - }); - void flushOutbox(); - }) - .catch((error) => { - setStatus({ - state: "error", - lastError: getErrorMessage(error), - }); - ws?.close(1008, "connect failed"); - }); - } - - function scheduleReconnect(): void { - if (requestedStop || !config.enabled || !config.gatewayUrl) return; - clearReconnectTimer(); - const delay = Math.min(1_000 * Math.max(1, 2 ** reconnectAttempt), MAX_RECONNECT_BACKOFF_MS); - reconnectAttempt += 1; - setStatus({ state: "reconnecting" }); - reconnectTimer = setTimeout(() => { - reconnectTimer = null; - void connectGateway(); - }, delay); - } - - function handleWsMessage(raw: string): void { - try { - const parsed = JSON.parse(raw) as unknown; - if (isEventFrame(parsed)) { - if (parsed.event === "connect.challenge") { - const nonce = trimToNull(parsed.payload?.nonce); - if (!nonce) { - throw new Error("OpenClaw gateway connect challenge did not include a nonce."); - } - wsConnectNonce = nonce; - clearConnectTimer(); - sendConnectFrame(); - return; - } - if (parsed.event === "tick") { - lastTickAt = Date.now(); - } - setStatus({ lastEventAt: nowIso() }); - return; - } - if (isResponseFrame(parsed)) { - const pending = pendingWsRequests.get(parsed.id); - if (!pending) return; - const responseStatus = isRecord(parsed.payload) ? parsed.payload.status : null; - if (pending.expectFinal && responseStatus === "accepted") return; - pendingWsRequests.delete(parsed.id); - if (parsed.ok) { - pending.resolve(parsed.payload ?? {}); - } else { - pending.reject(new Error(parsed.error?.message ?? "OpenClaw gateway returned an unknown error.")); - } - } - } catch (error) { - logger?.warn("openclaw.ws_message_parse_failed", { - error: getErrorMessage(error), - }); - } - } - - async function disconnectGateway(): Promise { - requestedStop = true; - clearConnectTimer(); - clearReconnectTimer(); - clearTickTimer(); - flushPendingWsErrors(new Error("OpenClaw gateway disconnected.")); - if (ws) { - const current = ws; - ws = null; - try { - current.close(); - } catch { - // best effort - } - } - if (config.enabled) { - setStatus({ state: "disconnected" }); - } else { - setStatus({ state: "disabled" }); - } - } - - async function connectGateway(): Promise { - requestedStop = false; - clearReconnectTimer(); - clearConnectTimer(); - if (!config.enabled) { - await disconnectGateway(); - return; - } - if (!config.gatewayUrl) { - setStatus({ - state: "disconnected", - lastError: "Gateway URL is not configured. HTTP fallback remains available.", - }); - return; - } - if (ws && (ws.readyState === WebSocket.OPEN || ws.readyState === WebSocket.CONNECTING)) { - return; - } - - setStatus({ state: status.lastConnectedAt ? "reconnecting" : "connecting", lastError: null }); - try { - ws = new WebSocket(config.gatewayUrl, { maxPayload: 25 * 1024 * 1024 }); - ws.on("open", () => { - wsConnectNonce = null; - queueConnectTimeout(); - }); - ws.on("message", (data: RawData) => { - const raw = typeof data === "string" ? data : Buffer.isBuffer(data) ? data.toString("utf8") : String(data); - handleWsMessage(raw); - }); - ws.on("close", (code: number, reason: Buffer) => { - ws = null; - clearConnectTimer(); - clearTickTimer(); - flushPendingWsErrors(new Error(`gateway closed (${code}): ${String(reason)}`)); - if (code === 1008 && String(reason).toLowerCase().includes("device token mismatch") && !config.gatewayToken) { - config = { ...config, deviceToken: null }; - persistConfig(config); - } - setStatus({ - state: config.enabled ? "disconnected" : "disabled", - lastError: `Gateway closed (${code}): ${String(reason)}`, - }); - scheduleReconnect(); - }); - ws.on("error", (error: Error) => { - setStatus({ - state: "error", - lastError: getErrorMessage(error), - }); - }); - } catch (error) { - setStatus({ - state: "error", - lastError: getErrorMessage(error), - }); - scheduleReconnect(); - } - } - - async function restartHttpServer(): Promise { - if (httpServer) { - const server = httpServer; - httpServer = null; - await new Promise((resolve) => server.close(() => resolve())); - setStatus({ httpListening: false }); - } - httpServer = http.createServer((req, res) => { - void handleHttpRequest(req, res).catch((error) => { - jsonResponse(res, 500, { ok: false, error: getErrorMessage(error) }); - }); - }); - await new Promise((resolve, reject) => { - httpServer!.once("error", reject); - const requestedPort = Number.isFinite(config.bridgePort) ? config.bridgePort : DEFAULT_BRIDGE_PORT; - httpServer!.listen(requestedPort, "127.0.0.1", () => resolve()); - }); - const address = httpServer.address(); - currentHttpPort = typeof address === "object" && address - ? address.port - : (Number.isFinite(config.bridgePort) ? config.bridgePort : DEFAULT_BRIDGE_PORT); - setStatus({ httpListening: true, bridgePort: currentHttpPort }); - } - - function authorizeRequest(req: IncomingMessage): void { - const configured = trimToNull(config.hooksToken); - if (!configured) return; - const provided = getRequestToken(req); - if (provided !== configured) { - throw new Error("Invalid OpenClaw hook token."); - } - } - - async function handleQueryRequest(envelope: OpenclawInboundEnvelope, res: ServerResponse): Promise { - const resolvedTarget = resolveTarget(envelope.targetHint ?? config.defaultTarget); - if (resolvedTarget.resolvedTarget !== "cto") { - const dispatch = await dispatchInbound("hook", envelope); - jsonResponse(res, 200, { - ok: true, - accepted: true, - async: true, - status: "working", - requestId: dispatch.requestId, - duplicate: dispatch.duplicate, - sessionId: dispatch.sessionId, - routeTarget: dispatch.routeTarget, - }); - return; - } - const timeoutMs = Number.isFinite(Number(envelope.timeoutMs)) - ? Math.max(1_000, Math.min(300_000, Math.floor(Number(envelope.timeoutMs)))) - : 120_000; - const requestId = trimToNull(envelope.requestId) ?? trimToNull(envelope.idempotencyKey) ?? randomUUID(); - const result = await new Promise<{ reply: string; sessionId: string; route: ConversationRoute }>(async (resolve, reject) => { - try { - const dispatch = await dispatchInbound( - "query", - { ...envelope, requestId }, - { - onQueryResolved: resolve, - onQueryRejected: reject, - timeoutMs, - }, - ); - if (dispatch.duplicate) { - reject(new Error("Duplicate idempotency key.")); - return; - } - } catch (error) { - reject(error instanceof Error ? error : new Error(String(error))); - } - }); - jsonResponse(res, 200, { - ok: true, - requestId, - reply: result.reply, - sessionId: result.sessionId, - route: { - agentId: result.route.agentId ?? null, - sessionKey: result.route.sessionKey ?? null, - targetHint: result.route.targetHint ?? null, - }, - }); - } - - async function handleHookRequest(envelope: OpenclawInboundEnvelope, res: ServerResponse): Promise { - const dispatch = await dispatchInbound("hook", envelope); - jsonResponse(res, 202, { - ok: true, - accepted: true, - duplicate: dispatch.duplicate, - requestId: dispatch.requestId, - sessionId: dispatch.sessionId, - routeTarget: dispatch.routeTarget, - }); - } - - async function handleHttpRequest(req: IncomingMessage, res: ServerResponse): Promise { - const method = req.method?.toUpperCase() ?? "GET"; - const pathname = new URL(req.url ?? "/", "http://127.0.0.1").pathname; - if (method === "GET" && pathname === "/openclaw/health") { - jsonResponse(res, 200, { - ok: true, - projectRoot: args.projectRoot, - state: readBridgeState(), - }); - return; - } - if (pathname !== "/openclaw/hook" && pathname !== "/openclaw/query") { - jsonResponse(res, 404, { ok: false, error: "Not found." }); - return; - } - if (method !== "POST") { - jsonResponse(res, 405, { ok: false, error: "Method not allowed." }); - return; - } - authorizeRequest(req); - const raw = await readBody(req); - const parsed = parseJsonBody(raw); - if (!isRecord(parsed)) { - jsonResponse(res, 400, { ok: false, error: "OpenClaw request body must be a JSON object." }); - return; - } - const envelope: OpenclawInboundEnvelope = { - requestId: trimToNull(parsed.requestId) ?? undefined, - idempotencyKey: trimToNull(parsed.idempotencyKey) ?? undefined, - agentId: trimToNull(parsed.agentId), - sessionKey: trimToNull(parsed.sessionKey), - channel: trimToNull(parsed.channel), - replyChannel: trimToNull(parsed.replyChannel), - accountId: trimToNull(parsed.accountId), - replyAccountId: trimToNull(parsed.replyAccountId), - threadId: trimToNull(parsed.threadId), - message: String(parsed.message ?? "").trim(), - targetHint: parsed.targetHint ? normalizeTargetHint(parsed.targetHint, config.defaultTarget) : undefined, - context: isRecord(parsed.context) ? parsed.context : null, - timeoutMs: Number.isFinite(Number(parsed.timeoutMs)) ? Number(parsed.timeoutMs) : undefined, - }; - try { - if (pathname === "/openclaw/query") { - await handleQueryRequest(envelope, res); - } else { - await handleHookRequest(envelope, res); - } - } catch (error) { - const statusCode = /timed out/i.test(getErrorMessage(error)) ? 504 : 400; - jsonResponse(res, statusCode, { ok: false, error: getErrorMessage(error) }); - } - } - - return { - async start(): Promise { - idempotencyState = pruneIdempotencyState(idempotencyState); - pruneRouteCache(); - persistRuntimeState(); - await restartHttpServer(); - if (config.enabled) { - await connectGateway(); - } else { - setStatus({ state: "disabled" }); - } - }, - - async stop(): Promise { - await disconnectGateway(); - // Clear all pending turn timeout handles and in-memory tracking maps. - for (const queue of pendingTurnsBySession.values()) { - for (const turn of queue) { - if (turn.timeoutHandle) clearTimeout(turn.timeoutHandle); - turn.reject?.(new Error("OpenClaw bridge stopped.")); - } - } - pendingTurnsBySession.clear(); - turnBindings.clear(); - activeSessionRoutes.clear(); - if (httpServer) { - const server = httpServer; - httpServer = null; - await new Promise((resolve) => server.close(() => resolve())); - } - setStatus({ httpListening: false, state: config.enabled ? "disconnected" : "disabled" }); - }, - - getState(): OpenclawBridgeState { - return readBridgeState(); - }, - - listMessages(limit = 40): OpenclawMessageRecord[] { - return getHistoryMessages(limit); - }, - - async updateConfig(patch: Partial): Promise { - config = normalizeConfig({ ...config, ...patch }); - persistConfig(config); - await restartHttpServer(); - if (config.enabled) { - await connectGateway(); - } else { - await disconnectGateway(); - } - setStatus({ - state: config.enabled ? status.state : "disabled", - lastError: config.enabled ? status.lastError : null, - }); - return readBridgeState(); - }, - - async testConnection(): Promise { - await restartHttpServer(); - if (!config.enabled || !config.gatewayUrl) { - setStatus({ - state: config.enabled ? "disconnected" : "disabled", - lastError: config.enabled && !config.gatewayUrl - ? "Gateway URL is not configured. HTTP fallback is ready." - : null, - }); - return status; - } - await connectGateway(); - const deadline = Date.now() + 8_000; - while (Date.now() < deadline) { - if (status.state === "connected") return status; - if (status.state === "error") break; - await new Promise((resolve) => setTimeout(resolve, 150)); - } - return status; - }, - - async sendMessage(envelope: OpenclawOutboundEnvelope): Promise { - return await sendOutboundNow(envelope); - }, - - onAgentChatEvent(envelope: AgentChatEventEnvelope): void { - const queue = pendingTurnsBySession.get(envelope.sessionId) ?? []; - if (envelope.event.type === "user_message" && envelope.event.turnId) { - const pending = queue.find((entry) => !entry.turnId); - if (pending) { - pending.turnId = envelope.event.turnId; - turnBindings.set(envelope.event.turnId, pending); - return; - } - const ambientRoute = activeSessionRoutes.get(envelope.sessionId); - if (ambientRoute && ambientRoute.expiresAt > Date.now()) { - const ambient: PendingBridgeTurn = { - requestId: randomUUID(), - mode: "ambient", - route: ambientRoute, - sessionId: envelope.sessionId, - displayText: envelope.event.text, - createdAt: nowIso(), - turnId: envelope.event.turnId, - chunks: [], - outputSent: false, - }; - turnBindings.set(envelope.event.turnId, ambient); - } - return; - } - - const turnId = envelope.event.type === "done" - ? envelope.event.turnId - : "turnId" in envelope.event - ? envelope.event.turnId - : undefined; - if (!turnId) return; - const binding = turnBindings.get(turnId); - if (!binding) return; - - if (envelope.event.type === "text") { - binding.chunks.push(envelope.event.text); - return; - } - - if (envelope.event.type === "status" && envelope.event.turnStatus === "failed") { - void finalizeTurn(binding, "failed", envelope.event.message ?? "ADE failed to complete the bridge turn."); - return; - } - - if (envelope.event.type === "status" && envelope.event.turnStatus === "interrupted") { - void finalizeTurn(binding, "interrupted", envelope.event.message ?? "ADE interrupted the bridge turn."); - return; - } - - if (envelope.event.type === "error") { - binding.chunks.push(`\n${envelope.event.message}`); - return; - } - - if (envelope.event.type === "done") { - void finalizeTurn(binding, envelope.event.status === "failed" ? "failed" : envelope.event.status === "interrupted" ? "interrupted" : "completed"); - } - }, - - onMissionEvent(event: MissionsEventPayload): void { - if (!event.missionId || event.reason !== "updated") return; - const mission = args.missionService?.get(event.missionId); - if (!mission || mission.status !== "completed") return; - void deliverNotification( - "mission_complete", - `Mission completed: ${mission.title}`, - { - missionId: mission.id, - status: mission.status, - updatedAt: mission.updatedAt, - }, - ); - }, - - onTestEvent(event: TestEvent): void { - if (event.type !== "run" || event.run.status !== "failed") return; - void deliverNotification( - "ci_broken", - `CI/test run failed: ${event.run.suiteName}`, - { - suiteId: event.run.suiteId, - runId: event.run.id, - laneId: event.run.laneId, - exitCode: event.run.exitCode, - }, - ); - }, - - onOrchestratorEvent(event: OrchestratorRuntimeEvent): void { - const reason = (event.reason ?? "").toLowerCase(); - if (!reason.includes("blocked")) return; - void deliverNotification( - "blocked_run", - `Orchestrator blocked: ${event.reason}`, - { - runId: event.runId ?? null, - stepId: event.stepId ?? null, - attemptId: event.attemptId ?? null, - }, - ); - }, - }; -} diff --git a/apps/desktop/src/main/services/cto/workerAdapterRuntimeService.ts b/apps/desktop/src/main/services/cto/workerAdapterRuntimeService.ts index e8424eebe..ce52f20aa 100644 --- a/apps/desktop/src/main/services/cto/workerAdapterRuntimeService.ts +++ b/apps/desktop/src/main/services/cto/workerAdapterRuntimeService.ts @@ -12,7 +12,6 @@ import type { createAgentChatService } from "../chat/agentChatService"; const ADE_CLI_WORKER_GUIDANCE = ADE_CLI_AGENT_GUIDANCE; type WorkerAdapterRuntimeServiceArgs = { - fetchImpl?: typeof fetch; spawnImpl?: typeof spawn; getAgentChatService?: () => Pick, "ensureIdentitySession" | "runSessionTurn"> | null; }; @@ -158,7 +157,6 @@ function runCommand( } export function createWorkerAdapterRuntimeService(args: WorkerAdapterRuntimeServiceArgs = {}) { - const fetchImpl = args.fetchImpl ?? fetch; const spawnImpl = args.spawnImpl ?? spawn; const run = async (input: WorkerAdapterRunArgs): Promise => { @@ -241,76 +239,6 @@ export function createWorkerAdapterRuntimeService(args: WorkerAdapterRuntimeServ }; } - if (adapterType === "openclaw-webhook") { - const url = String(config.url ?? "").trim(); - if (!/^https?:\/\//i.test(url)) { - throw new Error("openclaw-webhook requires a valid http(s) URL."); - } - const method = String(config.method ?? "POST").toUpperCase(); - if (method !== "POST") { - throw new Error("openclaw-webhook only supports POST."); - } - const headersRaw = config.headers && typeof config.headers === "object" ? config.headers as Record : {}; - const headers: Record = { - "content-type": "application/json", - }; - for (const [key, value] of Object.entries(headersRaw)) { - if (typeof value !== "string") continue; - headers[key] = value; - } - const timeoutMs = toPositiveTimeout(input.timeoutMs ?? config.timeoutMs, 60_000); - const controller = new AbortController(); - const timeout = setTimeout(() => controller.abort(), timeoutMs); - try { - const body = { - agentId: input.agent.id, - agentName: input.agent.name, - adapterType, - prompt, - context: input.context ?? {}, - bodyTemplate: typeof config.bodyTemplate === "string" ? config.bodyTemplate : undefined, - }; - const response = await fetchImpl(url, { - method, - headers, - body: JSON.stringify(body), - signal: controller.signal, - }); - const text = await response.text(); - let parsed: unknown = text; - try { - parsed = JSON.parse(text); - } catch { - // keep text payload - } - const outputText = typeof parsed === "string" - ? parsed - : (parsed && typeof parsed === "object" && typeof (parsed as { output?: unknown }).output === "string") - ? String((parsed as { output?: unknown }).output) - : text; - return { - adapterType, - effectiveSurface: "openclaw_webhook", - ok: response.ok, - statusCode: response.status, - outputText: outputText.trim(), - raw: parsed, - provider: null, - model: requestedModel, - modelId: requestedModelId, - continuation: { - surface: "openclaw_webhook", - provider: null, - model: requestedModel, - modelId: requestedModelId, - reasoningEffort: toOptionalString(config.reasoningEffort), - }, - }; - } finally { - clearTimeout(timeout); - } - } - if (adapterType === "process") { const command = String(config.command ?? "").trim(); if (!command.length) throw new Error("process adapter requires command."); diff --git a/apps/desktop/src/main/services/cto/workerAgentService.ts b/apps/desktop/src/main/services/cto/workerAgentService.ts index 472556383..684f4b0ba 100644 --- a/apps/desktop/src/main/services/cto/workerAgentService.ts +++ b/apps/desktop/src/main/services/cto/workerAgentService.ts @@ -67,7 +67,6 @@ const ALLOWED_STATUSES = new Set(["idle", "active", "paused", "runn const ALLOWED_ADAPTER_TYPES = new Set([ "claude-local", "codex-local", - "openclaw-webhook", "process", ]); @@ -267,35 +266,6 @@ function normalizeAdapterConfig(adapterType: AdapterType, config: Record = {}; - for (const [key, value] of Object.entries(config.headers as Record)) { - if (typeof value !== "string") continue; - headers[key] = value.trim(); - } - result.headers = headers; - } - if (Number.isFinite(timeoutMs) && timeoutMs > 0) result.timeoutMs = Math.floor(timeoutMs); - if (typeof config.bodyTemplate === "string" && config.bodyTemplate.trim()) { - result.bodyTemplate = config.bodyTemplate; - } - return result; - } - if (adapterType === "process") { const command = typeof config.command === "string" ? config.command.trim() : ""; if (!command.length) throw new Error("process adapter requires a non-empty command."); diff --git a/apps/desktop/src/main/services/cto/workerHeartbeatService.ts b/apps/desktop/src/main/services/cto/workerHeartbeatService.ts index 02dad02c2..9be5272a9 100644 --- a/apps/desktop/src/main/services/cto/workerHeartbeatService.ts +++ b/apps/desktop/src/main/services/cto/workerHeartbeatService.ts @@ -636,7 +636,7 @@ export function createWorkerHeartbeatService(args: WorkerHeartbeatServiceArgs) { run.task_key ? `Task: ${run.task_key}.` : "", run.issue_key ? `Issue: ${run.issue_key}.` : "", runtimeResult.ok ? "Adapter run completed." : "Adapter run failed.", - runtimeResult.effectiveSurface !== "process" && runtimeResult.effectiveSurface !== "openclaw_webhook" + runtimeResult.effectiveSurface !== "process" ? `Resumed via ${runtimeResult.effectiveSurface}.` : "", heartbeatOk ? "No action required." : outputPreview || "No output.", @@ -650,7 +650,7 @@ export function createWorkerHeartbeatService(args: WorkerHeartbeatServiceArgs) { provider: runtimeResult.provider ?? agent.adapterType, modelId: adapterModelId, capabilityMode: - runtimeResult.effectiveSurface === "process" || runtimeResult.effectiveSurface === "openclaw_webhook" + runtimeResult.effectiveSurface === "process" ? "fallback" : "full_tooling", }); diff --git a/apps/desktop/src/main/services/feedback/feedbackReporterService.test.ts b/apps/desktop/src/main/services/feedback/feedbackReporterService.test.ts index d4a575993..ce210606b 100644 --- a/apps/desktop/src/main/services/feedback/feedbackReporterService.test.ts +++ b/apps/desktop/src/main/services/feedback/feedbackReporterService.test.ts @@ -1,12 +1,6 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; import { createFeedbackReporterService } from "./feedbackReporterService"; -vi.mock("electron", () => ({ - BrowserWindow: { - getAllWindows: () => [], - }, -})); - function createDb() { const store = new Map(); return { @@ -114,6 +108,7 @@ describe("createFeedbackReporterService", () => { it("stores a failed submission when GitHub posting fails", async () => { const db = createDb(); const logger = createLogger(); + const onSubmissionUpdated = vi.fn(); const apiRequest = vi.fn(async () => { throw new Error("GitHub API unavailable"); }); @@ -124,6 +119,7 @@ describe("createFeedbackReporterService", () => { projectRoot: "/Users/admin/Projects/ADE", aiIntegrationService: { executeTask: vi.fn() } as any, githubService: { apiRequest } as any, + onSubmissionUpdated, }); const submission = await service.submitPreparedDraft({ @@ -162,6 +158,7 @@ describe("createFeedbackReporterService", () => { error: "Posting failed: GitHub API unavailable", }), ); + expect(onSubmissionUpdated).toHaveBeenCalledTimes(2); }); it("stores a posted submission after a reviewed draft is submitted", async () => { diff --git a/apps/desktop/src/main/services/feedback/feedbackReporterService.ts b/apps/desktop/src/main/services/feedback/feedbackReporterService.ts index 53cc4f3e9..5718bda58 100644 --- a/apps/desktop/src/main/services/feedback/feedbackReporterService.ts +++ b/apps/desktop/src/main/services/feedback/feedbackReporterService.ts @@ -1,6 +1,4 @@ import { randomUUID } from "node:crypto"; -import { BrowserWindow } from "electron"; -import { IPC } from "../../../shared/ipc"; import type { Logger } from "../logging/logger"; import type { AdeDb } from "../state/kvDb"; import type { createAiIntegrationService } from "../ai/aiIntegrationService"; @@ -247,14 +245,18 @@ function normalizeStoredSubmission(submission: FeedbackSubmission): FeedbackSubm }; } -function emitUpdate(submission: FeedbackSubmission): void { - const event: FeedbackSubmissionEvent = { +function toSubmissionUpdateEvent(submission: FeedbackSubmission): FeedbackSubmissionEvent { + return { type: "feedback-submission-updated", submission, }; - for (const win of BrowserWindow.getAllWindows()) { - win.webContents.send(IPC.feedbackOnUpdate, event); - } +} + +function emitUpdate( + submission: FeedbackSubmission, + onSubmissionUpdated: ((event: FeedbackSubmissionEvent) => void) | undefined, +): void { + onSubmissionUpdated?.(toSubmissionUpdateEvent(submission)); } const METADATA_SYSTEM_PROMPT = `You help convert structured ADE feedback into GitHub issue metadata. @@ -343,12 +345,14 @@ export function createFeedbackReporterService({ projectRoot, aiIntegrationService, githubService, + onSubmissionUpdated, }: { db: AdeDb; logger: Logger; projectRoot: string; aiIntegrationService: ReturnType; githubService: ReturnType; + onSubmissionUpdated?: (event: FeedbackSubmissionEvent) => void; }) { function loadAll(): FeedbackSubmission[] { return (db.getJson(DB_KEY) ?? []).map(normalizeStoredSubmission); @@ -461,7 +465,7 @@ export function createFeedbackReporterService({ }; save(submission); - emitUpdate(submission); + emitUpdate(submission, onSubmissionUpdated); try { const { data } = await githubService.apiRequest<{ @@ -483,7 +487,7 @@ export function createFeedbackReporterService({ submission.status = "posted"; submission.completedAt = nowIso(); save(submission); - emitUpdate(submission); + emitUpdate(submission, onSubmissionUpdated); logger.info("feedback.posted", { id: submission.id, @@ -495,7 +499,7 @@ export function createFeedbackReporterService({ submission.error = `Posting failed: ${message}`; submission.completedAt = nowIso(); save(submission); - emitUpdate(submission); + emitUpdate(submission, onSubmissionUpdated); logger.error("feedback.failed", { id: submission.id, diff --git a/apps/desktop/src/main/services/git/gitOperationsService.ts b/apps/desktop/src/main/services/git/gitOperationsService.ts index 88ece3511..38a435f4a 100644 --- a/apps/desktop/src/main/services/git/gitOperationsService.ts +++ b/apps/desktop/src/main/services/git/gitOperationsService.ts @@ -1,3 +1,4 @@ +import { spawn } from "node:child_process"; import path from "node:path"; import { getHeadSha, runGit, runGitOrThrow } from "./git"; import { detectConflictKind, parseNameOnly } from "./gitConflictState"; @@ -59,6 +60,18 @@ type CachedReadEntry = { promise?: Promise; }; +type GitOriginRemoteSummary = { + remoteUrl: string | null; + branch: string | null; +}; + +type GitOpenPrSummary = { + prUrl: string | null; + prNumber: number | null; + title: string | null; + headRefName: string | null; +}; + function localBranchNameFromRemoteRef(ref: string): string { const normalized = ref.trim(); const slashIndex = normalized.indexOf("/"); @@ -1243,6 +1256,87 @@ export function createGitOperationsService({ return { name, email }; }, + async getOriginRemote(args: { laneId: string }): Promise { + const fallback: GitOriginRemoteSummary = { remoteUrl: null, branch: null }; + const laneId = args.laneId.trim(); + if (!laneId) return fallback; + const lane = laneService.getLaneBaseAndBranch(laneId); + const [remoteRes, branchRes] = await Promise.all([ + runGit(["remote", "get-url", "origin"], { cwd: lane.worktreePath, timeoutMs: 8_000 }).catch(() => null), + lane.branchRef?.trim() + ? Promise.resolve(null) + : runGit(["rev-parse", "--abbrev-ref", "HEAD"], { cwd: lane.worktreePath, timeoutMs: 8_000 }).catch(() => null), + ]); + const rawRemote = remoteRes?.exitCode === 0 ? remoteRes.stdout.trim() || null : null; + const remoteUrl = ((): string | null => { + if (!rawRemote) return rawRemote; + try { + const parsed = new URL(rawRemote); + if (parsed.username || parsed.password) { + parsed.username = ""; + parsed.password = ""; + return parsed.toString(); + } + return rawRemote; + } catch { + return rawRemote; + } + })(); + let branch = lane.branchRef?.trim() || null; + if (!branch && branchRes?.exitCode === 0) { + const out = branchRes.stdout.trim(); + branch = out && out !== "HEAD" ? out : null; + } + return { remoteUrl, branch }; + }, + + async getOpenPrForBranch(args: { laneId: string; branch?: string }): Promise { + const fallback: GitOpenPrSummary = { prUrl: null, prNumber: null, title: null, headRefName: null }; + const laneId = args.laneId.trim(); + if (!laneId) return fallback; + const lane = laneService.getLaneBaseAndBranch(laneId); + const branch = args.branch?.trim() || lane.branchRef?.trim() || ""; + if (!branch) return fallback; + + try { + const stdout = await new Promise((resolve) => { + let settled = false; + let out = ""; + const child = spawn("gh", ["pr", "list", "--head", branch, "--state", "open", "--json", "url,number,title,headRefName", "--limit", "1"], { + cwd: lane.worktreePath, + env: process.env, + stdio: ["ignore", "pipe", "pipe"], + }); + const finish = (value: string) => { + if (settled) return; + settled = true; + clearTimeout(timer); + try { child.kill("SIGKILL"); } catch { /* noop */ } + resolve(value); + }; + const timer = setTimeout(() => finish(""), 8_000); + child.stdout.on("data", (d: Buffer | string) => { + out += Buffer.isBuffer(d) ? d.toString("utf8") : String(d); + }); + child.stderr.on("data", () => { /* swallow auth state */ }); + child.on("error", () => finish("")); + child.on("close", (code) => finish(code === 0 ? out : "")); + }); + if (!stdout.trim()) return fallback; + const parsed: unknown = JSON.parse(stdout); + if (!Array.isArray(parsed) || parsed.length === 0) return fallback; + const entry = parsed[0] as Record; + return { + prUrl: typeof entry.url === "string" && entry.url ? entry.url : null, + prNumber: typeof entry.number === "number" ? entry.number : null, + title: typeof entry.title === "string" && entry.title ? entry.title : null, + headRefName: typeof entry.headRefName === "string" && entry.headRefName ? entry.headRefName : null, + }; + } catch { + return fallback; + } + }, + async checkoutBranch(args: GitCheckoutBranchArgs): Promise { const branchName = args.branchName.trim(); if (!branchName.length) throw new Error("Branch name is required"); diff --git a/apps/desktop/src/main/services/github/githubService.ts b/apps/desktop/src/main/services/github/githubService.ts index 9812d7bc2..5a65ccae8 100644 --- a/apps/desktop/src/main/services/github/githubService.ts +++ b/apps/desktop/src/main/services/github/githubService.ts @@ -10,6 +10,22 @@ import { getGitHubTokenAccessState, parseGitHubScopeHeaders } from "../../../sha import { nowIso, asString } from "../shared/utils"; const AUTH_STORE_FILE_NAME = "github-token.v1.bin"; +const GITHUB_API_TIMEOUT_MS = 20_000; + +async function fetchGitHub(input: string | URL, init: RequestInit): Promise { + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), GITHUB_API_TIMEOUT_MS); + try { + return await fetch(input, { ...init, signal: controller.signal }); + } catch (error) { + if (error instanceof Error && error.name === "AbortError") { + throw new Error("GitHub API request timed out. Check network access on this machine."); + } + throw error; + } finally { + clearTimeout(timer); + } +} function detectGitHubTokenType(token: string): GitHubStatus["tokenType"] { if (token.startsWith("github_pat_")) return "fine-grained"; @@ -195,7 +211,7 @@ export function createGithubService({ }; const validateToken = async (token: string): Promise<{ userLogin: string | null; scopes: string[]; tokenType: GitHubStatus["tokenType"] }> => { - const response = await fetch("https://api.github.com/user", { + const response = await fetchGitHub("https://api.github.com/user", { method: "GET", headers: { accept: "application/vnd.github+json", @@ -229,7 +245,7 @@ export function createGithubService({ repo: GitHubRepoRef, ): Promise<{ ok: boolean; error: string | null }> => { try { - const response = await fetch( + const response = await fetchGitHub( `https://api.github.com/repos/${encodeURIComponent(repo.owner)}/${encodeURIComponent(repo.name)}`, { method: "GET", @@ -290,7 +306,7 @@ export function createGithubService({ } } - const response = await fetch(url.toString(), { + const response = await fetchGitHub(url.toString(), { method: args.method, headers, body: args.body != null ? JSON.stringify(args.body) : undefined diff --git a/apps/desktop/src/main/services/ipc/registerIpc.ts b/apps/desktop/src/main/services/ipc/registerIpc.ts index 3f27d342e..e6188a2e0 100644 --- a/apps/desktop/src/main/services/ipc/registerIpc.ts +++ b/apps/desktop/src/main/services/ipc/registerIpc.ts @@ -270,7 +270,6 @@ import type { AgentChatFileSearchResult, AgentChatGetTurnFileDiffArgs, AgentTool, - DeviceMarker, KeybindingOverride, KeybindingsSnapshot, ImportBranchLaneArgs, @@ -280,7 +279,6 @@ import type { OnboardingTourProgress, OnboardingTourVariant, LaneListSnapshot, - LaneRuntimeSummary, LaneSummary, ListOperationsArgs, ListOverlapsArgs, @@ -307,6 +305,7 @@ import type { ProjectDetail, ProjectIcon, ProjectInfo, + OpenProjectBinding, CreateProjectInput, CreateProjectResult, CloneProjectInput, @@ -438,13 +437,6 @@ import type { CtoUpdateIdentityArgs, CtoUpdateCoreMemoryArgs, CtoListSessionLogsArgs, - CtoGetOpenclawStateResult, - CtoUpdateOpenclawConfigArgs, - CtoTestOpenclawConnectionArgs, - CtoTestOpenclawConnectionResult, - CtoListOpenclawMessagesArgs, - CtoListOpenclawMessagesResult, - CtoSendOpenclawMessageArgs, CtoSnapshot, CtoSessionLogEntry, GetOrchestratorWorkerStatesArgs, @@ -654,7 +646,6 @@ import type { createOrchestratorService } from "../orchestrator/orchestratorServ import type { createAiOrchestratorService } from "../orchestrator/aiOrchestratorService"; import { readCoordinatorCheckpoint } from "../orchestrator/missionStateDoc"; import type { createMemoryService } from "../memory/memoryService"; -import type { createOpenclawBridgeService } from "../cto/openclawBridgeService"; import type { createBatchConsolidationService } from "../memory/batchConsolidationService"; import type { createMemoryLifecycleService } from "../memory/memoryLifecycleService"; import type { createMemoryBriefingService } from "../memory/memoryBriefingService"; @@ -673,6 +664,8 @@ import type { createWorkerHeartbeatService } from "../cto/workerHeartbeatService import type { createWorkerTaskSessionService } from "../cto/workerTaskSessionService"; import type { createLinearCredentialService } from "../cto/linearCredentialService"; import { createLinearOAuthService, type LinearOAuthService } from "../cto/linearOAuthService"; +import type { LocalRuntimeConnectionPool } from "../localRuntime/localRuntimeConnectionPool"; +import { registerRuntimeBridge } from "./runtimeBridge"; import type { createFlowPolicyService } from "../cto/flowPolicyService"; import type { createLinearRoutingService } from "../cto/linearRoutingService"; import type { createLinearIngressService } from "../cto/linearIngressService"; @@ -682,6 +675,11 @@ import type { createUsageTrackingService } from "../usage/usageTrackingService"; import type { createBudgetCapService } from "../usage/budgetCapService"; import type { createSyncHostService } from "../sync/syncHostService"; import type { createSyncService } from "../sync/syncService"; +import { + buildLaneListSnapshots, + buildLanePresenceByLaneId, + decorateLaneSummariesWithPresence, +} from "../lanes/laneListSnapshotService"; import type { createFeedbackReporterService } from "../feedback/feedbackReporterService"; import type { AdeProjectService } from "../projects/adeProjectService"; import type { ConfigReloadService } from "../projects/configReloadService"; @@ -689,7 +687,6 @@ import type { createProjectScaffoldService } from "../projects/projectScaffoldSe import type { createAdeCliService } from "../cli/adeCliService"; import { getErrorMessage, isRecord, nowIso, resolvePathWithinRoot, toMemoryEntryDto } from "../shared/utils"; import { quoteWindowsCmdArg } from "../shared/processExecution"; -import { resolveAdeLayout } from "../../../shared/adeLayout"; export type AppContext = { db: AdeDb; @@ -764,7 +761,6 @@ export type AppContext = { embeddingService?: ReturnType | null; embeddingWorkerService?: ReturnType | null; ctoStateService?: ReturnType | null; - openclawBridgeService?: ReturnType | null; workerAgentService?: ReturnType | null; adeProjectService?: AdeProjectService | null; workerRevisionService?: ReturnType | null; @@ -814,204 +810,6 @@ function escapeCsvCell(value: string | null | undefined): string { return /[",\r\n]/.test(input) ? `"${input.replace(/"/g, "\"\"")}"` : input; } -function sessionStatusBucket(args: { - status: string; - lastOutputPreview: string | null | undefined; - runtimeState?: string | null; -}): "running" | "awaiting-input" | "ended" { - if (args.status === "running") { - if (args.runtimeState === "waiting-input") return "awaiting-input"; - const preview = args.lastOutputPreview ?? ""; - if (/\b(?:waiting|awaiting)\b.{0,28}\b(?:input|confirmation|response|prompt)\b/i.test(preview)) { - return "awaiting-input"; - } - if (/\((?:y\/n|yes\/no)\)/i.test(preview) || /\[(?:y\/n|yes\/no)\]/i.test(preview)) { - return "awaiting-input"; - } - return "running"; - } - return "ended"; -} - -function summarizeLaneRuntime( - laneId: string, - sessions: Array<{ - laneId: string; - status: string; - lastOutputPreview: string | null; - runtimeState?: string | null; - }>, -): LaneRuntimeSummary { - let runningCount = 0; - let awaitingInputCount = 0; - let endedCount = 0; - let sessionCount = 0; - - for (const session of sessions) { - if (session.laneId !== laneId) continue; - sessionCount += 1; - const bucket = sessionStatusBucket(session); - if (bucket === "running") runningCount += 1; - else if (bucket === "awaiting-input") awaitingInputCount += 1; - else endedCount += 1; - } - - const bucket = awaitingInputCount > 0 - ? "awaiting-input" - : runningCount > 0 - ? "running" - : endedCount > 0 - ? "ended" - : "none"; - - return { - bucket, - runningCount, - awaitingInputCount, - endedCount, - sessionCount, - }; -} - -function buildLanePresenceByLaneId(syncService: ReturnType | null | undefined): Map { - const hostService = syncService?.getHostService?.() ?? null; - const snapshot = hostService?.getLanePresenceSnapshot?.() ?? []; - return new Map(snapshot.map((entry) => [entry.laneId, entry.devicesOpen] as const)); -} - -function decorateLaneSummaryWithPresence( - lane: LaneSummary, - devicesOpenByLaneId: Map, -): LaneSummary { - const devicesOpen = devicesOpenByLaneId.get(lane.id) ?? []; - return { ...lane, devicesOpen: devicesOpen.length > 0 ? devicesOpen : undefined }; -} - -function decorateLaneSummariesWithPresence( - lanes: LaneSummary[], - devicesOpenByLaneId: Map, -): LaneSummary[] { - return lanes.map((lane) => decorateLaneSummaryWithPresence(lane, devicesOpenByLaneId)); -} - -async function enrichSessionsForLaneList( - args: Pick, -): Promise { - let sessions = args.ptyService.enrichSessions(args.sessionService.list({})); - let allChats: AgentChatSessionSummary[] = []; - try { - allChats = await args.agentChatService.listSessions(undefined, { includeIdentity: true }); - } catch { - allChats = []; - } - const identitySessionIds = new Set( - allChats - .filter((chat) => Boolean(chat.identityKey)) - .map((chat) => chat.sessionId), - ); - if (identitySessionIds.size > 0) { - sessions = sessions.filter((session) => !identitySessionIds.has(session.id)); - } - const chats = allChats.filter((chat) => !chat.identityKey); - if (chats.length === 0) return sessions; - const chatSummaryBySessionId = new Map(chats.map((chat) => [chat.sessionId, chat] as const)); - return sessions.map((session) => { - if (!isChatToolType(session.toolType)) return session; - if (session.status !== "running") return session; - const chat = chatSummaryBySessionId.get(session.id); - if (!chat) return session; - if (chat.awaitingInput) return { ...session, runtimeState: "waiting-input" as const, chatIdleSinceAt: null }; - if (chat.status === "active") return { ...session, runtimeState: "running" as const, chatIdleSinceAt: null }; - if (chat.status === "idle") return { ...session, runtimeState: "idle" as const, chatIdleSinceAt: chat.idleSinceAt ?? null }; - return session; - }); -} - -async function buildLaneListSnapshots( - args: Pick & { - syncService?: ReturnType | null; - }, - lanes: LaneSummary[], - options: { includeConflictStatus?: boolean; includeRebaseSuggestions?: boolean; includeAutoRebaseStatus?: boolean } = {}, -): Promise { - const startedAt = Date.now(); - const phases: Array<{ phase: string; durationMs: number }> = []; - const timePhase = async (phase: string, work: () => Promise | T): Promise => { - const phaseStartedAt = Date.now(); - try { - return await work(); - } finally { - const durationMs = Date.now() - phaseStartedAt; - phases.push({ phase, durationMs }); - if (durationMs >= 120) { - args.logger.info("lanes.listSnapshots.phase", { - phase, - durationMs, - laneCount: lanes.length, - includeConflictStatus: options.includeConflictStatus !== false, - includeRebaseSuggestions: options.includeRebaseSuggestions !== false, - includeAutoRebaseStatus: options.includeAutoRebaseStatus !== false, - }); - } - } - }; - - const [sessions, rebaseSuggestions, autoRebaseStatuses, stateSnapshots, batchAssessment] = await Promise.all([ - timePhase("sessions", () => enrichSessionsForLaneList(args)), - options.includeRebaseSuggestions === false - ? Promise.resolve([]) - : timePhase("rebase_suggestions", () => - Promise.resolve() - .then(() => args.rebaseSuggestionService?.listSuggestions({ lanes }) ?? []) - .catch(() => [])), - options.includeAutoRebaseStatus === false - ? Promise.resolve([]) - : timePhase("auto_rebase_statuses", () => - Promise.resolve() - .then(() => args.autoRebaseService?.listStatuses({ lanes }) ?? []) - .catch(() => [])), - timePhase("state_snapshots", () => - Promise.resolve() - .then(() => args.laneService.listStateSnapshots()) - .catch(() => [])), - options.includeConflictStatus === false - ? Promise.resolve(null) - : timePhase("conflict_assessment", () => - Promise.resolve() - .then(() => args.conflictService?.getBatchAssessment({ lanes }) ?? null) - .catch(() => null)), - ]); - const durationMs = Date.now() - startedAt; - if (durationMs >= 120) { - args.logger.info("lanes.listSnapshots.summary", { - durationMs, - laneCount: lanes.length, - includeConflictStatus: options.includeConflictStatus !== false, - includeRebaseSuggestions: options.includeRebaseSuggestions !== false, - includeAutoRebaseStatus: options.includeAutoRebaseStatus !== false, - phases: phases - .filter((phase) => phase.durationMs >= 10) - .sort((left, right) => right.durationMs - left.durationMs), - }); - } - - const rebaseByLaneId = new Map(rebaseSuggestions.map((entry) => [entry.laneId, entry] as const)); - const autoRebaseByLaneId = new Map(autoRebaseStatuses.map((entry) => [entry.laneId, entry] as const)); - const stateByLaneId = new Map(stateSnapshots.map((entry) => [entry.laneId, entry] as const)); - const conflictByLaneId = new Map((batchAssessment?.lanes ?? []).map((entry) => [entry.laneId, entry] as const)); - const devicesOpenByLaneId = buildLanePresenceByLaneId(args.syncService); - - return lanes.map((lane) => ({ - lane: decorateLaneSummaryWithPresence(lane, devicesOpenByLaneId), - runtime: summarizeLaneRuntime(lane.id, sessions), - rebaseSuggestion: rebaseByLaneId.get(lane.id) ?? null, - autoRebaseStatus: autoRebaseByLaneId.get(lane.id) ?? null, - conflictStatus: conflictByLaneId.get(lane.id) ?? null, - stateSnapshot: stateByLaneId.get(lane.id) ?? null, - adoptableAttached: lane.laneType === "attached" && lane.archivedAt == null, - })); -} - const AI_USAGE_FEATURE_KEYS: AiFeatureKey[] = [ "narratives", "conflict_proposals", @@ -1690,10 +1488,25 @@ async function buildLinearConnectionStatus( authMode: credentialStatus.authMode, oauthAvailable: credentialStatus.oauthConfigured, tokenExpiresAt: credentialStatus.tokenExpiresAt, - message: status.message, + message: formatLinearConnectionMessage(status.message, credentialStatus.authMode), }; } +function formatLinearConnectionMessage( + message: string | null | undefined, + authMode: "manual" | "oauth" | null | undefined, +): string | null { + const trimmed = message?.trim(); + if ( + authMode === "manual" + && trimmed + && /authentication required|not authenticated/i.test(trimmed) + ) { + return "Linear rejected the API key. Paste a Linear personal API key from linear.app/settings/api; it should start with lin_api_."; + } + return trimmed || null; +} + function summarizeProjectScan(result: OnboardingDetectionResult | null): Partial<{ projectSummary: string; criticalConventions: string[]; @@ -1915,6 +1728,8 @@ export function registerIpc({ resolveSyncService, runWithIpcWindow, getWindowSession, + bindRemoteProject, + localRuntimeConnectionPool, createWindow, closeWindow, switchProjectFromDialog, @@ -1927,7 +1742,9 @@ export function registerIpc({ getSyncService?: () => ReturnType | null | undefined; resolveSyncService?: () => Promise | null | undefined>; runWithIpcWindow?: (event: { sender: Electron.WebContents }, fn: () => T | Promise) => T | Promise; - getWindowSession?: (windowId: number | null) => { windowId: number | null; project: ProjectInfo | null }; + getWindowSession?: (windowId: number | null) => { windowId: number | null; project: ProjectInfo | null; binding: OpenProjectBinding | null }; + bindRemoteProject?: (windowId: number | null, binding: OpenProjectBinding & { kind: "remote" }) => void; + localRuntimeConnectionPool?: LocalRuntimeConnectionPool | null; createWindow?: (args?: { projectRoot?: string | null }) => Promise<{ windowId: number | null; project: ProjectInfo | null }>; closeWindow?: (windowId: number | null) => Promise<{ closed: boolean }>; switchProjectFromDialog: (selectedPath: string) => Promise; @@ -1947,6 +1764,9 @@ export function registerIpc({ if (getSyncService) return getSyncService() ?? null; return getCtx().syncService ?? null; }; + const allowLocalRuntimeFallback = + process.env.ADE_LOCAL_RUNTIME_FALLBACK === "1" || + process.env.ADE_DISABLE_LOCAL_RUNTIME_DAEMON === "1"; const requireSyncService = async (): Promise> => { const service = resolveSyncService @@ -1958,6 +1778,32 @@ export function registerIpc({ return service; }; + const getLocalRuntimeRootForEvent = (event: { sender: Electron.WebContents }): string | null => { + if (!getWindowSession) return null; + const windowId = BrowserWindow.fromWebContents(event.sender)?.id ?? null; + const session = getWindowSession(windowId); + const binding = session?.binding; + if (binding?.kind === "local") return binding.rootPath; + return session?.project?.rootPath ?? null; + }; + + const tryLocalRuntimeSync = async ( + event: { sender: Electron.WebContents }, + action: (pool: LocalRuntimeConnectionPool, rootPath: string) => Promise, + ): Promise => { + if (!localRuntimeConnectionPool) return null; + const rootPath = getLocalRuntimeRootForEvent(event); + if (!rootPath) return null; + try { + return await action(localRuntimeConnectionPool, rootPath); + } catch (error) { + if (!allowLocalRuntimeFallback) { + throw error; + } + return null; + } + }; + // Backend services use Error.code for known failures (e.g. // "github_not_connected", "remote_already_exists"). Electron IPC strips // custom properties from thrown errors, so we re-throw with the code @@ -3242,6 +3088,14 @@ export function registerIpc({ return { windowId, project: ctx.hasUserSelectedProject ? ctx.project : null, + binding: ctx.hasUserSelectedProject + ? { + kind: "local", + key: `local:${ctx.project.rootPath}`, + rootPath: ctx.project.rootPath, + displayName: ctx.project.displayName, + } + : null, }; }); @@ -3656,7 +3510,8 @@ export function registerIpc({ env: { nodeEnv: process.env.NODE_ENV, viteDevServerUrl: process.env.VITE_DEV_SERVER_URL - } + }, + localRuntime: localRuntimeConnectionPool?.getStatus() ?? null }; }); @@ -3821,7 +3676,10 @@ export function registerIpc({ ipcMain.handle(IPC.projectClearLocalData, async (_event, arg: ClearLocalAdeDataArgs = {}): Promise => { const ctx = getCtx(); - const adePaths = ctx.adeProjectService?.paths; + if (ctx.adeProjectService) { + return ctx.adeProjectService.clearLocalData(arg); + } + const clearedAt = nowIso(); const deletedPaths: string[] = []; @@ -3836,9 +3694,9 @@ export function registerIpc({ deletedPaths.push(resolved); }; - if (arg.packs) rmrf(adePaths?.artifactsDir ?? path.join(ctx.adeDir, "artifacts")); - if (arg.logs) rmrf(adePaths?.logsDir ?? path.join(ctx.adeDir, "transcripts", "logs")); - if (arg.transcripts) rmrf(adePaths?.transcriptsDir ?? path.join(ctx.adeDir, "transcripts")); + if (arg.packs) rmrf(path.join(ctx.adeDir, "artifacts")); + if (arg.logs) rmrf(path.join(ctx.adeDir, "transcripts", "logs")); + if (arg.transcripts) rmrf(path.join(ctx.adeDir, "transcripts")); return { deletedPaths, clearedAt }; }); @@ -3848,6 +3706,21 @@ export function registerIpc({ return (state.recentProjects ?? []).map(toRecentProjectSummary); }); + registerRuntimeBridge({ + appVersion: app.getVersion(), + bindRemoteProject, + getGitHubTokenForRemoteClone: () => { + try { + return getCtx().githubService.getTokenOrThrow(); + } catch { + return null; + } + }, + getWindowSession, + globalStatePath, + localRuntimeConnectionPool, + }); + ipcMain.handle( IPC.projectCreateLocal, async (_event, arg: CreateProjectInput): Promise => { @@ -4187,27 +4060,46 @@ export function registerIpc({ }, ); - ipcMain.handle(IPC.syncGetStatus, async (_event, arg?: SyncGetStatusArgs): Promise => { + ipcMain.handle(IPC.syncGetStatus, async (event, arg?: SyncGetStatusArgs): Promise => { + const runtimeStatus = await tryLocalRuntimeSync(event, (pool, rootPath) => + pool.syncStatusForRoot(rootPath, arg ?? {}) + ); + if (runtimeStatus) return runtimeStatus; return await (await requireSyncService()).getStatus({ includeTransferReadiness: arg?.includeTransferReadiness, forceTransferReadiness: arg?.forceTransferReadiness, }); }); - ipcMain.handle(IPC.syncRefreshDiscovery, async (): Promise => { + ipcMain.handle(IPC.syncRefreshDiscovery, async (event): Promise => { + const runtimeStatus = await tryLocalRuntimeSync(event, (pool, rootPath) => + pool.refreshSyncDiscoveryForRoot(rootPath) + ); + if (runtimeStatus) return runtimeStatus; return await (await requireSyncService()).refreshDiscovery(); }); - ipcMain.handle(IPC.syncListDevices, async (): Promise => { + ipcMain.handle(IPC.syncListDevices, async (event): Promise => { + const runtimeDevices = await tryLocalRuntimeSync(event, (pool, rootPath) => + pool.syncDevicesForRoot(rootPath) + ); + if (runtimeDevices) return runtimeDevices; return await (await requireSyncService()).listDevices(); }); ipcMain.handle( IPC.syncUpdateLocalDevice, async ( - _event, + event, arg: { name?: string; deviceType?: SyncPeerDeviceType }, ): Promise => { + const runtimeDevice = await tryLocalRuntimeSync(event, (pool, rootPath) => + pool.updateSyncLocalDeviceForRoot(rootPath, { + name: typeof arg?.name === "string" ? arg.name : undefined, + deviceType: arg?.deviceType, + }) + ); + if (runtimeDevice) return runtimeDevice; return await (await requireSyncService()).updateLocalDevice({ name: typeof arg?.name === "string" ? arg.name : undefined, deviceType: arg?.deviceType, @@ -4217,44 +4109,102 @@ export function registerIpc({ ipcMain.handle( IPC.syncConnectToBrain, - async (_event, arg: SyncDesktopConnectionDraft): Promise => { + async (event, arg: SyncDesktopConnectionDraft): Promise => { + const runtimeStatus = await tryLocalRuntimeSync(event, (pool, rootPath) => + pool.callSyncForRoot( + rootPath, + "sync.connectToBrain", + (arg ?? {}) as unknown as Record, + ) + ); + if (runtimeStatus) return runtimeStatus; return await (await requireSyncService()).connectToBrain(arg); }, ); - ipcMain.handle(IPC.syncDisconnectFromBrain, async (): Promise => { + ipcMain.handle(IPC.syncDisconnectFromBrain, async (event): Promise => { + const runtimeStatus = await tryLocalRuntimeSync(event, (pool, rootPath) => + pool.callSyncForRoot(rootPath, "sync.disconnectFromBrain") + ); + if (runtimeStatus) return runtimeStatus; return await (await requireSyncService()).disconnectFromBrain(); }); - ipcMain.handle(IPC.syncForgetDevice, async (_event, arg: { deviceId: string }): Promise => { - return await (await requireSyncService()).forgetDevice(typeof arg?.deviceId === "string" ? arg.deviceId : ""); + ipcMain.handle(IPC.syncForgetDevice, async (event, arg: { deviceId: string }): Promise => { + const deviceId = typeof arg?.deviceId === "string" ? arg.deviceId : ""; + const runtimeStatus = await tryLocalRuntimeSync(event, (pool, rootPath) => + pool.forgetSyncDeviceForRoot(rootPath, deviceId) + ); + if (runtimeStatus) return runtimeStatus; + return await (await requireSyncService()).forgetDevice(deviceId); }); - ipcMain.handle(IPC.syncGetTransferReadiness, async (): Promise => { + ipcMain.handle(IPC.syncGetTransferReadiness, async (event): Promise => { + const runtimeReadiness = await tryLocalRuntimeSync(event, (pool, rootPath) => + pool.callSyncForRoot(rootPath, "sync.getTransferReadiness") + ); + if (runtimeReadiness) return runtimeReadiness; return await (await requireSyncService()).getTransferReadiness(); }); - ipcMain.handle(IPC.syncTransferBrainToLocal, async (): Promise => { + ipcMain.handle(IPC.syncTransferBrainToLocal, async (event): Promise => { + const runtimeStatus = await tryLocalRuntimeSync(event, (pool, rootPath) => + pool.callSyncForRoot(rootPath, "sync.transferBrainToLocal") + ); + if (runtimeStatus) return runtimeStatus; return await (await requireSyncService()).transferBrainToLocal(); }); - ipcMain.handle(IPC.syncGetPin, async (): Promise<{ pin: string | null }> => { + ipcMain.handle(IPC.syncGetPin, async (event): Promise<{ pin: string | null }> => { + const runtimePin = await tryLocalRuntimeSync(event, (pool, rootPath) => + pool.syncPinForRoot(rootPath) + ); + if (runtimePin) return runtimePin; return { pin: (await requireSyncService()).getPin() }; }); - ipcMain.handle(IPC.syncSetPin, async (_event, pin: string): Promise => { - return await (await requireSyncService()).setPin(typeof pin === "string" ? pin : ""); + ipcMain.handle(IPC.syncSetPin, async (event, pin: string): Promise => { + const normalizedPin = typeof pin === "string" ? pin : ""; + const runtimeStatus = await tryLocalRuntimeSync(event, (pool, rootPath) => + pool.setSyncPinForRoot(rootPath, normalizedPin) + ); + if (runtimeStatus) return runtimeStatus; + return await (await requireSyncService()).setPin(normalizedPin); + }); + + ipcMain.handle(IPC.syncGeneratePin, async (event): Promise => { + const runtimeStatus = await tryLocalRuntimeSync(event, (pool, rootPath) => + pool.generateSyncPinForRoot(rootPath) + ); + if (runtimeStatus) return runtimeStatus; + return await (await requireSyncService()).generatePin(); }); - ipcMain.handle(IPC.syncClearPin, async (): Promise => { + ipcMain.handle(IPC.syncClearPin, async (event): Promise => { + const runtimeStatus = await tryLocalRuntimeSync(event, (pool, rootPath) => + pool.clearSyncPinForRoot(rootPath) + ); + if (runtimeStatus) return runtimeStatus; return await (await requireSyncService()).clearPin(); }); ipcMain.handle( IPC.syncSetActiveLanePresence, - async (_event, arg: { laneIds?: string[] | null }): Promise => { + async (event, arg: { laneIds?: string[] | null }): Promise => { + const laneIds = Array.isArray(arg?.laneIds) ? arg.laneIds : []; + const rootPath = getLocalRuntimeRootForEvent(event); + if (localRuntimeConnectionPool && rootPath) { + try { + await localRuntimeConnectionPool.callSyncForRoot(rootPath, "sync.setActiveLanePresence", { laneIds }); + return; + } catch (error) { + if (!allowLocalRuntimeFallback) { + throw error; + } + } + } await (await requireSyncService()).setActiveLanePresence( - Array.isArray(arg?.laneIds) ? arg.laneIds : [], + laneIds, ); }, ); @@ -6585,41 +6535,8 @@ export function registerIpc({ }); ipcMain.handle(IPC.computerUseReadArtifactPreview, async (_event, arg: { uri: string }): Promise => { - const ctx = getCtx(); - const projectRoot = ctx.project.rootPath; - const layout = resolveAdeLayout(projectRoot); - // Only allow files under artifactsDir — consistent with the ade-artifact:// protocol - // handler in main.ts which validates exclusively against currentArtifactsDir. - const allowedRoots = [layout.artifactsDir]; - - const filePath = resolveRendererSuppliedPath(arg.uri, projectRoot); - // Canonicalize and verify the resolved path is inside an allowed artifact root. - const canonical = path.normalize(path.resolve(filePath)); - const inside = allowedRoots.some((root) => { - try { - resolvePathWithinRoot(root, canonical); - return true; - } catch { - return false; - } - }); - if (!inside) return null; - - // Cap preview size to 10 MB to avoid loading arbitrarily large files into memory. - const PREVIEW_SIZE_CAP = 10 * 1024 * 1024; - try { - const stat = await fs.promises.stat(canonical); - if (!stat.isFile()) return null; - if (stat.size > PREVIEW_SIZE_CAP) return null; - const buf = await fs.promises.readFile(canonical); - const ext = path.extname(canonical).replace(/^\./, "").toLowerCase(); - const mimeMap: Record = { png: "image/png", jpg: "image/jpeg", jpeg: "image/jpeg", webp: "image/webp", gif: "image/gif", bmp: "image/bmp", svg: "image/svg+xml" }; - const mime = mimeMap[ext]; - if (!mime) return null; - return `data:${mime};base64,${buf.toString("base64")}`; - } catch { - return null; - } + const ctx = ensureComputerUseBroker(); + return ctx.computerUseArtifactBrokerService.readArtifactPreview(arg); }); ipcMain.handle(IPC.iosSimulatorGetStatus, async () => ensureIosSimulator().getStatus()); @@ -8679,27 +8596,47 @@ export function registerIpc({ return ctx.operationService.list(arg); }); - ipcMain.handle(IPC.historyExportOperations, async (event, arg: ExportHistoryArgs): Promise => { + type HistoryExportIpcArgs = ExportHistoryArgs & { + rows?: OperationRecord[]; + project?: { + rootPath?: string | null; + displayName?: string | null; + } | null; + }; + + ipcMain.handle(IPC.historyExportOperations, async (event, arg: HistoryExportIpcArgs): Promise => { const ctx = getCtx(); const format: "csv" | "json" = arg?.format === "csv" ? "csv" : "json"; const laneId = typeof arg?.laneId === "string" && arg.laneId.trim().length > 0 ? arg.laneId.trim() : undefined; const kind = typeof arg?.kind === "string" && arg.kind.trim().length > 0 ? arg.kind.trim() : undefined; const status = arg?.status; - const rows = ctx.operationService.list({ - laneId, - kind, - limit: typeof arg?.limit === "number" ? arg.limit : 1000 - }); + const rows = Array.isArray(arg?.rows) + ? arg.rows + : ctx.operationService.list({ + laneId, + kind, + limit: typeof arg?.limit === "number" ? arg.limit : 1000 + }); const filteredRows = status && status !== "all" ? rows.filter((row) => row.status === status) : rows; const exportedAt = nowIso(); - const projectSlug = ctx.project.displayName.replace(/[^a-zA-Z0-9._-]+/g, "_"); + const exportProject = arg?.project; + const projectDisplayName = + typeof exportProject?.displayName === "string" && exportProject.displayName.trim() + ? exportProject.displayName.trim() + : ctx.project.displayName; + const projectRoot = + typeof exportProject?.rootPath === "string" && exportProject.rootPath.trim() + ? exportProject.rootPath.trim() + : ctx.project.rootPath; + const projectSlug = projectDisplayName.replace(/[^a-zA-Z0-9._-]+/g, "_"); const dateStamp = exportedAt.slice(0, 10); - const defaultPath = path.join(ctx.project.rootPath, `ade-history-${projectSlug}-${dateStamp}.${format}`); + const defaultDir = fs.existsSync(projectRoot) ? projectRoot : app.getPath("documents"); + const defaultPath = path.join(defaultDir, `ade-history-${projectSlug}-${dateStamp}.${format}`); const win = BrowserWindow.fromWebContents(event.sender) ?? undefined; const result = win @@ -8732,8 +8669,8 @@ export function registerIpc({ { exportedAt, project: { - rootPath: ctx.project.rootPath, - displayName: ctx.project.displayName + rootPath: projectRoot, + displayName: projectDisplayName }, filters: { laneId: laneId ?? null, @@ -9307,36 +9244,6 @@ export function registerIpc({ return ctx.ctoStateService.updateIdentity(arg.patch ?? {}); }); - ipcMain.handle(IPC.ctoGetOpenclawState, async (): Promise => { - const ctx = getCtx(); - if (!ctx.openclawBridgeService) throw new Error("OpenClaw bridge service is not available."); - return ctx.openclawBridgeService.getState(); - }); - - ipcMain.handle(IPC.ctoUpdateOpenclawConfig, async (_event, arg: CtoUpdateOpenclawConfigArgs): Promise => { - const ctx = getCtx(); - if (!ctx.openclawBridgeService) throw new Error("OpenClaw bridge service is not available."); - return await ctx.openclawBridgeService.updateConfig(arg.patch ?? {}); - }); - - ipcMain.handle(IPC.ctoTestOpenclawConnection, async (_event, _arg: CtoTestOpenclawConnectionArgs = {}): Promise => { - const ctx = getCtx(); - if (!ctx.openclawBridgeService) throw new Error("OpenClaw bridge service is not available."); - return await ctx.openclawBridgeService.testConnection(); - }); - - ipcMain.handle(IPC.ctoListOpenclawMessages, async (_event, arg: CtoListOpenclawMessagesArgs = {}): Promise => { - const ctx = getCtx(); - if (!ctx.openclawBridgeService) throw new Error("OpenClaw bridge service is not available."); - return ctx.openclawBridgeService.listMessages(arg.limit ?? 40); - }); - - ipcMain.handle(IPC.ctoSendOpenclawMessage, async (_event, arg: CtoSendOpenclawMessageArgs): Promise => { - const ctx = getCtx(); - if (!ctx.openclawBridgeService) throw new Error("OpenClaw bridge service is not available."); - return await ctx.openclawBridgeService.sendMessage(arg); - }); - // -- W3: Heartbeat & Activation -- ipcMain.handle(IPC.ctoTriggerAgentWakeup, async (_event, arg: CtoTriggerAgentWakeupArgs): Promise => { diff --git a/apps/desktop/src/main/services/ipc/runtimeBridge.test.ts b/apps/desktop/src/main/services/ipc/runtimeBridge.test.ts new file mode 100644 index 000000000..881115a02 --- /dev/null +++ b/apps/desktop/src/main/services/ipc/runtimeBridge.test.ts @@ -0,0 +1,375 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { IPC } from "../../../shared/ipc"; +import type { + OpenProjectBinding, + RemoteRuntimeTarget, +} from "../../../shared/types"; + +const ipcHandlers = vi.hoisted( + () => new Map unknown>(), +); +const browserWindowFromWebContents = vi.hoisted(() => vi.fn()); +const browserWindowGetAllWindows = vi.hoisted(() => vi.fn(() => [])); +const remoteRegistryGetMock = vi.hoisted(() => vi.fn()); +const remoteRegistryListMock = vi.hoisted(() => vi.fn(() => [])); +const remoteRegistrySaveMock = vi.hoisted(() => vi.fn()); +const remoteRegistryRemoveMock = vi.hoisted(() => vi.fn()); +const remoteConnectMock = vi.hoisted(() => vi.fn()); +const remoteProjectsForTargetMock = vi.hoisted(() => vi.fn()); +const remoteCallActionForTargetMock = vi.hoisted(() => vi.fn()); +const remoteCallSyncForTargetMock = vi.hoisted(() => vi.fn()); +const remoteCallMachineForTargetMock = vi.hoisted(() => vi.fn()); +const remoteDisconnectMock = vi.hoisted(() => vi.fn()); + +vi.mock("electron", () => ({ + BrowserWindow: { + fromWebContents: browserWindowFromWebContents, + getAllWindows: browserWindowGetAllWindows, + }, + ipcMain: { + handle: vi.fn((channel: string, handler: (...args: any[]) => unknown) => { + ipcHandlers.set(channel, handler); + }), + }, +})); + +vi.mock("../remoteRuntime/remoteTargetRegistry", () => ({ + RemoteTargetRegistry: vi.fn().mockImplementation(() => ({ + get: remoteRegistryGetMock, + list: remoteRegistryListMock, + save: remoteRegistrySaveMock, + remove: remoteRegistryRemoveMock, + })), +})); + +vi.mock("../remoteRuntime/remoteConnectionPool", () => ({ + RemoteConnectionPool: vi.fn().mockImplementation(() => ({ + connect: remoteConnectMock, + projectsForTarget: remoteProjectsForTargetMock, + callActionForTarget: remoteCallActionForTargetMock, + callSyncForTarget: remoteCallSyncForTargetMock, + callMachineForTarget: remoteCallMachineForTargetMock, + disconnect: remoteDisconnectMock, + })), +})); + +vi.mock("../remoteRuntime/runtimeDiscovery", () => ({ + discoverLanRuntimes: vi.fn(() => []), +})); + +vi.mock("../git/git", () => ({ + runGit: vi.fn(), +})); + +import { registerRuntimeBridge } from "./runtimeBridge"; + +const target: RemoteRuntimeTarget = { + id: "target-1", + name: "Remote", + hostname: "remote.example.test", + sshUser: "ade", + port: 22, + sshKeyPath: null, + lastSeenArch: null, + runtimeBinaryVersion: null, + lastConnectedAt: null, +}; + +function sender(id = 42) { + return { + id, + isDestroyed: vi.fn(() => false), + once: vi.fn(), + send: vi.fn(), + } as any; +} + +function eventForSender(nextSender = sender()) { + return { sender: nextSender } as any; +} + +function localBinding(rootPath = "/repo"): OpenProjectBinding { + return { + kind: "local", + key: `local:${rootPath}`, + rootPath, + displayName: "Repo", + }; +} + +describe("registerRuntimeBridge", () => { + beforeEach(() => { + ipcHandlers.clear(); + browserWindowFromWebContents.mockReset(); + browserWindowGetAllWindows.mockReset().mockReturnValue([]); + remoteRegistryGetMock.mockReset(); + remoteRegistryListMock.mockReset().mockReturnValue([]); + remoteRegistrySaveMock.mockReset(); + remoteRegistryRemoveMock.mockReset(); + remoteConnectMock.mockReset().mockResolvedValue({ + target, + arch: "darwin-arm64", + version: null, + projects: [], + }); + remoteProjectsForTargetMock.mockReset(); + remoteCallActionForTargetMock.mockReset(); + remoteCallSyncForTargetMock.mockReset(); + remoteCallMachineForTargetMock.mockReset(); + remoteDisconnectMock.mockReset(); + browserWindowFromWebContents.mockReturnValue({ id: 7 }); + }); + + it("forwards local project runtime actions with renderer client metadata for file watches", async () => { + const localRuntimeConnectionPool = { + callActionForRoot: vi.fn(async () => ({ + ok: true, + domain: "file", + action: "watchWorkspace", + result: { ok: true }, + statusHints: {}, + })), + }; + registerRuntimeBridge({ + appVersion: "1.0.0", + globalStatePath: "/tmp/ade-state.json", + localRuntimeConnectionPool: localRuntimeConnectionPool as any, + getWindowSession: () => ({ + windowId: 7, + project: null, + binding: localBinding("/repo"), + }), + }); + + await expect( + ipcHandlers.get(IPC.localRuntimeCallAction)?.( + eventForSender(sender(101)), + { + request: { + domain: "file", + action: "watchWorkspace", + args: { workspaceId: "main" }, + }, + }, + ), + ).resolves.toMatchObject({ result: { ok: true } }); + + expect(localRuntimeConnectionPool.callActionForRoot).toHaveBeenCalledWith( + "/repo", + { + domain: "file", + action: "watchWorkspace", + args: { + workspaceId: "main", + __adeRuntimeClientId: 101, + }, + }, + ); + }); + + it("forwards remote project runtime actions through the selected target and project", async () => { + remoteRegistryGetMock.mockReturnValue(target); + remoteConnectMock.mockResolvedValue({ + target, + arch: "linux-x64", + version: "1.0.0", + projects: [], + }); + remoteCallActionForTargetMock.mockResolvedValue({ + ok: true, + domain: "pty", + action: "create", + result: { ptyId: "pty-1" }, + statusHints: {}, + }); + registerRuntimeBridge({ + appVersion: "1.0.0", + globalStatePath: "/tmp/ade-state.json", + }); + + await expect( + ipcHandlers.get(IPC.remoteRuntimeCallAction)?.( + eventForSender(sender(202)), + { + id: "target-1", + projectId: "project-1", + request: { + domain: "pty", + action: "create", + args: { startupCommand: "codex login" }, + }, + }, + ), + ).resolves.toMatchObject({ result: { ptyId: "pty-1" } }); + + expect(remoteConnectMock).toHaveBeenCalledWith(target); + expect(remoteCallActionForTargetMock).toHaveBeenCalledWith( + target, + "project-1", + { + domain: "pty", + action: "create", + args: { startupCommand: "codex login" }, + }, + ); + }); + + it("rejects unexposed sync methods before calling local or remote runtimes", async () => { + const localRuntimeConnectionPool = { + callSyncForRoot: vi.fn(), + }; + registerRuntimeBridge({ + appVersion: "1.0.0", + globalStatePath: "/tmp/ade-state.json", + localRuntimeConnectionPool: localRuntimeConnectionPool as any, + getWindowSession: () => ({ + windowId: 7, + project: null, + binding: localBinding("/repo"), + }), + }); + remoteRegistryGetMock.mockReturnValue(target); + + await expect( + ipcHandlers.get(IPC.localRuntimeCallSync)?.(eventForSender(), { + method: "git.status", + params: {}, + }), + ).rejects.toThrow(/not exposed/i); + await expect( + ipcHandlers.get(IPC.remoteRuntimeCallSync)?.(eventForSender(), { + id: "target-1", + projectId: "project-1", + method: "git.status", + params: {}, + }), + ).rejects.toThrow(/not exposed/i); + + expect(localRuntimeConnectionPool.callSyncForRoot).not.toHaveBeenCalled(); + expect(remoteCallSyncForTargetMock).not.toHaveBeenCalled(); + }); + + it("forwards allowlisted sync methods with project scope", async () => { + remoteRegistryGetMock.mockReturnValue(target); + remoteCallSyncForTargetMock.mockResolvedValue({ connectedPeers: [] }); + registerRuntimeBridge({ + appVersion: "1.0.0", + globalStatePath: "/tmp/ade-state.json", + }); + + await expect( + ipcHandlers.get(IPC.remoteRuntimeCallSync)?.(eventForSender(), { + id: "target-1", + projectId: "project-1", + method: "sync.getStatus", + params: { includeTransferReadiness: true }, + }), + ).resolves.toEqual({ connectedPeers: [] }); + + expect(remoteCallSyncForTargetMock).toHaveBeenCalledWith( + target, + "project-1", + "sync.getStatus", + { + includeTransferReadiness: true, + }, + ); + }); + + it("opens a remote project after refreshing a stale connect project list", async () => { + const project = { + projectId: "project-1", + rootPath: "/srv/ade", + displayName: "ADE", + addedAt: 1, + lastOpenedAt: 2, + gitOriginUrl: "git@github.com:example/ade.git", + }; + const bindRemoteProject = vi.fn(); + remoteRegistryGetMock.mockReturnValue(target); + remoteConnectMock.mockResolvedValue({ + target, + arch: "linux-x64", + version: "1.0.0", + projects: [], + }); + remoteProjectsForTargetMock.mockResolvedValue([project]); + registerRuntimeBridge({ + appVersion: "1.0.0", + globalStatePath: "/tmp/ade-state.json", + bindRemoteProject, + }); + + await expect( + ipcHandlers.get(IPC.remoteRuntimeOpenProject)?.( + eventForSender(sender(303)), + { + id: " target-1 ", + projectId: " project-1 ", + }, + ), + ).resolves.toEqual({ + kind: "remote", + key: "remote:target-1:project-1", + targetId: "target-1", + runtimeName: "Remote", + projectId: "project-1", + rootPath: "/srv/ade", + displayName: "ADE", + }); + + expect(remoteConnectMock).toHaveBeenCalledWith(target); + expect(remoteProjectsForTargetMock).toHaveBeenCalledWith(target); + expect(bindRemoteProject).toHaveBeenCalledWith(7, { + kind: "remote", + key: "remote:target-1:project-1", + targetId: "target-1", + runtimeName: "Remote", + projectId: "project-1", + rootPath: "/srv/ade", + displayName: "ADE", + }); + }); + + it("forwards a one-shot local GitHub auth header for remote clones", async () => { + remoteRegistryGetMock.mockReturnValue(target); + remoteCallMachineForTargetMock.mockResolvedValue({ + projectId: "project-cloned", + rootPath: "/srv/ADE", + displayName: "ADE", + addedAt: 1, + lastOpenedAt: 1, + gitOriginUrl: "https://github.com/example/ADE.git", + }); + registerRuntimeBridge({ + appVersion: "1.0.0", + globalStatePath: "/tmp/ade-state.json", + getGitHubTokenForRemoteClone: () => "ghp_local_secret", + }); + + await expect( + ipcHandlers.get(IPC.remoteRuntimeCloneProject)?.(eventForSender(), { + id: "target-1", + input: { + url: "https://github.com/example/ADE.git", + parentDir: "/srv", + }, + }), + ).resolves.toMatchObject({ rootPath: "/srv/ADE" }); + + const expectedBasic = Buffer.from( + "x-access-token:ghp_local_secret", + "utf8", + ).toString("base64"); + expect(remoteCallMachineForTargetMock).toHaveBeenCalledWith( + target, + "projects.clone", + { + url: "https://github.com/example/ADE.git", + parentDir: "/srv", + githubAuthHeader: `basic ${expectedBasic}`, + }, + { retryOnConnectionError: false }, + ); + }); +}); diff --git a/apps/desktop/src/main/services/ipc/runtimeBridge.ts b/apps/desktop/src/main/services/ipc/runtimeBridge.ts new file mode 100644 index 000000000..816bb3101 --- /dev/null +++ b/apps/desktop/src/main/services/ipc/runtimeBridge.ts @@ -0,0 +1,857 @@ +import { BrowserWindow, ipcMain, type WebContents } from "electron"; +import fs from "node:fs"; +import path from "node:path"; +import { IPC } from "../../../shared/ipc"; +import type { + CloneProjectInput, + CreateProjectInput, + ListMyGitHubReposInput, + ListMyGitHubReposResult, + OpenProjectBinding, + ProjectInfo, + ProjectBrowseInput, + ProjectBrowseResult, + ProjectDetail, + RemoteRuntimeConnectionSnapshot, + RemoteRuntimeActionRequest, + RemoteRuntimeActionResult, + RemoteRuntimeBufferedEvent, + RemoteRuntimeConnectResult, + RemoteRuntimeDiscoveredMachine, + RemoteRuntimeEventNotificationPayload, + RemoteRuntimeLocalWorkCheckResult, + RemoteRuntimeProjectRecord, + RemoteRuntimeProjectWorkSummary, + RemoteRuntimeStreamEventsRequest, + RemoteRuntimeStreamEventsResult, + RemoteRuntimeTarget, + RemoteRuntimeTargetInput, +} from "../../../shared/types"; +import type { LocalRuntimeConnectionPool } from "../localRuntime/localRuntimeConnectionPool"; +import { RemoteConnectionPool } from "../remoteRuntime/remoteConnectionPool"; +import { RemoteConnectionService } from "../remoteRuntime/remoteConnectionService"; +import { discoverLanRuntimes } from "../remoteRuntime/runtimeDiscovery"; +import { RemoteTargetRegistry } from "../remoteRuntime/remoteTargetRegistry"; +import { runGit } from "../git/git"; +import { getProjectWorkSummary } from "../projects/projectDetailService"; +import { toRecentProjectSummary } from "../projects/recentProjectSummary"; +import { readGlobalState } from "../state/globalState"; + +type RuntimeBridgeArgs = { + appVersion: string; + globalStatePath: string; + getWindowSession?: (windowId: number | null) => { + windowId: number | null; + project: ProjectInfo | null; + binding: OpenProjectBinding | null; + }; + bindRemoteProject?: ( + windowId: number | null, + binding: OpenProjectBinding & { kind: "remote" }, + ) => void; + localRuntimeConnectionPool?: LocalRuntimeConnectionPool | null; + getGitHubTokenForRemoteClone?: (() => string | null) | null; +}; + +const RUNTIME_ACTION_CLIENT_ID_FIELD = "__adeRuntimeClientId"; +const REMOTE_RUNTIME_SYNC_METHODS = new Set([ + "sync.getStatus", + "sync.refreshDiscovery", + "sync.listDevices", + "sync.updateLocalDevice", + "sync.connectToBrain", + "sync.disconnectFromBrain", + "sync.forgetDevice", + "sync.getTransferReadiness", + "sync.transferBrainToLocal", + "sync.getPin", + "sync.setPin", + "sync.generatePin", + "sync.clearPin", + "sync.setActiveLanePresence", +]); + +type RuntimeEventWindowSubscription = { + bindingKey: string; + cleanup: (() => void) | null; +}; + +type RuntimeEventSubscribe = ( + onEvent: (event: RemoteRuntimeBufferedEvent) => void, + onEnded: () => void, +) => Promise<() => void>; + +function isObjectRecord(value: unknown): value is Record { + return value !== null && typeof value === "object" && !Array.isArray(value); +} + +function isRemoteRuntimeSyncMethod(value: string): boolean { + return REMOTE_RUNTIME_SYNC_METHODS.has(value); +} + +function withRuntimeActionClientMetadata( + request: RemoteRuntimeActionRequest, + senderId: number, +): RemoteRuntimeActionRequest { + if ( + request.domain !== "file" || + (request.action !== "watchWorkspace" && + request.action !== "stopWatching") || + !Number.isInteger(senderId) || + senderId <= 0 + ) { + return request; + } + + const args = isObjectRecord(request.args) ? request.args : {}; + return { + ...request, + args: { + ...args, + [RUNTIME_ACTION_CLIENT_ID_FIELD]: senderId, + }, + }; +} + +function normalizeGitRemoteForComparison( + value: string | null | undefined, +): string | null { + const trimmed = typeof value === "string" ? value.trim() : ""; + if (!trimmed) return null; + const withoutGitSuffix = trimmed.replace(/\.git$/i, ""); + if (!withoutGitSuffix.includes("://")) { + const scpLike = /^(?:[^@/:]+@)?([^:]+):(.+)$/.exec(withoutGitSuffix); + if (scpLike?.[1] && scpLike[2]) { + return `${scpLike[1].toLowerCase()}/${scpLike[2].replace(/^\/+/, "")}`.toLowerCase(); + } + } + try { + const parsed = new URL(withoutGitSuffix); + return `${parsed.hostname.toLowerCase()}/${parsed.pathname.replace(/^\/+/, "")}`.toLowerCase(); + } catch { + return withoutGitSuffix.toLowerCase(); + } +} + +async function inspectLocalWorkForRemoteOrigin(args: { + rootPath: string; + displayName: string; + remoteOriginKey: string; +}): Promise { + if (!fs.existsSync(args.rootPath)) return null; + const origin = await runGit(["remote", "get-url", "origin"], { + cwd: args.rootPath, + timeoutMs: 8_000, + }); + if (origin.exitCode !== 0) return null; + const originUrl = origin.stdout.trim(); + if (normalizeGitRemoteForComparison(originUrl) !== args.remoteOriginKey) + return null; + const workSummary = await getProjectWorkSummary(args.rootPath).catch( + () => null, + ); + const dirtyCount = workSummary?.dirtyFileCount ?? 0; + if (dirtyCount <= 0) return null; + return { + rootPath: args.rootPath, + displayName: args.displayName, + gitOriginUrl: originUrl, + dirtyCount, + workSummary, + }; +} + +async function getRemoteProjectWorkSummary(args: { + targetId: string; + rootPath: string | null; + remoteConnectionService: RemoteConnectionService; +}): Promise { + if (!args.targetId || !args.rootPath) return null; + return await args.remoteConnectionService + .getProjectWorkSummary(args.targetId, args.rootPath) + .catch(() => null); +} + +function createGitHubAuthHeader(token: string | null | undefined): string | null { + const trimmed = token?.trim(); + if (!trimmed) return null; + const basic = Buffer.from(`x-access-token:${trimmed}`, "utf8").toString("base64"); + return `basic ${basic}`; +} + +export function registerRuntimeBridge({ + appVersion, + bindRemoteProject, + getGitHubTokenForRemoteClone, + getWindowSession, + globalStatePath, + localRuntimeConnectionPool, +}: RuntimeBridgeArgs): void { + const remoteTargetRegistry = new RemoteTargetRegistry(); + const remoteConnectionPool = new RemoteConnectionPool( + remoteTargetRegistry, + appVersion, + ); + const remoteConnectionService = new RemoteConnectionService( + remoteTargetRegistry, + remoteConnectionPool, + ); + const runtimeEventSubscriptions = new Map< + number, + RuntimeEventWindowSubscription + >(); + const runtimeEventWatchedSenders = new Set(); + + remoteConnectionService.onSnapshotChanged((snapshot) => { + for (const window of BrowserWindow.getAllWindows()) { + if (window.webContents.isDestroyed()) continue; + window.webContents.send( + IPC.remoteRuntimeConnectionSnapshotChanged, + snapshot, + ); + } + }); + const autoconnectTimer = setTimeout(() => { + remoteConnectionService.startAutoconnect(); + }, 0); + autoconnectTimer.unref?.(); + + const cleanupRuntimeEventSubscription = (senderId: number): void => { + const existing = runtimeEventSubscriptions.get(senderId); + runtimeEventSubscriptions.delete(senderId); + try { + existing?.cleanup?.(); + } catch { + // Best-effort subscription cleanup. + } + }; + + const watchRuntimeEventSender = (sender: WebContents): void => { + if (runtimeEventWatchedSenders.has(sender.id)) return; + runtimeEventWatchedSenders.add(sender.id); + sender.once("destroyed", () => { + runtimeEventWatchedSenders.delete(sender.id); + cleanupRuntimeEventSubscription(sender.id); + }); + }; + + const sendRuntimeEvent = ( + sender: WebContents, + bindingKey: string, + event: RemoteRuntimeBufferedEvent, + ): void => { + const existing = runtimeEventSubscriptions.get(sender.id); + if (!existing || existing.bindingKey !== bindingKey || sender.isDestroyed()) + return; + const payload: RemoteRuntimeEventNotificationPayload = { + bindingKey, + event, + }; + try { + sender.send(IPC.runtimeEvent, payload); + } catch { + // Renderer may have gone away between the destroyed check and send. + } + }; + + const ensureRuntimeEventSubscription = ( + sender: WebContents, + bindingKey: string, + subscribe: RuntimeEventSubscribe, + ): void => { + const existing = runtimeEventSubscriptions.get(sender.id); + if (existing?.bindingKey === bindingKey) return; + cleanupRuntimeEventSubscription(sender.id); + watchRuntimeEventSender(sender); + runtimeEventSubscriptions.set(sender.id, { bindingKey, cleanup: null }); + const onEnded = () => { + const current = runtimeEventSubscriptions.get(sender.id); + if (current?.bindingKey === bindingKey) { + runtimeEventSubscriptions.delete(sender.id); + } + }; + void subscribe( + (event) => sendRuntimeEvent(sender, bindingKey, event), + onEnded, + ) + .then((cleanup) => { + const current = runtimeEventSubscriptions.get(sender.id); + if ( + !current || + current.bindingKey !== bindingKey || + sender.isDestroyed() + ) { + cleanup(); + return; + } + current.cleanup = cleanup; + }) + .catch((error) => { + const current = runtimeEventSubscriptions.get(sender.id); + if (current?.bindingKey === bindingKey && !current.cleanup) { + runtimeEventSubscriptions.delete(sender.id); + } + console.warn("Runtime event subscription failed", error); + }); + }; + + ipcMain.handle( + IPC.remoteRuntimeListTargets, + async (): Promise => { + return remoteConnectionService.listTargets(); + }, + ); + + ipcMain.handle( + IPC.remoteRuntimeGetConnectionSnapshot, + async (): Promise => { + return remoteConnectionService.snapshot(); + }, + ); + + ipcMain.handle( + IPC.remoteRuntimeListDiscoveredMachines, + async (): Promise => { + return discoverLanRuntimes(); + }, + ); + + ipcMain.handle( + IPC.remoteRuntimeSaveTarget, + async ( + _event, + arg: RemoteRuntimeTargetInput, + ): Promise => { + return remoteConnectionService.saveTarget(arg); + }, + ); + + ipcMain.handle( + IPC.remoteRuntimeRemoveTarget, + async (_event, arg: { id: string }): Promise<{ removed: boolean }> => { + const id = typeof arg?.id === "string" ? arg.id.trim() : ""; + if (!id) return { removed: false }; + return { removed: remoteConnectionService.removeTarget(id) }; + }, + ); + + ipcMain.handle( + IPC.remoteRuntimeConnect, + async ( + _event, + arg: { id: string }, + ): Promise => { + const id = typeof arg?.id === "string" ? arg.id.trim() : ""; + return await remoteConnectionService.connect(id); + }, + ); + + ipcMain.handle( + IPC.remoteRuntimeListProjects, + async ( + _event, + arg: { id: string }, + ): Promise => { + const id = typeof arg?.id === "string" ? arg.id.trim() : ""; + if (!id) return []; + return await remoteConnectionService.projects(id); + }, + ); + + ipcMain.handle( + IPC.remoteRuntimeAddProject, + async ( + _event, + arg: { id: string; rootPath: string }, + ): Promise => { + const id = typeof arg?.id === "string" ? arg.id.trim() : ""; + const rootPath = + typeof arg?.rootPath === "string" ? arg.rootPath.trim() : ""; + if (!rootPath) throw new Error("Remote project path is required."); + return await remoteConnectionService.addProject(id, rootPath); + }, + ); + + ipcMain.handle( + IPC.remoteRuntimeBrowseDirectories, + async ( + _event, + arg: { id: string; args?: ProjectBrowseInput }, + ): Promise => { + const id = typeof arg?.id === "string" ? arg.id.trim() : ""; + return await remoteConnectionService.browseDirectories( + id, + arg?.args ?? {}, + ); + }, + ); + + ipcMain.handle( + IPC.remoteRuntimeGetProjectDetail, + async ( + _event, + arg: { id: string; rootPath: string }, + ): Promise => { + const id = typeof arg?.id === "string" ? arg.id.trim() : ""; + const rootPath = + typeof arg?.rootPath === "string" ? arg.rootPath.trim() : ""; + if (!rootPath) throw new Error("Remote project path is required."); + return await remoteConnectionService.getProjectDetail(id, rootPath); + }, + ); + + ipcMain.handle( + IPC.remoteRuntimeGetDefaultParentDir, + async (_event, arg: { id: string }): Promise => { + const id = typeof arg?.id === "string" ? arg.id.trim() : ""; + return await remoteConnectionService.getDefaultParentDir(id); + }, + ); + + ipcMain.handle( + IPC.remoteRuntimeCreateProject, + async ( + _event, + arg: { id: string; input?: CreateProjectInput }, + ): Promise => { + const id = typeof arg?.id === "string" ? arg.id.trim() : ""; + return await remoteConnectionService.createProject( + id, + arg?.input ?? { name: "", parentDir: "" }, + ); + }, + ); + + ipcMain.handle( + IPC.remoteRuntimeCloneProject, + async ( + _event, + arg: { id: string; input?: CloneProjectInput }, + ): Promise => { + const id = typeof arg?.id === "string" ? arg.id.trim() : ""; + const input = arg?.input ?? { url: "", parentDir: "" }; + let githubAuthHeader: string | null = null; + try { + githubAuthHeader = createGitHubAuthHeader( + getGitHubTokenForRemoteClone?.() ?? null, + ); + } catch { + githubAuthHeader = null; + } + return await remoteConnectionService.cloneProject( + id, + githubAuthHeader && !input.githubAuthHeader + ? { ...input, githubAuthHeader } + : input, + ); + }, + ); + + ipcMain.handle( + IPC.remoteRuntimeListMyGitHubRepos, + async ( + _event, + arg: { id: string; input?: ListMyGitHubReposInput }, + ): Promise => { + const id = typeof arg?.id === "string" ? arg.id.trim() : ""; + return await remoteConnectionService.listMyGitHubRepos( + id, + arg?.input ?? {}, + ); + }, + ); + + ipcMain.handle( + IPC.remoteRuntimeOpenProject, + async ( + event, + arg: { id: string; projectId: string }, + ): Promise => { + const id = typeof arg?.id === "string" ? arg.id.trim() : ""; + const projectId = + typeof arg?.projectId === "string" ? arg.projectId.trim() : ""; + const target = id ? remoteConnectionService.getTarget(id) : null; + if (!target) throw new Error("Remote target was not found."); + if (!projectId) throw new Error("Remote project is required."); + + const connection = await remoteConnectionService.connect(target.id); + let project = + connection.projects.find( + (candidate) => candidate.projectId === projectId, + ) ?? null; + if (!project) { + const projects = await remoteConnectionService.projects(target.id); + project = + projects.find((candidate) => candidate.projectId === projectId) ?? + null; + } + if (!project) + throw new Error("Remote project was not found on this runtime."); + + const binding: OpenProjectBinding & { kind: "remote" } = { + kind: "remote", + key: `remote:${target.id}:${project.projectId}`, + targetId: target.id, + runtimeName: target.name, + projectId: project.projectId, + rootPath: project.rootPath, + displayName: project.displayName || path.basename(project.rootPath), + }; + bindRemoteProject?.( + BrowserWindow.fromWebContents(event.sender)?.id ?? null, + binding, + ); + return binding; + }, + ); + + ipcMain.handle( + IPC.remoteRuntimeCallAction, + async ( + event, + arg: { + id: string; + projectId: string; + request: RemoteRuntimeActionRequest; + }, + ): Promise => { + const id = typeof arg?.id === "string" ? arg.id.trim() : ""; + const projectId = + typeof arg?.projectId === "string" ? arg.projectId.trim() : ""; + const request = + arg?.request && + typeof arg.request === "object" && + !Array.isArray(arg.request) + ? arg.request + : null; + const target = id ? remoteConnectionService.getTarget(id) : null; + const domain = + typeof request?.domain === "string" ? request.domain.trim() : ""; + const action = + typeof request?.action === "string" ? request.action.trim() : ""; + if (!target) throw new Error("Remote target was not found."); + if (!projectId) throw new Error("Remote project is required."); + if (!domain || !action) + throw new Error("Remote action domain and action are required."); + await remoteConnectionService.connect(target.id); + const actionRequest = withRuntimeActionClientMetadata( + { ...request!, domain, action }, + event.sender.id, + ); + return await remoteConnectionPool.callActionForTarget( + target, + projectId, + actionRequest, + ); + }, + ); + + ipcMain.handle( + IPC.remoteRuntimeCallSync, + async ( + _event, + arg: { + id: string; + projectId: string; + method: string; + params?: Record; + }, + ): Promise => { + const id = typeof arg?.id === "string" ? arg.id.trim() : ""; + const projectId = + typeof arg?.projectId === "string" ? arg.projectId.trim() : ""; + const method = typeof arg?.method === "string" ? arg.method.trim() : ""; + const params = isObjectRecord(arg?.params) ? arg.params : {}; + const target = id ? remoteConnectionService.getTarget(id) : null; + if (!target) throw new Error("Remote target was not found."); + if (!projectId) throw new Error("Remote project is required."); + if (!isRemoteRuntimeSyncMethod(method)) + throw new Error("Remote sync method is not exposed."); + await remoteConnectionService.connect(target.id); + return await remoteConnectionPool.callSyncForTarget( + target, + projectId, + method, + params, + ); + }, + ); + + ipcMain.handle( + IPC.localRuntimeCallAction, + async ( + event, + arg: { request: RemoteRuntimeActionRequest }, + ): Promise => { + if (!localRuntimeConnectionPool) { + throw new Error("Local runtime daemon is not available."); + } + const request = + arg?.request && + typeof arg.request === "object" && + !Array.isArray(arg.request) + ? arg.request + : null; + const domain = + typeof request?.domain === "string" ? request.domain.trim() : ""; + const action = + typeof request?.action === "string" ? request.action.trim() : ""; + if (!domain || !action) + throw new Error("Local runtime action domain and action are required."); + + const windowId = BrowserWindow.fromWebContents(event.sender)?.id ?? null; + const session = getWindowSession ? getWindowSession(windowId) : null; + const binding = session?.binding; + const rootPath = + binding?.kind === "local" + ? binding.rootPath + : (session?.project?.rootPath ?? null); + if (!rootPath) { + throw new Error( + "Local runtime project is not available for this window.", + ); + } + const actionRequest = withRuntimeActionClientMetadata( + { ...request!, domain, action }, + event.sender.id, + ); + return await localRuntimeConnectionPool.callActionForRoot( + rootPath, + actionRequest, + ); + }, + ); + + ipcMain.handle( + IPC.localRuntimeCallSync, + async ( + event, + arg: { method: string; params?: Record }, + ): Promise => { + if (!localRuntimeConnectionPool) { + throw new Error("Local runtime daemon is not available."); + } + const method = typeof arg?.method === "string" ? arg.method.trim() : ""; + const params = isObjectRecord(arg?.params) ? arg.params : {}; + if (!isRemoteRuntimeSyncMethod(method)) + throw new Error("Local sync method is not exposed."); + + const windowId = BrowserWindow.fromWebContents(event.sender)?.id ?? null; + const session = getWindowSession ? getWindowSession(windowId) : null; + const binding = session?.binding; + const rootPath = + binding?.kind === "local" + ? binding.rootPath + : (session?.project?.rootPath ?? null); + if (!rootPath) { + throw new Error( + "Local runtime project is not available for this window.", + ); + } + return await localRuntimeConnectionPool.callSyncForRoot( + rootPath, + method, + params, + ); + }, + ); + + ipcMain.handle( + IPC.localRuntimeStreamEvents, + async ( + event, + arg: { request?: RemoteRuntimeStreamEventsRequest }, + ): Promise => { + if (!localRuntimeConnectionPool) { + throw new Error("Local runtime daemon is not available."); + } + + const windowId = BrowserWindow.fromWebContents(event.sender)?.id ?? null; + const session = getWindowSession ? getWindowSession(windowId) : null; + const binding = session?.binding; + const rootPath = + binding?.kind === "local" + ? binding.rootPath + : (session?.project?.rootPath ?? null); + if (!rootPath) { + return { events: [], nextCursor: 0, hasMore: false }; + } + if (binding?.kind === "local") { + ensureRuntimeEventSubscription( + event.sender, + binding.key, + (onEvent, onEnded) => + localRuntimeConnectionPool.subscribeEventsForRoot( + rootPath, + { + cursor: arg?.request?.cursor, + limit: arg?.request?.limit, + category: "runtime", + }, + onEvent, + onEnded, + ), + ); + } + return await localRuntimeConnectionPool.streamEventsForRoot( + rootPath, + arg?.request ?? {}, + ); + }, + ); + + ipcMain.handle( + IPC.remoteRuntimeStreamEvents, + async ( + event, + arg: { + id: string; + projectId: string; + request?: RemoteRuntimeStreamEventsRequest; + }, + ): Promise => { + const id = typeof arg?.id === "string" ? arg.id.trim() : ""; + const projectId = + typeof arg?.projectId === "string" ? arg.projectId.trim() : ""; + if (!id) throw new Error("Remote target id is required."); + if (!projectId) throw new Error("Remote project id is required."); + const target = remoteConnectionService.getTarget(id); + if (!target) throw new Error("Remote target was not found."); + await remoteConnectionService.connect(target.id); + ensureRuntimeEventSubscription( + event.sender, + `remote:${target.id}:${projectId}`, + (onEvent, onEnded) => + remoteConnectionPool.subscribeEventsForTarget( + target, + projectId, + { + cursor: arg?.request?.cursor, + limit: arg?.request?.limit, + category: "runtime", + }, + onEvent, + onEnded, + ), + ); + return remoteConnectionPool.streamEventsForTarget( + target, + projectId, + arg?.request ?? {}, + ); + }, + ); + + ipcMain.handle( + IPC.remoteRuntimeCheckLocalWork, + async ( + _event, + arg: { id?: string; project?: RemoteRuntimeProjectRecord }, + ): Promise => { + const targetId = typeof arg?.id === "string" ? arg.id.trim() : ""; + const project = + arg?.project && + typeof arg.project === "object" && + !Array.isArray(arg.project) + ? arg.project + : null; + const remoteProjectId = + typeof project?.projectId === "string" ? project.projectId : ""; + const remoteDisplayName = + typeof project?.displayName === "string" && project.displayName.trim() + ? project.displayName.trim() + : typeof project?.rootPath === "string" + ? path.basename(project.rootPath) + : "remote project"; + const remoteGitOriginUrl = + typeof project?.gitOriginUrl === "string" && project.gitOriginUrl.trim() + ? project.gitOriginUrl.trim() + : null; + const remoteWorkSummary = await getRemoteProjectWorkSummary({ + targetId, + rootPath: + typeof project?.rootPath === "string" && project.rootPath.trim() + ? project.rootPath.trim() + : null, + remoteConnectionService, + }); + const remoteOriginKey = + normalizeGitRemoteForComparison(remoteGitOriginUrl); + if (!remoteOriginKey) { + return { + remoteProjectId, + remoteDisplayName, + remoteGitOriginUrl, + remoteWorkSummary, + matches: [], + hasDirtyWork: false, + }; + } + + const state = readGlobalState(globalStatePath); + const recents = (state.recentProjects ?? []) + .slice(0, 100) + .map((entry) => ({ + rootPath: entry.rootPath, + displayName: toRecentProjectSummary(entry).displayName, + })); + const localRuntimeProjects = localRuntimeConnectionPool + ? await localRuntimeConnectionPool + .projects() + .catch(() => [] as RemoteRuntimeProjectRecord[]) + : []; + const entriesByRoot = new Map< + string, + { rootPath: string; displayName: string } + >(); + for (const entry of recents) { + if (!entry.rootPath) continue; + entriesByRoot.set(path.resolve(entry.rootPath), entry); + } + for (const project of localRuntimeProjects) { + if (!project.rootPath) continue; + const rootPath = path.resolve(project.rootPath); + if (entriesByRoot.has(rootPath)) continue; + entriesByRoot.set(rootPath, { + rootPath: project.rootPath, + displayName: project.displayName || path.basename(project.rootPath), + }); + } + const matches = ( + await Promise.all( + [...entriesByRoot.values()].map((entry) => + inspectLocalWorkForRemoteOrigin({ + rootPath: entry.rootPath, + displayName: entry.displayName, + remoteOriginKey, + }), + ), + ) + ).filter( + ( + entry, + ): entry is RemoteRuntimeLocalWorkCheckResult["matches"][number] => + entry != null, + ); + + return { + remoteProjectId, + remoteDisplayName, + remoteGitOriginUrl, + remoteWorkSummary, + matches, + hasDirtyWork: matches.length > 0, + }; + }, + ); + + ipcMain.handle( + IPC.remoteRuntimeDisconnect, + async (_event, arg: { id: string }): Promise<{ disconnected: boolean }> => { + const id = typeof arg?.id === "string" ? arg.id.trim() : ""; + if (!id) return { disconnected: false }; + remoteConnectionService.disconnect(id); + return { disconnected: true }; + }, + ); +} diff --git a/apps/desktop/src/main/services/lanes/laneListSnapshotService.ts b/apps/desktop/src/main/services/lanes/laneListSnapshotService.ts new file mode 100644 index 000000000..5b3d2dd0b --- /dev/null +++ b/apps/desktop/src/main/services/lanes/laneListSnapshotService.ts @@ -0,0 +1,259 @@ +import type { + AgentChatSessionSummary, + DeviceMarker, + LaneListSnapshot, + LaneRuntimeSummary, + LaneStateSnapshotSummary, + LaneSummary, + TerminalSessionSummary, +} from "../../../shared/types"; +import type { Logger } from "../logging/logger"; + +type LanePresenceHost = { + getLanePresenceSnapshot?: () => Array<{ laneId: string; devicesOpen: DeviceMarker[] }>; +}; + +type LanePresenceSyncService = { + getHostService?: () => LanePresenceHost | null | undefined; +}; + +type LaneListSnapshotServices = { + laneService: { + listStateSnapshots: () => Promise | LaneStateSnapshotSummary[]; + }; + sessionService: { + list: (args: Record) => TerminalSessionSummary[]; + }; + ptyService: { + enrichSessions: (rows: T[]) => T[]; + }; + agentChatService?: { + listSessions: ( + laneId?: string, + options?: { includeIdentity?: boolean }, + ) => Promise | AgentChatSessionSummary[]; + } | null; + rebaseSuggestionService?: { + listSuggestions: (args?: { lanes?: LaneSummary[] }) => + | Promise>> + | Array>; + } | null; + autoRebaseService?: { + listStatuses: (args?: { lanes?: LaneSummary[] }) => + | Promise>> + | Array>; + } | null; + conflictService?: { + getBatchAssessment: (args: { lanes: LaneSummary[] }) => + | Promise<{ lanes?: Array> } | null> + | { lanes?: Array> } | null; + } | null; + syncService?: LanePresenceSyncService | null; + logger: Pick; +}; + +export type LaneListSnapshotOptions = { + includeConflictStatus?: boolean; + includeRebaseSuggestions?: boolean; + includeAutoRebaseStatus?: boolean; +}; + +function isChatToolType(toolType: string | null | undefined): boolean { + if (!toolType) return false; + const t = toolType.trim().toLowerCase(); + return t === "cursor" || t.endsWith("-chat"); +} + +function sessionStatusBucket(args: { + status: string; + lastOutputPreview: string | null | undefined; + runtimeState?: string | null; +}): "running" | "awaiting-input" | "ended" { + if (args.status === "running") { + if (args.runtimeState === "waiting-input") return "awaiting-input"; + const preview = args.lastOutputPreview ?? ""; + if (/\b(?:waiting|awaiting)\b.{0,28}\b(?:input|confirmation|response|prompt)\b/i.test(preview)) { + return "awaiting-input"; + } + if (/\((?:y\/n|yes\/no)\)/i.test(preview) || /\[(?:y\/n|yes\/no)\]/i.test(preview)) { + return "awaiting-input"; + } + return "running"; + } + return "ended"; +} + +function summarizeLaneRuntime( + laneId: string, + sessions: Array<{ + laneId: string; + status: string; + lastOutputPreview: string | null; + runtimeState?: string | null; + }>, +): LaneRuntimeSummary { + let runningCount = 0; + let awaitingInputCount = 0; + let endedCount = 0; + let sessionCount = 0; + + for (const session of sessions) { + if (session.laneId !== laneId) continue; + sessionCount += 1; + const bucket = sessionStatusBucket(session); + if (bucket === "running") runningCount += 1; + else if (bucket === "awaiting-input") awaitingInputCount += 1; + else endedCount += 1; + } + + let bucket: LaneRuntimeSummary["bucket"]; + if (awaitingInputCount > 0) bucket = "awaiting-input"; + else if (runningCount > 0) bucket = "running"; + else if (endedCount > 0) bucket = "ended"; + else bucket = "none"; + + return { + bucket, + runningCount, + awaitingInputCount, + endedCount, + sessionCount, + }; +} + +export function buildLanePresenceByLaneId(syncService: LanePresenceSyncService | null | undefined): Map { + const hostService = syncService?.getHostService?.() ?? null; + const snapshot = hostService?.getLanePresenceSnapshot?.() ?? []; + return new Map(snapshot.map((entry) => [entry.laneId, entry.devicesOpen] as const)); +} + +function decorateLaneSummaryWithPresence( + lane: LaneSummary, + devicesOpenByLaneId: Map, +): LaneSummary { + const devicesOpen = devicesOpenByLaneId.get(lane.id) ?? []; + return { ...lane, devicesOpen: devicesOpen.length > 0 ? devicesOpen : undefined }; +} + +export function decorateLaneSummariesWithPresence( + lanes: LaneSummary[], + devicesOpenByLaneId: Map, +): LaneSummary[] { + return lanes.map((lane) => decorateLaneSummaryWithPresence(lane, devicesOpenByLaneId)); +} + +async function enrichSessionsForLaneList( + args: Pick, +): Promise { + let sessions = args.ptyService.enrichSessions(args.sessionService.list({})); + let allChats: AgentChatSessionSummary[] = []; + try { + allChats = await (args.agentChatService?.listSessions(undefined, { includeIdentity: true }) ?? []); + } catch { + allChats = []; + } + const identitySessionIds = new Set( + allChats + .filter((chat) => Boolean(chat.identityKey)) + .map((chat) => chat.sessionId), + ); + if (identitySessionIds.size > 0) { + sessions = sessions.filter((session) => !identitySessionIds.has(session.id)); + } + const chats = allChats.filter((chat) => !chat.identityKey); + if (chats.length === 0) return sessions; + const chatSummaryBySessionId = new Map(chats.map((chat) => [chat.sessionId, chat] as const)); + return sessions.map((session) => { + if (!isChatToolType(session.toolType)) return session; + if (session.status !== "running") return session; + const chat = chatSummaryBySessionId.get(session.id); + if (!chat) return session; + if (chat.awaitingInput) return { ...session, runtimeState: "waiting-input" as const, chatIdleSinceAt: null }; + if (chat.status === "active") return { ...session, runtimeState: "running" as const, chatIdleSinceAt: null }; + if (chat.status === "idle") return { ...session, runtimeState: "idle" as const, chatIdleSinceAt: chat.idleSinceAt ?? null }; + return session; + }); +} + +export async function buildLaneListSnapshots( + args: LaneListSnapshotServices, + lanes: LaneSummary[], + options: LaneListSnapshotOptions = {}, +): Promise { + const startedAt = Date.now(); + const phases: Array<{ phase: string; durationMs: number }> = []; + const timePhase = async (phase: string, work: () => Promise | T): Promise => { + const phaseStartedAt = Date.now(); + try { + return await work(); + } finally { + const durationMs = Date.now() - phaseStartedAt; + phases.push({ phase, durationMs }); + if (durationMs >= 120) { + args.logger.info("lanes.listSnapshots.phase", { + phase, + durationMs, + laneCount: lanes.length, + includeConflictStatus: options.includeConflictStatus !== false, + includeRebaseSuggestions: options.includeRebaseSuggestions !== false, + includeAutoRebaseStatus: options.includeAutoRebaseStatus !== false, + }); + } + } + }; + + const [sessions, rebaseSuggestions, autoRebaseStatuses, stateSnapshots, batchAssessment] = await Promise.all([ + timePhase("sessions", () => enrichSessionsForLaneList(args)), + options.includeRebaseSuggestions === false + ? Promise.resolve([]) + : timePhase("rebase_suggestions", () => + Promise.resolve() + .then(() => args.rebaseSuggestionService?.listSuggestions({ lanes }) ?? []) + .catch(() => [])), + options.includeAutoRebaseStatus === false + ? Promise.resolve([]) + : timePhase("auto_rebase_statuses", () => + Promise.resolve() + .then(() => args.autoRebaseService?.listStatuses({ lanes }) ?? []) + .catch(() => [])), + timePhase("state_snapshots", () => + Promise.resolve() + .then(() => args.laneService.listStateSnapshots()) + .catch(() => [])), + options.includeConflictStatus === false + ? Promise.resolve(null) + : timePhase("conflict_assessment", () => + Promise.resolve() + .then(() => args.conflictService?.getBatchAssessment({ lanes }) ?? null) + .catch(() => null)), + ]); + const durationMs = Date.now() - startedAt; + if (durationMs >= 120) { + args.logger.info("lanes.listSnapshots.summary", { + durationMs, + laneCount: lanes.length, + includeConflictStatus: options.includeConflictStatus !== false, + includeRebaseSuggestions: options.includeRebaseSuggestions !== false, + includeAutoRebaseStatus: options.includeAutoRebaseStatus !== false, + phases: phases + .filter((phase) => phase.durationMs >= 10) + .sort((left, right) => right.durationMs - left.durationMs), + }); + } + + const rebaseByLaneId = new Map(rebaseSuggestions.map((entry) => [entry.laneId, entry] as const)); + const autoRebaseByLaneId = new Map(autoRebaseStatuses.map((entry) => [entry.laneId, entry] as const)); + const stateByLaneId = new Map(stateSnapshots.map((entry) => [entry.laneId, entry] as const)); + const conflictByLaneId = new Map((batchAssessment?.lanes ?? []).map((entry) => [entry.laneId, entry] as const)); + const devicesOpenByLaneId = buildLanePresenceByLaneId(args.syncService); + + return lanes.map((lane) => ({ + lane: decorateLaneSummaryWithPresence(lane, devicesOpenByLaneId), + runtime: summarizeLaneRuntime(lane.id, sessions), + rebaseSuggestion: rebaseByLaneId.get(lane.id) ?? null, + autoRebaseStatus: autoRebaseByLaneId.get(lane.id) ?? null, + conflictStatus: conflictByLaneId.get(lane.id) ?? null, + stateSnapshot: stateByLaneId.get(lane.id) ?? null, + adoptableAttached: lane.laneType === "attached" && lane.archivedAt == null, + })); +} diff --git a/apps/desktop/src/main/services/lanes/laneService.ts b/apps/desktop/src/main/services/lanes/laneService.ts index bcbe7cd01..2ad7cc23a 100644 --- a/apps/desktop/src/main/services/lanes/laneService.ts +++ b/apps/desktop/src/main/services/lanes/laneService.ts @@ -977,11 +977,7 @@ export function createLaneService({ : ""; const suggested = explicitBranch || linearBranch; const isCustomBranch = suggested.length > 0; - const branchSource: "explicit" | "linear" | "fallback" = explicitBranch - ? "explicit" - : linearBranch - ? "linear" - : "fallback"; + const isLinearBranch = !explicitBranch && linearBranch.length > 0; const slug = slugify(args.name); const fallback = `ade/${slug}-${args.laneId.slice(0, 8)}`; const branchRef = suggested @@ -1004,7 +1000,7 @@ export function createLaneService({ throw new Error(`Branch "${branchRef}" already exists locally.`); } - const remoteCollisionMessage = branchSource === "linear" + const remoteCollisionMessage = isLinearBranch ? `Branch "origin/${branchRef}" already exists on the remote. Detach the Linear issue or choose one whose branch name is unused.` : `Branch "origin/${branchRef}" already exists on the remote. Choose a different branch name.`; diff --git a/apps/desktop/src/main/services/localRuntime/localRuntimeConnectionPool.test.ts b/apps/desktop/src/main/services/localRuntime/localRuntimeConnectionPool.test.ts new file mode 100644 index 000000000..7ab2270e6 --- /dev/null +++ b/apps/desktop/src/main/services/localRuntime/localRuntimeConnectionPool.test.ts @@ -0,0 +1,720 @@ +import { spawn, type ChildProcess } from "node:child_process"; +import fs from "node:fs"; +import net from "node:net"; +import os from "node:os"; +import path from "node:path"; +import { describe, expect, it, vi } from "vitest"; + +vi.mock("electron", () => ({ + app: { + getAppPath: () => "/Applications/ADE.app/Contents/Resources/app.asar", + }, +})); + +import { + buildLocalRuntimeNodeEnv, + buildLocalRuntimeNodePath, + buildLocalRuntimeServeArgs, + computeLocalRuntimeBuildHash, + LocalRuntimeConnectionPool, + parseRuntimeServiceManagerOutput, +} from "./localRuntimeConnectionPool"; + +type RawPendingRequest = { + resolve: (value: unknown) => void; + reject: (error: Error) => void; +}; + +class RawRuntimeSocketClient { + private nextId = 1; + private buffer = ""; + private readonly pending = new Map(); + + private constructor(private readonly socket: net.Socket) { + socket.on("data", (chunk) => this.handleData(chunk.toString("utf8"))); + socket.on("error", (error) => this.rejectAll(error)); + socket.on("close", () => this.rejectAll(new Error("ADE service socket closed."))); + } + + static connect(socketPath: string): Promise { + return new Promise((resolve, reject) => { + const socket = net.createConnection(socketPath); + const cleanup = () => { + socket.off("connect", onConnect); + socket.off("error", onError); + }; + const onConnect = () => { + cleanup(); + resolve(new RawRuntimeSocketClient(socket)); + }; + const onError = (error: Error) => { + cleanup(); + socket.destroy(); + reject(error); + }; + socket.once("connect", onConnect); + socket.once("error", onError); + }); + } + + request(method: string, params?: unknown): Promise { + const id = this.nextId++; + const payload = { + jsonrpc: "2.0", + id, + method, + ...(params !== undefined ? { params } : {}), + }; + return new Promise((resolve, reject) => { + this.pending.set(id, { resolve, reject }); + this.socket.write(`${JSON.stringify(payload)}\n`, "utf8", (error) => { + if (!error) return; + this.pending.delete(id); + reject(error); + }); + }); + } + + close(): void { + this.socket.destroy(); + } + + private handleData(chunk: string): void { + this.buffer += chunk; + while (true) { + const newline = this.buffer.indexOf("\n"); + if (newline < 0) return; + const line = this.buffer.slice(0, newline).trim(); + this.buffer = this.buffer.slice(newline + 1); + if (!line) continue; + const parsed = JSON.parse(line) as { id?: number; result?: unknown; error?: { message?: string } }; + if (typeof parsed.id !== "number") continue; + const pending = this.pending.get(parsed.id); + if (!pending) continue; + this.pending.delete(parsed.id); + if (parsed.error) pending.reject(new Error(parsed.error.message ?? "ADE service request failed.")); + else pending.resolve(parsed.result); + } + } + + private rejectAll(error: Error): void { + for (const [id, pending] of this.pending) { + this.pending.delete(id); + pending.reject(error); + } + } +} + +function withTsxNodeOptions(value: string | undefined, loaderPath: string): string { + const existing = value?.trim(); + return existing ? `${existing} --import ${loaderPath}` : `--import ${loaderPath}`; +} + +async function waitForRuntimeSocket(socketPath: string, timeoutMs = 10_000): Promise { + const startedAt = Date.now(); + let lastError: Error | null = null; + while (Date.now() - startedAt < timeoutMs) { + try { + const client = await RawRuntimeSocketClient.connect(socketPath); + client.close(); + return; + } catch (error) { + lastError = error instanceof Error ? error : new Error(String(error)); + await new Promise((resolve) => setTimeout(resolve, 100)); + } + } + throw lastError ?? new Error(`ADE service socket did not become available: ${socketPath}`); +} + +function startServeProcess(args: { + cliPath: string; + cwd: string; + env: NodeJS.ProcessEnv; + socketPath: string; +}): ChildProcess { + return spawn(process.execPath, [args.cliPath, "serve", "--socket", args.socketPath, "--no-sync"], { + cwd: args.cwd, + env: args.env, + stdio: ["ignore", "ignore", "ignore"], + }); +} + +async function shutdownRuntime(socketPath: string): Promise { + let client: RawRuntimeSocketClient | null = null; + try { + client = await RawRuntimeSocketClient.connect(socketPath); + await client.request("ade/initialize", { + protocolVersion: "2025-06-18", + clientName: "local-runtime-test-cleanup", + identity: { role: "external", callerId: "local-runtime-test-cleanup" }, + }); + await client.request("shutdown").catch((error: unknown) => { + const message = error instanceof Error ? error.message : String(error); + if (!message.includes("socket closed")) throw error; + }); + } catch { + // Best-effort cleanup; a failed test should not mask the original assertion. + } finally { + client?.close(); + } +} + +describe("local runtime connection pool", () => { + it("starts fallback runtimes with sync enabled by default", () => { + const args = buildLocalRuntimeServeArgs("/opt/ade/cli.cjs", "/tmp/ade.sock"); + + expect(args).toEqual(["/opt/ade/cli.cjs", "serve", "--socket", "/tmp/ade.sock"]); + expect(args).not.toContain("--no-sync"); + }); + + it("keeps explicit no-sync support for narrow test or diagnostic launches", () => { + const args = buildLocalRuntimeServeArgs("/opt/ade/cli.cjs", "/tmp/ade.sock", { disableSync: true }); + + expect(args).toContain("--no-sync"); + }); + + it("builds packaged runtime NODE_PATH for macOS universal app layouts", () => { + const nodePath = buildLocalRuntimeNodePath({ + resourcesPath: "/Applications/ADE.app/Contents/Resources", + platform: "darwin", + arch: "arm64", + existingNodePath: "/custom/node_modules", + }); + + expect(nodePath?.split(path.delimiter)).toEqual([ + "/Applications/ADE.app/Contents/Resources/app-arm64.asar.unpacked/node_modules", + "/Applications/ADE.app/Contents/Resources/app.asar.unpacked/node_modules", + "/Applications/ADE.app/Contents/Resources/app-arm64.asar/node_modules", + "/Applications/ADE.app/Contents/Resources/app.asar/node_modules", + "/custom/node_modules", + ]); + }); + + it("uses the packaged runtime module path when spawning the service", () => { + const env = buildLocalRuntimeNodeEnv( + "1.2.3", + { NODE_PATH: "/custom/node_modules" }, + { resourcesPath: "/Applications/ADE.app/Contents/Resources", platform: "darwin", arch: "x64" }, + ); + + expect(env.ADE_DEFAULT_ROLE).toBe("cto"); + expect(env.ELECTRON_RUN_AS_NODE).toBe("1"); + expect(env.ADE_CLI_VERSION).toBe("1.2.3"); + expect(env.NODE_PATH).toContain("app-x64.asar.unpacked"); + expect(env.NODE_PATH).toContain("app.asar.unpacked"); + expect(env.NODE_PATH).toContain("/custom/node_modules"); + }); + + it("reports local ADE service install and connection status", () => { + const pool = new LocalRuntimeConnectionPool("1.2.3", { + debug: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + } as never, { + queryServiceStatus: () => ({ + ok: true, + serviceName: "com.ade.runtime", + action: "status", + installed: true, + running: true, + path: "/tmp/com.ade.runtime.plist", + message: "ADE service is running.", + }), + }); + + expect(pool.getStatus()).toMatchObject({ + connectionState: "idle", + serviceInstall: { + state: "not_attempted", + attempted: false, + }, + serviceHealth: { + state: "running", + installed: true, + running: true, + path: "/tmp/com.ade.runtime.plist", + }, + }); + + pool.noteServiceInstallSkipped("Disabled for this test."); + (pool as unknown as { activeClient: unknown }).activeClient = {}; + + expect(pool.getStatus()).toMatchObject({ + connectionState: "connected", + serviceInstall: { + state: "skipped", + attempted: false, + message: "Disabled for this test.", + }, + serviceHealth: { + state: "running", + }, + }); + }); + + it("parses structured service manager output for settings status", () => { + expect(parseRuntimeServiceManagerOutput(JSON.stringify({ + ok: false, + serviceName: "com.ade.runtime", + action: "status", + installed: true, + running: false, + path: "/Users/admin/Library/LaunchAgents/com.ade.runtime.plist", + message: "launchctl failed", + }))).toEqual({ + ok: false, + path: "/Users/admin/Library/LaunchAgents/com.ade.runtime.plist", + message: "launchctl failed", + }); + + expect(parseRuntimeServiceManagerOutput("not json")).toBeNull(); + }); + + it("disposes the desktop client without shutting down the ADE service", async () => { + const pool = new LocalRuntimeConnectionPool("1.2.3", { + debug: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + } as never, { disableSync: true }); + const client = { + call: vi.fn(), + close: vi.fn(), + }; + (pool as unknown as { connection: Promise; activeClient: unknown }).connection = Promise.resolve({ + client, + child: null, + socketPath: "/tmp/ade.sock", + }); + (pool as unknown as { activeClient: unknown }).activeClient = client; + + pool.dispose(); + await new Promise((resolve) => setImmediate(resolve)); + + expect(client.close).toHaveBeenCalledTimes(1); + expect(client.call).not.toHaveBeenCalledWith("shutdown", expect.anything()); + expect(pool.getStatus().connectionState).toBe("idle"); + }); + + it("reattaches to a machine daemon after the desktop-side client disconnects", async () => { + const adeCliRoot = path.resolve(process.cwd(), "../ade-cli"); + const cliPath = path.join(adeCliRoot, "src", "cli.ts"); + const tsxLoaderPath = path.join(adeCliRoot, "node_modules", "tsx", "dist", "loader.mjs"); + expect(fs.existsSync(cliPath)).toBe(true); + expect(fs.existsSync(tsxLoaderPath)).toBe(true); + + const adeHome = fs.mkdtempSync(path.join(os.tmpdir(), "ade-local-runtime-")); + const projectRoot = fs.mkdtempSync(path.join(os.tmpdir(), "ade-local-runtime-project-")); + const socketPath = path.join(adeHome, "sock", "ade.sock"); + const originalEnv = { + ADE_CLI_JS: process.env.ADE_CLI_JS, + ADE_HOME: process.env.ADE_HOME, + ADE_RUNTIME_SOCKET_PATH: process.env.ADE_RUNTIME_SOCKET_PATH, + NODE_OPTIONS: process.env.NODE_OPTIONS, + }; + + const logger = { + debug: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + }; + let firstPool: LocalRuntimeConnectionPool | null = null; + let secondPool: LocalRuntimeConnectionPool | null = null; + + try { + process.env.ADE_CLI_JS = cliPath; + process.env.ADE_HOME = adeHome; + process.env.ADE_RUNTIME_SOCKET_PATH = socketPath; + process.env.NODE_OPTIONS = withTsxNodeOptions(originalEnv.NODE_OPTIONS, tsxLoaderPath); + + firstPool = new LocalRuntimeConnectionPool("1.2.3", logger as never, { disableSync: true }); + const registered = await firstPool.ensureProject(projectRoot); + firstPool.dispose(); + + secondPool = new LocalRuntimeConnectionPool("1.2.3", logger as never, { disableSync: true }); + const projects = await secondPool.projects(); + + expect(registered.rootPath).toBe(projectRoot); + expect(projects).toContainEqual(expect.objectContaining({ + projectId: registered.projectId, + rootPath: projectRoot, + })); + } finally { + firstPool?.dispose(); + secondPool?.dispose(); + await shutdownRuntime(socketPath); + if (originalEnv.ADE_CLI_JS === undefined) delete process.env.ADE_CLI_JS; + else process.env.ADE_CLI_JS = originalEnv.ADE_CLI_JS; + if (originalEnv.ADE_HOME === undefined) delete process.env.ADE_HOME; + else process.env.ADE_HOME = originalEnv.ADE_HOME; + if (originalEnv.ADE_RUNTIME_SOCKET_PATH === undefined) delete process.env.ADE_RUNTIME_SOCKET_PATH; + else process.env.ADE_RUNTIME_SOCKET_PATH = originalEnv.ADE_RUNTIME_SOCKET_PATH; + if (originalEnv.NODE_OPTIONS === undefined) delete process.env.NODE_OPTIONS; + else process.env.NODE_OPTIONS = originalEnv.NODE_OPTIONS; + } + }, 45_000); + + it("restarts a stale local daemon before attaching", async () => { + const adeCliRoot = path.resolve(process.cwd(), "../ade-cli"); + const cliPath = path.join(adeCliRoot, "src", "cli.ts"); + const tsxLoaderPath = path.join(adeCliRoot, "node_modules", "tsx", "dist", "loader.mjs"); + expect(fs.existsSync(cliPath)).toBe(true); + expect(fs.existsSync(tsxLoaderPath)).toBe(true); + + const adeHome = fs.mkdtempSync(path.join(os.tmpdir(), "ade-local-runtime-version-")); + const projectRoot = fs.mkdtempSync(path.join(os.tmpdir(), "ade-local-runtime-version-project-")); + const socketPath = path.join(adeHome, "sock", "ade.sock"); + const originalEnv = { + ADE_CLI_JS: process.env.ADE_CLI_JS, + ADE_HOME: process.env.ADE_HOME, + ADE_RUNTIME_SOCKET_PATH: process.env.ADE_RUNTIME_SOCKET_PATH, + NODE_OPTIONS: process.env.NODE_OPTIONS, + }; + const baseEnv = { + ...process.env, + ADE_HOME: adeHome, + ADE_RUNTIME_SOCKET_PATH: socketPath, + NODE_OPTIONS: withTsxNodeOptions(originalEnv.NODE_OPTIONS, tsxLoaderPath), + }; + const oldDaemon = startServeProcess({ + cliPath, + cwd: adeCliRoot, + env: { + ...baseEnv, + ADE_CLI_VERSION: "1.0.0", + }, + socketPath, + }); + const logger = { + debug: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + }; + let pool: LocalRuntimeConnectionPool | null = null; + + try { + await waitForRuntimeSocket(socketPath); + process.env.ADE_CLI_JS = cliPath; + process.env.ADE_HOME = adeHome; + process.env.ADE_RUNTIME_SOCKET_PATH = socketPath; + process.env.NODE_OPTIONS = baseEnv.NODE_OPTIONS; + + pool = new LocalRuntimeConnectionPool("2.0.0", logger as never, { disableSync: true }); + const registered = await pool.ensureProject(projectRoot); + + expect(registered.rootPath).toBe(projectRoot); + expect(logger.info).toHaveBeenCalledWith("local_runtime.version_mismatch_restart", expect.objectContaining({ + runtimeVersion: "1.0.0", + appVersion: "2.0.0", + })); + + pool.dispose(); + const client = await RawRuntimeSocketClient.connect(socketPath); + try { + const initialized = await client.request("ade/initialize", { + protocolVersion: "2025-06-18", + clientName: "local-runtime-version-test", + identity: { role: "external", callerId: "local-runtime-version-test" }, + }); + expect(initialized).toMatchObject({ + runtimeInfo: { + version: "2.0.0", + }, + }); + } finally { + client.close(); + } + } finally { + pool?.dispose(); + await shutdownRuntime(socketPath); + if (!oldDaemon.killed) oldDaemon.kill(); + if (originalEnv.ADE_CLI_JS === undefined) delete process.env.ADE_CLI_JS; + else process.env.ADE_CLI_JS = originalEnv.ADE_CLI_JS; + if (originalEnv.ADE_HOME === undefined) delete process.env.ADE_HOME; + else process.env.ADE_HOME = originalEnv.ADE_HOME; + if (originalEnv.ADE_RUNTIME_SOCKET_PATH === undefined) delete process.env.ADE_RUNTIME_SOCKET_PATH; + else process.env.ADE_RUNTIME_SOCKET_PATH = originalEnv.ADE_RUNTIME_SOCKET_PATH; + if (originalEnv.NODE_OPTIONS === undefined) delete process.env.NODE_OPTIONS; + else process.env.NODE_OPTIONS = originalEnv.NODE_OPTIONS; + } + }, 45_000); + + it("restarts a same-version local daemon when the packaged runtime build changed", async () => { + const adeCliRoot = path.resolve(process.cwd(), "../ade-cli"); + const cliPath = path.join(adeCliRoot, "src", "cli.ts"); + const tsxLoaderPath = path.join(adeCliRoot, "node_modules", "tsx", "dist", "loader.mjs"); + expect(fs.existsSync(cliPath)).toBe(true); + expect(fs.existsSync(tsxLoaderPath)).toBe(true); + + const adeHome = fs.mkdtempSync(path.join(os.tmpdir(), "ade-local-runtime-build-")); + const projectRoot = fs.mkdtempSync(path.join(os.tmpdir(), "ade-local-runtime-build-project-")); + const socketPath = path.join(adeHome, "sock", "ade.sock"); + const originalEnv = { + ADE_CLI_JS: process.env.ADE_CLI_JS, + ADE_HOME: process.env.ADE_HOME, + ADE_RUNTIME_SOCKET_PATH: process.env.ADE_RUNTIME_SOCKET_PATH, + NODE_OPTIONS: process.env.NODE_OPTIONS, + }; + const baseEnv = { + ...process.env, + ADE_HOME: adeHome, + ADE_RUNTIME_SOCKET_PATH: socketPath, + NODE_OPTIONS: withTsxNodeOptions(originalEnv.NODE_OPTIONS, tsxLoaderPath), + }; + const oldDaemon = startServeProcess({ + cliPath, + cwd: adeCliRoot, + env: { + ...baseEnv, + ADE_CLI_VERSION: "1.0.0", + ADE_RUNTIME_BUILD_HASH: "old-build", + }, + socketPath, + }); + const logger = { + debug: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + }; + let pool: LocalRuntimeConnectionPool | null = null; + + try { + await waitForRuntimeSocket(socketPath); + process.env.ADE_CLI_JS = cliPath; + process.env.ADE_HOME = adeHome; + process.env.ADE_RUNTIME_SOCKET_PATH = socketPath; + process.env.NODE_OPTIONS = baseEnv.NODE_OPTIONS; + + const expectedBuildHash = computeLocalRuntimeBuildHash(cliPath); + expect(expectedBuildHash).toBeTruthy(); + pool = new LocalRuntimeConnectionPool("1.0.0", logger as never, { disableSync: true }); + const registered = await pool.ensureProject(projectRoot); + + expect(registered.rootPath).toBe(projectRoot); + expect(logger.info).toHaveBeenCalledWith("local_runtime.build_mismatch_restart", expect.objectContaining({ + runtimeBuildHash: "old-build", + expectedBuildHash, + })); + + pool.dispose(); + const client = await RawRuntimeSocketClient.connect(socketPath); + try { + const initialized = await client.request("ade/initialize", { + protocolVersion: "2025-06-18", + clientName: "local-runtime-build-test", + identity: { role: "external", callerId: "local-runtime-build-test" }, + }); + expect(initialized).toMatchObject({ + runtimeInfo: { + version: "1.0.0", + buildHash: expectedBuildHash, + }, + }); + } finally { + client.close(); + } + } finally { + pool?.dispose(); + await shutdownRuntime(socketPath); + if (!oldDaemon.killed) oldDaemon.kill(); + if (originalEnv.ADE_CLI_JS === undefined) delete process.env.ADE_CLI_JS; + else process.env.ADE_CLI_JS = originalEnv.ADE_CLI_JS; + if (originalEnv.ADE_HOME === undefined) delete process.env.ADE_HOME; + else process.env.ADE_HOME = originalEnv.ADE_HOME; + if (originalEnv.ADE_RUNTIME_SOCKET_PATH === undefined) delete process.env.ADE_RUNTIME_SOCKET_PATH; + else process.env.ADE_RUNTIME_SOCKET_PATH = originalEnv.ADE_RUNTIME_SOCKET_PATH; + if (originalEnv.NODE_OPTIONS === undefined) delete process.env.NODE_OPTIONS; + else process.env.NODE_OPTIONS = originalEnv.NODE_OPTIONS; + } + }, 45_000); + + it("streams local runtime events through the project-scoped RPC action", async () => { + const call = vi.fn().mockResolvedValue({ + events: [ + { + id: 12, + timestamp: "2026-05-10T12:00:00.000Z", + category: "runtime", + payload: { type: "pty_data", event: { ptyId: "pty-1", data: "hello" } }, + }, + { id: "bad", timestamp: "nope", category: "runtime", payload: {} }, + ], + nextCursor: 13, + hasMore: true, + }); + const pool = new LocalRuntimeConnectionPool("1.2.3", { + debug: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + } as never); + const rootPath = path.resolve("/repo"); + (pool as unknown as { projectsByRoot: Map }).projectsByRoot.set(rootPath, { + projectId: "project-1", + rootPath, + displayName: "repo", + addedAt: 1, + lastOpenedAt: 1, + gitOriginUrl: null, + }); + (pool as unknown as { connection: Promise }).connection = Promise.resolve({ + client: { call }, + child: null, + socketPath: "/tmp/ade.sock", + }); + + const result = await pool.streamEventsForRoot(rootPath, { + cursor: 7.5, + limit: 2, + category: "runtime", + }); + + expect(call).toHaveBeenCalledWith("ade/actions/call", { + projectId: "project-1", + name: "stream_events", + arguments: { + cursor: 7, + limit: 2, + category: "runtime", + }, + }); + expect(result).toEqual({ + events: [ + { + id: 12, + timestamp: "2026-05-10T12:00:00.000Z", + category: "runtime", + payload: { type: "pty_data", event: { ptyId: "pty-1", data: "hello" } }, + }, + ], + nextCursor: 13, + hasMore: true, + }); + }); + + it("routes local sync calls through the project-scoped runtime RPC", async () => { + const call = vi.fn().mockResolvedValue({ + mode: "standalone", + connectedPeers: [], + }); + const pool = new LocalRuntimeConnectionPool("1.2.3", { + debug: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + } as never); + const rootPath = path.resolve("/repo"); + (pool as unknown as { projectsByRoot: Map }).projectsByRoot.set(rootPath, { + projectId: "project-1", + rootPath, + displayName: "repo", + addedAt: 1, + lastOpenedAt: 1, + gitOriginUrl: null, + }); + (pool as unknown as { connection: Promise }).connection = Promise.resolve({ + client: { call }, + child: null, + socketPath: "/tmp/ade.sock", + }); + + await expect(pool.callSyncForRoot(rootPath, "sync.getStatus", { + includeTransferReadiness: true, + })).resolves.toEqual({ + mode: "standalone", + connectedPeers: [], + }); + + expect(call).toHaveBeenCalledWith("sync.getStatus", { + projectId: "project-1", + includeTransferReadiness: true, + }); + }); + + it("subscribes to local runtime event notifications", async () => { + const notificationListeners = new Map void>>(); + const call = vi.fn(async (method: string) => { + if (method === "runtimeEvents.subscribe") { + for (const listener of notificationListeners.get("runtime/event") ?? []) { + listener({ + subscriptionId: "runtime-events-4", + projectId: "project-1", + event: { + id: 21, + timestamp: "2026-05-10T12:00:00.000Z", + category: "runtime", + payload: { type: "file_change" }, + }, + }); + } + return { subscriptionId: "runtime-events-4", nextCursor: 22, hasMore: false }; + } + if (method === "runtimeEvents.unsubscribe") { + return { removed: true }; + } + return null; + }); + const client = { + call, + onDisconnect: vi.fn(() => () => {}), + onNotification: vi.fn((method: string, callback: (params: unknown) => void) => { + const existing = notificationListeners.get(method) ?? new Set<(params: unknown) => void>(); + existing.add(callback); + notificationListeners.set(method, existing); + return () => { + existing.delete(callback); + if (existing.size === 0) { + notificationListeners.delete(method); + } + }; + }), + }; + const pool = new LocalRuntimeConnectionPool("1.2.3", { + debug: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + } as never); + const rootPath = path.resolve("/repo"); + (pool as unknown as { projectsByRoot: Map }).projectsByRoot.set(rootPath, { + projectId: "project-1", + rootPath, + displayName: "repo", + addedAt: 1, + lastOpenedAt: 1, + gitOriginUrl: null, + }); + (pool as unknown as { connection: Promise }).connection = Promise.resolve({ + client, + child: null, + socketPath: "/tmp/ade.sock", + }); + const onEvent = vi.fn(); + + const cleanup = await pool.subscribeEventsForRoot(rootPath, { + cursor: 20, + limit: 5, + category: "runtime", + }, onEvent); + + expect(call).toHaveBeenCalledWith("runtimeEvents.subscribe", { + projectId: "project-1", + cursor: 20, + limit: 5, + category: "runtime", + }); + expect(onEvent).toHaveBeenCalledWith({ + id: 21, + timestamp: "2026-05-10T12:00:00.000Z", + category: "runtime", + payload: { type: "file_change" }, + }); + + cleanup(); + expect(call).toHaveBeenCalledWith("runtimeEvents.unsubscribe", { subscriptionId: "runtime-events-4" }); + }); +}); diff --git a/apps/desktop/src/main/services/localRuntime/localRuntimeConnectionPool.ts b/apps/desktop/src/main/services/localRuntime/localRuntimeConnectionPool.ts new file mode 100644 index 000000000..f35cb05af --- /dev/null +++ b/apps/desktop/src/main/services/localRuntime/localRuntimeConnectionPool.ts @@ -0,0 +1,823 @@ +import { spawn, type ChildProcess } from "node:child_process"; +import { createHash } from "node:crypto"; +import fs from "node:fs"; +import net from "node:net"; +import path from "node:path"; +import { app } from "electron"; +import { isAdeMcpNamedPipePath } from "../../../shared/adeMcpIpc"; +import type { + RemoteRuntimeActionRequest, + RemoteRuntimeActionResult, + RemoteRuntimeBufferedEvent, + RemoteRuntimeEventCategory, + RemoteRuntimeProjectRecord, + RemoteRuntimeStreamEventsRequest, + RemoteRuntimeStreamEventsResult, +} from "../../../shared/types/remoteRuntime"; +import type { + LocalRuntimeStatus, + SyncDeviceRecord, + SyncDeviceRuntimeState, + SyncGetStatusArgs, + SyncPeerDeviceType, + SyncRoleSnapshot, +} from "../../../shared/types"; +import { resolveMachineAdeLayout } from "../../../../../ade-cli/src/services/projects/machineLayout"; +import { RuntimeRpcClient, type RuntimeRpcTransport } from "../remoteRuntime/runtimeRpcClient"; +import { coerceProjects } from "../remoteRuntime/remoteBootstrap"; +import type { Logger } from "../logging/logger"; +import { getRuntimeServiceStatus, type ServiceManagerStatusResult } from "../../../../../ade-cli/src/serviceManager"; + +type LocalRuntimeConnection = { + client: RuntimeRpcClient; + child: ChildProcess | null; + socketPath: string; +}; + +type RuntimeEventNotification = { + subscriptionId: string; + projectId: string; + event: RemoteRuntimeBufferedEvent; +}; + +type RuntimeServiceManagerOutput = { + ok: boolean | null; + path: string | null; + message: string | null; +}; + +type LocalRuntimeConnectionPoolOptions = { + disableSync?: boolean; + queryServiceStatus?: () => ServiceManagerStatusResult; +}; + +type LocalRuntimeNodePathOptions = { + resourcesPath?: string; + platform?: NodeJS.Platform; + arch?: NodeJS.Architecture; + existingNodePath?: string; +}; + +export function buildLocalRuntimeServeArgs( + cliPath: string, + socketPath: string, + options: { disableSync?: boolean } = {}, +): string[] { + const args = [cliPath, "serve", "--socket", socketPath]; + if (options.disableSync) args.push("--no-sync"); + return args; +} + +export function buildLocalRuntimeNodePath(options: LocalRuntimeNodePathOptions = {}): string | undefined { + const resourcesPath = options.resourcesPath ?? process.resourcesPath; + const platform = options.platform ?? process.platform; + const arch = options.arch ?? process.arch; + const entries: string[] = []; + + if (resourcesPath) { + if (platform === "darwin") { + const archAsar = arch === "arm64" ? "app-arm64.asar" : "app-x64.asar"; + entries.push( + path.join(resourcesPath, `${archAsar}.unpacked`, "node_modules"), + path.join(resourcesPath, "app.asar.unpacked", "node_modules"), + path.join(resourcesPath, archAsar, "node_modules"), + path.join(resourcesPath, "app.asar", "node_modules"), + ); + } else { + entries.push( + path.join(resourcesPath, "app.asar.unpacked", "node_modules"), + path.join(resourcesPath, "app.asar", "node_modules"), + ); + } + } + + const existingNodePath = options.existingNodePath ?? process.env.NODE_PATH; + if (existingNodePath?.trim()) entries.push(existingNodePath); + return entries.length ? entries.join(path.delimiter) : undefined; +} + +export function buildLocalRuntimeNodeEnv( + appVersion: string, + baseEnv: NodeJS.ProcessEnv = process.env, + nodePathOptions: Omit = {}, +): NodeJS.ProcessEnv { + const env: NodeJS.ProcessEnv = { + ...baseEnv, + ADE_DEFAULT_ROLE: "cto", + ELECTRON_RUN_AS_NODE: "1", + ADE_CLI_VERSION: appVersion, + }; + const nodePath = buildLocalRuntimeNodePath({ ...nodePathOptions, existingNodePath: baseEnv.NODE_PATH }); + if (nodePath) env.NODE_PATH = nodePath; + return env; +} + +function resolveCliScriptPath(): string { + const override = process.env.ADE_CLI_JS?.trim(); + if (override) return path.resolve(override); + + const candidates = [ + path.join(process.resourcesPath ?? "", "ade-cli", "cli.cjs"), + path.join(app.getAppPath(), "..", "ade-cli", "dist", "cli.cjs"), + path.resolve(process.cwd(), "..", "ade-cli", "dist", "cli.cjs"), + ]; + return candidates.find((candidate) => { + try { + return Boolean(candidate) && fs.statSync(candidate).isFile(); + } catch { + return false; + } + }) ?? path.resolve(process.cwd(), "..", "ade-cli", "dist", "cli.cjs"); +} + +function openSocketTransport(socketPath: string, timeoutMs = 3_000): Promise { + return new Promise((resolve, reject) => { + const socket = isAdeMcpNamedPipePath(socketPath) + ? net.createConnection(socketPath) + : net.createConnection({ path: socketPath }); + let settled = false; + const timer = setTimeout(() => { + if (settled) return; + settled = true; + socket.destroy(); + reject(new Error(`Timed out connecting to ADE service socket: ${socketPath}`)); + }, timeoutMs); + const fail = (error: Error) => { + if (settled) return; + settled = true; + clearTimeout(timer); + socket.destroy(); + reject(error); + }; + socket.once("error", fail); + socket.once("connect", () => { + if (settled) return; + settled = true; + clearTimeout(timer); + socket.off("error", fail); + const closeCallbacks = new Set<() => void>(); + const errorCallbacks = new Set<(error: Error) => void>(); + socket.on("error", (error) => { + for (const callback of [...errorCallbacks]) { + callback(error); + } + }); + socket.on("close", () => { + for (const callback of [...closeCallbacks]) { + callback(); + } + }); + resolve({ + onData(callback) { + socket.on("data", (chunk) => callback(Buffer.from(chunk))); + }, + onClose(callback) { + closeCallbacks.add(callback); + }, + onError(callback) { + errorCallbacks.add(callback); + }, + write(data) { + socket.write(data); + }, + close() { + socket.end(); + }, + }); + }); + }); +} + +function readRuntimeInfo(value: unknown): { version: string | null; buildHash: string | null } { + if (!value || typeof value !== "object" || Array.isArray(value)) { + return { version: null, buildHash: null }; + } + const runtimeInfo = (value as { runtimeInfo?: unknown }).runtimeInfo; + if (!runtimeInfo || typeof runtimeInfo !== "object" || Array.isArray(runtimeInfo)) { + return { version: null, buildHash: null }; + } + const version = (runtimeInfo as { version?: unknown }).version; + const buildHash = (runtimeInfo as { buildHash?: unknown }).buildHash; + return { + version: typeof version === "string" && version.trim() ? version.trim() : null, + buildHash: typeof buildHash === "string" && buildHash.trim() ? buildHash.trim() : null, + }; +} + +export function computeLocalRuntimeBuildHash(cliPath = resolveCliScriptPath()): string | null { + try { + const content = fs.readFileSync(cliPath); + return createHash("sha256").update(content).digest("hex"); + } catch { + return null; + } +} + +async function shutdownRuntimeClient(client: RuntimeRpcClient): Promise { + try { + await client.call("shutdown", {}); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + if (!message.includes("socket closed")) throw error; + } finally { + try { client.close(); } catch {} + } +} + +async function waitForSocket(socketPath: string, timeoutMs = 10_000): Promise { + const startedAt = Date.now(); + let lastError: Error | null = null; + while (Date.now() - startedAt < timeoutMs) { + try { + const transport = await openSocketTransport(socketPath, 500); + transport.close(); + return; + } catch (error) { + lastError = error instanceof Error ? error : new Error(String(error)); + await new Promise((resolve) => setTimeout(resolve, 250)); + } + } + throw lastError ?? new Error(`ADE service socket did not become available: ${socketPath}`); +} + +export function parseRuntimeServiceManagerOutput(output: string): RuntimeServiceManagerOutput | null { + const trimmed = output.trim(); + if (!trimmed) return null; + let parsed: unknown; + try { + parsed = JSON.parse(trimmed); + } catch { + return null; + } + if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return null; + const record = parsed as Record; + return { + ok: typeof record.ok === "boolean" ? record.ok : null, + path: typeof record.path === "string" && record.path.trim() ? record.path.trim() : null, + message: typeof record.message === "string" && record.message.trim() ? record.message.trim() : null, + }; +} + +function serviceHealthState( + status: ServiceManagerStatusResult, +): LocalRuntimeStatus["serviceHealth"]["state"] { + if (!status.ok) return status.installed == null ? "unsupported" : "error"; + if (status.installed === false) return "not_installed"; + if (status.running === true) return "running"; + if (status.installed === true) return "installed"; + return "unknown"; +} + +export class LocalRuntimeConnectionPool { + private connection: Promise | null = null; + private activeClient: RuntimeRpcClient | null = null; + private readonly projectsByRoot = new Map(); + private serviceInstallStatus: LocalRuntimeStatus["serviceInstall"] = { + state: "not_attempted", + attempted: false, + path: null, + message: "Background service installation has not run in this session.", + exitCode: null, + updatedAt: null, + }; + private serviceHealthStatus: LocalRuntimeStatus["serviceHealth"] = { + state: "unknown", + installed: null, + running: null, + path: null, + message: "Background service status has not been checked in this session.", + checkedAt: null, + }; + private serviceHealthCheckedAtMs = 0; + + constructor( + private readonly appVersion: string, + private readonly logger: Logger, + private readonly options: LocalRuntimeConnectionPoolOptions = {}, + ) {} + + async ensureRunning(): Promise { + await this.connect(); + } + + getStatus(): LocalRuntimeStatus { + this.refreshServiceHealthIfStale(); + return { + connectionState: this.activeClient + ? "connected" + : this.connection + ? "connecting" + : "idle", + serviceInstall: { ...this.serviceInstallStatus }, + serviceHealth: { ...this.serviceHealthStatus }, + }; + } + + noteServiceInstallSkipped(message: string): void { + this.serviceInstallStatus = { + state: "skipped", + attempted: false, + path: null, + message, + exitCode: null, + updatedAt: new Date().toISOString(), + }; + } + + private refreshServiceHealthIfStale(maxAgeMs = 2_000): void { + if (Date.now() - this.serviceHealthCheckedAtMs < maxAgeMs) return; + this.serviceHealthCheckedAtMs = Date.now(); + try { + const status = (this.options.queryServiceStatus ?? getRuntimeServiceStatus)(); + this.serviceHealthStatus = { + state: serviceHealthState(status), + installed: status.installed, + running: status.running, + path: status.path, + message: status.message, + checkedAt: new Date().toISOString(), + }; + } catch (error) { + this.serviceHealthStatus = { + state: "error", + installed: null, + running: null, + path: null, + message: error instanceof Error ? error.message : String(error), + checkedAt: new Date().toISOString(), + }; + this.logger.warn("local_runtime.service_status_failed", { + error: this.serviceHealthStatus.message, + }); + } + } + + async installServiceBestEffort(): Promise { + const cliPath = resolveCliScriptPath(); + this.serviceInstallStatus = { + state: "installing", + attempted: true, + path: cliPath, + message: "Installing the ADE service login item.", + exitCode: null, + updatedAt: new Date().toISOString(), + }; + await new Promise((resolve) => { + const child = spawn(process.execPath, [cliPath, "serve", "--install-service"], { + env: buildLocalRuntimeNodeEnv(this.appVersion), + stdio: ["ignore", "pipe", "pipe"], + detached: false, + }); + let stdout = ""; + let stderr = ""; + child.stdout?.on("data", (chunk) => { + stdout += chunk.toString("utf8"); + }); + child.stderr?.on("data", (chunk) => { + stderr += chunk.toString("utf8"); + }); + child.once("error", (error) => { + this.serviceInstallStatus = { + state: "failed", + attempted: true, + path: cliPath, + message: error.message, + exitCode: null, + updatedAt: new Date().toISOString(), + }; + this.logger.warn("local_runtime.service_install_failed", { error: error.message }); + resolve(); + }); + child.once("close", (code) => { + const output = stdout.trim(); + const errorOutput = stderr.trim(); + const parsed = parseRuntimeServiceManagerOutput(output); + const failed = code !== 0 || parsed?.ok === false; + const statusPath = parsed ? parsed.path : cliPath; + const payload = { + cliPath, + servicePath: parsed?.path ?? null, + exitCode: code, + stdout: output || null, + stderr: errorOutput || null, + }; + if (!failed) { + this.serviceInstallStatus = { + state: "installed", + attempted: true, + path: statusPath, + message: parsed?.message || output || "ADE service login item is installed.", + exitCode: code, + updatedAt: new Date().toISOString(), + }; + this.logger.info("local_runtime.service_install_succeeded", payload); + } else { + this.serviceInstallStatus = { + state: "failed", + attempted: true, + path: statusPath, + message: parsed?.message || errorOutput || output || "ADE service login item installation failed.", + exitCode: code, + updatedAt: new Date().toISOString(), + }; + this.logger.warn("local_runtime.service_install_failed", payload); + } + resolve(); + }); + }); + } + + async ensureProject(rootPath: string): Promise { + const normalizedRoot = path.resolve(rootPath); + const cached = this.projectsByRoot.get(normalizedRoot); + if (cached) return cached; + const entry = await this.connect(); + const project = await entry.client.call("projects.add", { rootPath: normalizedRoot }); + const record = coerceProjects([project])[0]; + if (!record) throw new Error("Local ADE service did not return a project record."); + this.projectsByRoot.set(normalizedRoot, record); + return record; + } + + async projects(): Promise { + const entry = await this.connect(); + return coerceProjects(await entry.client.call("projects.list", {})); + } + + async syncStatusForRoot(rootPath: string, args: SyncGetStatusArgs = {}): Promise { + return await this.callSyncForRoot(rootPath, "sync.getStatus", { + includeTransferReadiness: args.includeTransferReadiness === true, + forceTransferReadiness: args.forceTransferReadiness === true, + }); + } + + async refreshSyncDiscoveryForRoot(rootPath: string): Promise { + return await this.callSyncForRoot(rootPath, "sync.refreshDiscovery"); + } + + async syncDevicesForRoot(rootPath: string): Promise { + return await this.callSyncForRoot(rootPath, "sync.listDevices"); + } + + async updateSyncLocalDeviceForRoot( + rootPath: string, + args: { name?: string; deviceType?: SyncPeerDeviceType }, + ): Promise { + return await this.callSyncForRoot(rootPath, "sync.updateLocalDevice", args); + } + + async forgetSyncDeviceForRoot(rootPath: string, deviceId: string): Promise { + return await this.callSyncForRoot(rootPath, "sync.forgetDevice", { deviceId }); + } + + async syncPinForRoot(rootPath: string): Promise<{ pin: string | null }> { + return await this.callSyncForRoot<{ pin: string | null }>(rootPath, "sync.getPin"); + } + + async setSyncPinForRoot(rootPath: string, pin: string): Promise { + return await this.callSyncForRoot(rootPath, "sync.setPin", { pin }); + } + + async generateSyncPinForRoot(rootPath: string): Promise { + return await this.callSyncForRoot(rootPath, "sync.generatePin"); + } + + async clearSyncPinForRoot(rootPath: string): Promise { + return await this.callSyncForRoot(rootPath, "sync.clearPin"); + } + + async callActionForRoot( + rootPath: string, + request: RemoteRuntimeActionRequest, + ): Promise { + const project = await this.ensureProject(rootPath); + const entry = await this.connect(); + const value = await entry.client.call("ade/actions/call", { + projectId: project.projectId, + name: "run_ade_action", + arguments: { + domain: request.domain, + action: request.action, + ...(request.args ? { args: request.args } : {}), + ...(Object.prototype.hasOwnProperty.call(request, "arg") ? { arg: request.arg } : {}), + ...(request.argsList ? { argsList: request.argsList } : {}), + }, + }); + + if (value && typeof value === "object" && !Array.isArray(value)) { + const record = value as Record; + if (record.ok === false) { + const error = record.error && typeof record.error === "object" && !Array.isArray(record.error) + ? record.error as Record + : {}; + throw new Error(typeof error.message === "string" ? error.message : "Local ADE service action failed."); + } + return { + domain: typeof record.domain === "string" ? record.domain : request.domain, + action: typeof record.action === "string" ? record.action : request.action, + result: record.result, + statusHints: record.statusHints && typeof record.statusHints === "object" && !Array.isArray(record.statusHints) + ? record.statusHints as Record + : {}, + }; + } + + return { + domain: request.domain, + action: request.action, + result: value, + statusHints: {}, + }; + } + + async streamEventsForRoot( + rootPath: string, + request: RemoteRuntimeStreamEventsRequest = {}, + ): Promise { + const project = await this.ensureProject(rootPath); + const entry = await this.connect(); + const value = await entry.client.call("ade/actions/call", { + projectId: project.projectId, + name: "stream_events", + arguments: { + cursor: clampCursor(request.cursor), + limit: clampLimit(request.limit), + ...(isRemoteRuntimeEventCategory(request.category) ? { category: request.category } : {}), + }, + }); + + if (value && typeof value === "object" && !Array.isArray(value)) { + const record = value as Record; + if (record.ok === false) { + const error = record.error && typeof record.error === "object" && !Array.isArray(record.error) + ? record.error as Record + : {}; + throw new Error(typeof error.message === "string" ? error.message : "Local ADE service event stream failed."); + } + + return { + events: Array.isArray(record.events) + ? record.events.map(normalizeBufferedEvent).filter((event): event is RemoteRuntimeBufferedEvent => event != null) + : [], + nextCursor: typeof record.nextCursor === "number" && Number.isFinite(record.nextCursor) + ? Math.max(0, Math.floor(record.nextCursor)) + : clampCursor(request.cursor), + hasMore: record.hasMore === true, + }; + } + + return { + events: [], + nextCursor: clampCursor(request.cursor), + hasMore: false, + }; + } + + async subscribeEventsForRoot( + rootPath: string, + request: RemoteRuntimeStreamEventsRequest = {}, + onEvent: (event: RemoteRuntimeBufferedEvent) => void, + onEnded?: () => void, + ): Promise<() => void> { + const project = await this.ensureProject(rootPath); + const entry = await this.connect(); + return await subscribeToRuntimeEvents(entry.client, project.projectId, request, onEvent, onEnded); + } + + async callSyncForRoot( + rootPath: string, + method: string, + params: Record = {}, + ): Promise { + const project = await this.ensureProject(rootPath); + const entry = await this.connect(); + return await entry.client.call(method, { + ...params, + projectId: project.projectId, + }) as T; + } + + dispose(): void { + const pending = this.connection; + this.connection = null; + this.activeClient = null; + this.projectsByRoot.clear(); + void pending?.then((entry) => { + try { entry.client.close(); } catch {} + }).catch(() => {}); + } + + private async connect(): Promise { + if (this.connection) return this.connection; + this.connection = this.createConnection().catch((error) => { + this.connection = null; + throw error; + }); + return this.connection; + } + + private async createConnection(): Promise { + const layout = resolveMachineAdeLayout(); + const socketPath = process.env.ADE_RUNTIME_SOCKET_PATH?.trim() || layout.socketPath; + const existing = await this.tryConnect(socketPath); + if (existing) return { client: existing, child: null, socketPath }; + + const child = this.spawnRuntime(socketPath); + await waitForSocket(socketPath); + const client = await this.connectClient(socketPath); + return { client, child, socketPath }; + } + + private async tryConnect(socketPath: string): Promise { + try { + return await this.connectClient(socketPath); + } catch (error) { + this.logger.debug("local_runtime.connect_existing_failed", { + socketPath, + error: error instanceof Error ? error.message : String(error), + }); + return null; + } + } + + private async connectClient(socketPath: string): Promise { + const transport = await openSocketTransport(socketPath); + const client = new RuntimeRpcClient(transport); + const initializeResult = await client.initialize("ade-desktop-local", this.appVersion); + const runtimeInfo = readRuntimeInfo(initializeResult); + if (runtimeInfo.version && runtimeInfo.version !== this.appVersion) { + this.logger.info("local_runtime.version_mismatch_restart", { + socketPath, + runtimeVersion: runtimeInfo.version, + appVersion: this.appVersion, + }); + await shutdownRuntimeClient(client); + throw new Error(`ADE service version ${runtimeInfo.version} does not match desktop version ${this.appVersion}.`); + } + const expectedBuildHash = computeLocalRuntimeBuildHash(); + if (expectedBuildHash && runtimeInfo.buildHash !== expectedBuildHash) { + this.logger.info("local_runtime.build_mismatch_restart", { + socketPath, + runtimeBuildHash: runtimeInfo.buildHash, + expectedBuildHash, + }); + await shutdownRuntimeClient(client); + throw new Error("ADE service build does not match the packaged desktop runtime."); + } + this.activeClient = client; + client.onDisconnect((error) => { + if (this.activeClient !== client) return; + this.logger.warn("local_runtime.disconnected", { + socketPath, + error: error.message, + }); + this.connection = null; + this.activeClient = null; + this.projectsByRoot.clear(); + }); + return client; + } + + private spawnRuntime(socketPath: string): ChildProcess { + const cliPath = resolveCliScriptPath(); + const args = buildLocalRuntimeServeArgs(cliPath, socketPath, this.options); + this.logger.info("local_runtime.spawn", { cliPath, socketPath, disableSync: this.options.disableSync === true }); + const env = buildLocalRuntimeNodeEnv(this.appVersion); + const buildHash = computeLocalRuntimeBuildHash(cliPath); + if (buildHash) env.ADE_RUNTIME_BUILD_HASH = buildHash; + const child = spawn(process.execPath, args, { + env, + stdio: "ignore", + detached: true, + }); + child.unref(); + child.once("exit", (code, signal) => { + this.logger.warn("local_runtime.exited", { code, signal }); + this.connection = null; + }); + child.once("error", (error) => { + this.logger.warn("local_runtime.spawn_failed", { error: error.message }); + this.connection = null; + }); + return child; + } +} + +function clampCursor(value: unknown): number { + return typeof value === "number" && Number.isFinite(value) + ? Math.max(0, Math.floor(value)) + : 0; +} + +function clampLimit(value: unknown): number { + return typeof value === "number" && Number.isFinite(value) + ? Math.max(1, Math.min(1000, Math.floor(value))) + : 100; +} + +function isRemoteRuntimeEventCategory(value: unknown): value is RemoteRuntimeEventCategory { + return value === "orchestrator" || value === "dag_mutation" || value === "runtime" || value === "mission"; +} + +function normalizeBufferedEvent(value: unknown): RemoteRuntimeBufferedEvent | null { + if (!value || typeof value !== "object" || Array.isArray(value)) return null; + const record = value as Record; + if (typeof record.id !== "number" || !Number.isFinite(record.id)) return null; + if (typeof record.timestamp !== "string") return null; + if (!isRemoteRuntimeEventCategory(record.category)) return null; + const payload = record.payload && typeof record.payload === "object" && !Array.isArray(record.payload) + ? record.payload as Record + : {}; + return { + id: Math.max(0, Math.floor(record.id)), + timestamp: record.timestamp, + category: record.category, + payload, + }; +} + +async function subscribeToRuntimeEvents( + client: RuntimeRpcClient, + projectId: string, + request: RemoteRuntimeStreamEventsRequest, + onEvent: (event: RemoteRuntimeBufferedEvent) => void, + onEnded?: () => void, +): Promise<() => void> { + const pendingNotifications: RuntimeEventNotification[] = []; + let closed = false; + let subscriptionId: string | null = null; + + const removeNotificationListener = client.onNotification("runtime/event", (params) => { + if (closed) return; + const notification = normalizeRuntimeEventNotification(params); + if (!notification || notification.projectId !== projectId) return; + if (subscriptionId == null) { + pendingNotifications.push(notification); + return; + } + if (notification.subscriptionId === subscriptionId) { + onEvent(notification.event); + } + }); + const removeDisconnectListener = client.onDisconnect(() => { + if (closed) return; + closed = true; + removeNotificationListener(); + onEnded?.(); + }); + + try { + const value = await client.call("runtimeEvents.subscribe", { + projectId, + cursor: clampCursor(request.cursor), + limit: clampLimit(request.limit), + ...(isRemoteRuntimeEventCategory(request.category) ? { category: request.category } : {}), + }); + subscriptionId = readSubscriptionId(value); + for (const notification of pendingNotifications) { + if (closed) break; + if (notification.subscriptionId === subscriptionId) { + onEvent(notification.event); + } + } + } catch (error) { + closed = true; + removeNotificationListener(); + removeDisconnectListener(); + throw error; + } + + return () => { + if (closed) return; + closed = true; + removeNotificationListener(); + removeDisconnectListener(); + const id = subscriptionId; + if (id != null) { + void client.call("runtimeEvents.unsubscribe", { subscriptionId: id }).catch(() => {}); + } + }; +} + +function readSubscriptionId(value: unknown): string { + if (!value || typeof value !== "object" || Array.isArray(value)) { + throw new Error("ADE service event subscription did not return a subscription id."); + } + const id = (value as Record).subscriptionId; + if (typeof id !== "string" || !id.trim()) { + throw new Error("ADE service event subscription did not return a subscription id."); + } + return id.trim(); +} + +function normalizeRuntimeEventNotification(value: unknown): RuntimeEventNotification | null { + if (!value || typeof value !== "object" || Array.isArray(value)) return null; + const record = value as Record; + const subscriptionId = typeof record.subscriptionId === "string" && record.subscriptionId.trim() + ? record.subscriptionId.trim() + : null; + const projectId = typeof record.projectId === "string" ? record.projectId : ""; + const event = normalizeBufferedEvent(record.event); + if (subscriptionId == null || !projectId || !event) return null; + return { subscriptionId, projectId, event }; +} diff --git a/apps/desktop/src/main/services/macosVm/macosVmService.test.ts b/apps/desktop/src/main/services/macosVm/macosVmService.test.ts index 7f95530fd..0369f95da 100644 --- a/apps/desktop/src/main/services/macosVm/macosVmService.test.ts +++ b/apps/desktop/src/main/services/macosVm/macosVmService.test.ts @@ -118,7 +118,6 @@ describe("createMacosVmService", () => { expect(commands.some(({ command, args }) => command === "rsync" && args.includes("--delete-excluded"))).toBe(true); expect(commands.some(({ command, args }) => command === "rsync" && args.includes("--exclude") && args.includes("/.ade/secrets/***"))).toBe(true); expect(commands.some(({ command, args }) => command === "rsync" && args.includes("--exclude") && args.includes("/.ade/ade.db*"))).toBe(true); - expect(commands.some(({ command, args }) => command === "rsync" && args.includes("--exclude") && args.includes("/.ade/cto/openclaw-*.json"))).toBe(true); expect(commands.filter(({ command }) => command === "rsync")).toHaveLength(1); expect(commands.some(({ command, args }) => path.basename(command) === "lume" && JSON.stringify(args) === JSON.stringify(["run", started.name, "--shared-dir", policy.hostPath]), diff --git a/apps/desktop/src/main/services/macosVm/macosVmService.ts b/apps/desktop/src/main/services/macosVm/macosVmService.ts index 2c6bf6891..fe7c956e3 100644 --- a/apps/desktop/src/main/services/macosVm/macosVmService.ts +++ b/apps/desktop/src/main/services/macosVm/macosVmService.ts @@ -72,7 +72,6 @@ const MIRROR_SYNC_EXCLUDES = [ "/.ade/cto/daily/***", "/.ade/cto/sessions.jsonl", "/.ade/cto/subordinate-activity.jsonl", - "/.ade/cto/openclaw-*.json", "/.ade/context/***", "/.ade/memory/***", "/.ade/history/***", @@ -277,8 +276,7 @@ function isIgnoredMirrorSyncPath(value: string | Buffer | null | undefined): boo || relative === ".ade/cto/MEMORY.md" || relative === ".ade/cto/core-memory.json" || relative === ".ade/cto/sessions.jsonl" - || relative === ".ade/cto/subordinate-activity.jsonl" - || /^\.ade\/cto\/openclaw-.*\.json$/.test(relative); + || relative === ".ade/cto/subordinate-activity.jsonl"; } function readPngDataUrl(filePath: string): string | null { diff --git a/apps/desktop/src/main/services/orchestrator/aiOrchestratorService.test.ts b/apps/desktop/src/main/services/orchestrator/aiOrchestratorService.test.ts index e056d93c7..138750879 100644 --- a/apps/desktop/src/main/services/orchestrator/aiOrchestratorService.test.ts +++ b/apps/desktop/src/main/services/orchestrator/aiOrchestratorService.test.ts @@ -4964,8 +4964,8 @@ describe("aiOrchestratorService", () => { "12f2b.txt')\"", "ADE_MISSION_ID='mission-1' exec claude --model 'sonnet' --permission-mode 'default'", "orchestrator/worker-prompts/worker-ce33e94c-b964-42c9-9127-dfdeb6853d36", - "/Users/admin/.zshrc:3: no such file or directory: /Users/admin/.openclaw/get-codex-token.sh", - "/Users/admin/.openclaw/completions/openclaw.zsh:3803: command not found: compdef", + "/Users/admin/.zshrc:3: no such file or directory: /Users/admin/.legacy-cli/get-codex-token.sh", + "/Users/admin/.legacy-cli/completions/legacy.zsh:3803: command not found: compdef", "apps/desktop/src/main/services/orchestrator/coordinatorTools.test.ts:428: const result =", "- `.ade/step-output-worker_validate-test-tab_1772818763484.md` — structured step output for orchestration", "\"type\": \"text\",", diff --git a/apps/desktop/src/main/services/orchestrator/aiOrchestratorService.ts b/apps/desktop/src/main/services/orchestrator/aiOrchestratorService.ts index 58ad3210f..1c4f538a6 100644 --- a/apps/desktop/src/main/services/orchestrator/aiOrchestratorService.ts +++ b/apps/desktop/src/main/services/orchestrator/aiOrchestratorService.ts @@ -800,7 +800,7 @@ export function createAiOrchestratorService(args: { logger, missionService, orchestratorService, - agentChatService, + agentChatService: initialAgentChatService, laneService, projectConfigService, aiIntegrationService, @@ -814,6 +814,7 @@ export function createAiOrchestratorService(args: { onDagMutation, hookCommandRunner = runOrchestratorHookCommand } = args; + let agentChatService = initialAgentChatService ?? null; const plannerMemoryService = createMemoryService(db); const syncLocks = new Set(); const workerStates = new Map(); @@ -880,7 +881,7 @@ export function createAiOrchestratorService(args: { logger, missionService, orchestratorService, - agentChatService: agentChatService ?? null, + agentChatService, laneService: laneService ?? null, projectConfigService: projectConfigService ?? null, aiIntegrationService: aiIntegrationService ?? null, @@ -7819,8 +7820,9 @@ Check all worker statuses and continue managing the mission from here. Read work let interruptedSessions = 0; let disposedSessions = 0; - if (agentChatService) { - if (typeof agentChatService.sendMessage === "function") { + const chatService = agentChatService; + if (chatService) { + if (typeof chatService.sendMessage === "function") { const outcomes = await Promise.all( targets.map(async (target) => ({ sessionId: target.sessionId, @@ -7844,13 +7846,13 @@ Check all worker statuses and continue managing the mission from here. Read work } } - if (typeof agentChatService.interrupt === "function") { + if (typeof chatService.interrupt === "function") { const outcomes = await Promise.all( targets.map(async (target) => ({ sessionId: target.sessionId, outcome: await runBestEffortWithTimeout({ timeoutMs: GRACEFUL_CANCEL_INTERRUPT_TIMEOUT_MS, - work: () => agentChatService.interrupt({ sessionId: target.sessionId }) + work: () => chatService.interrupt({ sessionId: target.sessionId }) }) })) ); @@ -7868,13 +7870,13 @@ Check all worker statuses and continue managing the mission from here. Read work } } - if (typeof agentChatService.dispose === "function") { + if (typeof chatService.dispose === "function") { const outcomes = await Promise.all( targets.map(async (target) => ({ sessionId: target.sessionId, outcome: await runBestEffortWithTimeout({ timeoutMs: GRACEFUL_CANCEL_DISPOSE_TIMEOUT_MS, - work: () => agentChatService.dispose({ sessionId: target.sessionId }) + work: () => chatService.dispose({ sessionId: target.sessionId }) }) })) ); @@ -11123,6 +11125,10 @@ Check all worker statuses and continue managing the mission from here. Read work runHealthSweep: (reason = "manual") => runHealthSweep(reason), getMissionLogs, exportMissionLogs, + setAgentChatService: (service: ReturnType | null) => { + agentChatService = service; + ctx.agentChatService = service; + }, dispose: () => { disposed = true; disposedRef.current = true; diff --git a/apps/desktop/src/main/services/orchestrator/orchestratorService.test.ts b/apps/desktop/src/main/services/orchestrator/orchestratorService.test.ts index c756e624a..d123b21ae 100644 --- a/apps/desktop/src/main/services/orchestrator/orchestratorService.test.ts +++ b/apps/desktop/src/main/services/orchestrator/orchestratorService.test.ts @@ -6195,8 +6195,8 @@ describe("orchestratorService", () => { transcriptPath, [ "ADE_MISSION_ID='mission-1' ADE_RUN_ID='run-1' exec claude --model 'sonnet' --permission-mode 'default'", - "/Users/admin/.zshrc:3: no such file or directory: /Users/admin/.openclaw/get-codex-token.sh", - "/Users/admin/.openclaw/completions/openclaw.zsh:3803: command not found: compdef", + "/Users/admin/.zshrc:3: no such file or directory: /Users/admin/.legacy-cli/get-codex-token.sh", + "/Users/admin/.legacy-cli/completions/legacy.zsh:3803: command not found: compdef", "admin@Mac test-10-f4bb12de %", "-p \"$(cat '/Users/admin/Projects/ADE/.ade/orchestrator/worker-prompts/worker-123.txt')\"", ].join("\n"), diff --git a/apps/desktop/src/main/services/projects/adeProjectService.ts b/apps/desktop/src/main/services/projects/adeProjectService.ts index 8f89c4af3..3f0fec095 100644 --- a/apps/desktop/src/main/services/projects/adeProjectService.ts +++ b/apps/desktop/src/main/services/projects/adeProjectService.ts @@ -8,6 +8,8 @@ import type { AdePathEntry, AdeProjectSnapshot, AdeSyncAction, + ClearLocalAdeDataArgs, + ClearLocalAdeDataResult, } from "../../../shared/types"; import { buildAdeGitignore, ADE_LAYOUT_DEFINITIONS, resolveAdeLayout, type AdeLayoutPaths } from "../../../shared/adeLayout"; import type { Logger } from "../logging/logger"; @@ -75,10 +77,6 @@ const DEFAULT_CTO_IDENTITY = YAML.stringify( preCompactionFlush: true, temporalDecayHalfLifeDays: 30, }, - openclawContextPolicy: { - shareMode: "filtered", - blockedCategories: ["secret", "token", "system_prompt"], - }, updatedAt: "1970-01-01T00:00:00.000Z", }, { indent: 2 }, @@ -290,10 +288,6 @@ function repairLegacyPaths(paths: AdeLayoutPaths, actions: AdeSyncAction[]): voi moveIfExists(path.join(paths.adeDir, "log-bundles"), paths.logBundlesDir, "artifacts/log-bundles", actions); moveIfExists(path.join(paths.adeDir, "github"), paths.githubSecretsDir, "secrets/github", actions); moveIfExists(path.join(paths.adeDir, "api-keys.json"), path.join(paths.secretsDir, "api-keys.json"), "secrets/api-keys.json", actions); - moveIfExists(path.join(paths.ctoDir, "openclaw-history.json"), path.join(paths.cacheDir, "openclaw", "openclaw-history.json"), "cache/openclaw/openclaw-history.json", actions); - moveIfExists(path.join(paths.ctoDir, "openclaw-idempotency.json"), path.join(paths.cacheDir, "openclaw", "openclaw-idempotency.json"), "cache/openclaw/openclaw-idempotency.json", actions); - moveIfExists(path.join(paths.ctoDir, "openclaw-outbox.json"), path.join(paths.cacheDir, "openclaw", "openclaw-outbox.json"), "cache/openclaw/openclaw-outbox.json", actions); - moveIfExists(path.join(paths.ctoDir, "openclaw-routes.json"), path.join(paths.cacheDir, "openclaw", "openclaw-routes.json"), "cache/openclaw/openclaw-routes.json", actions); const legacyFiles = fs.existsSync(paths.adeDir) ? fs.readdirSync(paths.adeDir) : []; for (const fileName of legacyFiles) { @@ -366,7 +360,6 @@ export function initializeOrRepairAdeProject(projectRoot: string, options: Repai ensureDir(paths.chatSessionsDir, "cache/chat-sessions", actions); ensureDir(paths.chatTranscriptsDir, "transcripts/chat", actions); ensureDir(paths.orchestratorCacheDir, "cache/orchestrator", actions); - ensureDir(path.join(paths.cacheDir, "openclaw"), "cache/openclaw", actions); ensureDir(paths.missionStateDir, "cache/mission-state", actions); ensureDir(paths.packsDir, "artifacts/packs", actions); ensureDir(paths.logBundlesDir, "artifacts/log-bundles", actions); @@ -475,6 +468,28 @@ export function createAdeProjectService(args: AdeProjectServiceArgs) { return { changed: actions.length > 0, actions }; }; + const clearLocalData = (options: ClearLocalAdeDataArgs = {}): ClearLocalAdeDataResult => { + const clearedAt = new Date().toISOString(); + const deletedPaths: string[] = []; + + const rmrf = (absPath: string) => { + const resolved = path.resolve(absPath); + const allowedRoot = path.resolve(repair.paths.adeDir) + path.sep; + if (!resolved.startsWith(allowedRoot)) { + throw new Error("Refusing to delete outside .ade directory"); + } + if (!fs.existsSync(resolved)) return; + fs.rmSync(resolved, { recursive: true, force: true }); + deletedPaths.push(resolved); + }; + + if (options.packs) rmrf(repair.paths.artifactsDir); + if (options.logs) rmrf(repair.paths.logsDir); + if (options.transcripts) rmrf(repair.paths.transcriptsDir); + + return { deletedPaths, clearedAt }; + }; + const getSnapshot = (): AdeProjectSnapshot => { const configSnapshot = args.projectConfigService.get(); const configValidation = configSnapshot.validation; @@ -528,6 +543,7 @@ export function createAdeProjectService(args: AdeProjectServiceArgs) { getSnapshot, initializeOrRepair: () => initializeOrRepairAdeProject(args.projectRoot, { logger: args.logger, mode: "shared" }).cleanup, runIntegrityCheck, + clearLocalData, logIntegrityService, }; } diff --git a/apps/desktop/src/main/services/projects/projectDetailService.ts b/apps/desktop/src/main/services/projects/projectDetailService.ts index 72ae67544..e4679177b 100644 --- a/apps/desktop/src/main/services/projects/projectDetailService.ts +++ b/apps/desktop/src/main/services/projects/projectDetailService.ts @@ -1,6 +1,13 @@ import fs from "node:fs/promises"; import path from "node:path"; -import type { ProjectDetail, ProjectLanguageShare, ProjectLastCommit, RecentProjectSummary } from "../../../shared/types"; +import type { + ProjectDetail, + ProjectLanguageShare, + ProjectLastCommit, + RecentProjectSummary, + RemoteRuntimeProjectWorkSummary, + RemoteRuntimeProjectWorktreeSummary, +} from "../../../shared/types"; import { runGit } from "../git/git"; import { readGlobalState } from "../state/globalState"; import { toRecentProjectSummary } from "./recentProjectSummary"; @@ -199,6 +206,52 @@ async function readGitMetadata(rootPath: string): Promise { + const isRepo = await isGitRepo(args.rootPath); + if (!isRepo) return null; + const [branchRes, dirtyRes] = await Promise.all([ + runGit(["rev-parse", "--abbrev-ref", "HEAD"], { + cwd: args.rootPath, + timeoutMs: 5_000, + }), + runGit(["status", "--porcelain=v1", "--untracked-files=all"], { + cwd: args.rootPath, + timeoutMs: 8_000, + }), + ]); + return { + rootPath: args.rootPath, + name: args.name, + branchName: branchRes.exitCode === 0 ? branchRes.stdout.trim() || null : null, + dirtyCount: + dirtyRes.exitCode === 0 + ? dirtyRes.stdout + .split(/\r?\n/) + .filter((line) => line.trim().length > 0).length + : 0, + isPrimary: args.isPrimary, + }; +} + +async function listAdeWorktreeRoots(rootPath: string): Promise> { + const worktreesPath = path.join(rootPath, ".ade", "worktrees"); + try { + const dirents = await fs.readdir(worktreesPath, { withFileTypes: true }); + return dirents + .filter((dirent) => dirent.isDirectory()) + .map((dirent) => ({ + rootPath: path.join(worktreesPath, dirent.name), + name: dirent.name, + })); + } catch { + return []; + } +} + export type GetProjectDetailOptions = { globalStatePath?: string | null; }; @@ -285,6 +338,40 @@ export async function getProjectDetail(rootPath: string, options: GetProjectDeta }; } +export async function getProjectWorkSummary(rootPath: string): Promise { + const { requestedRoot, scanRoot } = await resolveProjectDetailScanRoot(rootPath); + const worktrees = await listAdeWorktreeRoots(scanRoot); + const summaries = ( + await Promise.all([ + readWorktreeSummary({ + rootPath: scanRoot, + name: "Primary", + isPrimary: true, + }), + ...worktrees.map((worktree) => + readWorktreeSummary({ + rootPath: worktree.rootPath, + name: worktree.name, + isPrimary: false, + }), + ), + ]) + ).filter((entry): entry is RemoteRuntimeProjectWorktreeSummary => entry != null); + const primary = summaries.find((summary) => summary.isPrimary); + return { + rootPath: requestedRoot, + laneCount: summaries.length, + checkedLaneCount: summaries.length, + dirtyLaneCount: summaries.filter((summary) => summary.dirtyCount > 0).length, + dirtyFileCount: summaries.reduce((sum, summary) => sum + summary.dirtyCount, 0), + primaryDirtyCount: primary?.dirtyCount ?? 0, + lanes: summaries.map((summary) => ({ + ...summary, + rootPath: summary.isPrimary ? requestedRoot : summary.rootPath, + })), + }; +} + export const __internal = { parseLastCommitLine, parseAheadBehind, diff --git a/apps/desktop/src/main/services/projects/projectLifecycle.test.ts b/apps/desktop/src/main/services/projects/projectLifecycle.test.ts index 2108c2f6a..262923102 100644 --- a/apps/desktop/src/main/services/projects/projectLifecycle.test.ts +++ b/apps/desktop/src/main/services/projects/projectLifecycle.test.ts @@ -5,7 +5,7 @@ import { execFileSync } from "node:child_process"; import { afterEach, describe, expect, it, vi } from "vitest"; import { buildAdeGitignore, resolveAdeLayout } from "../../../shared/adeLayout"; -import { initializeOrRepairAdeProject } from "./adeProjectService"; +import { createAdeProjectService, initializeOrRepairAdeProject } from "./adeProjectService"; import { browseProjectDirectories } from "./projectBrowserService"; import { __internal, getProjectDetail } from "./projectDetailService"; import { inspectRecentProject, toRecentProjectSummary } from "./recentProjectSummary"; @@ -144,8 +144,6 @@ describe("initializeOrRepairAdeProject", () => { fs.mkdirSync(path.join(root, ".ade", "chat-sessions"), { recursive: true }); fs.writeFileSync(path.join(root, ".ade", "chat-sessions", "session-1.json"), "{\"id\":\"session-1\"}\n", "utf8"); fs.writeFileSync(path.join(root, ".ade", "mission-state-run-1.json"), "{\"runId\":\"run-1\"}\n", "utf8"); - fs.mkdirSync(path.join(root, ".ade", "cto"), { recursive: true }); - fs.writeFileSync(path.join(root, ".ade", "cto", "openclaw-history.json"), "[]\n", "utf8"); return root; } @@ -173,10 +171,8 @@ describe("initializeOrRepairAdeProject", () => { expect(fs.existsSync(path.join(layout.logsDir, "main.jsonl"))).toBe(true); expect(fs.existsSync(path.join(layout.chatSessionsDir, "session-1.json"))).toBe(true); expect(fs.existsSync(path.join(layout.missionStateDir, "mission-state-run-1.json"))).toBe(true); - expect(fs.existsSync(path.join(layout.cacheDir, "openclaw", "openclaw-history.json"))).toBe(true); expect(fs.existsSync(path.join(layout.adeDir, "logs"))).toBe(false); expect(fs.existsSync(path.join(layout.adeDir, "chat-sessions"))).toBe(false); - expect(fs.existsSync(path.join(layout.ctoDir, "openclaw-history.json"))).toBe(false); }); it("is idempotent once the canonical structure is in place", () => { @@ -337,6 +333,46 @@ describe("initializeOrRepairAdeProject", () => { }); }); +describe("createAdeProjectService.clearLocalData", () => { + it("deletes only selected generated .ade data directories", () => { + const root = makeTempDir("ade-project-clear-local-data-"); + const layout = resolveAdeLayout(root); + const service = createAdeProjectService({ + projectRoot: root, + db: makeProjectConfigDb(), + projectId: "project-1", + logger: createLogger(), + projectConfigService: { + get: () => ({ validation: { ok: true, issues: [] } }), + }, + }); + fs.mkdirSync(layout.artifactsDir, { recursive: true }); + fs.mkdirSync(layout.logsDir, { recursive: true }); + fs.mkdirSync(layout.transcriptsDir, { recursive: true }); + fs.mkdirSync(layout.cacheDir, { recursive: true }); + fs.mkdirSync(layout.secretsDir, { recursive: true }); + fs.writeFileSync(path.join(layout.artifactsDir, "pack.txt"), "pack", "utf8"); + fs.writeFileSync(path.join(layout.logsDir, "run.log"), "log", "utf8"); + fs.writeFileSync(path.join(layout.transcriptsDir, "chat.jsonl"), "chat", "utf8"); + fs.writeFileSync(path.join(layout.cacheDir, "keep.json"), "cache", "utf8"); + fs.writeFileSync(path.join(layout.secretsDir, "keep"), "secret", "utf8"); + + const result = service.clearLocalData({ packs: true, logs: true, transcripts: true }); + + expect(result.clearedAt).toEqual(expect.any(String)); + expect(result.deletedPaths).toEqual(expect.arrayContaining([ + path.resolve(layout.artifactsDir), + path.resolve(layout.logsDir), + path.resolve(layout.transcriptsDir), + ])); + expect(fs.existsSync(layout.artifactsDir)).toBe(false); + expect(fs.existsSync(layout.logsDir)).toBe(false); + expect(fs.existsSync(layout.transcriptsDir)).toBe(false); + expect(fs.readFileSync(path.join(layout.cacheDir, "keep.json"), "utf8")).toBe("cache"); + expect(fs.readFileSync(path.join(layout.secretsDir, "keep"), "utf8")).toBe("secret"); + }); +}); + // --------------------------------------------------------------------------- // browseProjectDirectories — directory picker for "Add Project" // --------------------------------------------------------------------------- diff --git a/apps/desktop/src/main/services/projects/projectScaffoldService.test.ts b/apps/desktop/src/main/services/projects/projectScaffoldService.test.ts index 94c39cb5a..7bb278c08 100644 --- a/apps/desktop/src/main/services/projects/projectScaffoldService.test.ts +++ b/apps/desktop/src/main/services/projects/projectScaffoldService.test.ts @@ -420,6 +420,34 @@ describe("cloneRepository", () => { ]); }); + it("uses an explicit one-shot GitHub auth header for remote clone requests", async () => { + runGitMock.mockResolvedValue(gitOk()); + const parentDir = makeTempDir("ade-scaffold-clone-explicit-auth-"); + const service = createProjectScaffoldService({ + logger: makeLogger(), + githubService: makeGithubServiceStub({ + getTokenOrThrow: vi.fn(() => { + throw new Error("No local token on this machine."); + }), + }), + }); + + await service.cloneRepository({ + url: "https://github.com/octocat/Hello-World", + parentDir, + githubAuthHeader: "basic one-shot", + }); + + const cloneCall = runGitMock.mock.calls.find((c) => (c[0] as string[])[0] === "clone"); + expect(cloneCall?.[0]).toEqual([ + "clone", + "-c", + "http.https://github.com/.extraheader=AUTHORIZATION: basic one-shot", + "https://github.com/octocat/Hello-World", + path.join(parentDir, "Hello-World"), + ]); + }); + it("falls back to a plain clone (no extraheader) when no token is stored", async () => { runGitMock.mockResolvedValue(gitOk()); const parentDir = makeTempDir("ade-scaffold-clone-no-token-"); diff --git a/apps/desktop/src/main/services/projects/projectScaffoldService.ts b/apps/desktop/src/main/services/projects/projectScaffoldService.ts index 72fcc880a..4fabd542c 100644 --- a/apps/desktop/src/main/services/projects/projectScaffoldService.ts +++ b/apps/desktop/src/main/services/projects/projectScaffoldService.ts @@ -215,19 +215,22 @@ export function createProjectScaffoldService({ // clones work in environments without a system credential helper. Using // the basic-auth shape (x-access-token:) is the GitHub-recommended // form and avoids leaking the token via the URL in process listings. - let storedToken: string | null = null; - try { - storedToken = githubService.getTokenOrThrow(); - } catch { - storedToken = null; + let authHeader = (input.githubAuthHeader ?? "").trim(); + if (!authHeader) { + try { + const storedToken = githubService.getTokenOrThrow(); + const basic = Buffer.from(`x-access-token:${storedToken}`, "utf8").toString("base64"); + authHeader = `basic ${basic}`; + } catch { + authHeader = ""; + } } const cloneArgs: string[] = ["clone"]; - if (storedToken) { - const basic = Buffer.from(`x-access-token:${storedToken}`, "utf8").toString("base64"); + if (authHeader) { cloneArgs.push( "-c", - `http.https://github.com/.extraheader=AUTHORIZATION: basic ${basic}`, + `http.https://github.com/.extraheader=AUTHORIZATION: ${authHeader}`, ); } cloneArgs.push(url, rootPath); diff --git a/apps/desktop/src/main/services/remoteRuntime/remoteBootstrap.test.ts b/apps/desktop/src/main/services/remoteRuntime/remoteBootstrap.test.ts new file mode 100644 index 000000000..caf10e2a4 --- /dev/null +++ b/apps/desktop/src/main/services/remoteRuntime/remoteBootstrap.test.ts @@ -0,0 +1,528 @@ +import crypto from "node:crypto"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import type { Client } from "ssh2"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import type { RemoteRuntimeTarget } from "../../../shared/types/remoteRuntime"; +import type { RemoteTargetRegistry } from "./remoteTargetRegistry"; +import { + bootstrapRemoteRuntime, + buildRemoteRuntimeEnvironmentPrefix, + normalizeRemoteArch, + normalizeRuntimeVersion, + resolveRemoteRuntimeLayout, + selectRemoteRuntimeVersion, + shouldUploadBundledRuntime, + validateRemoteRuntimeInitializeResult, +} from "./remoteBootstrap"; + +const connectSshMock = vi.hoisted(() => vi.fn()); +const execSshMock = vi.hoisted(() => vi.fn()); +const openSshRuntimeTransportMock = vi.hoisted(() => vi.fn()); +const initializeMock = vi.hoisted(() => vi.fn()); +const callMock = vi.hoisted(() => vi.fn()); +const runtimeRpcClientMock = vi.hoisted(() => vi.fn()); + +vi.mock("./sshTransport", () => ({ + connectSsh: connectSshMock, + execSsh: execSshMock, + openSshRuntimeTransport: openSshRuntimeTransportMock, +})); + +vi.mock("./runtimeRpcClient", () => ({ + RuntimeRpcClient: runtimeRpcClientMock, +})); + +describe("normalizeRemoteArch", () => { + it("normalizes supported uname platform and architecture pairs", () => { + expect(normalizeRemoteArch("Darwin arm64")).toEqual({ + platform: "darwin", + arch: "arm64", + label: "darwin-arm64", + }); + expect(normalizeRemoteArch("Linux x86_64")).toEqual({ + platform: "linux", + arch: "x64", + label: "linux-x64", + }); + expect(normalizeRemoteArch("Linux aarch64")).toEqual({ + platform: "linux", + arch: "arm64", + label: "linux-arm64", + }); + }); + + it("rejects unsupported remote ADE service targets instead of guessing", () => { + expect(() => normalizeRemoteArch("FreeBSD riscv64")).toThrow(/unsupported remote ade service platform/i); + expect(() => normalizeRemoteArch("Linux riscv64")).toThrow(/unsupported remote ade service platform/i); + }); +}); + +describe("normalizeRuntimeVersion", () => { + it("normalizes plain and prefixed ADE version output", () => { + expect(normalizeRuntimeVersion("1.0.0-beta.1\n")).toBe("1.0.0-beta.1"); + expect(normalizeRuntimeVersion("ade 1.0.0-beta.1\n")).toBe("1.0.0-beta.1"); + }); + + it("returns null for empty version output", () => { + expect(normalizeRuntimeVersion("\n")).toBeNull(); + }); +}); + +describe("selectRemoteRuntimeVersion", () => { + it("prefers executable output over the marker file", () => { + expect(selectRemoteRuntimeVersion({ + markerVersion: "1.0.0", + executableVersion: "1.0.1", + })).toBe("1.0.1"); + }); + + it("uses the marker when the executable cannot report a version", () => { + expect(selectRemoteRuntimeVersion({ + markerVersion: "1.0.0", + executableVersion: null, + })).toBe("1.0.0"); + }); +}); + +describe("shouldUploadBundledRuntime", () => { + it("uploads when the marker matches but the remote executable is missing", () => { + expect(shouldUploadBundledRuntime({ + localBinaryAvailable: true, + executableVersion: null, + appVersion: "1.0.0", + })).toBe(true); + }); + + it("skips upload when the executable itself matches the desktop version", () => { + expect(shouldUploadBundledRuntime({ + localBinaryAvailable: true, + executableVersion: "1.0.0", + appVersion: "1.0.0", + localBinarySha256: "abc", + remoteBinarySha256: "abc", + })).toBe(false); + }); + + it("uploads when the executable version matches but the binary hash changed", () => { + expect(shouldUploadBundledRuntime({ + localBinaryAvailable: true, + executableVersion: "1.0.0", + appVersion: "1.0.0", + localBinarySha256: "new", + remoteBinarySha256: "old", + })).toBe(true); + }); + + it("does not upload when no bundled runtime exists for the remote architecture", () => { + expect(shouldUploadBundledRuntime({ + localBinaryAvailable: false, + executableVersion: null, + appVersion: "1.0.0", + })).toBe(false); + }); +}); + +describe("buildRemoteRuntimeEnvironmentPrefix", () => { + it("adds ADE and user-install bins to the remote runtime PATH", () => { + expect(buildRemoteRuntimeEnvironmentPrefix({ + archLabel: "linux-x64", + nativeDepsReady: false, + })).toBe('ADE_HOME="$HOME/.ade" PATH="$HOME/.ade/bin:$HOME/.local/bin:$HOME/.npm-global/bin${PATH:+:$PATH}" ADE_DEFAULT_ROLE="cto" '); + }); + + it("adds the uploaded native dependency bundle to NODE_PATH", () => { + expect(buildRemoteRuntimeEnvironmentPrefix({ + archLabel: "darwin-arm64", + nativeDepsReady: true, + })).toContain('NODE_PATH="$HOME/.ade/runtime/darwin-arm64/node_modules${NODE_PATH:+:$NODE_PATH}"'); + }); + + it("uses isolated remote paths for Alpha and Beta channels", () => { + const alphaLayout = resolveRemoteRuntimeLayout({ ADE_PACKAGE_CHANNEL: "alpha" } as NodeJS.ProcessEnv); + const betaLayout = resolveRemoteRuntimeLayout({ ADE_PACKAGE_CHANNEL: "beta" } as NodeJS.ProcessEnv); + + expect(alphaLayout).toMatchObject({ + homeDirName: ".ade-alpha", + binaryRelative: ".ade-alpha/bin/ade", + versionExpr: "$HOME/.ade-alpha/bin/ade.version", + }); + expect(buildRemoteRuntimeEnvironmentPrefix({ + archLabel: "darwin-arm64", + nativeDepsReady: true, + layout: alphaLayout, + })).toBe('ADE_HOME="$HOME/.ade-alpha" PATH="$HOME/.ade-alpha/bin:$HOME/.local/bin:$HOME/.npm-global/bin${PATH:+:$PATH}" ADE_DEFAULT_ROLE="cto" ADE_PACKAGE_CHANNEL="alpha" ADE_DISABLE_RUNTIME_SERVICE_INSTALL=1 NODE_PATH="$HOME/.ade-alpha/runtime/darwin-arm64/node_modules${NODE_PATH:+:$NODE_PATH}" '); + expect(betaLayout).toMatchObject({ + homeDirName: ".ade-beta", + binaryRelative: ".ade-beta/bin/ade", + versionExpr: "$HOME/.ade-beta/bin/ade.version", + }); + }); +}); + +describe("validateRemoteRuntimeInitializeResult", () => { + it("accepts a multi-project runtime with the expected version", () => { + expect(() => validateRemoteRuntimeInitializeResult({ + expectedVersion: "1.0.0", + result: { + runtimeInfo: { version: "1.0.0", multiProject: true }, + capabilities: { + projects: true, + machineProjects: { + browseDirectories: true, + getDetail: true, + getWorkSummary: true, + getDefaultParentDir: true, + create: true, + clone: true, + listMyGitHubRepos: true, + }, + }, + }, + })).not.toThrow(); + }); + + it("rejects a stale single-project runtime", () => { + expect(() => validateRemoteRuntimeInitializeResult({ + expectedVersion: null, + result: { + runtimeInfo: { version: "0.9.0" }, + capabilities: { actions: { listChanged: true } }, + }, + })).toThrow(/multi-project/i); + }); + + it("rejects a multi-project runtime that cannot handle machine-level project operations", () => { + expect(() => validateRemoteRuntimeInitializeResult({ + expectedVersion: "1.0.0", + result: { + runtimeInfo: { version: "1.0.0", multiProject: true }, + capabilities: { projects: true }, + }, + })).toThrow(/missing project capability/i); + }); + + it("rejects a bundled runtime with the wrong reported version", () => { + expect(() => validateRemoteRuntimeInitializeResult({ + expectedVersion: "1.0.0", + result: { + runtimeInfo: { version: "0.9.0", multiProject: true }, + capabilities: { + projects: true, + machineProjects: { + browseDirectories: true, + getDetail: true, + getWorkSummary: true, + getDefaultParentDir: true, + create: true, + clone: true, + listMyGitHubRepos: true, + }, + }, + }, + })).toThrow(/version mismatch/i); + }); +}); + +const APP_VERSION = "2.0.0"; + +const uploadTarget: RemoteRuntimeTarget = { + id: "target-1", + name: "Build host", + hostname: "build-host.local", + sshUser: "ade", + port: 22, + sshKeyPath: null, + lastSeenArch: null, + runtimeBinaryVersion: null, + lastConnectedAt: null, +}; + +function ok(stdout = "") { + return { stdout, stderr: "", code: 0 }; +} + +function createTempResources(archLabel = "linux-x64"): { resourcesPath: string; binaryPath: string; binarySha256: string; cleanup: () => void } { + const resourcesPath = fs.mkdtempSync(path.join(os.tmpdir(), "ade-remote-runtime-")); + const runtimeDir = path.join(resourcesPath, "runtime"); + fs.mkdirSync(runtimeDir, { recursive: true }); + const binaryPath = path.join(runtimeDir, `ade-${archLabel}`); + fs.writeFileSync(binaryPath, "#!/bin/sh\n"); + const binarySha256 = crypto.createHash("sha256").update(fs.readFileSync(binaryPath)).digest("hex"); + return { + resourcesPath, + binaryPath, + binarySha256, + cleanup: () => fs.rmSync(resourcesPath, { recursive: true, force: true }), + }; +} + +function createFakeSsh() { + const sftpEnd = vi.fn(); + const fastPut = vi.fn((_localPath: string, _remotePath: string, _options: object, callback: (error?: Error | null) => void) => { + callback(null); + }); + const sftp = vi.fn((callback: (error: Error | null, sftp: { fastPut: typeof fastPut; end: typeof sftpEnd }) => void) => { + callback(null, { fastPut, end: sftpEnd }); + }); + const end = vi.fn(); + const ssh = { sftp, end } as unknown as Client; + return { ssh, sftp, fastPut, sftpEnd, end }; +} + +function createRegistry() { + return { + update: vi.fn((_id: string, patch: Partial) => ({ + ...uploadTarget, + ...patch, + })), + } as unknown as RemoteTargetRegistry & { update: ReturnType }; +} + +describe("bootstrapRemoteRuntime upload flow", () => { + let cleanupResources: (() => void) | null = null; + const originalPackageChannel = process.env.ADE_PACKAGE_CHANNEL; + + beforeEach(() => { + if (originalPackageChannel === undefined) delete process.env.ADE_PACKAGE_CHANNEL; + else process.env.ADE_PACKAGE_CHANNEL = originalPackageChannel; + connectSshMock.mockReset(); + execSshMock.mockReset(); + openSshRuntimeTransportMock.mockReset(); + initializeMock.mockReset(); + callMock.mockReset(); + runtimeRpcClientMock.mockReset(); + cleanupResources = null; + + runtimeRpcClientMock.mockImplementation(() => ({ + initialize: initializeMock, + call: callMock, + close: vi.fn(), + })); + openSshRuntimeTransportMock.mockResolvedValue({ + onData: vi.fn(), + onError: vi.fn(), + onClose: vi.fn(), + write: vi.fn(), + close: vi.fn(), + }); + initializeMock.mockResolvedValue({ + runtimeInfo: { version: APP_VERSION, multiProject: true }, + capabilities: { + projects: true, + machineProjects: { + browseDirectories: true, + getDetail: true, + getWorkSummary: true, + getDefaultParentDir: true, + create: true, + clone: true, + listMyGitHubRepos: true, + }, + }, + }); + callMock.mockImplementation(async (method: string) => { + if (method === "projects.list") { + return [{ + projectId: "project-1", + rootPath: "/srv/ade", + displayName: "ADE", + addedAt: 1, + lastOpenedAt: 2, + gitOriginUrl: "git@github.com:example/ade.git", + }]; + } + throw new Error(`Unexpected RPC method: ${method}`); + }); + }); + + afterEach(() => { + if (originalPackageChannel === undefined) delete process.env.ADE_PACKAGE_CHANNEL; + else process.env.ADE_PACKAGE_CHANNEL = originalPackageChannel; + cleanupResources?.(); + }); + + it("uploads a missing bundled runtime, verifies its version, and opens stdio RPC from ~/.ade/bin", async () => { + const resources = createTempResources(); + cleanupResources = resources.cleanup; + const fakeSsh = createFakeSsh(); + const registry = createRegistry(); + connectSshMock.mockResolvedValue(fakeSsh.ssh); + const commands: string[] = []; + execSshMock.mockImplementation(async (_client: Client, command: string) => { + commands.push(command); + if (command === "uname -sm") return ok("Linux x86_64\n"); + if (command === "cat $HOME/.ade/bin/ade.version 2>/dev/null || true") return ok(""); + if (command === "cat $HOME/.ade/bin/ade.sha256 2>/dev/null || true") return ok(""); + if (command === "test -x $HOME/.ade/bin/ade && $HOME/.ade/bin/ade --version || true") return ok(""); + if (command === "mkdir -p $HOME/.ade/bin") return ok(""); + if (command.includes("printf '%s\\n' '2.0.0' > $HOME/.ade/bin/ade.version")) return ok(""); + if (command.includes("$HOME/.ade/bin/ade --version")) return ok("ade 2.0.0\n"); + if (command.includes("$HOME/.ade/bin/ade runtime stop --text")) return ok(""); + throw new Error(`Unexpected SSH command: ${command}`); + }); + + const connected = await bootstrapRemoteRuntime({ + target: uploadTarget, + registry, + resourcesPath: resources.resourcesPath, + appVersion: APP_VERSION, + }); + + expect(connectSshMock).toHaveBeenCalledWith(uploadTarget); + expect(fakeSsh.fastPut).toHaveBeenCalledWith(resources.binaryPath, ".ade/bin/ade", {}, expect.any(Function)); + expect(commands).toEqual([ + "uname -sm", + "cat $HOME/.ade/bin/ade.version 2>/dev/null || true", + "cat $HOME/.ade/bin/ade.sha256 2>/dev/null || true", + "test -x $HOME/.ade/bin/ade && $HOME/.ade/bin/ade --version || true", + "mkdir -p $HOME/.ade/bin", + `chmod 700 $HOME/.ade/bin && chmod +x $HOME/.ade/bin/ade && printf '%s\\n' '2.0.0' > $HOME/.ade/bin/ade.version && printf '%s\\n' '${resources.binarySha256}' > $HOME/.ade/bin/ade.sha256 && chmod 600 $HOME/.ade/bin/ade.version && chmod 600 $HOME/.ade/bin/ade.sha256`, + 'ADE_HOME="$HOME/.ade" PATH="$HOME/.ade/bin:$HOME/.local/bin:$HOME/.npm-global/bin${PATH:+:$PATH}" ADE_DEFAULT_ROLE="cto" $HOME/.ade/bin/ade --version', + 'ADE_HOME="$HOME/.ade" PATH="$HOME/.ade/bin:$HOME/.local/bin:$HOME/.npm-global/bin${PATH:+:$PATH}" ADE_DEFAULT_ROLE="cto" $HOME/.ade/bin/ade runtime stop --text >/dev/null 2>&1 || true', + ]); + expect(openSshRuntimeTransportMock).toHaveBeenCalledWith( + fakeSsh.ssh, + 'ADE_HOME="$HOME/.ade" PATH="$HOME/.ade/bin:$HOME/.local/bin:$HOME/.npm-global/bin${PATH:+:$PATH}" ADE_DEFAULT_ROLE="cto" $HOME/.ade/bin/ade rpc --stdio', + ); + expect(initializeMock).toHaveBeenCalledWith("ade-desktop-remote", APP_VERSION); + expect(callMock).toHaveBeenCalledWith("projects.list", {}); + expect(registry.update).toHaveBeenCalledWith("target-1", { + lastSeenArch: "linux-x64", + runtimeBinaryVersion: APP_VERSION, + lastConnectedAt: expect.any(Number), + }); + expect(connected.result).toMatchObject({ + arch: "linux-x64", + version: APP_VERSION, + projects: [{ projectId: "project-1", rootPath: "/srv/ade" }], + }); + expect(fakeSsh.end).not.toHaveBeenCalled(); + }); + + it("fails closed when an uploaded runtime reports the wrong version", async () => { + const resources = createTempResources(); + cleanupResources = resources.cleanup; + const fakeSsh = createFakeSsh(); + const registry = createRegistry(); + connectSshMock.mockResolvedValue(fakeSsh.ssh); + execSshMock.mockImplementation(async (_client: Client, command: string) => { + if (command === "uname -sm") return ok("Linux x86_64\n"); + if (command === "cat $HOME/.ade/bin/ade.version 2>/dev/null || true") return ok(""); + if (command === "cat $HOME/.ade/bin/ade.sha256 2>/dev/null || true") return ok(""); + if (command === "test -x $HOME/.ade/bin/ade && $HOME/.ade/bin/ade --version || true") return ok(""); + if (command === "mkdir -p $HOME/.ade/bin") return ok(""); + if (command.includes("printf '%s\\n' '2.0.0' > $HOME/.ade/bin/ade.version")) return ok(""); + if (command.includes("$HOME/.ade/bin/ade --version")) return ok("ade 1.9.0\n"); + throw new Error(`Unexpected SSH command: ${command}`); + }); + + await expect(bootstrapRemoteRuntime({ + target: uploadTarget, + registry, + resourcesPath: resources.resourcesPath, + appVersion: APP_VERSION, + })).rejects.toThrow(/uploaded ade service version mismatch/i); + + expect(fakeSsh.fastPut).toHaveBeenCalledWith(resources.binaryPath, ".ade/bin/ade", {}, expect.any(Function)); + expect(openSshRuntimeTransportMock).not.toHaveBeenCalled(); + expect(initializeMock).not.toHaveBeenCalled(); + expect(registry.update).not.toHaveBeenCalled(); + expect(fakeSsh.end).toHaveBeenCalledTimes(1); + }); + + it("uses the matching isolated remote home for Alpha channel bootstrap", async () => { + process.env.ADE_PACKAGE_CHANNEL = "alpha"; + const resources = createTempResources("darwin-arm64"); + cleanupResources = resources.cleanup; + const fakeSsh = createFakeSsh(); + const registry = createRegistry(); + connectSshMock.mockResolvedValue(fakeSsh.ssh); + execSshMock.mockImplementation(async (_client: Client, command: string) => { + if (command === "uname -sm") return ok("Darwin arm64\n"); + if (command === "cat $HOME/.ade-alpha/bin/ade.version 2>/dev/null || true") return ok(""); + if (command === "cat $HOME/.ade-alpha/bin/ade.sha256 2>/dev/null || true") return ok(""); + if (command === "test -x $HOME/.ade-alpha/bin/ade && $HOME/.ade-alpha/bin/ade --version || true") return ok(""); + if (command === "mkdir -p $HOME/.ade-alpha/bin") return ok(""); + if (command === "mkdir -p $HOME/.ade-alpha/runtime") return ok(""); + if (command.includes("printf '%s\\n' '2.0.0' > $HOME/.ade-alpha/bin/ade.version")) return ok(""); + if (command.includes("test -d $HOME/.ade-alpha/runtime/darwin-arm64/node_modules")) return ok("ok\n"); + if (command.includes("tar -xzf $HOME/.ade-alpha/runtime/ade-darwin-arm64.native.tar.gz")) return ok(""); + if (command === "codesign --force --sign - $HOME/.ade-alpha/bin/ade") return ok(""); + if (command.includes("$HOME/.ade-alpha/bin/ade --version")) return ok("ade 2.0.0\n"); + if (command.includes("$HOME/.ade-alpha/bin/ade runtime stop --text")) return ok(""); + throw new Error(`Unexpected SSH command: ${command}`); + }); + + await bootstrapRemoteRuntime({ + target: uploadTarget, + registry, + resourcesPath: resources.resourcesPath, + appVersion: APP_VERSION, + }); + + expect(fakeSsh.fastPut).toHaveBeenCalledWith(resources.binaryPath, ".ade-alpha/bin/ade", {}, expect.any(Function)); + expect(execSshMock).toHaveBeenCalledWith(fakeSsh.ssh, "codesign --force --sign - $HOME/.ade-alpha/bin/ade"); + expect(openSshRuntimeTransportMock).toHaveBeenCalledWith( + fakeSsh.ssh, + 'ADE_HOME="$HOME/.ade-alpha" PATH="$HOME/.ade-alpha/bin:$HOME/.local/bin:$HOME/.npm-global/bin${PATH:+:$PATH}" ADE_DEFAULT_ROLE="cto" ADE_PACKAGE_CHANNEL="alpha" ADE_DISABLE_RUNTIME_SERVICE_INSTALL=1 NODE_PATH="$HOME/.ade-alpha/runtime/darwin-arm64/node_modules${NODE_PATH:+:$NODE_PATH}" $HOME/.ade-alpha/bin/ade rpc --stdio', + ); + }); + + it("restarts and retries a same-version runtime daemon that is missing machine project capabilities", async () => { + const resources = createTempResources(); + cleanupResources = resources.cleanup; + const fakeSsh = createFakeSsh(); + const registry = createRegistry(); + connectSshMock.mockResolvedValue(fakeSsh.ssh); + const commands: string[] = []; + execSshMock.mockImplementation(async (_client: Client, command: string) => { + commands.push(command); + if (command === "uname -sm") return ok("Linux x86_64\n"); + if (command === "cat $HOME/.ade/bin/ade.version 2>/dev/null || true") return ok("2.0.0\n"); + if (command === "cat $HOME/.ade/bin/ade.sha256 2>/dev/null || true") return ok(`${resources.binarySha256}\n`); + if (command === "test -x $HOME/.ade/bin/ade && $HOME/.ade/bin/ade --version || true") return ok("ade 2.0.0\n"); + if (command.includes("$HOME/.ade/bin/ade runtime stop --text")) return ok(""); + throw new Error(`Unexpected SSH command: ${command}`); + }); + initializeMock + .mockResolvedValueOnce({ + runtimeInfo: { version: APP_VERSION, multiProject: true }, + capabilities: { projects: true }, + }) + .mockResolvedValueOnce({ + runtimeInfo: { version: APP_VERSION, multiProject: true }, + capabilities: { + projects: true, + machineProjects: { + browseDirectories: true, + getDetail: true, + getWorkSummary: true, + getDefaultParentDir: true, + create: true, + clone: true, + listMyGitHubRepos: true, + }, + }, + }); + + await expect(bootstrapRemoteRuntime({ + target: uploadTarget, + registry, + resourcesPath: resources.resourcesPath, + appVersion: APP_VERSION, + })).resolves.toMatchObject({ + result: { + arch: "linux-x64", + version: APP_VERSION, + }, + }); + + expect(fakeSsh.fastPut).not.toHaveBeenCalled(); + expect(openSshRuntimeTransportMock).toHaveBeenCalledTimes(2); + expect(commands).toContain( + 'ADE_HOME="$HOME/.ade" PATH="$HOME/.ade/bin:$HOME/.local/bin:$HOME/.npm-global/bin${PATH:+:$PATH}" ADE_DEFAULT_ROLE="cto" $HOME/.ade/bin/ade runtime stop --text >/dev/null 2>&1 || true', + ); + }); +}); diff --git a/apps/desktop/src/main/services/remoteRuntime/remoteBootstrap.ts b/apps/desktop/src/main/services/remoteRuntime/remoteBootstrap.ts new file mode 100644 index 000000000..35c87cd40 --- /dev/null +++ b/apps/desktop/src/main/services/remoteRuntime/remoteBootstrap.ts @@ -0,0 +1,462 @@ +import crypto from "node:crypto"; +import fs from "node:fs"; +import path from "node:path"; +import type { Client } from "ssh2"; +import type { RemoteRuntimeConnectResult, RemoteRuntimeProjectRecord, RemoteRuntimeTarget } from "../../../shared/types/remoteRuntime"; +import { RuntimeRpcClient } from "./runtimeRpcClient"; +import { connectSsh, execSsh, openSshRuntimeTransport } from "./sshTransport"; +import type { RemoteTargetRegistry } from "./remoteTargetRegistry"; + +export function normalizeRemoteArch(raw: string): { platform: string; arch: string; label: string } { + const lower = raw.toLowerCase(); + const platform = lower.includes("darwin") + ? "darwin" + : lower.includes("linux") + ? "linux" + : null; + const arch = lower.includes("arm64") || lower.includes("aarch64") + ? "arm64" + : lower.includes("x86_64") || lower.includes("amd64") + ? "x64" + : null; + if (!platform || !arch) { + throw new Error(`Unsupported remote ADE service platform: ${raw.trim() || "unknown"}. Supported targets are macOS/Linux on arm64 or x64.`); + } + return { platform, arch, label: `${platform}-${arch}` }; +} + +export function normalizeRuntimeVersion(raw: string): string | null { + const version = raw.trim().replace(/^ade\s+/i, "").trim(); + return version || null; +} + +export function selectRemoteRuntimeVersion(args: { + markerVersion: string | null; + executableVersion: string | null; +}): string | null { + return args.executableVersion ?? args.markerVersion; +} + +export function shouldUploadBundledRuntime(args: { + localBinaryAvailable: boolean; + executableVersion: string | null; + appVersion: string; + localBinarySha256?: string | null; + remoteBinarySha256?: string | null; +}): boolean { + if (!args.localBinaryAvailable) return false; + if (args.executableVersion !== args.appVersion) return true; + if (args.localBinarySha256) { + return args.remoteBinarySha256 !== args.localBinarySha256; + } + return false; +} + +function isRecord(value: unknown): value is Record { + return Boolean(value) && typeof value === "object" && !Array.isArray(value); +} + +export function validateRemoteRuntimeInitializeResult(args: { + result: unknown; + expectedVersion: string | null; +}): void { + if (!isRecord(args.result)) { + throw new Error("Remote ADE service returned an invalid initialize response."); + } + const runtimeInfo = isRecord(args.result.runtimeInfo) ? args.result.runtimeInfo : {}; + const capabilities = isRecord(args.result.capabilities) ? args.result.capabilities : {}; + if (runtimeInfo.multiProject !== true || capabilities.projects !== true) { + throw new Error("Remote ADE service does not support multi-project mode. Update the ADE service on that machine."); + } + const machineProjects = isRecord(capabilities.machineProjects) + ? capabilities.machineProjects + : {}; + const requiredMachineProjectCapabilities = [ + "browseDirectories", + "getDetail", + "getWorkSummary", + "getDefaultParentDir", + "create", + "clone", + ]; + const missingMachineProjectCapability = + requiredMachineProjectCapabilities.find((capability) => machineProjects[capability] !== true); + if (missingMachineProjectCapability) { + throw new Error( + `Remote ADE service is missing project capability '${missingMachineProjectCapability}'. Reconnect after rebuilding or reinstalling ADE on that machine.`, + ); + } + const version = typeof runtimeInfo.version === "string" && runtimeInfo.version.trim() + ? runtimeInfo.version.trim() + : null; + if (args.expectedVersion && version !== args.expectedVersion) { + throw new Error(`Remote ADE service version mismatch: expected ${args.expectedVersion}, got ${version ?? "unknown"}.`); + } +} + +type RemoteRuntimeChannel = "alpha" | "beta" | null; + +type RemoteRuntimeLayout = { + channel: RemoteRuntimeChannel; + homeDirName: ".ade" | ".ade-alpha" | ".ade-beta"; + homeDirExpr: string; + binDirExpr: string; + binDirRelative: string; + runtimeDirExpr: string; + runtimeDirRelative: string; + binaryExpr: string; + binaryRelative: string; + versionExpr: string; + sha256Expr: string; +}; + +function normalizeRemoteRuntimeChannel(value: unknown): RemoteRuntimeChannel { + const normalized = typeof value === "string" ? value.trim().toLowerCase() : ""; + if (normalized === "alpha" || normalized === "beta") return normalized; + return null; +} + +export function resolveRemoteRuntimeLayout(env: NodeJS.ProcessEnv = process.env): RemoteRuntimeLayout { + const channel = normalizeRemoteRuntimeChannel(env.ADE_PACKAGE_CHANNEL); + const homeDirName = channel === "alpha" + ? ".ade-alpha" + : channel === "beta" + ? ".ade-beta" + : ".ade"; + const homeDirExpr = `$HOME/${homeDirName}`; + const binDirExpr = `${homeDirExpr}/bin`; + const runtimeDirExpr = `${homeDirExpr}/runtime`; + return { + channel, + homeDirName, + homeDirExpr, + binDirExpr, + binDirRelative: `${homeDirName}/bin`, + runtimeDirExpr, + runtimeDirRelative: `${homeDirName}/runtime`, + binaryExpr: `${binDirExpr}/ade`, + binaryRelative: `${homeDirName}/bin/ade`, + versionExpr: `${binDirExpr}/ade.version`, + sha256Expr: `${binDirExpr}/ade.sha256`, + }; +} + +export function buildRemoteRuntimeEnvironmentPrefix(args: { + archLabel: string; + nativeDepsReady: boolean; + layout?: RemoteRuntimeLayout; +}): string { + const layout = args.layout ?? resolveRemoteRuntimeLayout(); + const parts = [ + `ADE_HOME="${layout.homeDirExpr}"`, + `PATH="${layout.binDirExpr}:$HOME/.local/bin:$HOME/.npm-global/bin${"${PATH:+:$PATH}"}"`, + `ADE_DEFAULT_ROLE="cto"`, + ]; + if (layout.channel) { + parts.push(`ADE_PACKAGE_CHANNEL="${layout.channel}"`); + parts.push("ADE_DISABLE_RUNTIME_SERVICE_INSTALL=1"); + } + if (args.nativeDepsReady) { + parts.push(`NODE_PATH="${layout.runtimeDirExpr}/${args.archLabel}/node_modules${"${NODE_PATH:+:$NODE_PATH}"}"`); + } + return `${parts.join(" ")} `; +} + +function shellQuote(value: string): string { + return `'${value.replace(/'/g, "'\\''")}'`; +} + +function bundledRuntimePath(resourcesPath: string, archLabel: string): string | null { + const candidates = [ + path.join(resourcesPath, "runtime", `ade-${archLabel}`), + path.join(resourcesPath, "app.asar.unpacked", "runtime", `ade-${archLabel}`), + path.resolve(process.cwd(), "resources", "runtime", `ade-${archLabel}`), + ]; + return candidates.find((candidate) => { + try { + return fs.statSync(candidate).isFile(); + } catch { + return false; + } + }) ?? null; +} + +function bundledNativeDepsPath(resourcesPath: string, archLabel: string): string | null { + const archiveName = `ade-${archLabel}.native.tar.gz`; + const candidates = [ + path.join(resourcesPath, "runtime", archiveName), + path.join(resourcesPath, "app.asar.unpacked", "runtime", archiveName), + path.resolve(process.cwd(), "resources", "runtime", archiveName), + ]; + return candidates.find((candidate) => { + try { + return fs.statSync(candidate).isFile(); + } catch { + return false; + } + }) ?? null; +} + +function hashRuntimeBinary(localPath: string): string { + return crypto.createHash("sha256").update(fs.readFileSync(localPath)).digest("hex"); +} + +async function uploadRuntimeBinary(client: Client, layout: RemoteRuntimeLayout, localPath: string, appVersion: string, localBinarySha256: string): Promise { + await execSsh(client, `mkdir -p ${layout.binDirExpr}`); + await new Promise((resolve, reject) => { + client.sftp((error, sftp) => { + if (error) { + reject(error); + return; + } + sftp.fastPut(localPath, layout.binaryRelative, {}, (putError) => { + sftp.end(); + if (putError) reject(putError); + else resolve(); + }); + }); + }); + await execSsh(client, [ + `chmod 700 ${layout.binDirExpr}`, + `chmod +x ${layout.binaryExpr}`, + `printf '%s\\n' ${shellQuote(appVersion)} > ${layout.versionExpr}`, + `printf '%s\\n' ${shellQuote(localBinarySha256)} > ${layout.sha256Expr}`, + `chmod 600 ${layout.versionExpr}`, + `chmod 600 ${layout.sha256Expr}`, + ].join(" && ")); +} + +async function signUploadedRuntimeBinaryIfNeeded(client: Client, layout: RemoteRuntimeLayout, platform: string): Promise { + if (platform !== "darwin") return; + const signed = await execSsh(client, `codesign --force --sign - ${layout.binaryExpr}`); + if (signed.code !== 0) { + throw new Error( + signed.stderr.trim() || + signed.stdout.trim() || + "Uploaded ADE service could not be signed on the remote Mac.", + ); + } +} + +async function uploadNativeDepsBundle(client: Client, layout: RemoteRuntimeLayout, archLabel: string, localPath: string, appVersion: string): Promise { + await execSsh(client, `mkdir -p ${layout.runtimeDirExpr}`); + const remoteArchive = `${layout.runtimeDirRelative}/ade-${archLabel}.native.tar.gz`; + await new Promise((resolve, reject) => { + client.sftp((error, sftp) => { + if (error) { + reject(error); + return; + } + sftp.fastPut(localPath, remoteArchive, {}, (putError) => { + sftp.end(); + if (putError) reject(putError); + else resolve(); + }); + }); + }); + const extract = await execSsh(client, [ + `rm -rf ${layout.runtimeDirExpr}/${archLabel}`, + `mkdir -p ${layout.runtimeDirExpr}/${archLabel}`, + `tar -xzf ${layout.runtimeDirExpr}/ade-${archLabel}.native.tar.gz -C ${layout.runtimeDirExpr}/${archLabel}`, + `printf '%s\\n' ${shellQuote(appVersion)} > ${layout.runtimeDirExpr}/${archLabel}/.ade-version`, + ].join(" && ")); + if (extract.code !== 0) { + throw new Error(extract.stderr.trim() || "Unable to unpack ADE service native dependencies on the remote machine."); + } +} + +async function stopRemoteRuntimeDaemon(client: Client, layout: RemoteRuntimeLayout, runtimeEnvPrefix: string): Promise { + await execSsh( + client, + `${runtimeEnvPrefix}${layout.binaryExpr} runtime stop --text >/dev/null 2>&1 || true`, + ); +} + +function isMissingMachineProjectCapability(error: unknown): boolean { + const message = error instanceof Error ? error.message : String(error); + return /missing project capability/i.test(message); +} + +async function openValidatedRuntimeClient(args: { + ssh: Client; + command: string; + appVersion: string; + expectedVersion: string | null; +}): Promise { + const transport = await openSshRuntimeTransport(args.ssh, args.command); + const client = new RuntimeRpcClient(transport); + try { + const initializeResult = await client.initialize( + "ade-desktop-remote", + args.appVersion, + ); + validateRemoteRuntimeInitializeResult({ + result: initializeResult, + expectedVersion: args.expectedVersion, + }); + return client; + } catch (error) { + client.close(); + throw error; + } +} + +export function coerceProjects(value: unknown): RemoteRuntimeProjectRecord[] { + if (!Array.isArray(value)) return []; + return value.flatMap((entry) => { + if (!entry || typeof entry !== "object" || Array.isArray(entry)) return []; + const record = entry as Record; + const projectId = typeof record.projectId === "string" ? record.projectId : ""; + const rootPath = typeof record.rootPath === "string" ? record.rootPath : ""; + if (!projectId || !rootPath) return []; + return [{ + projectId, + rootPath, + displayName: typeof record.displayName === "string" ? record.displayName : path.basename(rootPath), + addedAt: typeof record.addedAt === "number" ? record.addedAt : 0, + lastOpenedAt: typeof record.lastOpenedAt === "number" ? record.lastOpenedAt : 0, + gitOriginUrl: typeof record.gitOriginUrl === "string" ? record.gitOriginUrl : null, + }]; + }); +} + +export async function bootstrapRemoteRuntime(args: { + target: RemoteRuntimeTarget; + registry: RemoteTargetRegistry; + resourcesPath: string; + appVersion: string; +}): Promise<{ client: RuntimeRpcClient; result: RemoteRuntimeConnectResult; ssh: Client }> { + const ssh = await connectSsh(args.target); + try { + const uname = await execSsh(ssh, "uname -sm"); + if (uname.code !== 0) { + throw new Error(uname.stderr.trim() || "Unable to detect remote architecture."); + } + const arch = normalizeRemoteArch(uname.stdout.trim()); + const layout = resolveRemoteRuntimeLayout(); + const binaryMarkerCheck = await execSsh(ssh, `cat ${layout.versionExpr} 2>/dev/null || true`); + const markedRuntimeVersion = normalizeRuntimeVersion(binaryMarkerCheck.stdout); + const binaryHashCheck = await execSsh(ssh, `cat ${layout.sha256Expr} 2>/dev/null || true`); + const remoteBinarySha256 = binaryHashCheck.stdout.trim() || null; + const versionCheck = await execSsh(ssh, `test -x ${layout.binaryExpr} && ${layout.binaryExpr} --version || true`); + const executableRuntimeVersion = normalizeRuntimeVersion(versionCheck.stdout); + let runtimeVersion = selectRemoteRuntimeVersion({ + markerVersion: markedRuntimeVersion, + executableVersion: executableRuntimeVersion, + }); + const localBinary = bundledRuntimePath(args.resourcesPath, arch.label); + const localBinarySha256 = localBinary ? hashRuntimeBinary(localBinary) : null; + const nativeDepsBundle = bundledNativeDepsPath(args.resourcesPath, arch.label); + let runtimeUploaded = false; + if (localBinary && localBinarySha256 && shouldUploadBundledRuntime({ + localBinaryAvailable: true, + executableVersion: executableRuntimeVersion, + appVersion: args.appVersion, + localBinarySha256, + remoteBinarySha256, + })) { + await uploadRuntimeBinary(ssh, layout, localBinary, args.appVersion, localBinarySha256); + await signUploadedRuntimeBinaryIfNeeded(ssh, layout, arch.platform); + runtimeUploaded = true; + runtimeVersion = args.appVersion; + } + + let nativeDepsReady = false; + if (nativeDepsBundle) { + const nativeDepsCheck = await execSsh(ssh, [ + `test -d ${layout.runtimeDirExpr}/${arch.label}/node_modules`, + `test "$(cat ${layout.runtimeDirExpr}/${arch.label}/.ade-version 2>/dev/null)" = ${shellQuote(args.appVersion)}`, + "echo ok", + ].join(" && ") + " || true"); + const shouldUploadNativeDeps = runtimeUploaded || nativeDepsCheck.stdout.trim() !== "ok"; + if (shouldUploadNativeDeps) { + await uploadNativeDepsBundle(ssh, layout, arch.label, nativeDepsBundle, args.appVersion); + } + nativeDepsReady = true; + } + + const runtimeEnvPrefix = buildRemoteRuntimeEnvironmentPrefix({ + archLabel: arch.label, + nativeDepsReady, + layout, + }); + + if (runtimeUploaded) { + const uploadedVersionCheck = await execSsh(ssh, `${runtimeEnvPrefix}${layout.binaryExpr} --version`); + const uploadedVersion = normalizeRuntimeVersion(uploadedVersionCheck.stdout); + if (uploadedVersionCheck.code !== 0 || !uploadedVersion) { + throw new Error( + uploadedVersionCheck.stderr.trim() + || "Uploaded ADE service did not report a version on the remote machine.", + ); + } + if (uploadedVersion !== args.appVersion) { + throw new Error(`Uploaded ADE service version mismatch: expected ${args.appVersion}, got ${uploadedVersion}.`); + } + runtimeVersion = uploadedVersion; + } + + if (runtimeUploaded) { + await stopRemoteRuntimeDaemon(ssh, layout, runtimeEnvPrefix); + } + + if (!runtimeVersion) { + const pathVersionCheck = await execSsh(ssh, `${runtimeEnvPrefix}ade --version || true`); + runtimeVersion = normalizeRuntimeVersion(pathVersionCheck.stdout); + if (!runtimeVersion) { + throw new Error(`ADE service is not installed on the remote machine and no bundled ADE service is available for ${arch.label}.`); + } + } + + const command = localBinary || runtimeUploaded + ? `${runtimeEnvPrefix}${layout.binaryExpr} rpc --stdio` + : `${runtimeEnvPrefix}ade rpc --stdio`; + let client: RuntimeRpcClient; + const expectedVersion = localBinary || runtimeUploaded ? args.appVersion : null; + try { + client = await openValidatedRuntimeClient({ + ssh, + command, + appVersion: args.appVersion, + expectedVersion, + }); + } catch (error) { + if (!localBinary || !isMissingMachineProjectCapability(error)) { + throw error; + } + await stopRemoteRuntimeDaemon(ssh, layout, runtimeEnvPrefix); + client = await openValidatedRuntimeClient({ + ssh, + command, + appVersion: args.appVersion, + expectedVersion, + }); + } + const projects = coerceProjects(await client.call("projects.list", {})); + const updated = args.registry.update(args.target.id, { + lastSeenArch: arch.label, + runtimeBinaryVersion: runtimeVersion, + lastConnectedAt: Date.now(), + }); + return { + client, + ssh, + result: { + target: updated, + arch: arch.label, + version: runtimeVersion, + projects, + }, + }; + } catch (error) { + ssh.end(); + throw error; + } +} + +export async function ensureRemoteProject(client: RuntimeRpcClient, rootPath: string): Promise { + const project = await client.call("projects.add", { rootPath }); + const records = coerceProjects([project]); + if (!records[0]) throw new Error("Remote ADE service did not return a project record."); + return records[0]; +} diff --git a/apps/desktop/src/main/services/remoteRuntime/remoteConnectionPool.test.ts b/apps/desktop/src/main/services/remoteRuntime/remoteConnectionPool.test.ts new file mode 100644 index 000000000..672a59338 --- /dev/null +++ b/apps/desktop/src/main/services/remoteRuntime/remoteConnectionPool.test.ts @@ -0,0 +1,548 @@ +import type { Client } from "ssh2"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import type { + RemoteRuntimeConnectResult, + RemoteRuntimeTarget, +} from "../../../shared/types/remoteRuntime"; +import type { RuntimeRpcClient } from "./runtimeRpcClient"; +import type { RemoteTargetRegistry } from "./remoteTargetRegistry"; + +const bootstrapRemoteRuntimeMock = vi.hoisted(() => vi.fn()); +const ensureRemoteProjectMock = vi.hoisted(() => vi.fn()); + +vi.mock("electron", () => ({ + app: { + getAppPath: () => "/mock/app", + }, +})); + +vi.mock("./remoteBootstrap", () => ({ + bootstrapRemoteRuntime: bootstrapRemoteRuntimeMock, + ensureRemoteProject: ensureRemoteProjectMock, +})); + +import { RemoteConnectionPool } from "./remoteConnectionPool"; + +type DisconnectListener = (error: Error) => void; + +type FakeRuntimeRpcClient = RuntimeRpcClient & { + call: ReturnType; + close: ReturnType; + emitDisconnect(error?: Error): void; + emitNotification(method: string, params: unknown): void; + onDisconnect: ReturnType; + onNotification: ReturnType; +}; + +type SshListener = (...args: unknown[]) => void; + +type FakeSshClient = Client & { + emitOnce(event: "close" | "error", ...args: unknown[]): void; + end: ReturnType; + once: ReturnType; +}; + +const target: RemoteRuntimeTarget = { + id: "target-1", + name: "Remote", + hostname: "remote.example.test", + sshUser: "ade", + port: 22, + sshKeyPath: null, + lastSeenArch: null, + runtimeBinaryVersion: null, + lastConnectedAt: null, +}; + +function connectResult(version: string): RemoteRuntimeConnectResult { + return { + target, + arch: "linux-x64", + version, + projects: [], + }; +} + +function createClient(): FakeRuntimeRpcClient { + const listeners = new Set(); + const notificationListeners = new Map< + string, + Set<(params: unknown) => void> + >(); + const client = { + call: vi.fn(), + close: vi.fn(() => { + for (const listener of [...listeners]) { + listener(new Error("closed")); + } + }), + onDisconnect: vi.fn((callback: DisconnectListener) => { + listeners.add(callback); + return () => { + listeners.delete(callback); + }; + }), + onNotification: vi.fn( + (method: string, callback: (params: unknown) => void) => { + const existing = + notificationListeners.get(method) ?? + new Set<(params: unknown) => void>(); + existing.add(callback); + notificationListeners.set(method, existing); + return () => { + existing.delete(callback); + if (existing.size === 0) { + notificationListeners.delete(method); + } + }; + }, + ), + emitDisconnect(error = new Error("lost")) { + for (const listener of [...listeners]) { + listener(error); + } + }, + emitNotification(method: string, params: unknown) { + for (const listener of [...(notificationListeners.get(method) ?? [])]) { + listener(params); + } + }, + }; + return client as unknown as FakeRuntimeRpcClient; +} + +function createSsh(): FakeSshClient { + const listeners = new Map(); + const fake = {} as { + emitOnce?: FakeSshClient["emitOnce"]; + end?: ReturnType; + once?: ReturnType; + }; + fake.end = vi.fn(); + fake.once = vi.fn((event: string, callback: SshListener): FakeSshClient => { + const existing = listeners.get(event) ?? []; + existing.push(callback); + listeners.set(event, existing); + return fake as unknown as FakeSshClient; + }); + fake.emitOnce = (event: "close" | "error", ...args: unknown[]): void => { + const callbacks = listeners.get(event) ?? []; + listeners.delete(event); + for (const callback of callbacks) { + callback(...args); + } + }; + return fake as unknown as FakeSshClient; +} + +describe("RemoteConnectionPool", () => { + beforeEach(() => { + bootstrapRemoteRuntimeMock.mockReset(); + ensureRemoteProjectMock.mockReset(); + }); + + it("evicts cached entries after the RPC client disconnects", async () => { + const firstClient = createClient(); + const firstSsh = createSsh(); + bootstrapRemoteRuntimeMock.mockResolvedValueOnce({ + client: firstClient, + ssh: firstSsh, + result: connectResult("1.0.0"), + }); + const pool = new RemoteConnectionPool({} as RemoteTargetRegistry, "1.0.0"); + + await expect(pool.connect(target)).resolves.toMatchObject({ + version: "1.0.0", + }); + firstClient.emitDisconnect(new Error("stream closed")); + + expect(firstSsh.end).toHaveBeenCalledTimes(1); + + const secondClient = createClient(); + const secondSsh = createSsh(); + bootstrapRemoteRuntimeMock.mockResolvedValueOnce({ + client: secondClient, + ssh: secondSsh, + result: connectResult("1.0.1"), + }); + + await expect(pool.connect(target)).resolves.toMatchObject({ + version: "1.0.1", + }); + expect(bootstrapRemoteRuntimeMock).toHaveBeenCalledTimes(2); + }); + + it("evicts cached entries and closes the RPC client after SSH closes", async () => { + const firstClient = createClient(); + const firstSsh = createSsh(); + bootstrapRemoteRuntimeMock.mockResolvedValueOnce({ + client: firstClient, + ssh: firstSsh, + result: connectResult("1.0.0"), + }); + const pool = new RemoteConnectionPool({} as RemoteTargetRegistry, "1.0.0"); + + await pool.connect(target); + firstSsh.emitOnce("close"); + + expect(firstClient.close).toHaveBeenCalledTimes(1); + expect(firstSsh.end).toHaveBeenCalledTimes(1); + + bootstrapRemoteRuntimeMock.mockResolvedValueOnce({ + client: createClient(), + ssh: createSsh(), + result: connectResult("1.0.1"), + }); + + await expect(pool.connect(target)).resolves.toMatchObject({ + version: "1.0.1", + }); + expect(bootstrapRemoteRuntimeMock).toHaveBeenCalledTimes(2); + }); + + it("connects before streaming events and reconnects after disconnect", async () => { + const firstClient = createClient(); + firstClient.call.mockResolvedValueOnce({ + ok: true, + events: [ + { + id: 1, + timestamp: "2026-05-10T00:00:00.000Z", + category: "runtime", + payload: {}, + }, + ], + nextCursor: 2, + hasMore: false, + }); + bootstrapRemoteRuntimeMock.mockResolvedValueOnce({ + client: firstClient, + ssh: createSsh(), + result: connectResult("1.0.0"), + }); + const pool = new RemoteConnectionPool({} as RemoteTargetRegistry, "1.0.0"); + + await expect( + pool.streamEventsForTarget(target, "project-1", { cursor: 1, limit: 10 }), + ).resolves.toMatchObject({ + nextCursor: 2, + events: [{ id: 1, category: "runtime" }], + }); + expect(firstClient.call).toHaveBeenCalledWith("ade/actions/call", { + projectId: "project-1", + name: "stream_events", + arguments: { + cursor: 1, + limit: 10, + }, + }); + + firstClient.emitDisconnect(new Error("lost")); + + const secondClient = createClient(); + secondClient.call.mockResolvedValueOnce({ + ok: true, + events: [], + nextCursor: 2, + hasMore: false, + }); + bootstrapRemoteRuntimeMock.mockResolvedValueOnce({ + client: secondClient, + ssh: createSsh(), + result: connectResult("1.0.1"), + }); + + await expect( + pool.streamEventsForTarget(target, "project-1", { cursor: 2 }), + ).resolves.toMatchObject({ + nextCursor: 2, + events: [], + }); + expect(bootstrapRemoteRuntimeMock).toHaveBeenCalledTimes(2); + }); + + it("retries idempotent reads once when the connection closes during the request", async () => { + const firstClient = createClient(); + const firstSsh = createSsh(); + firstClient.call.mockRejectedValueOnce( + new Error("Remote runtime connection closed."), + ); + bootstrapRemoteRuntimeMock.mockResolvedValueOnce({ + client: firstClient, + ssh: firstSsh, + result: connectResult("1.0.0"), + }); + const secondClient = createClient(); + secondClient.call.mockResolvedValueOnce([ + { + projectId: "project-1", + rootPath: "/srv/app", + displayName: "app", + addedAt: 1, + lastOpenedAt: 2, + gitOriginUrl: null, + }, + ]); + bootstrapRemoteRuntimeMock.mockResolvedValueOnce({ + client: secondClient, + ssh: createSsh(), + result: connectResult("1.0.1"), + }); + const pool = new RemoteConnectionPool({} as RemoteTargetRegistry, "1.0.0"); + + await expect(pool.projectsForTarget(target)).resolves.toEqual([ + { + projectId: "project-1", + rootPath: "/srv/app", + displayName: "app", + addedAt: 1, + lastOpenedAt: 2, + gitOriginUrl: null, + }, + ]); + + expect(firstSsh.end).toHaveBeenCalledTimes(1); + expect(bootstrapRemoteRuntimeMock).toHaveBeenCalledTimes(2); + expect(firstClient.call).toHaveBeenCalledWith("projects.list", {}); + expect(secondClient.call).toHaveBeenCalledWith("projects.list", {}); + }); + + it("does not replay non-idempotent machine calls after a connection interruption", async () => { + const firstClient = createClient(); + firstClient.call.mockRejectedValueOnce( + new Error("Remote ADE service connection closed."), + ); + bootstrapRemoteRuntimeMock.mockResolvedValueOnce({ + client: firstClient, + ssh: createSsh(), + result: connectResult("1.0.0"), + }); + const secondClient = createClient(); + bootstrapRemoteRuntimeMock.mockResolvedValueOnce({ + client: secondClient, + ssh: createSsh(), + result: connectResult("1.0.1"), + }); + const pool = new RemoteConnectionPool({} as RemoteTargetRegistry, "1.0.0"); + + await expect( + pool.callMachineForTarget( + target, + "projects.clone", + { url: "https://github.com/acme/app", parentDir: "/srv" }, + { retryOnConnectionError: false }, + ), + ).rejects.toThrow(/retry the action/i); + + expect(bootstrapRemoteRuntimeMock).toHaveBeenCalledTimes(2); + expect(firstClient.call).toHaveBeenCalledWith("projects.clone", { + url: "https://github.com/acme/app", + parentDir: "/srv", + }); + expect(secondClient.call).not.toHaveBeenCalled(); + }); + + it("reconnects after interrupted mutating actions and asks the caller to retry", async () => { + const firstClient = createClient(); + firstClient.call.mockRejectedValueOnce( + new Error("Remote runtime connection failed: channel closed"), + ); + bootstrapRemoteRuntimeMock.mockResolvedValueOnce({ + client: firstClient, + ssh: createSsh(), + result: connectResult("1.0.0"), + }); + const secondClient = createClient(); + bootstrapRemoteRuntimeMock.mockResolvedValueOnce({ + client: secondClient, + ssh: createSsh(), + result: connectResult("1.0.1"), + }); + const pool = new RemoteConnectionPool({} as RemoteTargetRegistry, "1.0.0"); + + await expect( + pool.callActionForTarget(target, "project-1", { + domain: "lane", + action: "create", + args: { name: "work" }, + }), + ).rejects.toThrow(/retry the action/i); + + expect(bootstrapRemoteRuntimeMock).toHaveBeenCalledTimes(2); + expect(secondClient.call).not.toHaveBeenCalled(); + }); + + it("reconnects before running a target-scoped action after the cached SSH session drops", async () => { + const firstClient = createClient(); + bootstrapRemoteRuntimeMock.mockResolvedValueOnce({ + client: firstClient, + ssh: createSsh(), + result: connectResult("1.0.0"), + }); + const pool = new RemoteConnectionPool({} as RemoteTargetRegistry, "1.0.0"); + + await expect(pool.connect(target)).resolves.toMatchObject({ + version: "1.0.0", + }); + firstClient.emitDisconnect(new Error("lost")); + + const secondClient = createClient(); + secondClient.call.mockResolvedValueOnce({ + ok: true, + domain: "lane", + action: "list", + result: [{ id: "lane-main" }], + statusHints: { reconnected: true }, + }); + bootstrapRemoteRuntimeMock.mockResolvedValueOnce({ + client: secondClient, + ssh: createSsh(), + result: connectResult("1.0.1"), + }); + + await expect( + pool.callActionForTarget(target, "project-1", { + domain: "lane", + action: "list", + }), + ).resolves.toEqual({ + domain: "lane", + action: "list", + result: [{ id: "lane-main" }], + statusHints: { reconnected: true }, + }); + + expect(bootstrapRemoteRuntimeMock).toHaveBeenCalledTimes(2); + expect(firstClient.call).not.toHaveBeenCalled(); + expect(secondClient.call).toHaveBeenCalledWith("ade/actions/call", { + projectId: "project-1", + name: "run_ade_action", + arguments: { + domain: "lane", + action: "list", + }, + }); + }); + + it("calls project-scoped sync methods on the connected runtime", async () => { + const client = createClient(); + client.call.mockResolvedValueOnce({ + pairingPin: "123456", + connectedPeers: [], + }); + bootstrapRemoteRuntimeMock.mockResolvedValueOnce({ + client, + ssh: createSsh(), + result: connectResult("1.0.0"), + }); + const pool = new RemoteConnectionPool({} as RemoteTargetRegistry, "1.0.0"); + + await expect( + pool.callSyncForTarget(target, "project-1", "sync.getStatus", { + includeTransferReadiness: true, + }), + ).resolves.toEqual({ pairingPin: "123456", connectedPeers: [] }); + + expect(client.call).toHaveBeenCalledWith("sync.getStatus", { + projectId: "project-1", + includeTransferReadiness: true, + }); + }); + + it("subscribes to runtime event notifications and unsubscribes on cleanup", async () => { + const client = createClient(); + client.call.mockImplementation(async (method: string) => { + if (method === "runtimeEvents.subscribe") { + client.emitNotification("runtime/event", { + subscriptionId: "runtime-events-7", + projectId: "project-1", + event: { + id: 12, + timestamp: "2026-05-10T12:00:00.000Z", + category: "runtime", + payload: { type: "pty_data" }, + }, + }); + client.emitNotification("runtime/event", { + subscriptionId: "runtime-events-8", + projectId: "project-1", + event: { + id: 13, + timestamp: "2026-05-10T12:00:01.000Z", + category: "runtime", + payload: { type: "other_subscription" }, + }, + }); + return { + subscriptionId: "runtime-events-7", + nextCursor: 13, + hasMore: false, + }; + } + if (method === "runtimeEvents.unsubscribe") { + return { removed: true }; + } + return null; + }); + bootstrapRemoteRuntimeMock.mockResolvedValueOnce({ + client, + ssh: createSsh(), + result: connectResult("1.0.0"), + }); + const pool = new RemoteConnectionPool({} as RemoteTargetRegistry, "1.0.0"); + const onEvent = vi.fn(); + + const cleanup = await pool.subscribeEventsForTarget( + target, + "project-1", + { + cursor: 5, + limit: 10, + category: "runtime", + }, + onEvent, + ); + + expect(client.call).toHaveBeenCalledWith("runtimeEvents.subscribe", { + projectId: "project-1", + cursor: 5, + limit: 10, + category: "runtime", + }); + expect(onEvent).toHaveBeenCalledTimes(1); + expect(onEvent).toHaveBeenCalledWith({ + id: 12, + timestamp: "2026-05-10T12:00:00.000Z", + category: "runtime", + payload: { type: "pty_data" }, + }); + + client.emitNotification("runtime/event", { + subscriptionId: "runtime-events-7", + projectId: "project-1", + event: { + id: 14, + timestamp: "2026-05-10T12:00:02.000Z", + category: "runtime", + payload: { type: "live" }, + }, + }); + expect(onEvent).toHaveBeenCalledTimes(2); + + cleanup(); + expect(client.call).toHaveBeenCalledWith("runtimeEvents.unsubscribe", { + subscriptionId: "runtime-events-7", + }); + client.emitNotification("runtime/event", { + subscriptionId: "runtime-events-7", + projectId: "project-1", + event: { + id: 15, + timestamp: "2026-05-10T12:00:03.000Z", + category: "runtime", + payload: { type: "after_cleanup" }, + }, + }); + expect(onEvent).toHaveBeenCalledTimes(2); + }); +}); diff --git a/apps/desktop/src/main/services/remoteRuntime/remoteConnectionPool.ts b/apps/desktop/src/main/services/remoteRuntime/remoteConnectionPool.ts new file mode 100644 index 000000000..76718bcac --- /dev/null +++ b/apps/desktop/src/main/services/remoteRuntime/remoteConnectionPool.ts @@ -0,0 +1,565 @@ +import { app } from "electron"; +import type { Client } from "ssh2"; +import type { + RemoteRuntimeActionRequest, + RemoteRuntimeActionResult, + RemoteRuntimeBufferedEvent, + RemoteRuntimeConnectResult, + RemoteRuntimeEventCategory, + RemoteRuntimeStreamEventsRequest, + RemoteRuntimeStreamEventsResult, + RemoteRuntimeProjectRecord, + RemoteRuntimeTarget, +} from "../../../shared/types/remoteRuntime"; +import type { RuntimeRpcClient } from "./runtimeRpcClient"; +import { bootstrapRemoteRuntime, ensureRemoteProject } from "./remoteBootstrap"; +import type { RemoteTargetRegistry } from "./remoteTargetRegistry"; + +type PoolEntry = { + client: RuntimeRpcClient; + ssh: Client; + result: RemoteRuntimeConnectResult; + dispose?: (closeClient: boolean) => void; +}; + +type RuntimeEventNotification = { + subscriptionId: string; + projectId: string; + event: RemoteRuntimeBufferedEvent; +}; + +function isRemoteRuntimeConnectionError(error: unknown): boolean { + const message = error instanceof Error ? error.message : String(error); + return /remote (?:runtime|ADE service) connection (?:closed|failed)|stream closed|channel closed|connection lost|socket closed/i.test( + message, + ); +} + +export class RemoteConnectionPool { + private readonly entries = new Map>(); + + constructor( + private readonly registry: RemoteTargetRegistry, + private readonly appVersion: string, + ) {} + + async connect( + target: RemoteRuntimeTarget, + ): Promise { + return (await this.connectEntry(target)).result; + } + + private async connectEntry(target: RemoteRuntimeTarget): Promise { + const existing = this.entries.get(target.id); + if (existing) return await existing; + const pending = bootstrapRemoteRuntime({ + target, + registry: this.registry, + resourcesPath: process.resourcesPath ?? app.getAppPath(), + appVersion: this.appVersion, + }); + let entryPromise: Promise; + entryPromise = pending.then(({ client, ssh, result }) => { + const entry = { client, ssh, result }; + this.attachEntryLifecycle(target.id, entryPromise, entry); + return entry; + }); + this.entries.set(target.id, entryPromise); + try { + return await entryPromise; + } catch (error) { + this.entries.delete(target.id); + throw error; + } + } + + async projects(targetId: string): Promise { + const entry = await this.requireEntry(targetId); + return await entry.client.call("projects.list", {}); + } + + async projectsForTarget(target: RemoteRuntimeTarget): Promise { + return await this.withEntryForTarget( + target, + (entry) => entry.client.call("projects.list", {}), + { retryOnConnectionError: true }, + ); + } + + async callMachineForTarget( + target: RemoteRuntimeTarget, + method: string, + params: Record = {}, + options: { retryOnConnectionError?: boolean } = {}, + ): Promise { + return await this.withEntryForTarget( + target, + (entry) => entry.client.call(method, params), + { retryOnConnectionError: options.retryOnConnectionError ?? true }, + ); + } + + async addProject( + targetId: string, + rootPath: string, + ): Promise { + const entry = await this.requireEntry(targetId); + return await this.addProjectWithEntry(entry, rootPath); + } + + async addProjectForTarget( + target: RemoteRuntimeTarget, + rootPath: string, + ): Promise { + const entry = await this.connectEntry(target); + return await this.addProjectWithEntry(entry, rootPath); + } + + async callAction( + targetId: string, + projectId: string, + request: RemoteRuntimeActionRequest, + ): Promise { + const entry = await this.requireEntry(targetId); + return await this.callActionWithEntry(entry, projectId, request); + } + + async callActionForTarget( + target: RemoteRuntimeTarget, + projectId: string, + request: RemoteRuntimeActionRequest, + ): Promise { + return await this.withEntryForTarget( + target, + (entry) => this.callActionWithEntry(entry, projectId, request), + { retryOnConnectionError: false }, + ); + } + + async callSyncForTarget( + target: RemoteRuntimeTarget, + projectId: string, + method: string, + params: Record = {}, + ): Promise { + const entry = await this.connectEntry(target); + return await entry.client.call(method, { + ...params, + projectId, + }); + } + + private async addProjectWithEntry( + entry: PoolEntry, + rootPath: string, + ): Promise { + const project = await ensureRemoteProject(entry.client, rootPath); + entry.result.projects = [ + project, + ...entry.result.projects.filter( + (candidate) => candidate.projectId !== project.projectId, + ), + ]; + return project; + } + + private async callActionWithEntry( + entry: PoolEntry, + projectId: string, + request: RemoteRuntimeActionRequest, + ): Promise { + const value = await entry.client.call("ade/actions/call", { + projectId, + name: "run_ade_action", + arguments: { + domain: request.domain, + action: request.action, + ...(request.args ? { args: request.args } : {}), + ...(Object.prototype.hasOwnProperty.call(request, "arg") + ? { arg: request.arg } + : {}), + ...(request.argsList ? { argsList: request.argsList } : {}), + }, + }); + + if (value && typeof value === "object" && !Array.isArray(value)) { + const record = value as Record; + if (record.ok === false) { + const error = + record.error && + typeof record.error === "object" && + !Array.isArray(record.error) + ? (record.error as Record) + : {}; + throw new Error( + typeof error.message === "string" + ? error.message + : "Remote ADE service action failed.", + ); + } + return { + domain: + typeof record.domain === "string" ? record.domain : request.domain, + action: + typeof record.action === "string" ? record.action : request.action, + result: record.result, + statusHints: + record.statusHints && + typeof record.statusHints === "object" && + !Array.isArray(record.statusHints) + ? (record.statusHints as Record) + : {}, + }; + } + + return { + domain: request.domain, + action: request.action, + result: value, + statusHints: {}, + }; + } + + async streamEvents( + targetId: string, + projectId: string, + request: RemoteRuntimeStreamEventsRequest = {}, + ): Promise { + const entry = await this.requireEntry(targetId); + return await this.streamEventsWithEntry(entry, projectId, request); + } + + private async streamEventsWithEntry( + entry: PoolEntry, + projectId: string, + request: RemoteRuntimeStreamEventsRequest = {}, + ): Promise { + const value = await entry.client.call("ade/actions/call", { + projectId, + name: "stream_events", + arguments: { + cursor: clampCursor(request.cursor), + limit: clampLimit(request.limit), + ...(isRemoteRuntimeEventCategory(request.category) + ? { category: request.category } + : {}), + }, + }); + + if (value && typeof value === "object" && !Array.isArray(value)) { + const record = value as Record; + if (record.ok === false) { + const error = + record.error && + typeof record.error === "object" && + !Array.isArray(record.error) + ? (record.error as Record) + : {}; + throw new Error( + typeof error.message === "string" + ? error.message + : "Remote ADE service event stream failed.", + ); + } + + return { + events: Array.isArray(record.events) + ? record.events + .map(normalizeBufferedEvent) + .filter( + (event): event is RemoteRuntimeBufferedEvent => event != null, + ) + : [], + nextCursor: + typeof record.nextCursor === "number" && + Number.isFinite(record.nextCursor) + ? Math.max(0, Math.floor(record.nextCursor)) + : clampCursor(request.cursor), + hasMore: record.hasMore === true, + }; + } + + return { + events: [], + nextCursor: clampCursor(request.cursor), + hasMore: false, + }; + } + + async streamEventsForTarget( + target: RemoteRuntimeTarget, + projectId: string, + request: RemoteRuntimeStreamEventsRequest = {}, + ): Promise { + return await this.withEntryForTarget( + target, + (entry) => this.streamEventsWithEntry(entry, projectId, request), + { retryOnConnectionError: true }, + ); + } + + async subscribeEvents( + targetId: string, + projectId: string, + request: RemoteRuntimeStreamEventsRequest = {}, + onEvent: (event: RemoteRuntimeBufferedEvent) => void, + onEnded?: () => void, + ): Promise<() => void> { + const entry = await this.requireEntry(targetId); + return await subscribeToRuntimeEvents( + entry.client, + projectId, + request, + onEvent, + onEnded, + ); + } + + async subscribeEventsForTarget( + target: RemoteRuntimeTarget, + projectId: string, + request: RemoteRuntimeStreamEventsRequest = {}, + onEvent: (event: RemoteRuntimeBufferedEvent) => void, + onEnded?: () => void, + ): Promise<() => void> { + return await this.withEntryForTarget( + target, + (entry) => + subscribeToRuntimeEvents( + entry.client, + projectId, + request, + onEvent, + onEnded, + ), + { retryOnConnectionError: true }, + ); + } + + disconnect(targetId: string): void { + const existing = this.entries.get(targetId); + this.entries.delete(targetId); + void existing + ?.then((entry) => { + if (entry.dispose) { + entry.dispose(true); + return; + } + try { + entry.client.close(); + } catch {} + try { + entry.ssh.end(); + } catch {} + }) + .catch(() => {}); + } + + dispose(): void { + for (const targetId of [...this.entries.keys()]) { + this.disconnect(targetId); + } + } + + private async requireEntry(targetId: string): Promise { + const entry = this.entries.get(targetId); + if (!entry) throw new Error(`Remote target is not connected: ${targetId}`); + return await entry; + } + + private async withEntryForTarget( + target: RemoteRuntimeTarget, + operation: (entry: PoolEntry) => Promise, + options: { retryOnConnectionError: boolean }, + ): Promise { + const entry = await this.connectEntry(target); + try { + return await operation(entry); + } catch (error) { + if (!isRemoteRuntimeConnectionError(error)) throw error; + this.disconnect(target.id); + const nextEntry = await this.connectEntry(target); + if (options.retryOnConnectionError) { + return await operation(nextEntry); + } + throw new Error( + "Remote ADE service connection was interrupted before ADE could confirm the action result. " + + "ADE reconnected to the machine; retry the action if it is still needed.", + ); + } + } + + private attachEntryLifecycle( + targetId: string, + entryPromise: Promise, + entry: PoolEntry, + ): void { + let cleanedUp = false; + const evict = (closeClient: boolean) => { + if (this.entries.get(targetId) === entryPromise) { + this.entries.delete(targetId); + } + if (cleanedUp) return; + cleanedUp = true; + if (closeClient) { + try { + entry.client.close(); + } catch {} + } + try { + entry.ssh.end(); + } catch {} + }; + + entry.client.onDisconnect(() => evict(false)); + entry.ssh.once("close", () => evict(true)); + entry.ssh.once("error", () => evict(true)); + entry.dispose = evict; + } +} + +function clampCursor(value: unknown): number { + return typeof value === "number" && Number.isFinite(value) + ? Math.max(0, Math.floor(value)) + : 0; +} + +function clampLimit(value: unknown): number { + return typeof value === "number" && Number.isFinite(value) + ? Math.max(1, Math.min(1000, Math.floor(value))) + : 100; +} + +function isRemoteRuntimeEventCategory( + value: unknown, +): value is RemoteRuntimeEventCategory { + return ( + value === "orchestrator" || + value === "dag_mutation" || + value === "runtime" || + value === "mission" + ); +} + +function normalizeBufferedEvent( + value: unknown, +): RemoteRuntimeBufferedEvent | null { + if (!value || typeof value !== "object" || Array.isArray(value)) return null; + const record = value as Record; + if (typeof record.id !== "number" || !Number.isFinite(record.id)) return null; + if (typeof record.timestamp !== "string") return null; + if (!isRemoteRuntimeEventCategory(record.category)) return null; + const payload = + record.payload && + typeof record.payload === "object" && + !Array.isArray(record.payload) + ? (record.payload as Record) + : {}; + return { + id: Math.max(0, Math.floor(record.id)), + timestamp: record.timestamp, + category: record.category, + payload, + }; +} + +async function subscribeToRuntimeEvents( + client: RuntimeRpcClient, + projectId: string, + request: RemoteRuntimeStreamEventsRequest, + onEvent: (event: RemoteRuntimeBufferedEvent) => void, + onEnded?: () => void, +): Promise<() => void> { + const pendingNotifications: RuntimeEventNotification[] = []; + let closed = false; + let subscriptionId: string | null = null; + + const removeNotificationListener = client.onNotification( + "runtime/event", + (params) => { + if (closed) return; + const notification = normalizeRuntimeEventNotification(params); + if (!notification || notification.projectId !== projectId) return; + if (subscriptionId == null) { + pendingNotifications.push(notification); + return; + } + if (notification.subscriptionId === subscriptionId) { + onEvent(notification.event); + } + }, + ); + const removeDisconnectListener = client.onDisconnect(() => { + if (closed) return; + closed = true; + removeNotificationListener(); + onEnded?.(); + }); + + try { + const value = await client.call("runtimeEvents.subscribe", { + projectId, + cursor: clampCursor(request.cursor), + limit: clampLimit(request.limit), + ...(isRemoteRuntimeEventCategory(request.category) + ? { category: request.category } + : {}), + }); + subscriptionId = readSubscriptionId(value); + for (const notification of pendingNotifications) { + if (closed) break; + if (notification.subscriptionId === subscriptionId) { + onEvent(notification.event); + } + } + } catch (error) { + closed = true; + removeNotificationListener(); + removeDisconnectListener(); + throw error; + } + + return () => { + if (closed) return; + closed = true; + removeNotificationListener(); + removeDisconnectListener(); + const id = subscriptionId; + if (id != null) { + void client + .call("runtimeEvents.unsubscribe", { subscriptionId: id }) + .catch(() => {}); + } + }; +} + +function readSubscriptionId(value: unknown): string { + if (!value || typeof value !== "object" || Array.isArray(value)) { + throw new Error( + "ADE service event subscription did not return a subscription id.", + ); + } + const id = (value as Record).subscriptionId; + if (typeof id !== "string" || !id.trim()) { + throw new Error( + "ADE service event subscription did not return a subscription id.", + ); + } + return id.trim(); +} + +function normalizeRuntimeEventNotification( + value: unknown, +): RuntimeEventNotification | null { + if (!value || typeof value !== "object" || Array.isArray(value)) return null; + const record = value as Record; + const subscriptionId = + typeof record.subscriptionId === "string" && record.subscriptionId.trim() + ? record.subscriptionId.trim() + : null; + const projectId = + typeof record.projectId === "string" ? record.projectId : ""; + const event = normalizeBufferedEvent(record.event); + if (subscriptionId == null || !projectId || !event) return null; + return { subscriptionId, projectId, event }; +} diff --git a/apps/desktop/src/main/services/remoteRuntime/remoteConnectionService.ts b/apps/desktop/src/main/services/remoteRuntime/remoteConnectionService.ts new file mode 100644 index 000000000..cbe604928 --- /dev/null +++ b/apps/desktop/src/main/services/remoteRuntime/remoteConnectionService.ts @@ -0,0 +1,387 @@ +import type { + CloneProjectInput, + CreateProjectInput, + ListMyGitHubReposInput, + ListMyGitHubReposResult, + ProjectBrowseInput, + ProjectBrowseResult, + ProjectDetail, + RemoteRuntimeProjectWorkSummary, + RemoteRuntimeConnectionSnapshot, + RemoteRuntimeConnectionState, + RemoteRuntimeConnectionStatus, + RemoteRuntimeConnectResult, + RemoteRuntimeProjectRecord, + RemoteRuntimeTarget, + RemoteRuntimeTargetInput, +} from "../../../shared/types"; +import { coerceProjects } from "./remoteBootstrap"; +import type { RemoteConnectionPool } from "./remoteConnectionPool"; +import type { RemoteTargetRegistry } from "./remoteTargetRegistry"; + +type StatusPatch = Partial>; + +type RemoteConnectionServiceOptions = { + autoconnectIntervalMs?: number; +}; + +function errorMessage(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} + +function asRecord(value: unknown): Record { + return value && typeof value === "object" && !Array.isArray(value) + ? (value as Record) + : {}; +} + +function coerceConnectionProject(value: unknown): RemoteRuntimeProjectRecord { + const project = coerceProjects([value])[0]; + if (!project) + throw new Error("Remote ADE service did not return a project record."); + return project; +} + +export class RemoteConnectionService { + private readonly statusById = new Map(); + private readonly listeners = new Set< + (snapshot: RemoteRuntimeConnectionSnapshot) => void + >(); + private autoconnectTimer: NodeJS.Timeout | null = null; + + constructor( + private readonly registry: RemoteTargetRegistry, + private readonly pool: RemoteConnectionPool, + private readonly options: RemoteConnectionServiceOptions = {}, + ) {} + + listTargets(): RemoteRuntimeTarget[] { + return this.registry.list(); + } + + getTarget(targetId: string): RemoteRuntimeTarget | null { + return this.registry.get(targetId); + } + + saveTarget(input: RemoteRuntimeTargetInput): RemoteRuntimeTarget { + const target = this.registry.save(input); + this.mergeStatus(target.id, { state: "idle", lastError: null }); + return target; + } + + removeTarget(targetId: string): boolean { + this.disconnect(targetId); + this.statusById.delete(targetId); + const removed = this.registry.remove(targetId); + this.emit(); + return removed; + } + + snapshot(): RemoteRuntimeConnectionSnapshot { + const connections = this.registry + .list() + .map((target): RemoteRuntimeConnectionStatus => { + const status = this.statusById.get(target.id) ?? {}; + return { + target, + state: status.state ?? (target.lastConnectedAt ? "idle" : "idle"), + arch: status.arch ?? target.lastSeenArch, + version: status.version ?? target.runtimeBinaryVersion, + projects: status.projects ?? [], + lastError: status.lastError ?? null, + lastAttemptedAt: status.lastAttemptedAt ?? null, + connectedAt: status.connectedAt ?? target.lastConnectedAt, + }; + }); + return { + connections, + connectedCount: connections.filter((entry) => entry.state === "connected") + .length, + updatedAt: Date.now(), + }; + } + + onSnapshotChanged( + listener: (snapshot: RemoteRuntimeConnectionSnapshot) => void, + ): () => void { + this.listeners.add(listener); + return () => { + this.listeners.delete(listener); + }; + } + + startAutoconnect(): void { + for (const target of this.registry.list()) { + void this.connect(target.id).catch(() => {}); + } + if (this.autoconnectTimer) return; + this.autoconnectTimer = setInterval(() => { + void this.maintainSavedConnections(); + }, this.options.autoconnectIntervalMs ?? 30_000); + this.autoconnectTimer.unref?.(); + } + + stopAutoconnect(): void { + if (!this.autoconnectTimer) return; + clearInterval(this.autoconnectTimer); + this.autoconnectTimer = null; + } + + async connect(targetId: string): Promise { + const target = this.requireTarget(targetId); + this.mergeStatus(target.id, { + state: "connecting", + lastAttemptedAt: Date.now(), + lastError: null, + }); + try { + const result = await this.pool.connect(target); + this.mergeStatus(result.target.id, { + state: "connected", + arch: result.arch, + version: result.version, + projects: result.projects, + connectedAt: result.target.lastConnectedAt ?? Date.now(), + lastAttemptedAt: Date.now(), + lastError: null, + }); + return result; + } catch (error) { + this.mergeStatus(target.id, { + state: "error", + lastError: errorMessage(error), + lastAttemptedAt: Date.now(), + }); + throw error; + } + } + + disconnect(targetId: string): void { + this.pool.disconnect(targetId); + this.mergeStatus(targetId, { state: "idle", lastError: null }); + } + + async projects(targetId: string): Promise { + const target = this.requireTarget(targetId); + try { + const value = await this.pool.projectsForTarget(target); + const projects = coerceProjects(value); + this.mergeStatus(targetId, { + state: "connected", + projects, + lastError: null, + }); + return projects; + } catch (error) { + this.mergeStatus(targetId, { + state: "error", + lastError: errorMessage(error), + lastAttemptedAt: Date.now(), + }); + throw error; + } + } + + async addProject( + targetId: string, + rootPath: string, + ): Promise { + const target = this.requireTarget(targetId); + try { + const value = await this.pool.addProjectForTarget(target, rootPath); + const project = coerceConnectionProject(value); + this.upsertProject(targetId, project); + return project; + } catch (error) { + this.mergeStatus(targetId, { + state: "error", + lastError: errorMessage(error), + lastAttemptedAt: Date.now(), + }); + throw error; + } + } + + async browseDirectories( + targetId: string, + input: ProjectBrowseInput, + ): Promise { + return (await this.callMachine( + this.requireTarget(targetId), + "projects.browseDirectories", + asRecord(input), + )) as ProjectBrowseResult; + } + + async getProjectDetail( + targetId: string, + rootPath: string, + ): Promise { + return (await this.callMachine( + this.requireTarget(targetId), + "projects.getDetail", + { rootPath }, + )) as ProjectDetail; + } + + async getProjectWorkSummary( + targetId: string, + rootPath: string, + ): Promise { + return (await this.callMachine( + this.requireTarget(targetId), + "projects.getWorkSummary", + { rootPath }, + )) as RemoteRuntimeProjectWorkSummary; + } + + async getDefaultParentDir(targetId: string): Promise { + const value = await this.callMachine( + this.requireTarget(targetId), + "projects.getDefaultParentDir", + {}, + ); + return typeof value === "string" && value.trim() + ? value.trim() + : "~/Projects"; + } + + async createProject( + targetId: string, + input: CreateProjectInput, + ): Promise { + const value = await this.callMachine( + this.requireTarget(targetId), + "projects.create", + asRecord(input), + { retryOnConnectionError: false }, + ); + const project = coerceConnectionProject(value); + this.upsertProject(targetId, project); + return project; + } + + async cloneProject( + targetId: string, + input: CloneProjectInput, + ): Promise { + const value = await this.callMachine( + this.requireTarget(targetId), + "projects.clone", + asRecord(input), + { retryOnConnectionError: false }, + ); + const project = coerceConnectionProject(value); + this.upsertProject(targetId, project); + return project; + } + + async listMyGitHubRepos( + targetId: string, + input: ListMyGitHubReposInput, + ): Promise { + return (await this.callMachine( + this.requireTarget(targetId), + "projects.listMyGitHubRepos", + asRecord(input), + )) as ListMyGitHubReposResult; + } + + dispose(): void { + this.stopAutoconnect(); + this.pool.dispose(); + this.listeners.clear(); + } + + private async maintainSavedConnections(): Promise { + for (const target of this.registry.list()) { + const status = this.statusById.get(target.id); + if (status?.state === "connecting") continue; + if (status?.state === "connected") { + try { + await this.pool.callMachineForTarget(target, "ping", {}); + continue; + } catch { + this.pool.disconnect(target.id); + this.mergeStatus(target.id, { + state: "error", + lastError: "Remote ADE service connection was interrupted.", + }); + } + } + void this.connect(target.id).catch(() => {}); + } + } + + private async callMachine( + target: RemoteRuntimeTarget, + method: string, + params: Record, + options: { retryOnConnectionError?: boolean } = {}, + ): Promise { + try { + const result = await this.pool.callMachineForTarget( + target, + method, + params, + options, + ); + const current = this.statusById.get(target.id); + if (current?.state !== "connected") { + this.mergeStatus(target.id, { state: "connected", lastError: null }); + } + return result; + } catch (error) { + this.mergeStatus(target.id, { + state: "error", + lastError: errorMessage(error), + lastAttemptedAt: Date.now(), + }); + throw error; + } + } + + private requireTarget(targetId: string): RemoteRuntimeTarget { + const target = this.registry.get(targetId); + if (!target) throw new Error("Remote target was not found."); + return target; + } + + private upsertProject( + targetId: string, + project: RemoteRuntimeProjectRecord, + ): void { + const current = this.statusById.get(targetId); + const projects = [ + project, + ...(current?.projects ?? []).filter( + (candidate) => candidate.projectId !== project.projectId, + ), + ]; + this.mergeStatus(targetId, { + state: "connected", + projects, + lastError: null, + }); + } + + private mergeStatus(targetId: string, patch: StatusPatch): void { + const current = this.statusById.get(targetId) ?? {}; + this.statusById.set(targetId, { + ...current, + ...patch, + state: (patch.state ?? + current.state ?? + "idle") as RemoteRuntimeConnectionState, + }); + this.emit(); + } + + private emit(): void { + if (this.listeners.size === 0) return; + const snapshot = this.snapshot(); + for (const listener of [...this.listeners]) { + listener(snapshot); + } + } +} diff --git a/apps/desktop/src/main/services/remoteRuntime/remoteRuntime.e2e.test.ts b/apps/desktop/src/main/services/remoteRuntime/remoteRuntime.e2e.test.ts new file mode 100644 index 000000000..334dccba3 --- /dev/null +++ b/apps/desktop/src/main/services/remoteRuntime/remoteRuntime.e2e.test.ts @@ -0,0 +1,169 @@ +import type { Client } from "ssh2"; +import { describe, expect, it } from "vitest"; +import type { RemoteRuntimeProjectRecord, RemoteRuntimeTarget } from "../../../shared/types/remoteRuntime"; +import { bootstrapRemoteRuntime, ensureRemoteProject } from "./remoteBootstrap"; +import type { RuntimeRpcClient } from "./runtimeRpcClient"; +import type { RemoteTargetRegistry } from "./remoteTargetRegistry"; + +type RemoteRuntimeE2eConfig = { + host: string; + user: string | null; + port: number | null; + keyPath: string | null; + projectRoot: string; + resourcesPath: string; + appVersion: string; + mutate: boolean; + createPr: boolean; + chatProvider: string; + chatModel: string; +}; + +function readRemoteRuntimeE2eConfig(): RemoteRuntimeE2eConfig | null { + const host = process.env.ADE_REMOTE_RUNTIME_E2E_HOST?.trim(); + const projectRoot = process.env.ADE_REMOTE_RUNTIME_E2E_PROJECT?.trim(); + if (!host || !projectRoot) return null; + const portValue = Number.parseInt(process.env.ADE_REMOTE_RUNTIME_E2E_PORT ?? "", 10); + return { + host, + user: process.env.ADE_REMOTE_RUNTIME_E2E_USER?.trim() || null, + port: Number.isFinite(portValue) && portValue > 0 ? portValue : null, + keyPath: process.env.ADE_REMOTE_RUNTIME_E2E_KEY?.trim() || null, + projectRoot, + resourcesPath: process.env.ADE_REMOTE_RUNTIME_E2E_RESOURCES?.trim() || process.resourcesPath || process.cwd(), + appVersion: process.env.ADE_REMOTE_RUNTIME_E2E_APP_VERSION?.trim() || "0.0.0-e2e", + mutate: process.env.ADE_REMOTE_RUNTIME_E2E_MUTATE === "1", + createPr: process.env.ADE_REMOTE_RUNTIME_E2E_CREATE_PR === "1", + chatProvider: process.env.ADE_REMOTE_RUNTIME_E2E_CHAT_PROVIDER?.trim() || "codex", + chatModel: process.env.ADE_REMOTE_RUNTIME_E2E_CHAT_MODEL?.trim() || "gpt-5.4", + }; +} + +const e2eConfig = readRemoteRuntimeE2eConfig(); +const describeRemoteRuntimeE2e = e2eConfig ? describe : describe.skip; +const itMutatingRemoteRuntimeE2e = e2eConfig?.mutate ? it : it.skip; + +type RemoteRuntimeE2eContext = { + client: RuntimeRpcClient; + project: RemoteRuntimeProjectRecord; +}; + +function targetForConfig(config: RemoteRuntimeE2eConfig): RemoteRuntimeTarget { + return { + id: "remote-runtime-e2e", + name: "Remote runtime E2E", + hostname: config.host, + sshUser: config.user, + port: config.port, + sshKeyPath: config.keyPath, + lastSeenArch: null, + runtimeBinaryVersion: null, + lastConnectedAt: null, + }; +} + +async function withRemoteRuntimeE2e( + config: RemoteRuntimeE2eConfig, + run: (ctx: RemoteRuntimeE2eContext) => Promise, +): Promise { + const target = targetForConfig(config); + const registry = { + update: (_id: string, patch: Partial) => ({ ...target, ...patch }), + } as unknown as RemoteTargetRegistry; + + let client: RuntimeRpcClient | null = null; + let ssh: Client | null = null; + try { + const connected = await bootstrapRemoteRuntime({ + target, + registry, + resourcesPath: config.resourcesPath, + appVersion: config.appVersion, + }); + client = connected.client; + ssh = connected.ssh; + + expect(connected.result.projects).toEqual(expect.any(Array)); + const project = await ensureRemoteProject(client, config.projectRoot); + expect(project.rootPath).toBe(config.projectRoot); + await run({ client, project }); + } finally { + client?.close(); + ssh?.end(); + } +} + +async function runAdeAction( + client: RuntimeRpcClient, + projectId: string, + domain: string, + action: string, + args?: Record, +): Promise> { + const value = await client.call("ade/actions/call", { + projectId, + name: "run_ade_action", + arguments: { + domain, + action, + ...(args ? { args } : {}), + }, + }); + expect(value).toMatchObject({ domain, action }); + return value as Record; +} + +describeRemoteRuntimeE2e("remote runtime SSH E2E", () => { + it("connects over SSH, initializes stdio RPC, registers a project, and calls lane.list", async () => { + const config = e2eConfig; + expect(config).not.toBeNull(); + if (!config) return; + + await withRemoteRuntimeE2e(config, async ({ client, project }) => { + const lanes = await runAdeAction(client, project.projectId, "lane", "list"); + expect(Array.isArray(lanes.result)).toBe(true); + }); + }, 120_000); + + itMutatingRemoteRuntimeE2e("exercises remote lane, chat, git, and optional PR operations", async () => { + const config = e2eConfig; + expect(config).not.toBeNull(); + if (!config) return; + + const suffix = `${Date.now()}-${process.pid}`; + await withRemoteRuntimeE2e(config, async ({ client, project }) => { + const createdLane = await runAdeAction(client, project.projectId, "lane", "create", { + name: `Remote runtime E2E ${suffix}`, + branchName: `ade/remote-runtime-e2e-${suffix}`, + description: "Created by ADE_REMOTE_RUNTIME_E2E_MUTATE acceptance coverage.", + }); + const lane = createdLane.result as { id?: unknown; name?: unknown }; + expect(typeof lane.id).toBe("string"); + const laneId = lane.id as string; + + const chat = await runAdeAction(client, project.projectId, "chat", "createSession", { + laneId, + provider: config.chatProvider, + model: config.chatModel, + title: `Remote runtime E2E ${suffix}`, + openInUi: false, + }); + const chatSession = chat.result as { id?: unknown; laneId?: unknown }; + expect(typeof chatSession.id).toBe("string"); + expect(chatSession.laneId).toBe(laneId); + + const gitStatus = await runAdeAction(client, project.projectId, "git", "getSyncStatus", { laneId }); + expect(gitStatus.result).toEqual(expect.any(Object)); + + if (config.createPr) { + const pr = await runAdeAction(client, project.projectId, "pr", "createFromLane", { + laneId, + title: `Remote runtime E2E ${suffix}`, + body: "Created by ADE_REMOTE_RUNTIME_E2E_CREATE_PR acceptance coverage.", + draft: true, + }); + expect(pr.result).toEqual(expect.objectContaining({ id: expect.any(String) })); + } + }); + }, 180_000); +}); diff --git a/apps/desktop/src/main/services/remoteRuntime/remoteRuntime.offlineRpc.integration.test.ts b/apps/desktop/src/main/services/remoteRuntime/remoteRuntime.offlineRpc.integration.test.ts new file mode 100644 index 000000000..fa98421a6 --- /dev/null +++ b/apps/desktop/src/main/services/remoteRuntime/remoteRuntime.offlineRpc.integration.test.ts @@ -0,0 +1,460 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import type { Client } from "ssh2"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { startJsonRpcServer, type JsonRpcHandler, type JsonRpcTransport } from "../../../../../ade-cli/src/jsonrpc"; +import { createMultiProjectRpcRequestHandler } from "../../../../../ade-cli/src/multiProjectRpcServer"; +import { createEventBuffer } from "../../../../../ade-cli/src/eventBuffer"; +import { ProjectRegistry } from "../../../../../ade-cli/src/services/projects/projectRegistry"; +import type { ProjectScopeRegistry } from "../../../../../ade-cli/src/services/projects/projectScope"; +import type { RemoteRuntimeTarget } from "../../../shared/types/remoteRuntime"; +import type { RemoteTargetRegistry } from "./remoteTargetRegistry"; +import { RuntimeRpcClient, type RuntimeRpcTransport } from "./runtimeRpcClient"; + +const bootstrapRemoteRuntimeMock = vi.hoisted(() => vi.fn()); + +vi.mock("electron", () => ({ + app: { + getAppPath: () => "/mock/app", + }, +})); + +vi.mock("./remoteBootstrap", () => ({ + bootstrapRemoteRuntime: bootstrapRemoteRuntimeMock, + ensureRemoteProject: vi.fn(), +})); + +import { RemoteConnectionPool } from "./remoteConnectionPool"; + +type NotifiableHandler = JsonRpcHandler & { + dispose?: () => void; + setNotifier?: (notify: ((method: string, params?: unknown) => void) | null) => void; +}; + +class LinkedRuntimeTransport implements RuntimeRpcTransport { + private readonly dataCallbacks = new Set<(chunk: Buffer) => void>(); + private readonly closeCallbacks = new Set<() => void>(); + private readonly errorCallbacks = new Set<(error: Error) => void>(); + private peer: LinkedRuntimeTransport | null = null; + private closed = false; + + connect(peer: LinkedRuntimeTransport): void { + this.peer = peer; + } + + onData(callback: (chunk: Buffer) => void): void { + this.dataCallbacks.add(callback); + } + + onClose(callback: () => void): void { + this.closeCallbacks.add(callback); + } + + onError(callback: (error: Error) => void): void { + this.errorCallbacks.add(callback); + } + + write(data: string): void { + if (this.closed) throw new Error("Transport is closed."); + const peer = this.peer; + if (!peer || peer.closed) throw new Error("Remote runtime connection closed."); + queueMicrotask(() => peer.emitData(Buffer.from(data, "utf8"))); + } + + close(): void { + this.closeBoth(); + } + + fail(error: Error): void { + if (this.closed) return; + this.closed = true; + for (const callback of [...this.errorCallbacks]) callback(error); + for (const callback of [...this.closeCallbacks]) callback(); + this.peer?.closeLocal(); + } + + private closeBoth(): void { + this.closeLocal(); + this.peer?.closeLocal(); + } + + private closeLocal(): void { + if (this.closed) return; + this.closed = true; + for (const callback of [...this.closeCallbacks]) callback(); + } + + private emitData(chunk: Buffer): void { + if (this.closed) return; + for (const callback of [...this.dataCallbacks]) callback(chunk); + } +} + +function linkedTransports(): { client: LinkedRuntimeTransport; server: LinkedRuntimeTransport } { + const client = new LinkedRuntimeTransport(); + const server = new LinkedRuntimeTransport(); + client.connect(server); + server.connect(client); + return { client, server }; +} + +function startRuntimeClient(handler: NotifiableHandler): { + client: RuntimeRpcClient; + close: () => void; + serverTransport: LinkedRuntimeTransport; +} { + const transports = linkedTransports(); + const stop = startJsonRpcServer(handler, transports.server as JsonRpcTransport, { nonFatal: true }); + handler.setNotifier?.((method, params) => stop.notify(method, params)); + const client = new RuntimeRpcClient(transports.client, 5_000); + return { + client, + serverTransport: transports.server, + close: () => { + stop(); + handler.dispose?.(); + client.close(); + }, + }; +} + +function createSsh(): Client { + const listeners = new Map void>>(); + const ssh: { + end: ReturnType; + once: ReturnType; + } = { + end: vi.fn(), + once: vi.fn((event: string, callback: (...args: unknown[]) => void): typeof ssh => { + const existing = listeners.get(event) ?? []; + existing.push(callback); + listeners.set(event, existing); + return ssh; + }), + }; + return ssh as unknown as Client; +} + +function createRegistry() { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "ade-desktop-remote-rpc-")); + const projectRoot = path.join(root, "project"); + fs.mkdirSync(projectRoot, { recursive: true }); + const registry = new ProjectRegistry({ + adeDir: path.join(root, "home"), + projectsPath: path.join(root, "home", "projects.json"), + secretsDir: path.join(root, "home", "secrets"), + sockDir: path.join(root, "home", "sock"), + socketPath: path.join(root, "home", "sock", "ade.sock"), + binDir: path.join(root, "home", "bin"), + runtimeDir: path.join(root, "home", "runtime"), + }); + return { root, projectRoot, registry }; +} + +function createRuntime(projectRoot: string) { + const operation = { operationId: "op-1" }; + const runGraph = { + run: { + id: "run-1", + missionId: "mission-1", + status: "running", + metadata: {}, + }, + steps: [], + attempts: [], + edges: [], + timeline: [], + contextSnapshots: [], + handoffs: [], + runtimeEvents: [], + }; + return { + projectRoot, + workspaceRoot: projectRoot, + projectId: "project-1", + project: { rootPath: projectRoot, displayName: "project", baseRef: "main" }, + paths: { + adeDir: path.join(projectRoot, ".ade"), + logsDir: path.join(projectRoot, ".ade", "logs"), + processLogsDir: path.join(projectRoot, ".ade", "logs", "processes"), + testLogsDir: path.join(projectRoot, ".ade", "logs", "tests"), + transcriptsDir: path.join(projectRoot, ".ade", "transcripts"), + worktreesDir: path.join(projectRoot, ".ade", "worktrees"), + dbPath: path.join(projectRoot, ".ade", "ade.db"), + }, + logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() }, + laneService: { + list: vi.fn(async () => [{ id: "lane-main", name: "Main", archivedAt: null }]), + listUnregisteredWorktrees: vi.fn(async () => []), + }, + sessionService: { + get: vi.fn(), + readTranscriptTail: vi.fn(() => ""), + }, + operationService: { + start: vi.fn(() => operation), + finish: vi.fn(), + list: vi.fn(() => []), + }, + eventBuffer: createEventBuffer(), + orchestratorService: { + listRuns: vi.fn(() => [runGraph.run]), + getRunGraph: vi.fn(() => runGraph), + }, + dispose: vi.fn(), + }; +} + +function createScopeRegistry(projectId: string, runtime: ReturnType): ProjectScopeRegistry { + return { + get: vi.fn(async () => ({ + registryProjectId: projectId, + record: { + projectId, + rootPath: runtime.projectRoot, + displayName: "project", + addedAt: 1, + lastOpenedAt: 1, + gitOriginUrl: null, + }, + runtime, + dispose: vi.fn(), + })), + ensureSyncHost: vi.fn(), + dispose: vi.fn(), + disposeAll: vi.fn(), + } as unknown as ProjectScopeRegistry; +} + +const target: RemoteRuntimeTarget = { + id: "target-1", + name: "Remote", + hostname: "remote.example.test", + sshUser: "ade", + port: 22, + sshKeyPath: null, + lastSeenArch: null, + runtimeBinaryVersion: null, + lastConnectedAt: null, +}; + +describe("remote runtime offline RPC integration", () => { + const clients: Array<{ close: () => void }> = []; + + beforeEach(() => { + bootstrapRemoteRuntimeMock.mockReset(); + }); + + afterEach(() => { + for (const client of clients.splice(0)) { + client.close(); + } + }); + + it("routes a remote lane action through real JSON-RPC and the multi-project handler", async () => { + const { projectRoot, registry } = createRegistry(); + const project = registry.add(projectRoot); + const runtime = createRuntime(projectRoot); + const scopeRegistry = createScopeRegistry(project.projectId, runtime); + const handler = createMultiProjectRpcRequestHandler({ + serverVersion: "1.2.3", + projectRegistry: registry, + scopeRegistry, + disposeScopesOnDispose: false, + }); + const runtimeClient = startRuntimeClient(handler); + clients.push(runtimeClient); + await runtimeClient.client.initialize("desktop-offline-test", "1.2.3"); + bootstrapRemoteRuntimeMock.mockResolvedValueOnce({ + client: runtimeClient.client, + ssh: createSsh(), + result: { + target, + arch: "linux-x64", + version: "1.2.3", + projects: [project], + }, + }); + const pool = new RemoteConnectionPool({} as RemoteTargetRegistry, "1.2.3"); + + await expect(pool.callActionForTarget(target, project.projectId, { + domain: "lane", + action: "list", + args: { includeArchived: false }, + })).resolves.toEqual({ + domain: "lane", + action: "list", + result: [{ id: "lane-main", name: "Main", archivedAt: null }], + statusHints: { + operationId: null, + testRunId: null, + chatSessionId: null, + runId: null, + missionId: null, + }, + }); + + expect(runtime.laneService.list).toHaveBeenCalledWith({ includeArchived: false }); + expect(scopeRegistry.get).toHaveBeenCalledWith(project.projectId); + }); + + it("retries a project registry read after the JSON-RPC transport disconnects mid-request", async () => { + const { projectRoot, registry } = createRegistry(); + const project = registry.add(projectRoot); + const firstHandler: JsonRpcHandler = async (request) => { + if (request.method === "projects.list") { + firstRuntime.serverTransport.fail(new Error("channel closed")); + await new Promise(() => {}); + } + return request.method === "ade/initialize" + ? { runtimeInfo: { version: "1.2.3", multiProject: true }, capabilities: { projects: true } } + : {}; + }; + const firstRuntime = startRuntimeClient(firstHandler); + const secondHandler = createMultiProjectRpcRequestHandler({ + serverVersion: "1.2.4", + projectRegistry: registry, + disposeScopesOnDispose: false, + }); + const secondRuntime = startRuntimeClient(secondHandler); + clients.push(firstRuntime, secondRuntime); + await firstRuntime.client.initialize("desktop-offline-test", "1.2.3"); + await secondRuntime.client.initialize("desktop-offline-test", "1.2.4"); + bootstrapRemoteRuntimeMock + .mockResolvedValueOnce({ + client: firstRuntime.client, + ssh: createSsh(), + result: { target, arch: "linux-x64", version: "1.2.3", projects: [] }, + }) + .mockResolvedValueOnce({ + client: secondRuntime.client, + ssh: createSsh(), + result: { target, arch: "linux-x64", version: "1.2.4", projects: [project] }, + }); + const pool = new RemoteConnectionPool({} as RemoteTargetRegistry, "1.2.4"); + + await expect(pool.projectsForTarget(target)).resolves.toEqual([project]); + expect(bootstrapRemoteRuntimeMock).toHaveBeenCalledTimes(2); + }); + + it("reconnects but does not replay an interrupted mutating action", async () => { + const { projectRoot, registry } = createRegistry(); + const project = registry.add(projectRoot); + const firstHandler: JsonRpcHandler = async (request) => { + if (request.method === "ade/actions/call") { + firstRuntime.serverTransport.fail(new Error("channel closed")); + await new Promise(() => {}); + } + return request.method === "ade/initialize" + ? { runtimeInfo: { version: "1.2.3", multiProject: true }, capabilities: { projects: true } } + : {}; + }; + const firstRuntime = startRuntimeClient(firstHandler); + const secondHandler = vi.fn(async () => ({ + runtimeInfo: { version: "1.2.4", multiProject: true }, + capabilities: { projects: true }, + })); + const secondRuntime = startRuntimeClient(secondHandler); + clients.push(firstRuntime, secondRuntime); + await firstRuntime.client.initialize("desktop-offline-test", "1.2.3"); + await secondRuntime.client.initialize("desktop-offline-test", "1.2.4"); + bootstrapRemoteRuntimeMock + .mockResolvedValueOnce({ + client: firstRuntime.client, + ssh: createSsh(), + result: { target, arch: "linux-x64", version: "1.2.3", projects: [project] }, + }) + .mockResolvedValueOnce({ + client: secondRuntime.client, + ssh: createSsh(), + result: { target, arch: "linux-x64", version: "1.2.4", projects: [project] }, + }); + const pool = new RemoteConnectionPool({} as RemoteTargetRegistry, "1.2.4"); + + await expect(pool.callActionForTarget(target, project.projectId, { + domain: "orchestrator_core", + action: "resumeRun", + args: { runId: "run-1" }, + })).rejects.toThrow(/retry the action/i); + + expect(bootstrapRemoteRuntimeMock).toHaveBeenCalledTimes(2); + expect(secondHandler).not.toHaveBeenCalledWith(expect.objectContaining({ method: "ade/actions/call" })); + }); + + it("reattaches to checkpointed run state and missed events after reconnect", async () => { + const { projectRoot, registry } = createRegistry(); + const project = registry.add(projectRoot); + const runtime = createRuntime(projectRoot); + const scopeRegistry = createScopeRegistry(project.projectId, runtime); + const handler = createMultiProjectRpcRequestHandler({ + serverVersion: "1.2.4", + projectRegistry: registry, + scopeRegistry, + disposeScopesOnDispose: false, + }); + runtime.eventBuffer.push({ + timestamp: "2026-05-10T12:00:00.000Z", + category: "mission", + payload: { type: "mission_started", missionId: "mission-1", runId: "run-1" }, + }); + const firstRuntime = startRuntimeClient(handler); + const secondRuntime = startRuntimeClient(handler); + clients.push(firstRuntime, secondRuntime); + await firstRuntime.client.initialize("desktop-offline-test", "1.2.3"); + await secondRuntime.client.initialize("desktop-offline-test", "1.2.4"); + bootstrapRemoteRuntimeMock + .mockResolvedValueOnce({ + client: firstRuntime.client, + ssh: createSsh(), + result: { target, arch: "linux-x64", version: "1.2.3", projects: [project] }, + }) + .mockResolvedValueOnce({ + client: secondRuntime.client, + ssh: createSsh(), + result: { target, arch: "linux-x64", version: "1.2.4", projects: [project] }, + }); + const pool = new RemoteConnectionPool({} as RemoteTargetRegistry, "1.2.4"); + + const initialEvents = await pool.streamEventsForTarget(target, project.projectId, { + cursor: 0, + limit: 10, + }); + expect(initialEvents.events.map((event) => event.payload.type)).toEqual(["mission_started"]); + expect(initialEvents.nextCursor).toBe(1); + + runtime.eventBuffer.push({ + timestamp: "2026-05-10T12:00:01.000Z", + category: "mission", + payload: { type: "mission_resume_recovered", missionId: "mission-1", runId: "run-1" }, + }); + firstRuntime.serverTransport.fail(new Error("channel closed")); + + await expect(pool.streamEventsForTarget(target, project.projectId, { + cursor: initialEvents.nextCursor, + limit: 10, + })).resolves.toMatchObject({ + events: [{ + id: 2, + category: "mission", + payload: { type: "mission_resume_recovered", missionId: "mission-1", runId: "run-1" }, + }], + nextCursor: 2, + hasMore: false, + }); + + await expect(pool.callActionForTarget(target, project.projectId, { + domain: "orchestrator_core", + action: "getRunGraph", + args: { runId: "run-1", timelineLimit: 0 }, + })).resolves.toMatchObject({ + domain: "orchestrator_core", + action: "getRunGraph", + result: { + run: { id: "run-1", missionId: "mission-1", status: "running" }, + }, + }); + expect(runtime.orchestratorService.getRunGraph).toHaveBeenCalledWith({ runId: "run-1", timelineLimit: 0 }); + expect(bootstrapRemoteRuntimeMock).toHaveBeenCalledTimes(2); + }); +}); diff --git a/apps/desktop/src/main/services/remoteRuntime/remoteTargetRegistry.test.ts b/apps/desktop/src/main/services/remoteRuntime/remoteTargetRegistry.test.ts new file mode 100644 index 000000000..c6c711d94 --- /dev/null +++ b/apps/desktop/src/main/services/remoteRuntime/remoteTargetRegistry.test.ts @@ -0,0 +1,34 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; +import { RemoteTargetRegistry } from "./remoteTargetRegistry"; + +const originalAdeHome = process.env.ADE_HOME; + +afterEach(() => { + if (originalAdeHome === undefined) delete process.env.ADE_HOME; + else process.env.ADE_HOME = originalAdeHome; +}); + +describe("RemoteTargetRegistry", () => { + it("stores targets under the active ADE_HOME", () => { + const adeHome = fs.mkdtempSync(path.join(os.tmpdir(), "ade-remote-targets-")); + process.env.ADE_HOME = adeHome; + + const registry = new RemoteTargetRegistry(); + const target = registry.save({ + name: "Mac Studio", + hostname: "100.75.20.63", + sshUser: "admin", + port: null, + sshKeyPath: null, + }); + + expect(registry.path).toBe(path.join(adeHome, "secrets", "remote-machines.json")); + expect(JSON.parse(fs.readFileSync(registry.path, "utf8"))).toMatchObject({ + version: 1, + targets: [target], + }); + }); +}); diff --git a/apps/desktop/src/main/services/remoteRuntime/remoteTargetRegistry.ts b/apps/desktop/src/main/services/remoteRuntime/remoteTargetRegistry.ts new file mode 100644 index 000000000..336c0819a --- /dev/null +++ b/apps/desktop/src/main/services/remoteRuntime/remoteTargetRegistry.ts @@ -0,0 +1,126 @@ +import { createHash } from "node:crypto"; +import fs from "node:fs"; +import path from "node:path"; +import { resolveMachineAdeLayout } from "../../../../../ade-cli/src/services/projects/machineLayout"; +import type { RemoteRuntimeTarget, RemoteRuntimeTargetInput } from "../../../shared/types/remoteRuntime"; + +type RegistryFile = { + version: 1; + targets: RemoteRuntimeTarget[]; +}; + +function registryPath(): string { + return path.join(resolveMachineAdeLayout().secretsDir, "remote-machines.json"); +} + +function normalizePort(port: number | null | undefined): number | null { + if (!port || !Number.isFinite(port)) return null; + return Math.max(1, Math.min(65_535, Math.floor(port))); +} + +function defaultName(input: RemoteRuntimeTargetInput): string { + return input.name?.trim() || input.hostname.trim(); +} + +function stableTargetId(input: RemoteRuntimeTargetInput): string { + const sshUser = input.sshUser?.trim() ?? ""; + const port = normalizePort(input.port) ?? ""; + return createHash("sha256") + .update(`${sshUser}@${input.hostname.trim()}:${port}`) + .digest("hex") + .slice(0, 24); +} + +function coerceTarget(value: unknown): RemoteRuntimeTarget | null { + if (!value || typeof value !== "object" || Array.isArray(value)) return null; + const record = value as Record; + const hostname = typeof record.hostname === "string" ? record.hostname.trim() : ""; + const sshUser = typeof record.sshUser === "string" && record.sshUser.trim() ? record.sshUser.trim() : null; + if (!hostname) return null; + const fallbackInput: RemoteRuntimeTargetInput = { hostname, sshUser, port: normalizePort(typeof record.port === "number" ? record.port : null) }; + return { + id: typeof record.id === "string" && record.id.trim() ? record.id.trim() : stableTargetId(fallbackInput), + name: typeof record.name === "string" && record.name.trim() ? record.name.trim() : hostname, + hostname, + sshUser, + port: normalizePort(typeof record.port === "number" ? record.port : null), + sshKeyPath: typeof record.sshKeyPath === "string" && record.sshKeyPath.trim() ? record.sshKeyPath.trim() : null, + lastSeenArch: typeof record.lastSeenArch === "string" && record.lastSeenArch.trim() ? record.lastSeenArch.trim() : null, + runtimeBinaryVersion: typeof record.runtimeBinaryVersion === "string" && record.runtimeBinaryVersion.trim() ? record.runtimeBinaryVersion.trim() : null, + lastConnectedAt: typeof record.lastConnectedAt === "number" && Number.isFinite(record.lastConnectedAt) ? record.lastConnectedAt : null, + }; +} + +export class RemoteTargetRegistry { + readonly path = registryPath(); + + list(): RemoteRuntimeTarget[] { + return this.read().targets; + } + + get(id: string): RemoteRuntimeTarget | null { + return this.list().find((target) => target.id === id) ?? null; + } + + save(input: RemoteRuntimeTargetInput): RemoteRuntimeTarget { + const hostname = input.hostname.trim(); + const sshUser = input.sshUser?.trim() || null; + if (!hostname) throw new Error("Remote hostname is required."); + const file = this.read(); + const id = stableTargetId(input); + const existing = file.targets.find((target) => target.id === id) ?? null; + const next: RemoteRuntimeTarget = { + id, + name: defaultName(input), + hostname, + sshUser, + port: normalizePort(input.port), + sshKeyPath: input.sshKeyPath?.trim() || null, + lastSeenArch: existing?.lastSeenArch ?? null, + runtimeBinaryVersion: existing?.runtimeBinaryVersion ?? null, + lastConnectedAt: existing?.lastConnectedAt ?? null, + }; + file.targets = [next, ...file.targets.filter((target) => target.id !== id)]; + this.write(file); + return next; + } + + update(id: string, patch: Partial): RemoteRuntimeTarget { + const file = this.read(); + const index = file.targets.findIndex((target) => target.id === id); + if (index < 0) throw new Error(`Unknown remote target: ${id}`); + const next = { ...file.targets[index]!, ...patch, id }; + file.targets[index] = next; + this.write(file); + return next; + } + + remove(id: string): boolean { + const file = this.read(); + const nextTargets = file.targets.filter((target) => target.id !== id); + if (nextTargets.length === file.targets.length) return false; + this.write({ version: 1, targets: nextTargets }); + return true; + } + + private read(): RegistryFile { + try { + const raw = fs.readFileSync(this.path, "utf8"); + const parsed = JSON.parse(raw) as unknown; + const targets = parsed && typeof parsed === "object" && !Array.isArray(parsed) && Array.isArray((parsed as { targets?: unknown }).targets) + ? (parsed as { targets: unknown[] }).targets.map(coerceTarget).filter((target): target is RemoteRuntimeTarget => target != null) + : []; + return { version: 1, targets }; + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") return { version: 1, targets: [] }; + throw error; + } + } + + private write(file: RegistryFile): void { + fs.mkdirSync(path.dirname(this.path), { recursive: true, mode: 0o700 }); + const tmp = `${this.path}.${process.pid}.${Date.now()}.tmp`; + fs.writeFileSync(tmp, `${JSON.stringify(file, null, 2)}\n`, { encoding: "utf8", mode: 0o600 }); + fs.renameSync(tmp, this.path); + } +} diff --git a/apps/desktop/src/main/services/remoteRuntime/runtimeDiscovery.test.ts b/apps/desktop/src/main/services/remoteRuntime/runtimeDiscovery.test.ts new file mode 100644 index 000000000..4618acf9e --- /dev/null +++ b/apps/desktop/src/main/services/remoteRuntime/runtimeDiscovery.test.ts @@ -0,0 +1,145 @@ +import { describe, expect, it } from "vitest"; +import { + discoveredRuntimeFromBonjourService, + discoveredRuntimesFromTailscaleStatus, +} from "./runtimeDiscovery"; + +describe("runtimeDiscovery", () => { + it("parses ADE sync Bonjour metadata into a discovered machine", () => { + const discovered = discoveredRuntimeFromBonjourService( + { + name: "ADE Sync Studio 8787", + fqdn: "ADE Sync Studio 8787._ade-sync._tcp.local", + host: "studio.local", + port: 8787, + addresses: ["127.0.0.1", "192.168.1.42"], + txt: { + deviceId: "device-123", + deviceName: "Studio", + runtimeKind: "daemon", + runtimeVersion: "0.0.0", + projects: "project-a, project-b", + projectCount: "2", + host: "192.168.1.42", + addresses: "127.0.0.1,100.75.20.63", + tailscaleDnsName: "studio.tailnet.ts.net", + tailscaleIp: "100.75.20.63", + }, + }, + 1234, + ); + + expect(discovered).toMatchObject({ + id: "device-123::ADE Sync Studio 8787._ade-sync._tcp.local", + serviceName: "ADE Sync Studio 8787", + machineName: "Studio", + hostIdentity: "device-123", + hostName: "studio.local", + port: 8787, + addresses: ["192.168.1.42", "100.75.20.63", "127.0.0.1"], + primaryRoute: "192.168.1.42", + tailscaleAddress: "studio.tailnet.ts.net", + runtimeKind: "daemon", + runtimeVersion: "0.0.0", + projectIds: ["project-a", "project-b"], + projectCount: 2, + lastSeenAt: 1234, + }); + }); + + it("falls back to service metadata when TXT identity is partial", () => { + const discovered = discoveredRuntimeFromBonjourService( + { + name: "ADE Sync Laptop 8787", + host: "laptop.local", + port: 0, + addresses: ["127.0.0.1"], + txt: { + port: "8787", + runtimeKind: "", + }, + }, + 5678, + ); + + expect(discovered).toMatchObject({ + id: "ADE Sync Laptop 8787@laptop.local:8787", + machineName: "laptop.local", + hostIdentity: null, + hostName: "laptop.local", + port: 8787, + addresses: ["127.0.0.1"], + primaryRoute: "laptop.local", + runtimeKind: null, + runtimeVersion: null, + projectIds: [], + projectCount: null, + lastSeenAt: 5678, + }); + }); + + it("turns Tailscale peers into SSH discovery targets", () => { + const discovered = discoveredRuntimesFromTailscaleStatus( + { + Peer: { + "nodekey:abc": { + ID: "peer-1", + HostName: "aruls-mac-studio", + DNSName: "aruls-mac-studio.tail7497a6.ts.net.", + OS: "macOS", + TailscaleIPs: ["100.75.20.63", "fd7a:115c:a1e0::1"], + Online: true, + }, + }, + }, + 9012, + ); + + expect(discovered).toHaveLength(1); + expect(discovered[0]).toMatchObject({ + id: "tailscale:peer-1", + serviceName: "Tailscale peer", + machineName: "aruls-mac-studio", + hostIdentity: "peer-1", + hostName: "aruls-mac-studio", + port: 22, + addresses: ["100.75.20.63", "aruls-mac-studio.tail7497a6.ts.net"], + primaryRoute: "aruls-mac-studio.tail7497a6.ts.net", + tailscaleAddress: "aruls-mac-studio.tail7497a6.ts.net", + runtimeKind: "tailscale-peer", + runtimeVersion: null, + projectIds: [], + projectCount: null, + lastSeenAt: 9012, + }); + }); + + it("skips mobile Tailscale peers in the SSH discovery list", () => { + const discovered = discoveredRuntimesFromTailscaleStatus( + { + Peer: { + "nodekey:iphone": { + ID: "peer-phone", + HostName: "iPhone", + DNSName: "iphone.tail7497a6.ts.net.", + OS: "iOS", + TailscaleIPs: ["100.75.20.64"], + Online: true, + }, + "nodekey:mac": { + ID: "peer-mac", + HostName: "studio", + DNSName: "studio.tail7497a6.ts.net.", + OS: "macOS", + TailscaleIPs: ["100.75.20.63"], + Online: true, + }, + }, + }, + 123, + ); + + expect(discovered).toHaveLength(1); + expect(discovered[0]?.machineName).toBe("studio"); + }); +}); diff --git a/apps/desktop/src/main/services/remoteRuntime/runtimeDiscovery.ts b/apps/desktop/src/main/services/remoteRuntime/runtimeDiscovery.ts new file mode 100644 index 000000000..0829e4d95 --- /dev/null +++ b/apps/desktop/src/main/services/remoteRuntime/runtimeDiscovery.ts @@ -0,0 +1,304 @@ +import { execFile } from "node:child_process"; +import { promisify } from "node:util"; +import { + Bonjour, + type Browser, + type Service as BonjourService, +} from "bonjour-service"; +import { resolveTailscaleCliPath } from "../../../../../ade-cli/src/services/sync/resolveTailscaleCliPath"; +import type { RemoteRuntimeDiscoveredMachine } from "../../../shared/types/remoteRuntime"; + +export const ADE_SYNC_MDNS_SERVICE_TYPE = "ade-sync"; +const TAILSCALE_SSH_PORT = 22; +const execFileAsync = promisify(execFile); + +type BonjourServiceLike = Partial & { + rawTxt?: unknown; +}; + +type TxtRecord = Record; +type TailscaleStatusPeer = { + ID?: unknown; + HostName?: unknown; + DNSName?: unknown; + OS?: unknown; + TailscaleIPs?: unknown; + Online?: unknown; +}; + +type TailscaleStatus = { + Peer?: unknown; +}; + +function trimmed(value: unknown): string | null { + if (value == null || value === false) return null; + const text = Buffer.isBuffer(value) ? value.toString("utf8") : String(value); + const next = text.trim(); + return next.length > 0 ? next : null; +} + +function normalizeTxtRecord(value: unknown): TxtRecord { + if (!value || typeof value !== "object" || Array.isArray(value)) return {}; + const record: TxtRecord = {}; + for (const [key, entry] of Object.entries(value as Record)) { + const normalizedKey = trimmed(key); + const normalizedValue = trimmed(entry); + if (!normalizedKey || normalizedValue == null) continue; + record[normalizedKey] = normalizedValue; + } + return record; +} + +function splitCsv(value: string | null): string[] { + if (!value) return []; + return value + .split(",") + .map((entry) => entry.trim()) + .filter(Boolean); +} + +function parsePositiveInteger(value: unknown): number | null { + const text = trimmed(value); + if (!text) return null; + const parsed = Number.parseInt(text, 10); + return Number.isFinite(parsed) && parsed > 0 ? parsed : null; +} + +function uniqueStrings(values: Array): string[] { + const seen = new Set(); + const result: string[] = []; + for (const value of values) { + const next = trimmed(value); + if (!next || seen.has(next)) continue; + seen.add(next); + result.push(next); + } + return result; +} + +function isLoopbackRoute(host: string): boolean { + const lower = host.toLowerCase(); + return ( + lower === "localhost" || + lower === "::1" || + lower === "0.0.0.0" || + lower.startsWith("127.") + ); +} + +function isTailscaleRoute(host: string): boolean { + const lower = host.toLowerCase().replace(/\.$/, ""); + if (lower.endsWith(".ts.net")) return true; + const match = /^100\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/.exec(lower); + if (!match) return false; + const second = Number.parseInt(match[1] ?? "", 10); + return second >= 64 && second <= 127; +} + +function normalizeTailscaleDnsName(value: unknown): string | null { + const text = trimmed(value); + if (!text) return null; + const normalized = text.replace(/\.$/, ""); + return normalized.endsWith(".ts.net") ? normalized : null; +} + +function isSshCapableTailscalePeer(osValue: unknown): boolean { + const os = trimmed(osValue)?.toLowerCase(); + if (!os) return true; + return os !== "ios" && os !== "android" && os !== "tvos" && os !== "watchos"; +} + +function orderAddresses(addresses: string[]): string[] { + const nonLoopback = addresses.filter((host) => !isLoopbackRoute(host)); + const loopback = addresses.filter(isLoopbackRoute); + return [...nonLoopback, ...loopback]; +} + +function firstNonEmpty(values: Array): string | null { + for (const value of values) { + const next = trimmed(value); + if (next) return next; + } + return null; +} + +export function discoveredRuntimeFromBonjourService( + service: BonjourServiceLike, + nowMs = Date.now(), +): RemoteRuntimeDiscoveredMachine | null { + const txt = normalizeTxtRecord(service.txt); + const serviceName = + firstNonEmpty([service.name, service.fqdn, "ADE Sync"]) ?? "ADE Sync"; + const servicePort = + parsePositiveInteger(service.port) ?? parsePositiveInteger(txt.port); + const serviceKey = + firstNonEmpty([ + service.fqdn, + `${serviceName}@${firstNonEmpty([service.host, txt.host]) ?? "unknown"}:${servicePort ?? ""}`, + ]) ?? serviceName; + const hostName = firstNonEmpty([service.host]); + const machineName = + firstNonEmpty([txt.deviceName, hostName, serviceName]) ?? serviceName; + const hostIdentity = firstNonEmpty([txt.deviceId]); + const port = servicePort ?? 8787; + const announcedAddresses = splitCsv(txt.addresses); + const tailscaleAddress = firstNonEmpty( + [txt.tailscaleDnsName, txt.tailscaleIp].filter((value): value is string => + Boolean(value && isTailscaleRoute(value)), + ), + ); + const addresses = orderAddresses( + uniqueStrings([ + txt.host, + ...(service.addresses ?? []), + ...announcedAddresses, + txt.tailscaleIp, + ]), + ); + const primaryRoute = firstNonEmpty([ + addresses.find( + (address) => !isLoopbackRoute(address) && !isTailscaleRoute(address), + ), + tailscaleAddress, + addresses.find((address) => !isLoopbackRoute(address)), + hostName, + addresses[0], + ]); + const projectIds = splitCsv(txt.projects); + const projectCount = + parsePositiveInteger(txt.projectCount) ?? + (projectIds.length > 0 ? projectIds.length : null); + + return { + id: hostIdentity ? `${hostIdentity}::${serviceKey}` : serviceKey, + serviceName, + machineName, + hostIdentity, + hostName, + port, + addresses, + primaryRoute, + tailscaleAddress, + runtimeKind: firstNonEmpty([txt.runtimeKind]), + runtimeVersion: firstNonEmpty([txt.runtimeVersion]), + projectIds, + projectCount, + lastSeenAt: nowMs, + }; +} + +export function discoveredRuntimesFromTailscaleStatus( + value: unknown, + nowMs = Date.now(), +): RemoteRuntimeDiscoveredMachine[] { + if (!value || typeof value !== "object" || Array.isArray(value)) return []; + const peers = (value as TailscaleStatus).Peer; + if (!peers || typeof peers !== "object" || Array.isArray(peers)) return []; + + const discovered: RemoteRuntimeDiscoveredMachine[] = []; + for (const [peerKey, rawPeer] of Object.entries( + peers as Record, + )) { + if (!rawPeer || typeof rawPeer !== "object" || Array.isArray(rawPeer)) + continue; + const peer = rawPeer as TailscaleStatusPeer; + if (!isSshCapableTailscalePeer(peer.OS)) continue; + const tailscaleIps = Array.isArray(peer.TailscaleIPs) + ? peer.TailscaleIPs.map((entry) => trimmed(entry)).filter( + (entry): entry is string => Boolean(entry && isTailscaleRoute(entry)), + ) + : []; + const dnsName = normalizeTailscaleDnsName(peer.DNSName); + const tailscaleAddress = firstNonEmpty([dnsName, tailscaleIps[0]]); + if (!tailscaleAddress) continue; + + const hostName = trimmed(peer.HostName) ?? dnsName; + const machineName = trimmed(peer.HostName) ?? dnsName ?? tailscaleAddress; + const hostIdentity = trimmed(peer.ID) ?? trimmed(peerKey); + const online = peer.Online === true; + const addresses = uniqueStrings([...tailscaleIps, dnsName]); + discovered.push({ + id: `tailscale:${hostIdentity ?? tailscaleAddress}`, + serviceName: "Tailscale peer", + machineName, + hostIdentity, + hostName, + port: TAILSCALE_SSH_PORT, + addresses, + primaryRoute: tailscaleAddress, + tailscaleAddress, + runtimeKind: online ? "tailscale-peer" : "tailscale-peer-offline", + runtimeVersion: null, + projectIds: [], + projectCount: null, + lastSeenAt: nowMs, + }); + } + + return discovered; +} + +async function discoverTailscalePeers( + timeoutMs = 1_200, +): Promise { + try { + const { stdout } = await execFileAsync( + resolveTailscaleCliPath(), + ["status", "--json"], + { + timeout: Math.max(500, timeoutMs), + maxBuffer: 1024 * 1024, + }, + ); + return discoveredRuntimesFromTailscaleStatus( + JSON.parse(stdout), + Date.now(), + ); + } catch { + return []; + } +} + +export async function discoverLanRuntimes( + timeoutMs = 1_200, +): Promise { + const bonjour = new Bonjour(); + const discovered = new Map(); + let browser: Browser | null = null; + + const remember = (service: BonjourService): void => { + const machine = discoveredRuntimeFromBonjourService(service); + if (!machine) return; + discovered.set(machine.id, machine); + }; + + await Promise.all([ + (async () => { + try { + browser = bonjour.find({ type: ADE_SYNC_MDNS_SERVICE_TYPE }); + browser.on("up", remember); + browser.on("txt-update", remember); + await new Promise((resolve) => + setTimeout(resolve, Math.max(100, timeoutMs)), + ); + } finally { + browser?.stop(); + await new Promise((resolve) => { + bonjour.destroy(() => resolve()); + setTimeout(resolve, 250); + }); + } + })(), + (async () => { + for (const machine of await discoverTailscalePeers(timeoutMs)) { + discovered.set(machine.id, machine); + } + })(), + ]); + + return [...discovered.values()].sort((a, b) => { + const name = a.machineName.localeCompare(b.machineName); + if (name !== 0) return name; + return a.serviceName.localeCompare(b.serviceName); + }); +} diff --git a/apps/desktop/src/main/services/remoteRuntime/runtimeRpcClient.test.ts b/apps/desktop/src/main/services/remoteRuntime/runtimeRpcClient.test.ts new file mode 100644 index 000000000..1ffb2de60 --- /dev/null +++ b/apps/desktop/src/main/services/remoteRuntime/runtimeRpcClient.test.ts @@ -0,0 +1,124 @@ +import { describe, expect, it, vi } from "vitest"; +import { RuntimeRpcClient, type RuntimeRpcTransport } from "./runtimeRpcClient"; + +class MockTransport implements RuntimeRpcTransport { + readonly writes: string[] = []; + private readonly dataCallbacks = new Set<(chunk: Buffer) => void>(); + private readonly closeCallbacks = new Set<() => void>(); + private readonly errorCallbacks = new Set<(error: Error) => void>(); + writeError: Error | null = null; + closed = false; + + onData(callback: (chunk: Buffer) => void): void { + this.dataCallbacks.add(callback); + } + + onClose(callback: () => void): void { + this.closeCallbacks.add(callback); + } + + onError(callback: (error: Error) => void): void { + this.errorCallbacks.add(callback); + } + + write(data: string): void { + if (this.writeError) throw this.writeError; + this.writes.push(data); + } + + close(): void { + this.closed = true; + this.emitClose(); + } + + emitData(message: unknown): void { + const chunk = typeof message === "string" ? message : `${JSON.stringify(message)}\n`; + for (const callback of this.dataCallbacks) { + callback(Buffer.from(chunk, "utf8")); + } + } + + emitClose(): void { + for (const callback of this.closeCallbacks) { + callback(); + } + } + + emitError(error: Error): void { + for (const callback of this.errorCallbacks) { + callback(error); + } + } +} + +function requestId(write: string): number { + const parsed = JSON.parse(write.trim()) as { id?: unknown }; + if (typeof parsed.id !== "number") throw new Error("Expected numeric JSON-RPC id."); + return parsed.id; +} + +describe("RuntimeRpcClient", () => { + it("resolves calls from JSON-RPC responses", async () => { + const transport = new MockTransport(); + const client = new RuntimeRpcClient(transport); + + const pending = client.call("projects.list", {}); + transport.emitData({ jsonrpc: "2.0", id: requestId(transport.writes[0]!), result: ["project"] }); + + await expect(pending).resolves.toEqual(["project"]); + }); + + it("rejects pending and future calls when the transport closes", async () => { + const transport = new MockTransport(); + const client = new RuntimeRpcClient(transport); + + const pending = client.call("projects.list", {}); + transport.emitClose(); + + await expect(pending).rejects.toThrow("Remote ADE service connection closed."); + await expect(client.call("projects.list", {})).rejects.toThrow("Remote ADE service connection closed."); + }); + + it("rejects pending calls and notifies disconnect listeners when the transport errors", async () => { + const transport = new MockTransport(); + const client = new RuntimeRpcClient(transport); + const onDisconnect = vi.fn(); + client.onDisconnect(onDisconnect); + + const pending = client.call("projects.list", {}); + transport.emitError(new Error("ECONNRESET")); + transport.emitClose(); + + await expect(pending).rejects.toThrow("Remote ADE service connection failed: ECONNRESET"); + expect(onDisconnect).toHaveBeenCalledTimes(1); + expect(onDisconnect.mock.calls[0]?.[0]).toMatchObject({ + message: "Remote ADE service connection failed: ECONNRESET", + }); + }); + + it("clears pending calls when writes fail", async () => { + const transport = new MockTransport(); + transport.writeError = new Error("broken pipe"); + const client = new RuntimeRpcClient(transport); + + await expect(client.call("projects.list", {})).rejects.toThrow("broken pipe"); + }); + + it("dispatches JSON-RPC notifications without resolving pending calls", async () => { + const transport = new MockTransport(); + const client = new RuntimeRpcClient(transport); + const onRuntimeEvent = vi.fn(); + const unsubscribe = client.onNotification("runtime/event", onRuntimeEvent); + + const pending = client.call("projects.list", {}); + transport.emitData({ jsonrpc: "2.0", method: "runtime/event", params: { projectId: "project-1" } }); + expect(onRuntimeEvent).toHaveBeenCalledWith({ projectId: "project-1" }); + + transport.emitData({ jsonrpc: "2.0", id: requestId(transport.writes[0]!), result: ["project"] }); + await expect(pending).resolves.toEqual(["project"]); + + unsubscribe(); + transport.emitData({ jsonrpc: "2.0", method: "runtime/event", params: { projectId: "project-2" } }); + expect(onRuntimeEvent).toHaveBeenCalledTimes(1); + }); +}); diff --git a/apps/desktop/src/main/services/remoteRuntime/runtimeRpcClient.ts b/apps/desktop/src/main/services/remoteRuntime/runtimeRpcClient.ts new file mode 100644 index 000000000..5a5619db5 --- /dev/null +++ b/apps/desktop/src/main/services/remoteRuntime/runtimeRpcClient.ts @@ -0,0 +1,175 @@ +import type { JsonRpcId, JsonRpcRequest, JsonRpcTransport } from "../../../../../ade-cli/src/jsonrpc"; + +export type RuntimeRpcTransport = JsonRpcTransport & { + onClose?: (callback: () => void) => void; + onError?: (callback: (error: Error) => void) => void; +}; + +type PendingRequest = { + resolve: (value: unknown) => void; + reject: (error: Error) => void; + timer: ReturnType; +}; + +const MAX_RPC_BUFFER_CHARS = 16 * 1024 * 1024; + +export class RuntimeRpcClient { + private nextId = 1; + private buffer = ""; + private readonly pending = new Map(); + private readonly notificationHandlers = new Map void>>(); + private readonly disconnectCallbacks = new Set<(error: Error) => void>(); + private closedError: Error | null = null; + + constructor( + private readonly transport: RuntimeRpcTransport, + private readonly timeoutMs = 10 * 60 * 1000, + ) { + this.transport.onData((chunk) => this.onData(chunk.toString("utf8"))); + this.transport.onError?.((error) => { + this.failConnection(new Error(`Remote ADE service connection failed: ${error.message}`)); + }); + this.transport.onClose?.(() => { + this.failConnection(new Error("Remote ADE service connection closed.")); + }); + } + + async initialize(clientName: string, version: string): Promise { + return await this.call("ade/initialize", { + protocolVersion: "2025-06-18", + clientInfo: { name: clientName, version }, + identity: { + callerId: `${clientName}:${process.pid}`, + role: "cto", + }, + }); + } + + call(method: string, params?: Record): Promise { + if (this.closedError) return Promise.reject(this.closedError); + const id = this.nextId++; + const payload: JsonRpcRequest = { + jsonrpc: "2.0", + id: id as JsonRpcId, + method, + ...(params ? { params } : {}), + }; + return new Promise((resolve, reject) => { + const timer = setTimeout(() => { + this.pending.delete(id); + reject(new Error(`Timed out waiting for remote ADE service method ${method}.`)); + }, this.timeoutMs); + this.pending.set(id, { resolve, reject, timer }); + try { + this.transport.write(`${JSON.stringify(payload)}\n`); + } catch (error) { + this.pending.delete(id); + clearTimeout(timer); + reject(error instanceof Error ? error : new Error(String(error))); + } + }); + } + + onDisconnect(callback: (error: Error) => void): () => void { + if (this.closedError) { + const error = this.closedError; + queueMicrotask(() => callback(error)); + return () => {}; + } + this.disconnectCallbacks.add(callback); + return () => { + this.disconnectCallbacks.delete(callback); + }; + } + + onNotification(method: string, callback: (params: unknown) => void): () => void { + const handlers = this.notificationHandlers.get(method) ?? new Set<(params: unknown) => void>(); + handlers.add(callback); + this.notificationHandlers.set(method, handlers); + return () => { + handlers.delete(callback); + if (handlers.size === 0) { + this.notificationHandlers.delete(method); + } + }; + } + + close(): void { + this.failConnection(new Error("Remote ADE service connection closed.")); + try { + this.transport.close(); + } catch { + // Best-effort close. Pending callers have already been rejected. + } + } + + private onData(chunk: string): void { + if (this.closedError) return; + this.buffer += chunk; + if (this.buffer.length > MAX_RPC_BUFFER_CHARS) { + this.failConnection(new Error("Remote ADE service response buffer exceeded 16 MiB.")); + return; + } + while (true) { + const newline = this.buffer.indexOf("\n"); + if (newline < 0) break; + const line = this.buffer.slice(0, newline).trim(); + this.buffer = this.buffer.slice(newline + 1); + if (!line) continue; + this.handleLine(line); + } + } + + private handleLine(line: string): void { + let parsed: unknown; + try { + parsed = JSON.parse(line); + } catch (error) { + this.failConnection(new Error(`Failed to parse remote ADE service response: ${error instanceof Error ? error.message : String(error)}`)); + return; + } + if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return; + const response = parsed as Record; + const id = typeof response.id === "number" ? response.id : null; + if (id == null) { + const method = typeof response.method === "string" ? response.method : ""; + if (!method) return; + for (const handler of this.notificationHandlers.get(method) ?? []) { + try { + handler(response.params); + } catch (error) { + console.error("Remote ADE notification handler failed", { method, error }); + } + } + return; + } + const pending = this.pending.get(id); + if (!pending) return; + this.pending.delete(id); + clearTimeout(pending.timer); + const error = response.error; + if (error && typeof error === "object" && !Array.isArray(error)) { + pending.reject(new Error(String((error as { message?: unknown }).message ?? "Remote ADE service request failed."))); + return; + } + pending.resolve(response.result); + } + + private failConnection(error: Error): void { + if (this.closedError) return; + this.closedError = error; + this.rejectAll(error); + for (const callback of this.disconnectCallbacks) { + callback(error); + } + this.disconnectCallbacks.clear(); + } + + private rejectAll(error: Error): void { + for (const [id, pending] of this.pending) { + this.pending.delete(id); + clearTimeout(pending.timer); + pending.reject(error); + } + } +} diff --git a/apps/desktop/src/main/services/remoteRuntime/sshTransport.test.ts b/apps/desktop/src/main/services/remoteRuntime/sshTransport.test.ts new file mode 100644 index 000000000..8a5439166 --- /dev/null +++ b/apps/desktop/src/main/services/remoteRuntime/sshTransport.test.ts @@ -0,0 +1,191 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; +import type { RemoteRuntimeTarget } from "../../../shared/types/remoteRuntime"; +import { buildSshConfig, buildSshConfigCandidates, buildSshUsernameCandidates, parseOpenSshHostConfig } from "./sshTransport"; + +const target: RemoteRuntimeTarget = { + id: "target-1", + name: "Remote", + hostname: "remote.example.test", + sshUser: "ade", + port: 22, + sshKeyPath: null, + lastSeenArch: null, + runtimeBinaryVersion: null, + lastConnectedAt: null, +}; + +const originalAgentSocket = process.env.SSH_AUTH_SOCK; + +afterEach(() => { + if (originalAgentSocket === undefined) { + delete process.env.SSH_AUTH_SOCK; + } else { + process.env.SSH_AUTH_SOCK = originalAgentSocket; + } +}); + +describe("buildSshConfig", () => { + it("uses the local ssh-agent socket when one is available", () => { + process.env.SSH_AUTH_SOCK = "/tmp/ade-agent.sock"; + + expect(buildSshConfig(target, { sshConfigPath: null })).toMatchObject({ + host: "remote.example.test", + port: 22, + username: "ade", + agent: "/tmp/ade-agent.sock", + }); + }); + + it("resolves OpenSSH HostName and IdentityFile entries", () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "ade-ssh-config-")); + const keyPath = path.join(dir, "id_ed25519"); + const configPath = path.join(dir, "config"); + fs.writeFileSync(keyPath, "PRIVATE KEY", "utf8"); + fs.writeFileSync(configPath, [ + "Host studio", + " HostName 192.168.1.42", + ` IdentityFile ${keyPath}`, + "", + "Host *", + " IdentityFile ~/.ssh/fallback", + ].join("\n"), "utf8"); + + const config = buildSshConfig({ ...target, hostname: "studio" }, { + env: {}, + sshConfigPath: configPath, + }); + + expect(config).toMatchObject({ + host: "192.168.1.42", + port: 22, + username: "ade", + privateKey: Buffer.from("PRIVATE KEY"), + }); + }); + + it("falls back to the local username and default SSH port when target and SSH config omit them", () => { + const config = buildSshConfig({ + ...target, + hostname: "studio", + sshUser: null, + port: null, + }, { + env: {}, + sshConfigPath: null, + }); + + expect(config).toMatchObject({ + host: "studio", + port: 22, + username: os.userInfo().username, + }); + }); + + it("builds an admin retry candidate when no SSH user is configured", () => { + expect(buildSshUsernameCandidates({ + ...target, + hostname: "100.75.20.63", + sshUser: null, + port: null, + }, { + sshConfigPath: null, + })).toEqual(Array.from(new Set([os.userInfo().username, "admin"]))); + }); + + it("does not add username retries when SSH config provides a user", () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "ade-ssh-config-")); + const configPath = path.join(dir, "config"); + fs.writeFileSync(configPath, [ + "Host studio", + " User remote-user", + ].join("\n"), "utf8"); + + expect(buildSshUsernameCandidates({ + ...target, + hostname: "studio", + sshUser: null, + port: null, + }, { + sshConfigPath: configPath, + })).toEqual(["remote-user"]); + }); + + it("builds retry configs with distinct SSH usernames", () => { + const configs = buildSshConfigCandidates({ + ...target, + hostname: "100.75.20.63", + sshUser: null, + port: null, + }, { + env: {}, + sshConfigPath: null, + }); + + expect(configs.map((config) => config.username)).toEqual(Array.from(new Set([os.userInfo().username, "admin"]))); + expect(configs.every((config) => config.host === "100.75.20.63" && config.port === 22)).toBe(true); + }); + + it("uses the first readable OpenSSH default identity when no explicit key is configured", () => { + const homeDir = fs.mkdtempSync(path.join(os.tmpdir(), "ade-ssh-home-")); + const sshDir = path.join(homeDir, ".ssh"); + fs.mkdirSync(sshDir, { recursive: true }); + fs.writeFileSync(path.join(sshDir, "id_ed25519"), "DEFAULT PRIVATE KEY", "utf8"); + + const config = buildSshConfig(target, { + env: {}, + homeDir, + sshConfigPath: null, + }); + + expect(config).toMatchObject({ + privateKey: Buffer.from("DEFAULT PRIVATE KEY"), + }); + }); + + it("uses OpenSSH User and Port entries from matching aliases", () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "ade-ssh-config-")); + const configPath = path.join(dir, "config"); + fs.writeFileSync(configPath, [ + "Host studio", + " HostName 192.168.1.42", + " User remote-user", + " Port 2200", + ].join("\n"), "utf8"); + + const config = buildSshConfig({ + ...target, + hostname: "studio", + sshUser: null, + port: null, + }, { + env: {}, + sshConfigPath: configPath, + }); + + expect(config).toMatchObject({ + host: "192.168.1.42", + port: 2200, + username: "remote-user", + }); + }); +}); + +describe("parseOpenSshHostConfig", () => { + it("keeps the first matching value and supports wildcard blocks", () => { + expect(parseOpenSshHostConfig([ + "Host *.example.test", + " User remote-user", + " Port 2200", + "Host remote.example.test", + " User ignored", + " HostName 10.0.0.5", + ].join("\n"), "remote.example.test")).toEqual({ + user: "remote-user", + port: 2200, + hostName: "10.0.0.5", + }); + }); +}); diff --git a/apps/desktop/src/main/services/remoteRuntime/sshTransport.ts b/apps/desktop/src/main/services/remoteRuntime/sshTransport.ts new file mode 100644 index 000000000..8f585aec4 --- /dev/null +++ b/apps/desktop/src/main/services/remoteRuntime/sshTransport.ts @@ -0,0 +1,308 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { Client, type ConnectConfig } from "ssh2"; +import type { RemoteRuntimeTarget } from "../../../shared/types/remoteRuntime"; +import type { RuntimeRpcTransport } from "./runtimeRpcClient"; + +export type SshExecResult = { + stdout: string; + stderr: string; + code: number | null; +}; + +const MAX_SSH_EXEC_OUTPUT_BYTES = 8 * 1024 * 1024; + +type OpenSshHostConfig = { + hostName?: string; + user?: string; + port?: number; + identityFile?: string; +}; + +type BuildSshConfigOptions = { + env?: NodeJS.ProcessEnv; + sshConfigPath?: string | null; + homeDir?: string; + usernameOverride?: string; +}; + +const DEFAULT_IDENTITY_FILES = [ + "id_ed25519", + "id_ecdsa", + "id_ecdsa_sk", + "id_rsa", +]; + +function stripInlineComment(line: string): string { + const hashIndex = line.indexOf("#"); + return hashIndex >= 0 ? line.slice(0, hashIndex).trim() : line.trim(); +} + +function splitSshConfigLine(line: string): [string, string] | null { + const trimmedLine = stripInlineComment(line); + if (!trimmedLine) return null; + const match = /^([A-Za-z][A-Za-z0-9]+)\s+(.*)$/.exec(trimmedLine); + if (!match) return null; + return [match[1]!.toLowerCase(), match[2]!.trim().replace(/^"|"$/g, "")]; +} + +function patternToRegExp(pattern: string): RegExp { + const escaped = pattern + .replace(/[.+^${}()|[\]\\]/g, "\\$&") + .replace(/\*/g, ".*") + .replace(/\?/g, "."); + return new RegExp(`^${escaped}$`, "i"); +} + +function hostPatternsMatch(patterns: string, host: string): boolean { + const entries = patterns.split(/\s+/).filter(Boolean); + if (entries.length === 0) return false; + let matched = false; + for (const entry of entries) { + const negated = entry.startsWith("!"); + const pattern = negated ? entry.slice(1) : entry; + if (!pattern) continue; + if (!patternToRegExp(pattern).test(host)) continue; + if (negated) return false; + matched = true; + } + return matched; +} + +function expandSshPath(value: string, args: { host: string; username: string; port: number }): string { + const expanded = value + .replace(/%h/g, args.host) + .replace(/%r/g, args.username) + .replace(/%p/g, String(args.port)); + if (expanded === "~") return os.homedir(); + if (expanded.startsWith("~/")) return path.join(os.homedir(), expanded.slice(2)); + return expanded; +} + +function firstReadableDefaultIdentity(homeDir: string): string | null { + for (const fileName of DEFAULT_IDENTITY_FILES) { + const candidate = path.join(homeDir, ".ssh", fileName); + try { + if (fs.statSync(candidate).isFile()) return candidate; + } catch { + // Try the next OpenSSH default identity path. + } + } + return null; +} + +export function parseOpenSshHostConfig(configText: string, hostAlias: string): OpenSshHostConfig { + const result: OpenSshHostConfig = {}; + let active = false; + for (const line of configText.split(/\r?\n/)) { + const parsed = splitSshConfigLine(line); + if (!parsed) continue; + const [keyword, value] = parsed; + if (keyword === "host") { + active = hostPatternsMatch(value, hostAlias); + continue; + } + if (!active) continue; + if (keyword === "hostname" && !result.hostName) { + result.hostName = value; + } else if (keyword === "user" && !result.user) { + result.user = value; + } else if (keyword === "port" && result.port == null) { + const port = Number.parseInt(value, 10); + if (Number.isFinite(port) && port > 0) result.port = port; + } else if (keyword === "identityfile" && !result.identityFile) { + result.identityFile = value; + } + } + return result; +} + +function readOpenSshHostConfig(target: RemoteRuntimeTarget, options: BuildSshConfigOptions): OpenSshHostConfig { + const configPath = options.sshConfigPath === undefined + ? path.join(os.homedir(), ".ssh", "config") + : options.sshConfigPath; + if (!configPath) return {}; + try { + return parseOpenSshHostConfig(fs.readFileSync(configPath, "utf8"), target.hostname); + } catch { + return {}; + } +} + +export function buildSshConfig(target: RemoteRuntimeTarget, options: BuildSshConfigOptions = {}): ConnectConfig { + const hostConfig = readOpenSshHostConfig(target, options); + const host = hostConfig.hostName ?? target.hostname; + const port = target.port && target.port > 0 ? target.port : hostConfig.port ?? 22; + const username = (options.usernameOverride ?? target.sshUser?.trim()) || hostConfig.user || os.userInfo().username; + const homeDir = options.homeDir ?? os.homedir(); + const config: ConnectConfig = { + host, + port, + username, + readyTimeout: 20_000, + }; + const identityFile = target.sshKeyPath + ?? (hostConfig.identityFile ? expandSshPath(hostConfig.identityFile, { host, username, port }) : null) + ?? firstReadableDefaultIdentity(homeDir); + if (identityFile) { + config.privateKey = fs.readFileSync(identityFile); + } + const env = options.env ?? process.env; + if (env.SSH_AUTH_SOCK) { + config.agent = env.SSH_AUTH_SOCK; + } + return config; +} + +function uniqueUsernames(values: Array): string[] { + const seen = new Set(); + const result: string[] = []; + for (const value of values) { + const username = value?.trim(); + if (!username || seen.has(username)) continue; + seen.add(username); + result.push(username); + } + return result; +} + +export function buildSshUsernameCandidates(target: RemoteRuntimeTarget, options: BuildSshConfigOptions = {}): string[] { + const hostConfig = readOpenSshHostConfig(target, options); + const explicitUser = target.sshUser?.trim() || hostConfig.user; + const localUser = os.userInfo().username; + if (explicitUser) return [explicitUser]; + return uniqueUsernames([localUser, "admin"]); +} + +export function buildSshConfigCandidates(target: RemoteRuntimeTarget, options: BuildSshConfigOptions = {}): ConnectConfig[] { + return buildSshUsernameCandidates(target, options).map((username) => + buildSshConfig(target, { ...options, usernameOverride: username })); +} + +function isSshAuthenticationFailure(error: unknown): boolean { + if (!error || typeof error !== "object") return false; + const candidate = error as { level?: unknown; message?: unknown }; + return candidate.level === "client-authentication" || + (typeof candidate.message === "string" && /authentication/i.test(candidate.message)); +} + +function connectSshWithConfig(config: ConnectConfig): Promise { + return new Promise((resolve, reject) => { + const client = new Client(); + client.once("ready", () => resolve(client)); + client.once("error", reject); + client.connect(config); + }); +} + +export async function connectSsh(target: RemoteRuntimeTarget): Promise { + const configs = buildSshConfigCandidates(target); + let lastError: unknown = null; + for (const [index, config] of configs.entries()) { + try { + return await connectSshWithConfig(config); + } catch (error) { + lastError = error; + if (index >= configs.length - 1 || !isSshAuthenticationFailure(error)) throw error; + } + } + throw lastError instanceof Error ? lastError : new Error(String(lastError ?? "SSH connection failed.")); +} + +export function execSsh(client: Client, command: string): Promise { + return new Promise((resolve, reject) => { + client.exec(command, (error, stream) => { + if (error) { + reject(error); + return; + } + let stdout = ""; + let stderr = ""; + let stdoutBytes = 0; + let stderrBytes = 0; + let code: number | null = null; + stream.on("data", (chunk: Buffer) => { + stdoutBytes += chunk.byteLength; + if (stdoutBytes > MAX_SSH_EXEC_OUTPUT_BYTES) { + reject(new Error(`SSH command stdout exceeded ${MAX_SSH_EXEC_OUTPUT_BYTES} bytes.`)); + stream.close(); + return; + } + stdout += chunk.toString("utf8"); + }); + stream.stderr.on("data", (chunk: Buffer) => { + stderrBytes += chunk.byteLength; + if (stderrBytes > MAX_SSH_EXEC_OUTPUT_BYTES) { + reject(new Error(`SSH command stderr exceeded ${MAX_SSH_EXEC_OUTPUT_BYTES} bytes.`)); + stream.close(); + return; + } + stderr += chunk.toString("utf8"); + }); + stream.on("exit", (exitCode: number | null) => { + code = exitCode; + }); + stream.on("close", () => resolve({ stdout, stderr, code })); + stream.on("error", reject); + }); + }); +} + +export function openSshRuntimeTransport(client: Client, command = "~/.ade/bin/ade rpc --stdio"): Promise { + return new Promise((resolve, reject) => { + client.exec(command, (error, stream) => { + if (error) { + reject(error); + return; + } + let closed = false; + let streamError: Error | null = null; + const closeCallbacks = new Set<() => void>(); + const errorCallbacks = new Set<(error: Error) => void>(); + + stream.once("error", (streamErrorValue: Error) => { + streamError = streamErrorValue; + for (const callback of errorCallbacks) { + callback(streamErrorValue); + } + errorCallbacks.clear(); + }); + stream.once("close", () => { + closed = true; + for (const callback of closeCallbacks) { + callback(); + } + closeCallbacks.clear(); + errorCallbacks.clear(); + }); + + resolve({ + onData(callback) { + stream.on("data", (chunk: Buffer) => callback(Buffer.from(chunk))); + }, + onError(callback) { + const currentError = streamError; + if (currentError) { + queueMicrotask(() => callback(currentError)); + return; + } + errorCallbacks.add(callback); + }, + onClose(callback) { + if (closed) { + queueMicrotask(callback); + return; + } + closeCallbacks.add(callback); + }, + write(data) { + stream.write(data); + }, + close() { + stream.end(); + }, + }); + }); + }); +} diff --git a/apps/desktop/src/main/services/runtime/machineStateMigration.test.ts b/apps/desktop/src/main/services/runtime/machineStateMigration.test.ts new file mode 100644 index 000000000..e38206427 --- /dev/null +++ b/apps/desktop/src/main/services/runtime/machineStateMigration.test.ts @@ -0,0 +1,131 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +import { describe, expect, it, vi } from "vitest"; + +import type { MachineAdeLayout } from "../../../../../ade-cli/src/services/projects/machineLayout"; +import { + MACHINE_STATE_MIGRATION_MARKER, + markMachineStateMigrationComplete, + runMachineStateMigration, +} from "./machineStateMigration"; + +function makeLayout(root: string): MachineAdeLayout { + return { + adeDir: root, + projectsPath: path.join(root, "projects.json"), + secretsDir: path.join(root, "secrets"), + sockDir: path.join(root, "sock"), + socketPath: path.join(root, "sock", "ade.sock"), + binDir: path.join(root, "bin"), + runtimeDir: path.join(root, "runtime"), + }; +} + +function makeProject(root: string, name: string): string { + const projectRoot = path.join(root, name); + fs.mkdirSync(path.join(projectRoot, ".ade", "secrets"), { recursive: true }); + return projectRoot; +} + +describe("machine state migration", () => { + it("skips work when the migration marker already exists", () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "ade-machine-migration-")); + const layout = makeLayout(path.join(root, ".ade-home")); + fs.mkdirSync(layout.adeDir, { recursive: true }); + fs.writeFileSync(path.join(layout.adeDir, MACHINE_STATE_MIGRATION_MARKER), "done\n", "utf8"); + const add = vi.fn(); + + const result = runMachineStateMigration({ + layout, + recentProjects: [{ rootPath: "/missing", displayName: "Missing", lastOpenedAt: "2026-05-10T00:00:00.000Z" }], + projectRegistry: { add }, + }); + + expect(result).toMatchObject({ didRun: false, shouldShowNotice: false }); + expect(add).not.toHaveBeenCalled(); + }); + + it("merges legacy sync secrets and registers valid recent projects", () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "ade-machine-migration-")); + const layout = makeLayout(path.join(root, ".ade-home")); + fs.mkdirSync(layout.secretsDir, { recursive: true }); + fs.writeFileSync( + path.join(layout.secretsDir, "sync-paired-devices.json"), + `${JSON.stringify({ existing: { name: "Machine" } })}\n`, + "utf8", + ); + const projectA = makeProject(root, "project-a"); + const projectB = makeProject(root, "project-b"); + const missingAdeProject = path.join(root, "missing-ade"); + fs.mkdirSync(missingAdeProject, { recursive: true }); + fs.writeFileSync(path.join(projectA, ".ade", "secrets", "sync-bootstrap-token"), "token-a", "utf8"); + fs.writeFileSync(path.join(projectB, ".ade", "secrets", "sync-bootstrap-token"), "token-b", "utf8"); + fs.writeFileSync(path.join(projectB, ".ade", "secrets", "sync-pin.json"), "{\"pin\":\"123456\"}", "utf8"); + fs.writeFileSync( + path.join(projectA, ".ade", "secrets", "sync-paired-devices.json"), + `${JSON.stringify({ existing: { name: "Legacy" }, phoneA: { name: "Phone A" } })}\n`, + "utf8", + ); + fs.writeFileSync( + path.join(projectB, ".ade", "secrets", "sync-paired-devices.json"), + `${JSON.stringify({ phoneB: { name: "Phone B" } })}\n`, + "utf8", + ); + const add = vi.fn(); + + const result = runMachineStateMigration({ + layout, + recentProjects: [ + { rootPath: projectA, displayName: "A", lastOpenedAt: "2026-05-10T00:00:00.000Z" }, + { rootPath: projectB, displayName: "B", lastOpenedAt: "2026-05-10T00:00:01.000Z" }, + { rootPath: missingAdeProject, displayName: "Missing", lastOpenedAt: "2026-05-10T00:00:02.000Z" }, + ], + projectRegistry: { add }, + }); + + expect(result).toMatchObject({ didRun: true, shouldShowNotice: true }); + expect(fs.readFileSync(path.join(layout.secretsDir, "sync-bootstrap-token"), "utf8")).toBe("token-a"); + expect(fs.readFileSync(path.join(layout.secretsDir, "sync-pin.json"), "utf8")).toBe("{\"pin\":\"123456\"}"); + expect(JSON.parse(fs.readFileSync(path.join(layout.secretsDir, "sync-paired-devices.json"), "utf8"))).toEqual({ + existing: { name: "Machine" }, + phoneA: { name: "Phone A" }, + phoneB: { name: "Phone B" }, + }); + expect(add).toHaveBeenCalledWith(projectA); + expect(add).toHaveBeenCalledWith(projectB); + expect(add).not.toHaveBeenCalledWith(missingAdeProject); + expect(fs.existsSync(path.join(layout.adeDir, MACHINE_STATE_MIGRATION_MARKER))).toBe(false); + }); + + it("marks migration complete only when explicitly requested", () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "ade-machine-migration-")); + const layout = makeLayout(path.join(root, ".ade-home")); + + markMachineStateMigrationComplete({ + layout, + completedAt: new Date("2026-05-10T12:00:00.000Z"), + }); + + expect(fs.readFileSync(path.join(layout.adeDir, MACHINE_STATE_MIGRATION_MARKER), "utf8")).toBe( + "2026-05-10T12:00:00.000Z\n", + ); + }); + + it("treats malformed legacy pairing files as empty", () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "ade-machine-migration-")); + const layout = makeLayout(path.join(root, ".ade-home")); + const projectRoot = makeProject(root, "project-a"); + fs.writeFileSync(path.join(projectRoot, ".ade", "secrets", "sync-paired-devices.json"), "{not-json", "utf8"); + + expect(() => + runMachineStateMigration({ + layout, + recentProjects: [{ rootPath: projectRoot, displayName: "A", lastOpenedAt: "2026-05-10T00:00:00.000Z" }], + projectRegistry: { add: vi.fn() }, + }) + ).not.toThrow(); + expect(JSON.parse(fs.readFileSync(path.join(layout.secretsDir, "sync-paired-devices.json"), "utf8"))).toEqual({}); + }); +}); diff --git a/apps/desktop/src/main/services/runtime/machineStateMigration.ts b/apps/desktop/src/main/services/runtime/machineStateMigration.ts new file mode 100644 index 000000000..5da8090ce --- /dev/null +++ b/apps/desktop/src/main/services/runtime/machineStateMigration.ts @@ -0,0 +1,115 @@ +import fs from "node:fs"; +import path from "node:path"; + +import type { MachineAdeLayout } from "../../../../../ade-cli/src/services/projects/machineLayout"; +import { ProjectRegistry, type ProjectRegistry as ProjectRegistryType } from "../../../../../ade-cli/src/services/projects/projectRegistry"; +import type { RecentProject } from "../state/globalState"; + +export const MACHINE_STATE_MIGRATION_MARKER = ".migrated-v2"; + +export type MachineStateMigrationResult = { + didRun: boolean; + shouldShowNotice: boolean; + markerPath: string; +}; + +type MachineStateMigrationArgs = { + layout: MachineAdeLayout; + recentProjects: RecentProject[]; + projectRegistry?: Pick; +}; + +function readObjectFile(filePath: string): Record { + try { + const parsed = JSON.parse(fs.readFileSync(filePath, "utf8")) as unknown; + return parsed && typeof parsed === "object" && !Array.isArray(parsed) + ? parsed as Record + : {}; + } catch { + return {}; + } +} + +function markerPath(layout: MachineAdeLayout): string { + return path.join(layout.adeDir, MACHINE_STATE_MIGRATION_MARKER); +} + +function copyFirstLegacySecret(args: { + layout: MachineAdeLayout; + recentProjects: RecentProject[]; + fileName: string; +}): void { + const target = path.join(args.layout.secretsDir, args.fileName); + if (fs.existsSync(target)) return; + for (const project of args.recentProjects) { + const source = path.join(project.rootPath, ".ade", "secrets", args.fileName); + if (!fs.existsSync(source)) continue; + try { + fs.copyFileSync(source, target, fs.constants.COPYFILE_EXCL); + fs.chmodSync(target, 0o600); + } catch { + // Best effort migration; the project-local copy remains for rollback. + } + return; + } +} + +function mergePairedDevices(args: { + layout: MachineAdeLayout; + recentProjects: RecentProject[]; +}): void { + const pairedDevicesPath = path.join(args.layout.secretsDir, "sync-paired-devices.json"); + const pairedDevices = fs.existsSync(pairedDevicesPath) + ? readObjectFile(pairedDevicesPath) + : {}; + let pairedDevicesChanged = false; + for (const project of args.recentProjects) { + const source = path.join(project.rootPath, ".ade", "secrets", "sync-paired-devices.json"); + if (!fs.existsSync(source)) continue; + const legacy = readObjectFile(source); + for (const [deviceId, record] of Object.entries(legacy)) { + if (!deviceId.trim() || Object.prototype.hasOwnProperty.call(pairedDevices, deviceId)) continue; + pairedDevices[deviceId] = record; + pairedDevicesChanged = true; + } + } + if (pairedDevicesChanged || !fs.existsSync(pairedDevicesPath)) { + fs.writeFileSync(pairedDevicesPath, `${JSON.stringify(pairedDevices, null, 2)}\n`, { mode: 0o600 }); + } +} + +export function runMachineStateMigration(args: MachineStateMigrationArgs): MachineStateMigrationResult { + const marker = markerPath(args.layout); + if (fs.existsSync(marker)) { + return { didRun: false, shouldShowNotice: false, markerPath: marker }; + } + + const hadExistingUserState = + args.recentProjects.length > 0 || fs.existsSync(args.layout.secretsDir); + fs.mkdirSync(args.layout.secretsDir, { recursive: true, mode: 0o700 }); + + copyFirstLegacySecret({ layout: args.layout, recentProjects: args.recentProjects, fileName: "sync-bootstrap-token" }); + copyFirstLegacySecret({ layout: args.layout, recentProjects: args.recentProjects, fileName: "sync-pin.json" }); + mergePairedDevices({ layout: args.layout, recentProjects: args.recentProjects }); + + const projectRegistry = args.projectRegistry ?? new ProjectRegistry(args.layout); + for (const project of args.recentProjects) { + if (!fs.existsSync(path.join(project.rootPath, ".ade"))) continue; + try { + projectRegistry.add(project.rootPath); + } catch { + // Ignore projects that disappeared or became unreadable during startup. + } + } + + return { didRun: true, shouldShowNotice: hadExistingUserState, markerPath: marker }; +} + +export function markMachineStateMigrationComplete(args: { + layout: MachineAdeLayout; + completedAt?: Date; +}): void { + const marker = markerPath(args.layout); + fs.mkdirSync(args.layout.adeDir, { recursive: true, mode: 0o700 }); + fs.writeFileSync(marker, `${(args.completedAt ?? new Date()).toISOString()}\n`, { mode: 0o600 }); +} diff --git a/apps/desktop/src/main/services/state/kvDb.test.ts b/apps/desktop/src/main/services/state/kvDb.test.ts index 9d06a0402..444b0f463 100644 --- a/apps/desktop/src/main/services/state/kvDb.test.ts +++ b/apps/desktop/src/main/services/state/kvDb.test.ts @@ -1,10 +1,13 @@ import fs from "node:fs"; import os from "node:os"; import path from "node:path"; -import { afterEach, describe, expect, it } from "vitest"; +import { createRequire } from "node:module"; +import { afterEach, describe, expect, it, vi } from "vitest"; import { openKvDb } from "./kvDb"; import { isCrsqliteAvailable } from "./crsqliteExtension"; +const require = createRequire(path.join(process.cwd(), "ade-runtime.cjs")); + function createLogger() { return { debug: () => {}, @@ -195,6 +198,29 @@ afterEach(async () => { } }); +describe("openKvDb SQL binding", () => { + it("binds boolean params and reports unsupported param types with context", async () => { + const projectRoot = makeProjectRoot("ade-kvdb-bind-values-"); + const dbPath = path.join(projectRoot, ".ade", "ade.db"); + const db = await openKvDb(dbPath, createLogger() as any); + activeDisposers.push(async () => db.close()); + + db.run("create table if not exists db_value_test(flag integer not null)"); + db.run("insert into db_value_test(flag) values (?)", [true]); + expect(db.get<{ flag: number }>("select flag from db_value_test limit 1")?.flag).toBe(1); + + expect(() => + db.run("insert into db_value_test(flag) values (?)", [{} as any]), + ).toThrow(/Unsupported database value at parameter 1: object .*sql=insert into db_value_test/i); + expect(() => + db.get("select flag from db_value_test where flag = ?", [{} as any]), + ).toThrow(/Unsupported database value at parameter 1: object .*sql=select flag from db_value_test/i); + expect(() => + db.all("select flag from db_value_test where flag = ?", [{} as any]), + ).toThrow(/Unsupported database value at parameter 1: object .*sql=select flag from db_value_test/i); + }); +}); + describe.skipIf(!isCrsqliteAvailable())("openKvDb CRR repair", () => { it("backfills phone-critical tables whose rows predate CRR enablement", async () => { const projectRoot = makeProjectRoot("ade-kvdb-pre-crr-"); @@ -248,3 +274,47 @@ describe.skipIf(!isCrsqliteAvailable())("openKvDb CRR repair", () => { ).toBe(1); }); }); + +describe.skipIf(!isCrsqliteAvailable())("openKvDb with unavailable crsqlite runtime", () => { + it("drops stale CRR triggers before migration writes touch CRR tables", async () => { + const projectRoot = makeProjectRoot("ade-kvdb-crr-unavailable-"); + const dbPath = path.join(projectRoot, ".ade", "ade.db"); + const first = await openKvDb(dbPath, createLogger() as any); + first.close(); + + const { DatabaseSync } = require("node:sqlite") as typeof import("node:sqlite"); + const raw = new DatabaseSync(dbPath); + expect( + raw + .prepare( + "select 1 as present from sqlite_master where type = 'trigger' and name = 'unified_memories__crsql_utrig' limit 1", + ) + .get(), + ).toBeTruthy(); + raw.close(); + + vi.resetModules(); + vi.doMock("./crsqliteExtension", () => ({ + resolveCrsqliteExtensionPath: () => null, + isCrsqliteAvailable: () => false, + })); + const { openKvDb: openWithoutCrsqlite } = await import("./kvDb"); + const reopened = await openWithoutCrsqlite(dbPath, createLogger() as any); + activeDisposers.push(async () => reopened.close()); + + expect(reopened.sync.isAvailable?.()).toBe(false); + expect( + reopened.get<{ present: number }>( + "select 1 as present from sqlite_master where type = 'trigger' and name = 'unified_memories__crsql_utrig' limit 1", + ), + ).toBeNull(); + expect( + reopened.get<{ present: number }>( + "select 1 as present from sqlite_master where type = 'table' and name = 'unified_memories__crsql_clock' limit 1", + )?.present, + ).toBe(1); + + vi.doUnmock("./crsqliteExtension"); + vi.resetModules(); + }); +}); diff --git a/apps/desktop/src/main/services/state/kvDb.ts b/apps/desktop/src/main/services/state/kvDb.ts index b4e02dc72..8dfb059e0 100644 --- a/apps/desktop/src/main/services/state/kvDb.ts +++ b/apps/desktop/src/main/services/state/kvDb.ts @@ -15,7 +15,7 @@ type DatabaseSyncConstructor = new (dbPath: string, options?: { allowExtension?: const require = createRequire(path.join(process.cwd(), "ade-runtime.cjs")); const { DatabaseSync } = require("node:sqlite") as { DatabaseSync: DatabaseSyncConstructor }; -export type SqlValue = string | number | null | Uint8Array; +export type SqlValue = string | number | boolean | null | Uint8Array; export type AdeDbSyncApi = { isAvailable?: () => boolean; @@ -80,22 +80,41 @@ function openRawDatabase(dbPath: string): DatabaseSyncType { return db; } -function toDbValue(value: SqlValue | SyncScalar): string | number | null | Uint8Array { +function describeUnsupportedDbValue(value: unknown): string { + const kind = value === undefined + ? "undefined" + : value === null + ? "null" + : Array.isArray(value) + ? "array" + : typeof value; + const ctor = + value && typeof value === "object" && !Array.isArray(value) + ? (value as { constructor?: { name?: string } }).constructor?.name + : null; + return ctor && ctor !== "Object" ? `${kind} (${ctor})` : kind; +} + +function toDbValue(value: SqlValue | SyncScalar, index?: number): string | number | null | Uint8Array { if (value == null || typeof value === "string" || typeof value === "number") { return value; } + if (typeof value === "boolean") { + return value ? 1 : 0; + } if (value instanceof Uint8Array) { return value; } if (typeof value === "object" && "type" in value && value.type === "bytes") { return Buffer.from(value.base64, "base64"); } - throw new Error("Unsupported database value"); + const suffix = typeof index === "number" ? ` at parameter ${index + 1}` : ""; + throw new Error(`Unsupported database value${suffix}: ${describeUnsupportedDbValue(value)}`); } function runStatement(db: DatabaseSyncType, sql: string, params: Array = []): { changes: number } { try { - return db.prepare(sql).run(...params.map((param) => toDbValue(param))) as { changes: number }; + return db.prepare(sql).run(...params.map((param, index) => toDbValue(param, index))) as { changes: number }; } catch (error) { const statement = sql.replace(/\s+/g, " ").trim(); const message = error instanceof Error ? error.message : String(error); @@ -104,11 +123,23 @@ function runStatement(db: DatabaseSyncType, sql: string, params: Array(db: DatabaseSyncType, sql: string, params: Array = []): T | null { - return (db.prepare(sql).get(...params.map((param) => toDbValue(param))) as T | undefined) ?? null; + try { + return (db.prepare(sql).get(...params.map((param, index) => toDbValue(param, index))) as T | undefined) ?? null; + } catch (error) { + const statement = sql.replace(/\s+/g, " ").trim(); + const message = error instanceof Error ? error.message : String(error); + throw new Error(`${message} [sql=${statement}]`); + } } function allRows(db: DatabaseSyncType, sql: string, params: Array = []): T[] { - return db.prepare(sql).all(...params.map((param) => toDbValue(param))) as T[]; + try { + return db.prepare(sql).all(...params.map((param, index) => toDbValue(param, index))) as T[]; + } catch (error) { + const statement = sql.replace(/\s+/g, " ").trim(); + const message = error instanceof Error ? error.message : String(error); + throw new Error(`${message} [sql=${statement}]`); + } } function rawHasTable(db: DatabaseSyncType, tableName: string): boolean { @@ -416,6 +447,16 @@ function hasCrsqlMetadata(db: DatabaseSyncType): boolean { ); } +function isCrsqliteRuntimeUsable(db: DatabaseSyncType): boolean { + try { + getRow(db, "select crsql_db_version() as db_version"); + getRow(db, "select crsql_internal_sync_bit() as sync_bit"); + return true; + } catch { + return false; + } +} + const PHONE_CRITICAL_CRR_TABLES = [ "lanes", "lane_state_snapshots", @@ -444,6 +485,49 @@ function tableNeedsCrrRepair(db: DatabaseSyncType, tableName: string): { baseRow return pkRowCount === baseRowCount ? null : { baseRowCount, pkRowCount }; } +function listCrrTriggers(db: DatabaseSyncType, tableName: string): string[] { + return allRows<{ name: string }>( + db, + `select name + from sqlite_master + where type = 'trigger' + and tbl_name = ? + and name like ?`, + [tableName, `${tableName}__crsql_%trig`], + ).map((row) => row.name); +} + +function tableNeedsCrrTriggerRepair(db: DatabaseSyncType, tableName: string): boolean { + if (!rawHasTable(db, `${tableName}__crsql_clock`)) { + return false; + } + return listCrrTriggers(db, tableName).length < 3; +} + +function disableCrrTriggersForUnavailableRuntime(db: DatabaseSyncType, logger?: Logger): void { + const triggers = allRows<{ name: string; tbl_name: string }>( + db, + `select name, tbl_name + from sqlite_master + where type = 'trigger' + and name like '%__crsql_%trig'`, + ); + for (const trigger of triggers) { + try { + runStatement(db, `drop trigger if exists ${quoteIdentifier(trigger.name)}`); + } catch (error) { + logger?.warn("db.crsqlite_trigger_disable_failed", { + tableName: trigger.tbl_name, + triggerName: trigger.name, + error: error instanceof Error ? error.message : String(error), + }); + } + } + if (triggers.length > 0) { + logger?.warn("db.crsqlite_triggers_disabled", { triggerCount: triggers.length }); + } +} + function rebuildCrrTableWithBackfill(db: DatabaseSyncType, tableName: string): void { const tableRow = getRow<{ sql: string | null }>( db, @@ -507,6 +591,9 @@ function ensureCrrTables(db: DatabaseSyncType, logger?: Logger): void { const repairTargets = new Set(PHONE_CRITICAL_CRR_TABLES); for (const tableName of listEligibleCrrTables(db)) { if (rawHasTable(db, `${tableName}__crsql_clock`)) { + if (tableNeedsCrrTriggerRepair(db, tableName)) { + getRow(db, "select crsql_as_crr(?) as ok", [tableName]); + } if (!repairTargets.has(tableName)) { continue; } @@ -3579,10 +3666,24 @@ export async function openKvDb(dbPath: string, logger: Logger): Promise { const existedBeforeOpen = fs.existsSync(dbPath); let db = openRawDatabase(dbPath); let crsqliteLoaded = false; - const loadCrsqliteIfAvailable = (): void => { - if (!extensionPath || crsqliteLoaded) return; - loadCrsqlite(db, extensionPath); - crsqliteLoaded = true; + const loadCrsqliteIfAvailable = (): boolean => { + if (crsqliteLoaded) return true; + if (!extensionPath) return false; + try { + loadCrsqlite(db, extensionPath); + crsqliteLoaded = isCrsqliteRuntimeUsable(db); + if (!crsqliteLoaded) { + logger.warn("db.crsqlite_unavailable", { dbPath, reason: "extension loaded but required functions are unavailable" }); + } + } catch (error) { + crsqliteLoaded = false; + logger.warn("db.crsqlite_unavailable", { + dbPath, + reason: "extension failed to load", + error: error instanceof Error ? error.message : String(error), + }); + } + return crsqliteLoaded; }; repairMalformedUnifiedMemoryFtsSchema(db); @@ -3594,6 +3695,9 @@ export async function openKvDb(dbPath: string, logger: Logger): Promise { // updates can touch those tables in source-mode CLI and desktop startup. loadCrsqliteIfAvailable(); const hadCrsqlMetadata = hasCrsqlMetadata(db); + if (hadCrsqlMetadata && !crsqliteLoaded) { + disableCrrTriggersForUnavailableRuntime(db, logger); + } // Build a CRR-aware run wrapper: when crsqlite is loaded and a table has // been converted to a CRR, ALTER TABLE statements must be wrapped with @@ -3641,6 +3745,9 @@ export async function openKvDb(dbPath: string, logger: Logger): Promise { db = openRawDatabase(dbPath); crsqliteLoaded = false; loadCrsqliteIfAvailable(); + if (hasCrsqlMetadata(db) && !crsqliteLoaded) { + disableCrrTriggersForUnavailableRuntime(db, logger); + } const remigrateDb = makeMigrateDb(); repairUnifiedMemoryFtsSchemaForRuntime(remigrateDb); migrate(remigrateDb); @@ -3648,7 +3755,7 @@ export async function openKvDb(dbPath: string, logger: Logger): Promise { let retrofittedForeignKeySchema = false; try { - retrofittedForeignKeySchema = retrofitForeignKeyCascadeActions(db, hasCrsqlite); + retrofittedForeignKeySchema = retrofitForeignKeyCascadeActions(db, crsqliteLoaded); } catch (error) { if (!isReadonlyDatabaseError(error)) throw error; } @@ -3657,12 +3764,15 @@ export async function openKvDb(dbPath: string, logger: Logger): Promise { db = openRawDatabase(dbPath); crsqliteLoaded = false; loadCrsqliteIfAvailable(); + if (hasCrsqlMetadata(db) && !crsqliteLoaded) { + disableCrrTriggersForUnavailableRuntime(db, logger); + } const remigrateDb = makeMigrateDb(); repairUnifiedMemoryFtsSchemaForRuntime(remigrateDb); migrate(remigrateDb); } - if (hasCrsqlite) { + if (crsqliteLoaded) { loadCrsqliteIfAvailable(); ensureCrrTables(db, logger); forceSiteId(db, desiredSiteId); @@ -3672,10 +3782,18 @@ export async function openKvDb(dbPath: string, logger: Logger): Promise { db = openRawDatabase(dbPath); crsqliteLoaded = false; loadCrsqliteIfAvailable(); - forceSiteId(db, desiredSiteId); + if (hasCrsqlMetadata(db) && !crsqliteLoaded) { + disableCrrTriggersForUnavailableRuntime(db, logger); + } + if (crsqliteLoaded) { + forceSiteId(db, desiredSiteId); + } } } else { - logger.warn("db.crsqlite_unavailable", { dbPath, reason: "extension not found for this platform" }); + logger.warn("db.crsqlite_unavailable", { + dbPath, + reason: hasCrsqlite ? "extension not usable for this runtime" : "extension not found for this platform", + }); } } catch (err) { try { @@ -3698,7 +3816,7 @@ export async function openKvDb(dbPath: string, logger: Logger): Promise { const run = (sql: string, params: SqlValue[] = []) => { const alterTable = parseAlterTableTarget(sql); - if (hasCrsqlite && alterTable && rawHasTable(db, `${alterTable}__crsql_clock`)) { + if (crsqliteLoaded && alterTable && rawHasTable(db, `${alterTable}__crsql_clock`)) { getRow(db, "select crsql_begin_alter(?) as ok", [alterTable]); try { runStatement(db, sql, params); @@ -3720,15 +3838,15 @@ export async function openKvDb(dbPath: string, logger: Logger): Promise { }; const sync: AdeDbSyncApi = { - isAvailable: () => hasCrsqlite, + isAvailable: () => crsqliteLoaded, getSiteId: () => desiredSiteId, getDbVersion: () => { - if (!hasCrsqlite) return 0; + if (!crsqliteLoaded) return 0; const row = get<{ db_version: number }>("select crsql_db_version() as db_version"); return Number(row?.db_version ?? 0); }, exportChangesSince: (version: number) => { - if (!hasCrsqlite) return []; + if (!crsqliteLoaded) return []; const rows = allRows<{ table_name: string; pk: unknown; @@ -3769,7 +3887,7 @@ export async function openKvDb(dbPath: string, logger: Logger): Promise { })); }, applyChanges: (changes: CrsqlChangeRow[]) => { - if (!hasCrsqlite) return { appliedCount: 0, dbVersion: 0, touchedTables: [], rebuiltFts: false }; + if (!crsqliteLoaded) return { appliedCount: 0, dbVersion: 0, touchedTables: [], rebuiltFts: false }; let appliedCount = 0; const touchedTables = new Set(); runStatement(db, "begin"); diff --git a/apps/desktop/src/main/services/sync/deviceRegistryService.ts b/apps/desktop/src/main/services/sync/deviceRegistryService.ts index 5889d2ffd..418815ca5 100644 --- a/apps/desktop/src/main/services/sync/deviceRegistryService.ts +++ b/apps/desktop/src/main/services/sync/deviceRegistryService.ts @@ -1,673 +1 @@ -import fs from "node:fs"; -import os from "node:os"; -import path from "node:path"; -import { randomUUID } from "node:crypto"; -import { execFileSync } from "node:child_process"; -import { resolveAdeLayout } from "../../../shared/adeLayout"; -import type { - SyncBrainStatusPayload, - SyncClusterState, - SyncDeviceRecord, - SyncPeerConnectionState, - SyncPeerDeviceType, - SyncPeerMetadata, - SyncPeerPlatform, -} from "../../../shared/types"; -import { normalizeNotificationPreferences, type NotificationPreferences } from "../../../shared/types/sync"; -import type { Logger } from "../logging/logger"; -import { mapPlatform } from "./syncProtocol"; -import { resolveTailscaleCliPath } from "./resolveTailscaleCliPath"; -import type { AdeDb } from "../state/kvDb"; -import { nowIso, safeJsonParse, toOptionalString, uniqueStrings } from "../shared/utils"; - -type DeviceRegistryServiceArgs = { - db: AdeDb; - logger: Logger; - projectRoot: string; - localDeviceIdPath?: string; -}; - -type DeviceRow = { - device_id: string; - site_id: string; - name: string; - platform: string; - device_type: string; - created_at: string; - updated_at: string; - last_seen_at: string | null; - last_host: string | null; - last_port: number | null; - tailscale_ip: string | null; - ip_addresses_json: string | null; - metadata_json: string | null; -}; - -type ClusterStateRow = { - cluster_id: string; - brain_device_id: string; - brain_epoch: number; - updated_at: string; - updated_by_device_id: string; -}; - -const DEVICE_ID_FILE = "sync-device-id"; -export const DEFAULT_SYNC_CLUSTER_ID = "default"; -const WORKSPACE_ACTIVITY_ID = "workspace"; -const TAILSCALE_STATUS_CACHE_MS = 30_000; - -let tailscaleStatusCache: - | { - expiresAt: number; - dnsName: string | null; - } - | null = null; - -function normalizeDeviceType(value: unknown): SyncPeerDeviceType { - const raw = typeof value === "string" ? value.trim() : ""; - if (raw === "desktop" || raw === "phone" || raw === "vps") return raw; - return "unknown"; -} - -function normalizePlatform(value: unknown): SyncPeerPlatform { - const raw = typeof value === "string" ? value.trim() : ""; - if (raw === "macOS" || raw === "linux" || raw === "windows" || raw === "iOS") return raw; - return "unknown"; -} - -function readJsonArray(raw: string | null | undefined): string[] { - return safeJsonParse(raw, []).filter((value) => typeof value === "string" && value.trim().length > 0); -} - -function mapDeviceRow(row: DeviceRow | null): SyncDeviceRecord | null { - if (!row) return null; - return { - deviceId: String(row.device_id), - siteId: String(row.site_id), - name: String(row.name), - platform: normalizePlatform(row.platform), - deviceType: normalizeDeviceType(row.device_type), - createdAt: String(row.created_at), - updatedAt: String(row.updated_at), - lastSeenAt: row.last_seen_at ? String(row.last_seen_at) : null, - lastHost: row.last_host ? String(row.last_host) : null, - lastPort: row.last_port == null ? null : Number(row.last_port), - tailscaleIp: row.tailscale_ip ? String(row.tailscale_ip) : null, - ipAddresses: readJsonArray(row.ip_addresses_json), - metadata: safeJsonParse>(row.metadata_json, {}), - }; -} - -function mapClusterStateRow(row: ClusterStateRow | null): SyncClusterState | null { - if (!row) return null; - return { - clusterId: String(row.cluster_id), - brainDeviceId: String(row.brain_device_id), - brainEpoch: Number(row.brain_epoch ?? 0), - updatedAt: String(row.updated_at), - updatedByDeviceId: String(row.updated_by_device_id), - }; -} - -type LocalNetworkMetadata = { - lanIpAddresses: string[]; - tailscaleIp: string | null; - tailscaleDnsName: string | null; -}; - -function isTailscaleAddress(ipAddress: string): boolean { - const parts = ipAddress.split("."); - if (parts.length !== 4) return false; - const octets = parts.map((part) => Number(part)); - if (octets.some((value) => !Number.isInteger(value) || value < 0 || value > 255)) return false; - return octets[0] === 100 && octets[1] >= 64 && octets[1] <= 127; -} - -function readLocalNetworkMetadata(): LocalNetworkMetadata { - const interfaces = os.networkInterfaces(); - const lan: string[] = []; - const tailscale: string[] = []; - for (const [interfaceName, entries] of Object.entries(interfaces)) { - const isLikelyTailscaleInterface = /tailscale|utun|tun/i.test(interfaceName); - for (const entry of entries ?? []) { - if (!entry || entry.internal || entry.family !== "IPv4") continue; - if (isLikelyTailscaleInterface || isTailscaleAddress(entry.address)) { - tailscale.push(entry.address); - } else { - lan.push(entry.address); - } - } - } - return { - lanIpAddresses: uniqueStrings(lan), - tailscaleIp: uniqueStrings(tailscale)[0] ?? null, - tailscaleDnsName: readLocalTailscaleDnsName(), - }; -} - -function normalizeTailscaleDnsName(value: unknown): string | null { - if (typeof value !== "string") return null; - const normalized = value.trim().replace(/\.$/, "").toLowerCase(); - return normalized.endsWith(".ts.net") ? normalized : null; -} - -function readLocalTailscaleDnsName(): string | null { - const now = Date.now(); - if (tailscaleStatusCache && tailscaleStatusCache.expiresAt > now) { - return tailscaleStatusCache.dnsName; - } - let dnsName: string | null = null; - try { - const raw = execFileSync(resolveTailscaleCliPath(), ["status", "--json"], { - encoding: "utf8", - stdio: ["ignore", "pipe", "ignore"], - timeout: 1_000, - }); - const parsed = safeJsonParse<{ Self?: { DNSName?: unknown } }>(raw, {}); - dnsName = normalizeTailscaleDnsName(parsed.Self?.DNSName); - } catch { - dnsName = null; - } - tailscaleStatusCache = { - expiresAt: now + TAILSCALE_STATUS_CACHE_MS, - dnsName, - }; - return dnsName; -} - -function firstPreferredHost(ipAddresses: string[]): string { - return ipAddresses[0] ?? os.hostname(); -} - -export function createDeviceRegistryService(args: DeviceRegistryServiceArgs) { - const layout = resolveAdeLayout(args.projectRoot); - const deviceIdPath = args.localDeviceIdPath ?? path.join(layout.secretsDir, DEVICE_ID_FILE); - const legacyProjectDeviceIdPath = path.join(layout.secretsDir, DEVICE_ID_FILE); - fs.mkdirSync(path.dirname(deviceIdPath), { recursive: true }); - - const readOrCreateLocalDeviceId = (): string => { - // One desktop, one device id: the shared file is authoritative across - // projects so each project's `sync_cluster_state.brain_device_id` agrees - // on the same local identity. If the shared file is empty, seed it from - // the first legacy per-project id we happen to see (one-time migration), - // otherwise mint a fresh id. `O_EXCL` on the seed write keeps two - // concurrent project contexts from racing to mint different ids. - const shared = fs.existsSync(deviceIdPath) ? fs.readFileSync(deviceIdPath, "utf8").trim() : ""; - if (shared.length > 0) return shared; - - const legacy = deviceIdPath !== legacyProjectDeviceIdPath && fs.existsSync(legacyProjectDeviceIdPath) - ? fs.readFileSync(legacyProjectDeviceIdPath, "utf8").trim() - : ""; - const candidate = legacy.length > 0 ? legacy : randomUUID(); - try { - fs.writeFileSync(deviceIdPath, `${candidate}\n`, { flag: "wx" }); - return candidate; - } catch (err) { - if ((err as NodeJS.ErrnoException).code !== "EEXIST") throw err; - // Another context won the race; use whatever they wrote. - return fs.readFileSync(deviceIdPath, "utf8").trim(); - } - }; - - const localDeviceId = readOrCreateLocalDeviceId(); - const localSiteId = args.db.sync.getSiteId(); - - const getLocalDefaults = () => { - const network = readLocalNetworkMetadata(); - const metadata: Record = { - hostname: os.hostname(), - }; - if (network.tailscaleDnsName) { - metadata.tailscaleDnsName = network.tailscaleDnsName; - } - return { - name: os.hostname(), - platform: mapPlatform(process.platform), - deviceType: "desktop" as SyncPeerDeviceType, - ipAddresses: network.lanIpAddresses, - tailscaleIp: network.tailscaleIp, - lastHost: firstPreferredHost(network.lanIpAddresses), - metadata, - }; - }; - - const upsertDeviceRecord = (record: { - deviceId: string; - siteId: string; - name: string; - platform: SyncPeerPlatform; - deviceType: SyncPeerDeviceType; - createdAt?: string; - updatedAt?: string; - lastSeenAt?: string | null; - lastHost?: string | null; - lastPort?: number | null; - tailscaleIp?: string | null; - ipAddresses?: string[]; - metadata?: Record; - }): SyncDeviceRecord => { - const now = nowIso(); - const existing = mapDeviceRow(args.db.get("select * from devices where device_id = ? limit 1", [record.deviceId])); - const nextCreatedAt = record.createdAt ?? existing?.createdAt ?? now; - const nextUpdatedAt = record.updatedAt ?? now; - const nextIpAddresses = uniqueStrings(record.ipAddresses ?? existing?.ipAddresses ?? []); - const nextMetadata = { - ...(existing?.metadata ?? {}), - ...(record.metadata ?? {}), - }; - args.db.run( - ` - insert into devices( - device_id, site_id, name, platform, device_type, - created_at, updated_at, last_seen_at, last_host, last_port, - tailscale_ip, ip_addresses_json, metadata_json - ) values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) - on conflict(device_id) do update set - site_id = excluded.site_id, - name = excluded.name, - platform = excluded.platform, - device_type = excluded.device_type, - updated_at = excluded.updated_at, - last_seen_at = excluded.last_seen_at, - last_host = excluded.last_host, - last_port = excluded.last_port, - tailscale_ip = excluded.tailscale_ip, - ip_addresses_json = excluded.ip_addresses_json, - metadata_json = excluded.metadata_json - `, - [ - record.deviceId, - record.siteId, - record.name, - record.platform, - record.deviceType, - nextCreatedAt, - nextUpdatedAt, - record.lastSeenAt ?? existing?.lastSeenAt ?? null, - record.lastHost ?? existing?.lastHost ?? null, - record.lastPort ?? existing?.lastPort ?? null, - record.tailscaleIp ?? existing?.tailscaleIp ?? null, - JSON.stringify(nextIpAddresses), - JSON.stringify(nextMetadata), - ], - ); - return mapDeviceRow(args.db.get("select * from devices where device_id = ? limit 1", [record.deviceId]))!; - }; - - const ensureLocalDevice = (): SyncDeviceRecord => { - const existing = mapDeviceRow(args.db.get("select * from devices where device_id = ? limit 1", [localDeviceId])); - const defaults = getLocalDefaults(); - return upsertDeviceRecord({ - deviceId: localDeviceId, - siteId: localSiteId, - name: existing?.name ?? defaults.name, - platform: existing?.platform ?? defaults.platform, - deviceType: existing?.deviceType ?? defaults.deviceType, - lastSeenAt: nowIso(), - lastHost: defaults.lastHost ?? existing?.lastHost ?? null, - lastPort: existing?.lastPort ?? null, - tailscaleIp: defaults.tailscaleIp ?? existing?.tailscaleIp ?? null, - ipAddresses: defaults.ipAddresses.length > 0 ? defaults.ipAddresses : (existing?.ipAddresses ?? []), - metadata: { - ...(existing?.metadata ?? {}), - ...defaults.metadata, - }, - }); - }; - - const listDevices = (): SyncDeviceRecord[] => { - return args.db - .all("select * from devices order by case when device_id = ? then 0 else 1 end, name collate nocase asc", [localDeviceId]) - .map((row) => mapDeviceRow(row)) - .filter((row): row is SyncDeviceRecord => row != null); - }; - - const getDevice = (deviceId: string): SyncDeviceRecord | null => { - const normalized = deviceId.trim(); - if (!normalized) return null; - return mapDeviceRow(args.db.get("select * from devices where device_id = ? limit 1", [normalized])); - }; - - const getClusterState = (): SyncClusterState | null => { - return mapClusterStateRow( - args.db.get("select * from sync_cluster_state where cluster_id = ? limit 1", [DEFAULT_SYNC_CLUSTER_ID]), - ); - }; - - const setClusterState = (argsIn: { - brainDeviceId: string; - brainEpoch: number; - updatedByDeviceId?: string; - }): SyncClusterState => { - const now = nowIso(); - args.db.run( - ` - insert into sync_cluster_state(cluster_id, brain_device_id, brain_epoch, updated_at, updated_by_device_id) - values (?, ?, ?, ?, ?) - on conflict(cluster_id) do update set - brain_device_id = excluded.brain_device_id, - brain_epoch = excluded.brain_epoch, - updated_at = excluded.updated_at, - updated_by_device_id = excluded.updated_by_device_id - `, - [ - DEFAULT_SYNC_CLUSTER_ID, - argsIn.brainDeviceId, - argsIn.brainEpoch, - now, - argsIn.updatedByDeviceId ?? localDeviceId, - ], - ); - return getClusterState()!; - }; - - const bootstrapLocalBrainIfNeeded = (): SyncClusterState => { - const existing = getClusterState(); - if (existing) return existing; - ensureLocalDevice(); - return setClusterState({ - brainDeviceId: localDeviceId, - brainEpoch: 1, - updatedByDeviceId: localDeviceId, - }); - }; - - const updateLocalDevice = (updates: { - name?: string; - deviceType?: SyncPeerDeviceType; - }): SyncDeviceRecord => { - const current = ensureLocalDevice(); - return upsertDeviceRecord({ - deviceId: localDeviceId, - siteId: localSiteId, - name: toOptionalString(updates.name) ?? current.name, - platform: current.platform, - deviceType: updates.deviceType ?? current.deviceType, - lastSeenAt: nowIso(), - lastHost: current.lastHost, - lastPort: current.lastPort, - tailscaleIp: current.tailscaleIp, - ipAddresses: current.ipAddresses, - metadata: current.metadata, - }); - }; - - const touchLocalDevice = (argsIn: { - lastSeenAt?: string | null; - lastHost?: string | null; - lastPort?: number | null; - metadata?: Record; - } = {}): SyncDeviceRecord => { - const current = ensureLocalDevice(); - const network = readLocalNetworkMetadata(); - return upsertDeviceRecord({ - deviceId: current.deviceId, - siteId: current.siteId, - name: current.name, - platform: current.platform, - deviceType: current.deviceType, - lastSeenAt: argsIn.lastSeenAt ?? nowIso(), - lastHost: argsIn.lastHost ?? current.lastHost ?? firstPreferredHost(network.lanIpAddresses), - lastPort: argsIn.lastPort ?? current.lastPort, - tailscaleIp: network.tailscaleIp ?? current.tailscaleIp, - ipAddresses: network.lanIpAddresses.length > 0 ? network.lanIpAddresses : current.ipAddresses, - metadata: { - ...current.metadata, - ...(argsIn.metadata ?? {}), - }, - }); - }; - - const upsertPeerMetadata = ( - peer: SyncPeerMetadata | SyncPeerConnectionState, - extras: { - lastSeenAt?: string | null; - lastHost?: string | null; - lastPort?: number | null; - metadata?: Record; - } = {}, - ): SyncDeviceRecord => { - return upsertDeviceRecord({ - deviceId: peer.deviceId, - siteId: peer.siteId, - name: peer.deviceName, - platform: peer.platform, - deviceType: peer.deviceType, - lastSeenAt: extras.lastSeenAt ?? ("lastSeenAt" in peer ? peer.lastSeenAt : nowIso()), - lastHost: extras.lastHost ?? ("remoteAddress" in peer ? peer.remoteAddress : null), - lastPort: extras.lastPort ?? ("remotePort" in peer ? peer.remotePort : null), - metadata: { - dbVersion: peer.dbVersion, - ...(extras.metadata ?? {}), - }, - }); - }; - - type ApnsTokenKind = "alert" | "activity-start" | "activity-update"; - - const apnsMetaKey = (kind: ApnsTokenKind): string => { - if (kind === "alert") return "apnsAlertToken"; - if (kind === "activity-start") return "apnsActivityStartToken"; - return "apnsActivityUpdateTokens"; - }; - - const setApnsToken = ( - deviceId: string, - token: string, - kind: ApnsTokenKind, - env: "sandbox" | "production", - extras: { bundleId?: string; activityId?: string } = {}, - ): SyncDeviceRecord | null => { - const device = getDevice(deviceId); - if (!device) return null; - const nextMetadata: Record = { - ...device.metadata, - apnsEnv: env, - apnsTokenUpdatedAt: nowIso(), - }; - if (extras.bundleId) nextMetadata.apnsBundleId = extras.bundleId; - if (kind === "activity-update") { - const existing = (device.metadata.apnsActivityUpdateTokens as Record | undefined) ?? {}; - const activityId = extras.activityId?.trim() || WORKSPACE_ACTIVITY_ID; - nextMetadata.apnsActivityUpdateTokens = { ...existing, [activityId]: token }; - } else { - nextMetadata[apnsMetaKey(kind)] = token; - } - return upsertDeviceRecord({ - deviceId: device.deviceId, - siteId: device.siteId, - name: device.name, - platform: device.platform, - deviceType: device.deviceType, - lastSeenAt: device.lastSeenAt, - lastHost: device.lastHost, - lastPort: device.lastPort, - tailscaleIp: device.tailscaleIp, - ipAddresses: device.ipAddresses, - metadata: nextMetadata, - }); - }; - - const getApnsTokenForDevice = ( - deviceId: string, - kind: ApnsTokenKind, - activityId?: string, - ): string | null => { - const device = getDevice(deviceId); - if (!device) return null; - if (kind === "activity-update") { - const map = (device.metadata.apnsActivityUpdateTokens as Record | undefined) ?? {}; - return map[activityId?.trim() || WORKSPACE_ACTIVITY_ID] ?? null; - } - const raw = device.metadata[apnsMetaKey(kind)]; - return typeof raw === "string" && raw.trim().length > 0 ? raw : null; - }; - - const setNotificationPreferences = ( - deviceId: string, - prefs: NotificationPreferences, - ): SyncDeviceRecord | null => { - const device = getDevice(deviceId); - if (!device) return null; - const normalizedPrefs = normalizeNotificationPreferences(prefs); - return upsertDeviceRecord({ - deviceId: device.deviceId, - siteId: device.siteId, - name: device.name, - platform: device.platform, - deviceType: device.deviceType, - lastSeenAt: device.lastSeenAt, - lastHost: device.lastHost, - lastPort: device.lastPort, - tailscaleIp: device.tailscaleIp, - ipAddresses: device.ipAddresses, - metadata: { - ...device.metadata, - notificationPreferences: normalizedPrefs, - notificationPreferencesUpdatedAt: nowIso(), - }, - }); - }; - - const getNotificationPreferences = (deviceId: string): NotificationPreferences | null => { - const prefs = getDevice(deviceId)?.metadata.notificationPreferences; - if (!prefs || typeof prefs !== "object" || Array.isArray(prefs)) return null; - return normalizeNotificationPreferences(prefs); - }; - - const invalidateApnsToken = (deviceToken: string): void => { - const token = deviceToken.trim(); - if (!token) return; - const device = findDeviceByApnsToken(token); - if (!device) return; - const nextMetadata = { ...device.metadata }; - if (nextMetadata.apnsAlertToken === token) { - delete nextMetadata.apnsAlertToken; - } - if (nextMetadata.apnsActivityStartToken === token) { - delete nextMetadata.apnsActivityStartToken; - } - const updates = nextMetadata.apnsActivityUpdateTokens; - if (updates && typeof updates === "object" && !Array.isArray(updates)) { - const nextUpdates = { ...(updates as Record) }; - for (const [activityId, value] of Object.entries(nextUpdates)) { - if (value === token) delete nextUpdates[activityId]; - } - if (Object.keys(nextUpdates).length > 0) { - nextMetadata.apnsActivityUpdateTokens = nextUpdates; - } else { - delete nextMetadata.apnsActivityUpdateTokens; - } - } - upsertDeviceRecord({ - deviceId: device.deviceId, - siteId: device.siteId, - name: device.name, - platform: device.platform, - deviceType: device.deviceType, - lastSeenAt: device.lastSeenAt, - lastHost: device.lastHost, - lastPort: device.lastPort, - tailscaleIp: device.tailscaleIp, - ipAddresses: device.ipAddresses, - metadata: nextMetadata, - }); - }; - - const invalidateApnsTokensForDevice = (deviceId: string): void => { - const device = getDevice(deviceId); - if (!device) return; - const nextMetadata = { ...device.metadata }; - delete nextMetadata.apnsAlertToken; - delete nextMetadata.apnsActivityStartToken; - delete nextMetadata.apnsActivityUpdateTokens; - upsertDeviceRecord({ - deviceId: device.deviceId, - siteId: device.siteId, - name: device.name, - platform: device.platform, - deviceType: device.deviceType, - lastSeenAt: device.lastSeenAt, - lastHost: device.lastHost, - lastPort: device.lastPort, - tailscaleIp: device.tailscaleIp, - ipAddresses: device.ipAddresses, - metadata: nextMetadata, - }); - }; - - const findDeviceByApnsToken = (token: string): SyncDeviceRecord | null => { - for (const device of listDevices()) { - const alert = device.metadata.apnsAlertToken; - const activity = device.metadata.apnsActivityStartToken; - if (alert === token || activity === token) return device; - const updates = device.metadata.apnsActivityUpdateTokens; - if (updates && typeof updates === "object") { - for (const value of Object.values(updates as Record)) { - if (value === token) return device; - } - } - } - return null; - }; - - const applyBrainStatus = (payload: SyncBrainStatusPayload): void => { - upsertPeerMetadata(payload.brain, { lastSeenAt: nowIso() }); - for (const peer of payload.connectedPeers) { - upsertPeerMetadata(peer, { - lastSeenAt: peer.lastSeenAt, - lastHost: peer.remoteAddress, - lastPort: peer.remotePort, - }); - } - }; - - const clearClusterRegistryForViewerJoin = (): void => { - args.logger.info("sync.device_registry.clear_for_viewer_join", { - projectRoot: args.projectRoot, - localDeviceId, - }); - args.db.run("delete from sync_cluster_state"); - args.db.run("delete from devices"); - }; - - const forgetDevice = (deviceId: string): void => { - const normalized = deviceId.trim(); - if (!normalized || normalized === localDeviceId) return; - args.db.run("delete from devices where device_id = ?", [normalized]); - }; - - ensureLocalDevice(); - - return { - getLocalDeviceId(): string { - return localDeviceId; - }, - - getLocalSiteId(): string { - return localSiteId; - }, - - ensureLocalDevice, - touchLocalDevice, - updateLocalDevice, - listDevices, - getDevice, - getClusterState, - setClusterState, - bootstrapLocalBrainIfNeeded, - upsertPeerMetadata, - applyBrainStatus, - clearClusterRegistryForViewerJoin, - forgetDevice, - setApnsToken, - getApnsTokenForDevice, - setNotificationPreferences, - getNotificationPreferences, - invalidateApnsToken, - invalidateApnsTokensForDevice, - findDeviceByApnsToken, - }; -} - -export type DeviceRegistryService = ReturnType; +export * from "../../../../../ade-cli/src/services/sync/deviceRegistryService"; diff --git a/apps/desktop/src/main/services/sync/resolveTailscaleCliPath.test.ts b/apps/desktop/src/main/services/sync/resolveTailscaleCliPath.test.ts index 7f9db5944..08e7e556b 100644 --- a/apps/desktop/src/main/services/sync/resolveTailscaleCliPath.test.ts +++ b/apps/desktop/src/main/services/sync/resolveTailscaleCliPath.test.ts @@ -10,6 +10,28 @@ describe("resolveTailscaleCliPath", () => { ).toBe("C:\\custom\\tailscale.exe"); }); + it("prefers the standalone macOS CLI over the app bundle helper", () => { + const standalone = "/usr/local/bin/tailscale"; + const bundleHelper = "/Applications/Tailscale.app/Contents/MacOS/Tailscale"; + expect( + resolveTailscaleCliPath({ + platform: "darwin", + existsSync: (p) => + String(p) === standalone || String(p) === bundleHelper, + }), + ).toBe(standalone); + }); + + it("falls back to the macOS app bundle helper when no standalone CLI exists", () => { + const bundleHelper = "/Applications/Tailscale.app/Contents/MacOS/Tailscale"; + expect( + resolveTailscaleCliPath({ + platform: "darwin", + existsSync: (p) => String(p) === bundleHelper, + }), + ).toBe(bundleHelper); + }); + it("prefers a default Windows install path when that exe exists", () => { const target = "C:\\Program Files\\Tailscale\\tailscale.exe"; expect( diff --git a/apps/desktop/src/main/services/sync/resolveTailscaleCliPath.ts b/apps/desktop/src/main/services/sync/resolveTailscaleCliPath.ts index b613928c0..73529aed6 100644 --- a/apps/desktop/src/main/services/sync/resolveTailscaleCliPath.ts +++ b/apps/desktop/src/main/services/sync/resolveTailscaleCliPath.ts @@ -1,51 +1 @@ -import fs from "node:fs"; -import path from "node:path"; -import type { PathLike } from "node:fs"; - -const TAILSCALE_CLI_MACOS_PATH = "/Applications/Tailscale.app/Contents/MacOS/Tailscale"; - -function windowsTailscaleExeCandidates(env: NodeJS.ProcessEnv): string[] { - const programFiles = env.ProgramFiles?.trim(); - const programFilesX86 = env["ProgramFiles(x86)"]?.trim(); - const { join: winJoin } = path.win32; - const out: string[] = []; - if (programFiles) { - out.push(winJoin(programFiles, "Tailscale", "tailscale.exe")); - } - if (programFilesX86) { - out.push(winJoin(programFilesX86, "Tailscale", "tailscale.exe")); - } - if (out.length === 0) { - out.push("C:\\Program Files\\Tailscale\\tailscale.exe", "C:\\Program Files (x86)\\Tailscale\\tailscale.exe"); - } - return out; -} - -export type ResolveTailscaleCliPathOptions = { - env?: NodeJS.ProcessEnv; - platform?: NodeJS.Platform; - /** Test seam; production uses `fs.existsSync`. */ - existsSync?: (path: PathLike) => boolean; -}; - -/** - * Resolves the Tailscale CLI for `status`, `serve`, etc. - * Precedence: `ADE_TAILSCALE_CLI`, known macOS bundle path, known Windows - * install paths, then `tailscale` (PATH lookup). - */ -export function resolveTailscaleCliPath(options?: ResolveTailscaleCliPathOptions): string { - const env = options?.env ?? process.env; - const platform = options?.platform ?? process.platform; - const exists = options?.existsSync ?? ((p: PathLike) => fs.existsSync(p)); - const configured = env.ADE_TAILSCALE_CLI?.trim(); - if (configured) return configured; - if (platform === "darwin" && exists(TAILSCALE_CLI_MACOS_PATH)) { - return TAILSCALE_CLI_MACOS_PATH; - } - if (platform === "win32") { - for (const candidate of windowsTailscaleExeCandidates(env)) { - if (exists(candidate)) return candidate; - } - } - return "tailscale"; -} +export * from "../../../../../ade-cli/src/services/sync/resolveTailscaleCliPath"; diff --git a/apps/desktop/src/main/services/sync/syncHostService.test.ts b/apps/desktop/src/main/services/sync/syncHostService.test.ts index f3894a69b..4332ae163 100644 --- a/apps/desktop/src/main/services/sync/syncHostService.test.ts +++ b/apps/desktop/src/main/services/sync/syncHostService.test.ts @@ -2306,6 +2306,148 @@ describe.skipIf(!isCrsqliteAvailable())("syncHostService", () => { await new Promise((resolve) => revokedWs.once("close", resolve)); }); + it("rejects project-scoped commands without projectId when the host is project-bound", async () => { + const brainDb = await openKvDb(makeDbPath("ade-sync-command-project-scope-"), createLogger() as any); + const projectRoot = makeProjectRoot("ade-sync-command-project-scope-project-"); + const workspaceRoot = path.join(projectRoot, "workspace"); + fs.mkdirSync(workspaceRoot, { recursive: true }); + + const host = createSyncHostService({ + db: brainDb, + logger: createLogger() as any, + projectId: "project-1", + projectRoot, + port: 0, + pinStore: createStubPinStore(), + fileService: createStubFileService(workspaceRoot) as any, + laneService: { + list: vi.fn().mockResolvedValue([]), + create: vi.fn(), + archive: vi.fn(), + } as any, + prService: { + listAll: vi.fn().mockResolvedValue([]), + refresh: vi.fn().mockResolvedValue([]), + } as any, + ptyService: { + create: vi.fn(), + enrichSessions: (rows: any[]) => rows, + } as any, + sessionService: { list: () => [] } as any, + computerUseArtifactBrokerService: { + listArtifacts: () => [], + } as any, + }); + activeDisposers.push(async () => { + await host.dispose(); + brainDb.close(); + }); + + const client = await connectClient({ + port: await host.waitUntilListening(), + token: host.getBootstrapToken(), + deviceId: "peer-project-scope", + deviceName: "Project Scope Phone", + siteId: brainDb.sync.getSiteId(), + dbVersion: brainDb.sync.getDbVersion(), + deviceType: "phone", + }); + activeDisposers.push(client.close); + + client.ws.send(encodeSyncEnvelope({ + type: "command", + requestId: "cmd-missing-project", + payload: { + commandId: "cmd-missing-project", + action: "lanes.list", + args: {}, + }, + })); + + const ack = await client.queue.next("command_ack"); + expect((ack.payload as { accepted: boolean }).accepted).toBe(false); + const result = await client.queue.next("command_result"); + expect((result.payload as { ok: boolean; error?: { code: string } }).ok).toBe(false); + expect((result.payload as { ok: boolean; error?: { code: string } }).error?.code).toBe("missing_project"); + }); + + it("routes project-scoped commands for another registered project through the remote command executor", async () => { + const brainDb = await openKvDb(makeDbPath("ade-sync-command-project-route-"), createLogger() as any); + const projectRoot = makeProjectRoot("ade-sync-command-project-route-project-"); + const workspaceRoot = path.join(projectRoot, "workspace"); + fs.mkdirSync(workspaceRoot, { recursive: true }); + const execute = vi.fn(async (payload: { projectId?: string | null; action?: string }) => ({ + routedProjectId: payload.projectId, + routedAction: payload.action, + })); + const laneList = vi.fn().mockResolvedValue([]); + + const host = createSyncHostService({ + db: brainDb, + logger: createLogger() as any, + projectId: "project-1", + projectRoot, + port: 0, + pinStore: createStubPinStore(), + fileService: createStubFileService(workspaceRoot) as any, + laneService: { + list: laneList, + create: vi.fn(), + archive: vi.fn(), + } as any, + prService: { + listAll: vi.fn().mockResolvedValue([]), + refresh: vi.fn().mockResolvedValue([]), + } as any, + ptyService: { + create: vi.fn(), + enrichSessions: (rows: any[]) => rows, + } as any, + sessionService: { list: () => [] } as any, + computerUseArtifactBrokerService: { + listArtifacts: () => [], + } as any, + remoteCommandExecutor: { execute }, + }); + activeDisposers.push(async () => { + await host.dispose(); + brainDb.close(); + }); + + const client = await connectClient({ + port: await host.waitUntilListening(), + token: host.getBootstrapToken(), + deviceId: "peer-project-route", + deviceName: "Project Route Phone", + siteId: brainDb.sync.getSiteId(), + dbVersion: brainDb.sync.getDbVersion(), + deviceType: "phone", + }); + activeDisposers.push(client.close); + + client.ws.send(encodeSyncEnvelope({ + type: "command", + projectId: "project-2", + requestId: "cmd-other-project", + payload: { + commandId: "cmd-other-project", + action: "lanes.list", + args: {}, + }, + })); + + const ack = await client.queue.next("command_ack"); + expect((ack.payload as { accepted: boolean }).accepted).toBe(true); + const result = await client.queue.next("command_result"); + expect((result.payload as { ok: boolean; result?: unknown }).ok).toBe(true); + expect((result.payload as { result: { routedProjectId: string; routedAction: string } }).result).toEqual({ + routedProjectId: "project-2", + routedAction: "lanes.list", + }); + expect(execute).toHaveBeenCalledTimes(1); + expect(laneList).not.toHaveBeenCalled(); + }); + it("clears prior PIN failures after a successful pair and still allows paired hello", async () => { const brainDb = await openKvDb(makeDbPath("ade-sync-pairing-cooldown-"), createLogger() as any); const projectRoot = makeProjectRoot("ade-sync-pairing-cooldown-project-"); diff --git a/apps/desktop/src/main/services/sync/syncHostService.ts b/apps/desktop/src/main/services/sync/syncHostService.ts index b8ac215a5..5649bf08f 100644 --- a/apps/desktop/src/main/services/sync/syncHostService.ts +++ b/apps/desktop/src/main/services/sync/syncHostService.ts @@ -1,2999 +1 @@ -import fs from "node:fs"; -import { execFile } from "node:child_process"; -import os from "node:os"; -import path from "node:path"; -import { fileURLToPath } from "node:url"; -import { promisify } from "node:util"; -import { createHash, randomBytes } from "node:crypto"; -import { Bonjour, type Service as BonjourService } from "bonjour-service"; -import { WebSocketServer, WebSocket, type RawData } from "ws"; -import { resolveAdeLayout } from "../../../shared/adeLayout"; -import type { - AgentChatEventEnvelope, - CrsqlChangeRow, - DeviceMarker, - FileContent, - FileTreeNode, - FilesQuickOpenItem, - FilesSearchTextMatch, - FilesWorkspace, - LaneDetailPayload, - LaneListSnapshot, - LaneSummary, - PtyDataEvent, - PtyExitEvent, - SyncBrainStatusPayload, - SyncChangesetAckPayload, - SyncChangesetBatchPayload, - SyncCommandAckPayload, - SyncCommandPayload, - SyncCommandResultPayload, - SyncEnvelope, - SyncChatSubscribeSnapshotPayload, - SyncChatUnsubscribePayload, - SyncFileBlob, - SyncFileRequest, - SyncFileResponsePayload, - SyncHelloPayload, - SyncMobileProjectSummary, - SyncPairingRequestPayload, - SyncPeerConnectionState, - SyncPeerMetadata, - SyncProjectCatalogChunkPayload, - SyncProjectCatalogPayload, - SyncProjectSwitchRequestPayload, - SyncProjectSwitchResultPayload, - SyncRemoteCommandDescriptor, - SyncTailnetDiscoveryStatus, - SyncTerminalSnapshotPayload, -} from "../../../shared/types"; -import { parseAgentChatTranscript } from "../../../shared/chatTranscript"; -import type { Logger } from "../logging/logger"; -import type { createAgentChatService } from "../chat/agentChatService"; -import type { createCtoStateService } from "../cto/ctoStateService"; -import type { createFlowPolicyService } from "../cto/flowPolicyService"; -import type { createLinearCredentialService } from "../cto/linearCredentialService"; -import type { createLinearIngressService } from "../cto/linearIngressService"; -import type { createLinearIssueTracker } from "../cto/linearIssueTracker"; -import type { createLinearSyncService } from "../cto/linearSyncService"; -import type { createWorkerAgentService } from "../cto/workerAgentService"; -import type { createWorkerBudgetService } from "../cto/workerBudgetService"; -import type { createWorkerHeartbeatService } from "../cto/workerHeartbeatService"; -import type { createWorkerRevisionService } from "../cto/workerRevisionService"; -import type { createProjectConfigService } from "../config/projectConfigService"; -import type { createConflictService } from "../conflicts/conflictService"; -import type { createFileService } from "../files/fileService"; -import type { createDiffService } from "../diffs/diffService"; -import type { createGitOperationsService } from "../git/gitOperationsService"; -import type { createAutoRebaseService } from "../lanes/autoRebaseService"; -import type { createLaneEnvironmentService } from "../lanes/laneEnvironmentService"; -import type { createLaneService } from "../lanes/laneService"; -import type { createLaneTemplateService } from "../lanes/laneTemplateService"; -import type { createPortAllocationService } from "../lanes/portAllocationService"; -import type { createRebaseSuggestionService } from "../lanes/rebaseSuggestionService"; -import type { createProcessService } from "../processes/processService"; -import type { createPtyService } from "../pty/ptyService"; -import type { createIssueInventoryService } from "../prs/issueInventoryService"; -import type { PathToMergeOrchestrator } from "../prs/pathToMergeOrchestrator"; -import type { createPrService } from "../prs/prService"; -import type { createQueueLandingService } from "../prs/queueLandingService"; -import type { createSessionService } from "../sessions/sessionService"; -import type { createComputerUseArtifactBrokerService } from "../computerUse/computerUseArtifactBrokerService"; -import type { AdeDb } from "../state/kvDb"; -import { hasNullByte, normalizeRelative, nowIso, resolvePathWithinRoot, safeJsonParse, toOptionalString, uniqueStrings, writeTextAtomic } from "../shared/utils"; -import type { DeviceRegistryService } from "./deviceRegistryService"; -import { createSyncPairingStore } from "./syncPairingStore"; -import type { NotificationEventBus } from "../notifications/notificationEventBus"; -import type { - ApnsEnvironment, - ApnsPushTokenKind, - NotificationPreferences, - SyncInAppNotificationPayload, - SyncNotificationPrefsPayload, - SyncRegisterPushTokenPayload, - SyncSendTestPushPayload, -} from "../../../shared/types/sync"; -import { DEFAULT_NOTIFICATION_PREFERENCES, normalizeNotificationPreferences } from "../../../shared/types/sync"; -import type { SyncPinStore } from "./syncPinStore"; -import { DEFAULT_SYNC_COMPRESSION_THRESHOLD_BYTES, DEFAULT_SYNC_HOST_PORT, encodeSyncEnvelope, mapPlatform, parseSyncEnvelope, wsDataToText } from "./syncProtocol"; -import { resolveTailscaleCliPath } from "./resolveTailscaleCliPath"; -import { createSyncRemoteCommandService } from "./syncRemoteCommandService"; -const execFileAsync = promisify(execFile); -const DEFAULT_SYNC_HEARTBEAT_INTERVAL_MS = 30_000; -const DEFAULT_SYNC_HEARTBEAT_MISS_LIMIT = 2; -const MOBILE_SYNC_HEARTBEAT_MISS_LIMIT = 6; -const DEFAULT_SYNC_POLL_INTERVAL_MS = 400; -const DEFAULT_BRAIN_STATUS_INTERVAL_MS = 5_000; -const DEFAULT_TERMINAL_SNAPSHOT_BYTES = 220_000; -const PEER_BACKPRESSURE_BYTES = 4 * 1024 * 1024; -const MOBILE_COMMAND_RESULT_CACHE_TTL_MS = 30 * 60 * 1000; -const MOBILE_COMMAND_RESULT_CACHE_MAX_ENTRIES = 512; -const CHANGESET_ACK_TIMEOUT_MS = 10_000; -const MAX_CHANGESET_ACK_RETRIES = 6; -const LANE_PRESENCE_TTL_MS = 60_000; -const SYNC_MDNS_SERVICE_TYPE = "ade-sync"; -export const SYNC_TAILNET_DISCOVERY_SERVICE_NAME = "svc:ade-sync"; -export const SYNC_TAILNET_DISCOVERY_SERVICE_PORT = DEFAULT_SYNC_HOST_PORT; -const MOBILE_MUTATING_FILE_ACTIONS = new Set([ - "writeText", - "createFile", - "createDirectory", - "rename", - "deletePath", -]); - -type LanePresenceEntry = { - marker: DeviceMarker; - lastAnnouncedAtMs: number; - source: "local" | "remote"; -}; - -type PeerState = { - ws: WebSocket; - metadata: SyncPeerMetadata | null; - authenticated: boolean; - authKind: "bootstrap" | "paired" | null; - pairedDeviceId: string | null; - connectedAt: string; - lastSeenAt: string; - lastAppliedAt: string | null; - lastKnownServerDbVersion: number; - latencyMs: number | null; - awaitingHeartbeatAt: string | null; - missedHeartbeatCount: number; - remoteAddress: string | null; - remotePort: number | null; - subscribedSessionIds: Set; - subscribedChatSessionIds: Set; - chatTranscriptOffsets: Map; - chatEventIdsSent: Map>; - pendingChangesetBatch: PendingChangesetBatch | null; -}; - -type PendingChangesetBatch = { - batchId: string; - fromDbVersion: number; - toDbVersion: number; - changes: CrsqlChangeRow[]; - reason: SyncChangesetBatchPayload["reason"]; - sentAtMs: number; - retryCount: number; -}; - -type CachedMobileCommandWaiter = { - peer: PeerState; - requestId: string | null; -}; - -type CachedMobileCommand = { - commandId: string; - action: string; - argsKey: string; - argsFingerprint: string; - ack: SyncCommandAckPayload; - result: SyncCommandResultPayload | null; - waiters: CachedMobileCommandWaiter[]; - acceptedAtMs: number; - completedAtMs: number | null; -}; - -type PersistedMobileCommand = { - key: string; - projectRoot: string; - deviceId: string; - commandId: string; - action: string; - argsFingerprint: string; - ack: SyncCommandAckPayload; - result: SyncCommandResultPayload; - acceptedAtMs: number; - completedAtMs: number; -}; - -const PERSISTED_MOBILE_COMMAND_ACTIONS = new Set([ - "lanes.presence.announce", - "lanes.presence.release", - "notification_prefs", - "work.runQuickCommand", - "work.startCliSession", - "work.closeSession", - "processes.start", - "processes.stop", - "processes.kill", - "chat.interrupt", - "chat.approve", - "chat.respondToInput", - "chat.dispose", - "chat.archive", - "chat.unarchive", - "chat.delete", -]); - -function stableJsonValue(value: unknown): unknown { - if (value == null) return value; - if (Array.isArray(value)) return value.map(stableJsonValue); - if (typeof value !== "object") return value; - const input = value as Record; - const output: Record = {}; - for (const key of Object.keys(input).sort()) { - output[key] = stableJsonValue(input[key]); - } - return output; -} - -function stableJsonKey(value: unknown): string { - return JSON.stringify(stableJsonValue(value)) ?? "null"; -} - -function mobileCommandArgsFingerprint(argsKey: string): string { - return createHash("sha256").update(argsKey).digest("hex"); -} - -function safeObjectValue(value: unknown): Record | null { - return value && typeof value === "object" && !Array.isArray(value) - ? value as Record - : null; -} - -function persistedMobileCommandResult(action: string, result: SyncCommandResultPayload): SyncCommandResultPayload | null { - if (!PERSISTED_MOBILE_COMMAND_ACTIONS.has(action)) return null; - if (!result.ok) { - return { - commandId: result.commandId, - ok: false, - error: { - code: result.error?.code ?? "command_failed", - message: "Command failed before reconnect.", - }, - }; - } - if (action === "work.runQuickCommand" || action === "work.startCliSession") { - const raw = safeObjectValue(result.result); - const replayResult: Record = {}; - if (typeof raw?.sessionId === "string") replayResult.sessionId = raw.sessionId; - if (typeof raw?.ptyId === "string") replayResult.ptyId = raw.ptyId; - if (action === "work.startCliSession" && safeObjectValue(raw?.session)) replayResult.session = raw?.session; - return { - commandId: result.commandId, - ok: true, - result: Object.keys(replayResult).length > 0 ? replayResult : { ok: true }, - }; - } - return { - commandId: result.commandId, - ok: true, - result: { ok: true }, - }; -} - -function mobileCommandCacheKey(projectRoot: string, peer: PeerState, commandId: string): string | null { - const deviceId = peer.metadata?.deviceId ?? peer.pairedDeviceId; - if (!deviceId || !commandId) return null; - return `${projectRoot}:${deviceId}:${commandId}`; -} - -function addMobileCommandWaiter(record: CachedMobileCommand, peer: PeerState, requestId: string | null): void { - if (record.waiters.some((waiter) => waiter.peer === peer && waiter.requestId === requestId)) return; - record.waiters.push({ peer, requestId }); -} - -type SyncHostServiceArgs = { - db: AdeDb; - logger: Logger; - projectRoot: string; - fileService: ReturnType; - laneService: ReturnType; - gitService?: ReturnType; - diffService?: ReturnType; - conflictService?: ReturnType; - prService: ReturnType; - issueInventoryService?: ReturnType | null; - /** Optional Path-to-Merge orchestrator (forwarded to remote command service). */ - pathToMergeOrchestrator?: PathToMergeOrchestrator | null; - queueLandingService?: ReturnType | null; - sessionService: ReturnType; - ptyService: ReturnType; - processService?: ReturnType; - agentChatService?: ReturnType; - workerAgentService?: ReturnType | null; - workerBudgetService?: ReturnType | null; - workerHeartbeatService?: ReturnType | null; - workerRevisionService?: ReturnType | null; - ctoStateService?: ReturnType | null; - flowPolicyService?: ReturnType | null; - linearCredentialService?: ReturnType | null; - getLinearIngressService?: () => ReturnType | null; - getLinearIssueTracker?: () => ReturnType | null; - getLinearSyncService?: () => ReturnType | null; - projectConfigService?: ReturnType; - portAllocationService?: ReturnType; - laneEnvironmentService?: ReturnType; - laneTemplateService?: ReturnType; - rebaseSuggestionService?: ReturnType; - autoRebaseService?: ReturnType; - computerUseArtifactBrokerService: ReturnType; - pinStore: SyncPinStore; - bootstrapTokenPath?: string; - pairingSecretsPath?: string; - port?: number; - discoveryEnabled?: boolean; - heartbeatIntervalMs?: number; - pollIntervalMs?: number; - brainStatusIntervalMs?: number; - compressionThresholdBytes?: number; - deviceRegistryService?: DeviceRegistryService; - projectCatalogProvider?: { - listProjects: () => Promise; - prepareProjectConnection: (args: SyncProjectSwitchRequestPayload) => Promise; - completeProjectConnection?: ( - args: SyncProjectSwitchRequestPayload, - result: SyncProjectSwitchResultPayload, - ) => Promise; - }; - onStateChanged?: () => void; - notificationEventBus?: NotificationEventBus | null; -}; - -function sanitizeRemoteAddress(remoteAddress: string | null | undefined): string | null { - const value = toOptionalString(remoteAddress); - if (!value) return null; - return value.startsWith("::ffff:") ? value.slice("::ffff:".length) : value; -} - -function ensureBootstrapToken(filePath: string): string { - fs.mkdirSync(path.dirname(filePath), { recursive: true }); - if (!fs.existsSync(filePath)) { - fs.writeFileSync(filePath, randomBytes(24).toString("hex"), "utf8"); - } - return fs.readFileSync(filePath, "utf8").trim(); -} - -function inferMimeType(filePath: string): string | null { - const ext = path.extname(filePath).toLowerCase(); - switch (ext) { - case ".png": - return "image/png"; - case ".jpg": - case ".jpeg": - return "image/jpeg"; - case ".gif": - return "image/gif"; - case ".webp": - return "image/webp"; - case ".mp4": - return "video/mp4"; - case ".mov": - return "video/quicktime"; - case ".zip": - return "application/zip"; - case ".json": - return "application/json"; - case ".md": - return "text/markdown"; - case ".txt": - case ".log": - return "text/plain"; - case ".yaml": - case ".yml": - return "application/yaml"; - default: - return null; - } -} - -function fileContentToBlob(filePath: string, content: FileContent): SyncFileBlob { - return { - path: filePath, - size: content.size, - mimeType: content.mimeType ?? inferMimeType(filePath), - encoding: content.encoding, - isBinary: content.isBinary, - content: content.content, - languageId: content.languageId, - }; -} - -function createBlobFromBuffer(filePath: string, buf: Buffer): SyncFileBlob { - const isBinary = hasNullByte(buf); - return { - path: filePath, - size: buf.length, - mimeType: inferMimeType(filePath), - encoding: isBinary ? "base64" : "utf-8", - isBinary, - content: isBinary ? buf.toString("base64") : buf.toString("utf8"), - languageId: null, - }; -} - -function toSyncPeerConnectionState(peer: PeerState, currentServerDbVersion: number): SyncPeerConnectionState | null { - if (!peer.metadata) return null; - return { - ...peer.metadata, - connectedAt: peer.connectedAt, - lastSeenAt: peer.lastSeenAt, - lastAppliedAt: peer.lastAppliedAt, - remoteAddress: peer.remoteAddress, - remotePort: peer.remotePort, - latencyMs: peer.latencyMs, - syncLag: Math.max(0, currentServerDbVersion - peer.lastKnownServerDbVersion), - isBrain: false, - isAuthenticated: peer.authenticated, - }; -} - -export function syncHeartbeatMissLimitForPeerMetadata(metadata: Pick | null | undefined): number { - return metadata?.platform === "iOS" || metadata?.deviceType === "phone" - ? MOBILE_SYNC_HEARTBEAT_MISS_LIMIT - : DEFAULT_SYNC_HEARTBEAT_MISS_LIMIT; -} - -function parseHelloPayload(payload: unknown): SyncHelloPayload | null { - const value = payload as SyncHelloPayload | null; - const peer = value?.peer; - if (!peer || typeof peer !== "object") return null; - if (!toOptionalString(peer.deviceId) || !toOptionalString(peer.deviceName) || !toOptionalString(peer.siteId)) { - return null; - } - const auth = value?.auth; - let normalizedAuth = auth ?? null; - if (!normalizedAuth) { - const token = toOptionalString(value?.token); - if (!token) return null; - normalizedAuth = { - kind: "bootstrap", - token, - }; - } - if (normalizedAuth.kind === "bootstrap") { - if (!toOptionalString(normalizedAuth.token)) return null; - } else if (normalizedAuth.kind === "paired") { - if (!toOptionalString(normalizedAuth.deviceId) || !toOptionalString(normalizedAuth.secret)) return null; - } else { - return null; - } - return { - peer: { - deviceId: String(peer.deviceId).trim(), - deviceName: String(peer.deviceName).trim(), - platform: peer.platform ?? "unknown", - deviceType: peer.deviceType ?? "unknown", - siteId: String(peer.siteId).trim(), - dbVersion: Number(peer.dbVersion ?? 0), - capabilities: Array.isArray(peer.capabilities) - ? peer.capabilities - .filter((capability): capability is string => typeof capability === "string") - .map((capability) => capability.trim()) - .filter(Boolean) - : [], - }, - auth: normalizedAuth, - }; -} - -function parsePairingRequestPayload(payload: unknown): SyncPairingRequestPayload | null { - const value = payload as SyncPairingRequestPayload | null; - const code = toOptionalString(value?.code); - const peer = value?.peer; - if (!code || !peer || typeof peer !== "object") return null; - if (!toOptionalString(peer.deviceId) || !toOptionalString(peer.deviceName) || !toOptionalString(peer.siteId)) { - return null; - } - return { - code, - peer: { - deviceId: String(peer.deviceId).trim(), - deviceName: String(peer.deviceName).trim(), - platform: peer.platform ?? "unknown", - deviceType: peer.deviceType ?? "unknown", - siteId: String(peer.siteId).trim(), - dbVersion: Number(peer.dbVersion ?? 0), - }, - }; -} - -function shouldAttemptTailnetServiceAdvertise(): boolean { - if (process.env.ADE_TAILSCALE_SERVE === "0") return false; - if (process.env.NODE_ENV === "test" || process.env.VITEST) return false; - return process.platform === "darwin" || process.platform === "linux" || process.platform === "win32"; -} - -function looksLikePendingTailnetApproval(text: string): boolean { - return /\b(pending|approval|approve|review)\b/i.test(text); -} - -export function createSyncHostService(args: SyncHostServiceArgs) { - const layout = resolveAdeLayout(args.projectRoot); - const bootstrapTokenPath = args.bootstrapTokenPath ?? path.join(layout.secretsDir, "sync-bootstrap-token"); - const pairingSecretsPath = args.pairingSecretsPath ?? path.join(layout.secretsDir, "sync-paired-devices.json"); - const commandLedgerPath = path.join(layout.cacheDir, "sync-mobile-command-ledger.json"); - const bootstrapToken = ensureBootstrapToken(bootstrapTokenPath); - const pairingStore = createSyncPairingStore({ - filePath: pairingSecretsPath, - pinStore: args.pinStore, - }); - const remoteCommandService = createSyncRemoteCommandService({ - laneService: args.laneService, - prService: args.prService, - ptyService: args.ptyService, - sessionService: args.sessionService, - fileService: args.fileService, - gitService: args.gitService, - diffService: args.diffService, - conflictService: args.conflictService, - agentChatService: args.agentChatService, - workerAgentService: args.workerAgentService, - workerBudgetService: args.workerBudgetService, - workerHeartbeatService: args.workerHeartbeatService, - workerRevisionService: args.workerRevisionService, - ctoStateService: args.ctoStateService, - flowPolicyService: args.flowPolicyService, - linearCredentialService: args.linearCredentialService, - getLinearIngressService: args.getLinearIngressService, - getLinearIssueTracker: args.getLinearIssueTracker, - getLinearSyncService: args.getLinearSyncService, - issueInventoryService: args.issueInventoryService, - pathToMergeOrchestrator: args.pathToMergeOrchestrator, - queueLandingService: args.queueLandingService, - projectConfigService: args.projectConfigService, - processService: args.processService, - portAllocationService: args.portAllocationService, - laneEnvironmentService: args.laneEnvironmentService, - laneTemplateService: args.laneTemplateService, - rebaseSuggestionService: args.rebaseSuggestionService, - autoRebaseService: args.autoRebaseService, - logger: args.logger, - }); - const heartbeatIntervalMs = Math.max(5_000, Math.floor(args.heartbeatIntervalMs ?? DEFAULT_SYNC_HEARTBEAT_INTERVAL_MS)); - const pollIntervalMs = Math.max(100, Math.floor(args.pollIntervalMs ?? DEFAULT_SYNC_POLL_INTERVAL_MS)); - const brainStatusIntervalMs = Math.max(1_000, Math.floor(args.brainStatusIntervalMs ?? DEFAULT_BRAIN_STATUS_INTERVAL_MS)); - const compressionThresholdBytes = Math.max(256, Math.floor(args.compressionThresholdBytes ?? DEFAULT_SYNC_COMPRESSION_THRESHOLD_BYTES)); - const maxChangesetBatchBytes = 256 * 1024; - const maxChangesetBatchRows = 250; - const maxProjectCatalogEnvelopeBytes = 768 * 1024; - const maxProjectCatalogChunkBytes = 192 * 1024; - const localPresenceCommandDescriptors: SyncRemoteCommandDescriptor[] = [ - { - action: "lanes.presence.announce", - policy: { viewerAllowed: true }, - }, - { - action: "lanes.presence.release", - policy: { viewerAllowed: true }, - }, - ]; - - const readBrainMetadata = (): SyncPeerMetadata => { - const localDevice = args.deviceRegistryService?.ensureLocalDevice(); - return { - deviceId: localDevice?.deviceId ?? args.db.sync.getSiteId(), - deviceName: localDevice?.name ?? os.hostname(), - platform: localDevice?.platform ?? mapPlatform(process.platform), - deviceType: localDevice?.deviceType ?? "desktop", - siteId: localDevice?.siteId ?? args.db.sync.getSiteId(), - dbVersion: args.db.sync.getDbVersion(), - }; - }; - - const peers = new Set(); - const mobileCommandResultCache = new Map(); - let commandReplayCount = 0; - let commandConflictCount = 0; - let lastCommandResultLatencyMs: number | null = null; - let lastChangesetAckLatencyMs: number | null = null; - - const pruneMobileCommandResultCache = (nowMs = Date.now()): void => { - for (const [key, record] of mobileCommandResultCache) { - if (record.completedAtMs == null) continue; - if (nowMs - record.completedAtMs > MOBILE_COMMAND_RESULT_CACHE_TTL_MS) { - mobileCommandResultCache.delete(key); - } - } - if (mobileCommandResultCache.size <= MOBILE_COMMAND_RESULT_CACHE_MAX_ENTRIES) return; - - const completed = [...mobileCommandResultCache.entries()] - .filter(([, record]) => record.completedAtMs != null) - .sort(([, left], [, right]) => (left.completedAtMs ?? left.acceptedAtMs) - (right.completedAtMs ?? right.acceptedAtMs)); - for (const [key] of completed) { - if (mobileCommandResultCache.size <= MOBILE_COMMAND_RESULT_CACHE_MAX_ENTRIES) break; - mobileCommandResultCache.delete(key); - } - }; - - const readPersistedCommandLedger = (): PersistedMobileCommand[] => { - try { - if (!fs.existsSync(commandLedgerPath)) return []; - const parsed = safeJsonParse<{ commands?: PersistedMobileCommand[] }>( - fs.readFileSync(commandLedgerPath, "utf8"), - { commands: [] }, - ); - return Array.isArray(parsed.commands) ? parsed.commands : []; - } catch (error) { - args.logger.warn("sync_host.command_ledger_read_failed", { - error: error instanceof Error ? error.message : String(error), - }); - return []; - } - }; - const writePersistedCommandLedger = (): void => { - const nowMs = Date.now(); - const commands: PersistedMobileCommand[] = []; - for (const [key, record] of mobileCommandResultCache) { - if (!record.result || record.completedAtMs == null) continue; - const persistedResult = persistedMobileCommandResult(record.action, record.result); - if (!persistedResult) continue; - if (!key.startsWith(`${args.projectRoot}:`)) continue; - if (nowMs - record.completedAtMs > MOBILE_COMMAND_RESULT_CACHE_TTL_MS) continue; - const deviceId = key.slice(`${args.projectRoot}:`.length).split(":")[0] ?? ""; - commands.push({ - key, - projectRoot: args.projectRoot, - deviceId, - commandId: record.commandId, - action: record.action, - argsFingerprint: record.argsFingerprint, - ack: record.ack, - result: persistedResult, - acceptedAtMs: record.acceptedAtMs, - completedAtMs: record.completedAtMs, - }); - } - commands.sort((left, right) => right.completedAtMs - left.completedAtMs); - writeTextAtomic(commandLedgerPath, `${JSON.stringify({ commands: commands.slice(0, MOBILE_COMMAND_RESULT_CACHE_MAX_ENTRIES) }, null, 2)}\n`); - }; - const loadPersistedCommandLedger = (): void => { - const nowMs = Date.now(); - for (const command of readPersistedCommandLedger()) { - if (command.projectRoot !== args.projectRoot) continue; - if (nowMs - command.completedAtMs > MOBILE_COMMAND_RESULT_CACHE_TTL_MS) continue; - const replayResult = persistedMobileCommandResult(command.action, command.result); - if (!replayResult) continue; - const legacyArgsKey = (command as { argsKey?: unknown }).argsKey; - const argsFingerprint = typeof command.argsFingerprint === "string" - ? command.argsFingerprint - : typeof legacyArgsKey === "string" - ? mobileCommandArgsFingerprint(legacyArgsKey) - : null; - if (!argsFingerprint) continue; - mobileCommandResultCache.set(command.key, { - commandId: command.commandId, - action: command.action, - argsKey: argsFingerprint, - argsFingerprint, - ack: command.ack, - result: replayResult, - waiters: [], - acceptedAtMs: command.acceptedAtMs, - completedAtMs: command.completedAtMs, - }); - } - }; - const commandLedgerSizeForProject = (): number => - [...mobileCommandResultCache.keys()].filter((key) => key.startsWith(`${args.projectRoot}:`)).length; - const dropInFlightCommandRecordsForProject = (): void => { - for (const [key, record] of mobileCommandResultCache) { - if (!key.startsWith(`${args.projectRoot}:`)) continue; - if (record.result == null) mobileCommandResultCache.delete(key); - } - }; - loadPersistedCommandLedger(); - /** Notification preferences keyed by deviceId. The map is a hot cache; - * device metadata is the restart-safe source for offline push fan-out. */ - const notificationPrefsByDeviceId = new Map(); - const storeNotificationPrefsForDevice = (deviceId: string, prefs: NotificationPreferences): void => { - const normalizedPrefs = normalizeNotificationPreferences(prefs); - notificationPrefsByDeviceId.set(deviceId, normalizedPrefs); - args.deviceRegistryService?.setNotificationPreferences?.(deviceId, normalizedPrefs); - }; - const readNotificationPrefsForDevice = (deviceId: string): NotificationPreferences => { - return notificationPrefsByDeviceId.get(deviceId) - ?? args.deviceRegistryService?.getNotificationPreferences?.(deviceId) - ?? DEFAULT_NOTIFICATION_PREFERENCES; - }; - const lanePresenceByLaneId = new Map>(); - let localActiveLaneIds = new Set(); - const PAIR_FAILURE_THRESHOLD = 5; - const PAIR_COOLDOWN_MS = 10 * 60_000; - const PAIR_FAILURE_WINDOW_MS = 10 * 60_000; - const pairFailures = new Map(); - const pruneExpiredPairFailures = (now = Date.now()): boolean => { - let changed = false; - for (const [ip, entry] of pairFailures) { - const cooldownExpired = entry.cooldownUntilMs > 0 && entry.cooldownUntilMs <= now; - const failureWindowExpired = entry.updatedAtMs + PAIR_FAILURE_WINDOW_MS <= now; - if (cooldownExpired || failureWindowExpired) { - pairFailures.delete(ip); - changed = true; - } - } - return changed; - }; - const registerPairFailure = (ip: string | null): void => { - if (!ip) return; - const now = Date.now(); - pruneExpiredPairFailures(now); - const entry = pairFailures.get(ip) ?? { count: 0, cooldownUntilMs: 0, updatedAtMs: now }; - entry.count += 1; - entry.updatedAtMs = now; - if (entry.count >= PAIR_FAILURE_THRESHOLD) { - entry.cooldownUntilMs = now + PAIR_COOLDOWN_MS; - entry.count = 0; - } - pairFailures.set(ip, entry); - }; - const pairingCooldownMsRemaining = (ip: string | null): number => { - if (!ip) return 0; - const entry = pairFailures.get(ip); - if (!entry) return 0; - const now = Date.now(); - const remaining = entry.cooldownUntilMs - now; - if (remaining > 0) return remaining; - if ( - (entry.cooldownUntilMs > 0 && remaining <= 0) - || entry.updatedAtMs + PAIR_FAILURE_WINDOW_MS <= now - ) { - pairFailures.delete(ip); - } - return 0; - }; - - const normalizeLaneId = (laneId: string | null | undefined): string | null => { - const normalized = toOptionalString(laneId); - return normalized && normalized.length > 0 ? normalized : null; - }; - - const listLanePresenceMarkers = (laneId: string): DeviceMarker[] => { - const entries = lanePresenceByLaneId.get(laneId); - if (!entries) return []; - return [...entries.values()] - .map((entry) => entry.marker) - .sort((left, right) => left.displayName.localeCompare(right.displayName)); - }; - - const upsertLanePresence = (argsIn: { - laneId: string; - marker: DeviceMarker; - source: "local" | "remote"; - }): boolean => { - const laneId = normalizeLaneId(argsIn.laneId); - if (!laneId) return false; - const byDevice = lanePresenceByLaneId.get(laneId) ?? new Map(); - const existing = byDevice.get(argsIn.marker.deviceId) ?? null; - const nextEntry: LanePresenceEntry = { - marker: argsIn.marker, - lastAnnouncedAtMs: Date.now(), - source: argsIn.source, - }; - byDevice.set(argsIn.marker.deviceId, nextEntry); - lanePresenceByLaneId.set(laneId, byDevice); - return ( - existing == null - || existing.source !== nextEntry.source - || existing.marker.displayName !== nextEntry.marker.displayName - || existing.marker.platform !== nextEntry.marker.platform - ); - }; - - const removeLanePresence = (laneId: string | null | undefined, deviceId: string | null | undefined): boolean => { - const normalizedLaneId = normalizeLaneId(laneId); - const normalizedDeviceId = toOptionalString(deviceId); - if (!normalizedLaneId || !normalizedDeviceId) return false; - const byDevice = lanePresenceByLaneId.get(normalizedLaneId); - if (!byDevice?.delete(normalizedDeviceId)) return false; - if (byDevice.size === 0) { - lanePresenceByLaneId.delete(normalizedLaneId); - } - return true; - }; - - const removeAllPresenceForDevice = ( - deviceId: string | null | undefined, - source?: LanePresenceEntry["source"], - ): boolean => { - const normalizedDeviceId = toOptionalString(deviceId); - if (!normalizedDeviceId) return false; - let changed = false; - for (const [laneId, byDevice] of lanePresenceByLaneId) { - const entry = byDevice.get(normalizedDeviceId); - if (!entry || (source && entry.source !== source)) continue; - byDevice.delete(normalizedDeviceId); - changed = true; - if (byDevice.size === 0) { - lanePresenceByLaneId.delete(laneId); - } - } - return changed; - }; - - const pruneExpiredLanePresence = (): boolean => { - const cutoff = Date.now() - LANE_PRESENCE_TTL_MS; - let changed = false; - for (const [laneId, byDevice] of lanePresenceByLaneId) { - for (const [deviceId, entry] of byDevice) { - if (entry.lastAnnouncedAtMs > cutoff) continue; - byDevice.delete(deviceId); - changed = true; - } - if (byDevice.size === 0) { - lanePresenceByLaneId.delete(laneId); - } - } - return changed; - }; - - const readLocalPresenceMarker = (): DeviceMarker | null => { - const localDevice = args.deviceRegistryService?.ensureLocalDevice() ?? null; - if (!localDevice) return null; - return { - deviceId: localDevice.deviceId, - displayName: localDevice.name, - platform: localDevice.platform, - }; - }; - - const refreshLocalLanePresence = (): boolean => { - if (localActiveLaneIds.size === 0) return false; - const marker = readLocalPresenceMarker(); - if (!marker) return false; - let changed = false; - for (const laneId of localActiveLaneIds) { - changed = upsertLanePresence({ - laneId, - marker, - source: "local", - }) || changed; - } - return changed; - }; - - const setLocalActiveLanePresence = (laneIds: string[]): void => { - const nextLaneIds = new Set( - laneIds - .map((laneId) => normalizeLaneId(laneId)) - .filter((laneId): laneId is string => laneId != null), - ); - const marker = readLocalPresenceMarker(); - let changed = false; - if (marker) { - for (const laneId of localActiveLaneIds) { - if (!nextLaneIds.has(laneId)) { - changed = removeLanePresence(laneId, marker.deviceId) || changed; - } - } - } - localActiveLaneIds = nextLaneIds; - if (marker) { - for (const laneId of localActiveLaneIds) { - changed = upsertLanePresence({ laneId, marker, source: "local" }) || changed; - } - } - if (changed) { - args.onStateChanged?.(); - broadcastBrainStatus(); - } - }; - - const buildRemotePresenceMarker = (peer: PeerState): DeviceMarker | null => { - if (!peer.metadata) return null; - return { - deviceId: peer.metadata.deviceId, - displayName: peer.metadata.deviceName, - platform: peer.metadata.platform, - }; - }; - - const decorateLaneSummary = (lane: LaneSummary): LaneSummary => { - const devicesOpen = listLanePresenceMarkers(lane.id); - return devicesOpen.length > 0 ? { ...lane, devicesOpen } : lane; - }; - - const decorateLaneSummaries = (lanes: LaneSummary[]): LaneSummary[] => - lanes.map((lane) => decorateLaneSummary(lane)); - - const decorateLaneListSnapshots = (snapshots: LaneListSnapshot[]): LaneListSnapshot[] => - snapshots.map((snapshot) => ({ - ...snapshot, - lane: decorateLaneSummary(snapshot.lane), - })); - - const decorateLaneDetailPayload = (detail: LaneDetailPayload): LaneDetailPayload => ({ - ...detail, - lane: decorateLaneSummary(detail.lane), - children: decorateLaneSummaries(detail.children), - }); - - const decorateCommandResult = ( - action: SyncCommandPayload["action"], - result: unknown, - ): unknown => { - pruneExpiredLanePresence(); - switch (action) { - case "lanes.list": - case "lanes.getChildren": - return Array.isArray(result) ? decorateLaneSummaries(result as LaneSummary[]) : result; - case "lanes.refreshSnapshots": { - const payload = result as - | { lanes?: LaneSummary[]; snapshots?: LaneListSnapshot[] } - | null - | undefined; - if (!payload || typeof payload !== "object") return result; - return { - ...payload, - ...(Array.isArray(payload.lanes) ? { lanes: decorateLaneSummaries(payload.lanes) } : {}), - ...(Array.isArray(payload.snapshots) - ? { snapshots: decorateLaneListSnapshots(payload.snapshots) } - : {}), - }; - } - case "lanes.getDetail": - return result && typeof result === "object" - ? decorateLaneDetailPayload(result as LaneDetailPayload) - : result; - case "lanes.create": - case "lanes.createChild": - case "lanes.createFromUnstaged": - case "lanes.importBranch": - case "lanes.attach": - case "lanes.adoptAttached": - return result && typeof result === "object" - ? decorateLaneSummary(result as LaneSummary) - : result; - default: - return result; - } - }; - const server = new WebSocketServer({ - host: "0.0.0.0", - port: args.port ?? DEFAULT_SYNC_HOST_PORT, - maxPayload: 25 * 1024 * 1024, - }); - - let disposed = false; - let startupError: Error | null = null; - let bonjourInstance: Bonjour | null = null; - let bonjourAnnouncement: BonjourService | null = null; - let bonjourPort: number | null = null; - let bonjourSignature: string | null = null; - let tailnetServeSignature: string | null = null; - let tailnetServeLastFailureSignature: string | null = null; - let tailnetServePublishSequence = 0; - let tailnetServeActivePublishToken = 0; - let discoveryEnabled = args.discoveryEnabled !== false; - let tailnetDiscoveryStatus: SyncTailnetDiscoveryStatus = { - state: !discoveryEnabled - ? "disabled" - : shouldAttemptTailnetServiceAdvertise() ? "disabled" : "unavailable", - serviceName: SYNC_TAILNET_DISCOVERY_SERVICE_NAME, - servicePort: SYNC_TAILNET_DISCOVERY_SERVICE_PORT, - target: null, - updatedAt: null, - error: !discoveryEnabled - ? "Tailnet discovery is disabled for this background project context." - : shouldAttemptTailnetServiceAdvertise() - ? "Tailnet discovery has not been published yet." - : "Tailscale Serve discovery is not available in this desktop process.", - stderr: null, - }; - let lastBroadcastAt: string | null = null; - const startedAtMs = Date.now(); - - server.on("error", (error: unknown) => { - const normalized = error instanceof Error ? error : new Error(String(error)); - if (!disposed && !server.address()) { - startupError = normalized; - } - args.logger.warn("sync_host.server_error", { - error: normalized.message, - code: (normalized as NodeJS.ErrnoException).code ?? null, - port: args.port ?? DEFAULT_SYNC_HOST_PORT, - }); - args.onStateChanged?.(); - }); - - const pollTimer = setInterval(() => { - void pumpChanges().catch((error) => { - args.logger.warn("sync_host.poll_failed", { error: error instanceof Error ? error.message : String(error) }); - }); - void pumpChatEvents().catch((error) => { - args.logger.warn("sync_host.chat_poll_failed", { error: error instanceof Error ? error.message : String(error) }); - }); - }, pollIntervalMs); - const heartbeatTimer = setInterval(() => { - pruneExpiredPairFailures(); - const refreshedLocalPresence = refreshLocalLanePresence(); - if (refreshedLocalPresence || pruneExpiredLanePresence()) { - args.onStateChanged?.(); - broadcastBrainStatus(); - } - const sentAt = nowIso(); - for (const peer of peers) { - if (!peer.authenticated || peer.ws.readyState !== WebSocket.OPEN) continue; - if (isPeerBackpressured(peer)) { - args.logger.debug("sync_host.heartbeat_deferred_backpressure", { - peerDeviceId: peer.metadata?.deviceId ?? null, - bufferedAmount: peer.ws.bufferedAmount, - }); - continue; - } - if (peer.awaitingHeartbeatAt) { - peer.missedHeartbeatCount += 1; - if (peer.missedHeartbeatCount >= syncHeartbeatMissLimitForPeerMetadata(peer.metadata)) { - try { - peer.ws.close(4001, "Heartbeat timed out"); - } catch { - // ignore - } - continue; - } - } else { - peer.missedHeartbeatCount = 0; - } - peer.awaitingHeartbeatAt = sentAt; - send(peer.ws, "heartbeat", { kind: "ping", sentAt, dbVersion: args.db.sync.getDbVersion() }); - } - }, heartbeatIntervalMs); - const brainStatusTimer = setInterval(() => { - broadcastBrainStatus(); - }, brainStatusIntervalMs); - const chatEventSubscription = args.agentChatService?.subscribeToEvents( - (event) => { - broadcastChatEvent(event); - // Let the notification bus (mobile push fan-out) observe chat events. - // Failures here must never break chat delivery to the UI. - try { - args.notificationEventBus?.publishChatEvent(event); - } catch (error) { - args.logger.warn("sync_host.notification_publish_failed", { - error: error instanceof Error ? error.message : String(error), - }); - } - }, - ) ?? null; - - server.on("connection", (ws, request) => { - const remoteAddress = sanitizeRemoteAddress(request.socket.remoteAddress); - const peer: PeerState = { - ws, - metadata: null, - authenticated: false, - authKind: null, - pairedDeviceId: null, - connectedAt: nowIso(), - lastSeenAt: nowIso(), - lastAppliedAt: null, - lastKnownServerDbVersion: 0, - latencyMs: null, - awaitingHeartbeatAt: null, - missedHeartbeatCount: 0, - remoteAddress, - remotePort: request.socket.remotePort ?? null, - subscribedSessionIds: new Set(), - subscribedChatSessionIds: new Set(), - chatTranscriptOffsets: new Map(), - chatEventIdsSent: new Map(), - pendingChangesetBatch: null, - }; - peers.add(peer); - ws.on("message", (raw) => { - void handleMessage(peer, raw).catch((error) => { - args.logger.warn("sync_host.message_failed", { - error: error instanceof Error ? error.message : String(error), - peerDeviceId: peer.metadata?.deviceId ?? null, - }); - }); - }); - ws.on("close", () => { - if (removeAllPresenceForDevice(peer.metadata?.deviceId, "remote")) { - broadcastBrainStatus(); - } - peers.delete(peer); - args.onStateChanged?.(); - broadcastBrainStatus(); - }); - ws.on("error", (error) => { - args.logger.warn("sync_host.socket_error", { - error: error instanceof Error ? error.message : String(error), - peerDeviceId: peer.metadata?.deviceId ?? null, - }); - }); - }); - - const publishLanDiscovery = (port: number): void => { - if (disposed) return; - if (!discoveryEnabled) { - unpublishLanDiscovery(); - return; - } - const localDevice = args.deviceRegistryService?.ensureLocalDevice() ?? null; - const hostName = localDevice?.name ?? os.hostname(); - const tailscaleDnsName = - typeof localDevice?.metadata?.tailscaleDnsName === "string" - ? localDevice.metadata.tailscaleDnsName.trim().replace(/\.$/, "").toLowerCase() - : ""; - const ipAddresses = uniqueStrings([ - ...(localDevice?.ipAddresses ?? []), - localDevice?.tailscaleIp ?? null, - ].filter((value): value is string => typeof value === "string" && value.trim().length > 0)); - const addressesCsv = ipAddresses.length > 0 ? ipAddresses.join(",") : "127.0.0.1"; - const preferredHost = ipAddresses[0] ?? localDevice?.lastHost ?? ""; - const txt = { - version: "1", - deviceId: localDevice?.deviceId ?? "", - siteId: localDevice?.siteId ?? "", - deviceName: hostName, - port: String(port), - host: preferredHost, - addresses: addressesCsv, - tailscaleIp: localDevice?.tailscaleIp ?? "", - tailscaleDnsName: tailscaleDnsName.endsWith(".ts.net") ? tailscaleDnsName : "", - }; - const signature = JSON.stringify({ hostName, port, txt }); - if (bonjourAnnouncement && bonjourPort === port && bonjourSignature === signature) return; - if (!bonjourInstance) { - bonjourInstance = new Bonjour(undefined, (error: unknown) => { - args.logger.warn("sync_host.discovery_error", { - error: error instanceof Error ? error.message : String(error), - }); - }); - } - if (bonjourAnnouncement) { - try { - bonjourAnnouncement.stop?.(); - } catch { - // ignore cleanup failures - } - bonjourAnnouncement = null; - } - bonjourPort = port; - bonjourSignature = signature; - bonjourAnnouncement = bonjourInstance.publish({ - name: `ADE Sync ${hostName} ${port}`, - type: SYNC_MDNS_SERVICE_TYPE, - protocol: "tcp", - port, - txt, - disableIPv6: true, - }); - bonjourAnnouncement.on("error", (error: unknown) => { - args.logger.warn("sync_host.discovery_publish_failed", { - error: error instanceof Error ? error.message : String(error), - }); - }); - }; - - const unpublishLanDiscovery = (): void => { - if (!bonjourAnnouncement) return; - try { - bonjourAnnouncement.stop?.(); - } catch { - // ignore cleanup failures - } - bonjourAnnouncement = null; - bonjourPort = null; - bonjourSignature = null; - }; - - const updateTailnetDiscoveryStatus = ( - next: SyncTailnetDiscoveryStatus, - ): void => { - tailnetDiscoveryStatus = next; - setTimeout(() => { - if (!disposed) args.onStateChanged?.(); - }, 0); - }; - - const publishTailnetDiscovery = ( - port: number, - options?: { force?: boolean }, - ): void => { - if (disposed) return; - if (!discoveryEnabled) { - void unpublishTailnetDiscovery(); - updateTailnetDiscoveryStatus({ - state: "disabled", - serviceName: SYNC_TAILNET_DISCOVERY_SERVICE_NAME, - servicePort: SYNC_TAILNET_DISCOVERY_SERVICE_PORT, - target: null, - updatedAt: nowIso(), - error: "Tailnet discovery is disabled for this background project context.", - stderr: null, - }); - return; - } - if (!shouldAttemptTailnetServiceAdvertise()) { - updateTailnetDiscoveryStatus({ - state: "unavailable", - serviceName: SYNC_TAILNET_DISCOVERY_SERVICE_NAME, - servicePort: SYNC_TAILNET_DISCOVERY_SERVICE_PORT, - target: null, - updatedAt: nowIso(), - error: "Tailscale Serve discovery is not available in this desktop process.", - stderr: null, - }); - return; - } - const cli = resolveTailscaleCliPath(); - const signature = `${SYNC_TAILNET_DISCOVERY_SERVICE_NAME}:${SYNC_TAILNET_DISCOVERY_SERVICE_PORT}->${port}`; - if (tailnetServeSignature === signature && !options?.force) return; - if (tailnetServeLastFailureSignature === signature && !options?.force) return; - const publishToken = ++tailnetServePublishSequence; - tailnetServeActivePublishToken = publishToken; - tailnetServeSignature = signature; - const target = `tcp://127.0.0.1:${port}`; - updateTailnetDiscoveryStatus({ - state: "publishing", - serviceName: SYNC_TAILNET_DISCOVERY_SERVICE_NAME, - servicePort: SYNC_TAILNET_DISCOVERY_SERVICE_PORT, - target, - updatedAt: nowIso(), - error: null, - stderr: null, - }); - const cliArgs = [ - "serve", - "--yes", - `--service=${SYNC_TAILNET_DISCOVERY_SERVICE_NAME}`, - `--tcp=${SYNC_TAILNET_DISCOVERY_SERVICE_PORT}`, - target, - ]; - void execFileAsync(cli, cliArgs, { timeout: 10_000 }) - .then(({ stdout, stderr }) => { - if (tailnetServeActivePublishToken !== publishToken) return; - tailnetServeLastFailureSignature = null; - const stdoutText = stdout.trim(); - const stderrText = stderr.trim(); - const outputText = [stdoutText, stderrText].filter(Boolean).join("\n"); - updateTailnetDiscoveryStatus({ - state: looksLikePendingTailnetApproval(outputText) ? "pending_approval" : "published", - serviceName: SYNC_TAILNET_DISCOVERY_SERVICE_NAME, - servicePort: SYNC_TAILNET_DISCOVERY_SERVICE_PORT, - target, - updatedAt: nowIso(), - error: null, - stderr: stderrText || null, - }); - args.logger.info("sync_host.tailnet_discovery_published", { - service: SYNC_TAILNET_DISCOVERY_SERVICE_NAME, - servicePort: SYNC_TAILNET_DISCOVERY_SERVICE_PORT, - target, - stdout: stdoutText || null, - stderr: stderrText || null, - }); - }) - .catch((error: unknown) => { - if (tailnetServeActivePublishToken !== publishToken) return; - if (tailnetServeSignature === signature) { - tailnetServeSignature = null; - } - tailnetServeLastFailureSignature = signature; - const errorMessage = error instanceof Error ? error.message : String(error); - const code = (error as NodeJS.ErrnoException | null | undefined)?.code ?? null; - const stderr = typeof (error as { stderr?: unknown })?.stderr === "string" - ? String((error as { stderr?: string }).stderr).trim() - : null; - const errorText = [errorMessage, stderr].filter(Boolean).join("\n"); - updateTailnetDiscoveryStatus({ - state: code === "ENOENT" - ? "unavailable" - : looksLikePendingTailnetApproval(errorText) - ? "pending_approval" - : "failed", - serviceName: SYNC_TAILNET_DISCOVERY_SERVICE_NAME, - servicePort: SYNC_TAILNET_DISCOVERY_SERVICE_PORT, - target, - updatedAt: nowIso(), - error: code === "ENOENT" ? "Tailscale CLI was not found." : errorMessage, - stderr, - }); - const logPayload = { - service: SYNC_TAILNET_DISCOVERY_SERVICE_NAME, - servicePort: SYNC_TAILNET_DISCOVERY_SERVICE_PORT, - target, - error: errorMessage, - code, - stderr, - }; - if (code === "ENOENT") { - args.logger.info("sync_host.tailnet_discovery_unavailable", logPayload); - } else { - args.logger.warn("sync_host.tailnet_discovery_failed", logPayload); - } - }); - }; - - const unpublishTailnetDiscovery = async (): Promise => { - if (!tailnetServeSignature) return; - tailnetServeActivePublishToken = ++tailnetServePublishSequence; - tailnetServeSignature = null; - if (!shouldAttemptTailnetServiceAdvertise()) { - updateTailnetDiscoveryStatus({ - state: "unavailable", - serviceName: SYNC_TAILNET_DISCOVERY_SERVICE_NAME, - servicePort: SYNC_TAILNET_DISCOVERY_SERVICE_PORT, - target: null, - updatedAt: nowIso(), - error: null, - stderr: null, - }); - return; - } - const cli = resolveTailscaleCliPath(); - try { - await execFileAsync( - cli, - ["serve", "--yes", `--service=${SYNC_TAILNET_DISCOVERY_SERVICE_NAME}`, "off"], - { timeout: 10_000 }, - ); - updateTailnetDiscoveryStatus({ - state: "disabled", - serviceName: SYNC_TAILNET_DISCOVERY_SERVICE_NAME, - servicePort: SYNC_TAILNET_DISCOVERY_SERVICE_PORT, - target: null, - updatedAt: nowIso(), - error: null, - stderr: null, - }); - args.logger.info("sync_host.tailnet_discovery_unpublished", { - service: SYNC_TAILNET_DISCOVERY_SERVICE_NAME, - servicePort: SYNC_TAILNET_DISCOVERY_SERVICE_PORT, - }); - } catch (error: unknown) { - const errorMessage = error instanceof Error ? error.message : String(error); - const code = (error as NodeJS.ErrnoException | null | undefined)?.code ?? null; - updateTailnetDiscoveryStatus({ - state: code === "ENOENT" ? "unavailable" : "disabled", - serviceName: SYNC_TAILNET_DISCOVERY_SERVICE_NAME, - servicePort: SYNC_TAILNET_DISCOVERY_SERVICE_PORT, - target: null, - updatedAt: nowIso(), - error: code === "ENOENT" ? "Tailscale CLI was not found." : errorMessage, - stderr: null, - }); - args.logger.warn("sync_host.tailnet_discovery_unpublish_failed", { - service: SYNC_TAILNET_DISCOVERY_SERVICE_NAME, - servicePort: SYNC_TAILNET_DISCOVERY_SERVICE_PORT, - error: errorMessage, - code, - }); - } - }; - - function send(target: WebSocket | PeerState, type: SyncEnvelope["type"], payload: TPayload, requestId?: string | null): boolean { - const ws = target instanceof WebSocket ? target : target.ws; - if (ws.readyState !== WebSocket.OPEN) return false; - // Drop sends to backpressured peers as the default — most envelopes are - // either replayable (chat events / changesets re-derived from db state) or - // tolerable to lose (acks, status pings). Routes that *must* deliver under - // backpressure should call ws.send / sendAndWait directly. - if (target instanceof WebSocket ? ws.bufferedAmount >= PEER_BACKPRESSURE_BYTES : isPeerBackpressured(target)) { - return false; - } - ws.send(encodeSyncEnvelope({ type, payload, requestId, compressionThresholdBytes })); - return true; - } - - function sendRequired(peer: PeerState, type: SyncEnvelope["type"], payload: TPayload, requestId?: string | null): boolean { - const ws = peer.ws; - if (ws.readyState !== WebSocket.OPEN) return false; - ws.send(encodeSyncEnvelope({ type, payload, requestId, compressionThresholdBytes }), (error) => { - if (!error) return; - args.logger.warn("sync_host.required_send_failed", { - type, - requestId: requestId ?? null, - peerDeviceId: peer.metadata?.deviceId ?? peer.pairedDeviceId ?? null, - error: error.message, - }); - }); - return true; - } - - function isPeerBackpressured(peer: PeerState): boolean { - return peer.ws.bufferedAmount >= PEER_BACKPRESSURE_BYTES; - } - - function sendAndWait( - ws: WebSocket, - type: SyncEnvelope["type"], - payload: TPayload, - requestId?: string | null, - ): Promise { - if (ws.readyState === WebSocket.CLOSING || ws.readyState === WebSocket.CLOSED) { - return Promise.reject(new Error("Cannot send on closed WebSocket.")); - } - return new Promise((resolve, reject) => { - ws.send( - encodeSyncEnvelope({ type, payload, requestId, compressionThresholdBytes }), - (error) => { - if (error) reject(error); - else resolve(); - }, - ); - }); - } - - function encodedEnvelopeBytes( - type: SyncEnvelope["type"], - payload: TPayload, - requestId?: string | null, - ): number { - return Buffer.byteLength(encodeSyncEnvelope({ type, payload, requestId, compressionThresholdBytes }), "utf8"); - } - - function closeExistingPeersForDevice(deviceId: string, currentPeer: PeerState): void { - const normalized = toOptionalString(deviceId); - if (!normalized) return; - for (const peer of peers) { - if (peer === currentPeer) continue; - if (peer.metadata?.deviceId !== normalized && peer.pairedDeviceId !== normalized) continue; - peer.authenticated = false; - peer.metadata = null; - peer.authKind = null; - peer.pairedDeviceId = null; - try { - peer.ws.close(4000, "Superseded by a newer connection for this device"); - } catch { - // ignore close failures - } - } - } - - function makeChangesetBatchId(peer: PeerState, fromDbVersion: number, toDbVersion: number): string { - const deviceId = peer.metadata?.deviceId ?? peer.pairedDeviceId ?? "peer"; - return `changeset:${deviceId}:${fromDbVersion}:${toDbVersion}:${Date.now()}:${randomBytes(4).toString("hex")}`; - } - - function peerSupportsChangesetAck(peer: PeerState): boolean { - return Array.isArray(peer.metadata?.capabilities) && peer.metadata.capabilities.includes("changesetAck"); - } - - function sendNextChangesetBatch( - peer: PeerState, - reason: SyncChangesetBatchPayload["reason"], - fromDbVersion: number, - toDbVersion: number, - changes: CrsqlChangeRow[], - ): PendingChangesetBatch | null { - let chunk: CrsqlChangeRow[] = []; - let chunkBytes = 0; - - for (const change of changes) { - const changeBytes = Buffer.byteLength(JSON.stringify(change), "utf8"); - if ( - chunk.length > 0 - && (chunk.length >= maxChangesetBatchRows || chunkBytes + changeBytes > maxChangesetBatchBytes) - ) { - break; - } - chunk.push(change); - chunkBytes += changeBytes; - } - if (chunk.length === 0 && changes.length > 0) { - chunk = [changes[0]!]; - } - if (chunk.length === 0 && toDbVersion <= fromDbVersion) return null; - - const chunkToDbVersion = chunk.length > 0 - ? Math.max(...chunk.map((change) => Number(change.db_version ?? fromDbVersion))) - : toDbVersion; - const batch: PendingChangesetBatch = { - batchId: makeChangesetBatchId(peer, fromDbVersion, chunkToDbVersion), - reason, - fromDbVersion, - toDbVersion: chunkToDbVersion, - changes: chunk, - sentAtMs: Date.now(), - retryCount: 0, - }; - const sent = send(peer, "changeset_batch", { - batchId: batch.batchId, - reason, - fromDbVersion, - toDbVersion: chunkToDbVersion, - changes: chunk, - }); - return sent ? batch : null; - } - - function resendPendingChangesetBatch(peer: PeerState): boolean { - const batch = peer.pendingChangesetBatch; - if (!batch) return false; - batch.sentAtMs = Date.now(); - batch.retryCount += 1; - return send(peer, "changeset_batch", { - batchId: batch.batchId, - reason: batch.reason, - fromDbVersion: batch.fromDbVersion, - toDbVersion: batch.toDbVersion, - changes: batch.changes, - }); - } - - async function buildProjectCatalogPayload(): Promise { - if (!args.projectCatalogProvider) { - return { projects: [] }; - } - try { - return await args.projectCatalogProvider.listProjects(); - } catch (error) { - args.logger.warn("sync_host.project_catalog_failed", { - error: error instanceof Error ? error.message : String(error), - }); - return { projects: [] }; - } - } - - function splitProjectCatalog(projects: SyncMobileProjectSummary[]): SyncMobileProjectSummary[][] { - const chunks: SyncMobileProjectSummary[][] = []; - let chunk: SyncMobileProjectSummary[] = []; - let chunkBytes = 0; - - const flush = (): void => { - if (chunk.length === 0) return; - chunks.push(chunk); - chunk = []; - chunkBytes = 0; - }; - - for (const project of projects) { - const projectBytes = Buffer.byteLength(JSON.stringify(project), "utf8"); - if (chunk.length > 0 && chunkBytes + projectBytes > maxProjectCatalogChunkBytes) { - flush(); - } - chunk.push(project); - chunkBytes += projectBytes; - } - flush(); - return chunks; - } - - function projectsForHello(projectCatalog: SyncProjectCatalogPayload): SyncMobileProjectSummary[] { - const payload = { - peer: readBrainMetadata(), - brain: readBrainMetadata(), - serverDbVersion: args.db.sync.getDbVersion(), - heartbeatIntervalMs, - pollIntervalMs, - projects: projectCatalog.projects, - features: {}, - }; - return encodedEnvelopeBytes("hello_ok", payload) <= maxProjectCatalogEnvelopeBytes - ? projectCatalog.projects - : []; - } - - function sendProjectCatalog( - peer: PeerState, - projectCatalog: SyncProjectCatalogPayload, - requestId?: string | null, - ): void { - if (encodedEnvelopeBytes("project_catalog", projectCatalog, requestId) <= maxProjectCatalogEnvelopeBytes) { - send(peer.ws, "project_catalog", projectCatalog, requestId); - return; - } - - const chunks = splitProjectCatalog(projectCatalog.projects); - const total = Math.max(1, chunks.length); - const catalogId = randomBytes(8).toString("hex"); - if (chunks.length === 0) { - send(peer.ws, "project_catalog_chunk", { - catalogId, - index: 0, - total, - done: true, - projects: [], - } satisfies SyncProjectCatalogChunkPayload, requestId); - return; - } - - chunks.forEach((projects, index) => { - send(peer.ws, "project_catalog_chunk", { - catalogId, - index, - total, - done: index === total - 1, - projects, - } satisfies SyncProjectCatalogChunkPayload, requestId); - }); - } - - async function handleProjectSwitchRequest( - peer: PeerState, - requestId: string | null | undefined, - payload: SyncProjectSwitchRequestPayload | null, - ): Promise { - if (!args.projectCatalogProvider) { - sendRequired(peer, "project_switch_result", { - ok: false, - message: "Desktop project switching is not available.", - }, requestId); - return; - } - try { - const result = await args.projectCatalogProvider.prepareProjectConnection(payload ?? {}); - await sendAndWait(peer.ws, "project_switch_result", result, requestId); - try { - await args.projectCatalogProvider.completeProjectConnection?.(payload ?? {}, result); - } catch (completionError) { - args.logger.warn("sync_host.project_switch_completion_failed", { - message: completionError instanceof Error ? completionError.message : String(completionError), - }); - } - } catch (error) { - const message = error instanceof Error ? error.message : String(error); - args.logger.warn("sync_host.project_switch_failed", { message }); - sendRequired(peer, "project_switch_result", { - ok: false, - message, - }, requestId); - } - } - - function buildBrainStatus(): SyncBrainStatusPayload { - const brainMetadata = readBrainMetadata(); - if (disposed) { - return { - brain: brainMetadata, - connectedPeers: [], - metrics: { - connectedPeerCount: 0, - runningSessionCount: 0, - dbVersion: brainMetadata.dbVersion, - uptimeMs: Date.now() - startedAtMs, - lastBroadcastAt, - pendingChangesetPeerCount: 0, - commandLedgerSize: commandLedgerSizeForProject(), - commandReplayCount, - commandConflictCount, - lastCommandResultLatencyMs, - lastChangesetAckLatencyMs, - }, - }; - } - const dbVersion = args.db.sync.getDbVersion(); - const connectedPeers = [...peers] - .map((peer) => toSyncPeerConnectionState(peer, dbVersion)) - .filter((peer): peer is SyncPeerConnectionState => peer != null); - return { - brain: { - ...brainMetadata, - dbVersion, - }, - connectedPeers, - metrics: { - connectedPeerCount: connectedPeers.length, - runningSessionCount: args.sessionService.list({ status: "running", limit: 200 }).length, - dbVersion, - uptimeMs: Date.now() - startedAtMs, - lastBroadcastAt, - pendingChangesetPeerCount: [...peers].filter((peer) => peer.pendingChangesetBatch != null).length, - commandLedgerSize: commandLedgerSizeForProject(), - commandReplayCount, - commandConflictCount, - lastCommandResultLatencyMs, - lastChangesetAckLatencyMs, - }, - }; - } - - function broadcastBrainStatus(): void { - if (disposed) return; - const payload = buildBrainStatus(); - for (const peer of peers) { - if (!peer.authenticated || peer.ws.readyState !== WebSocket.OPEN) continue; - send(peer.ws, "brain_status", payload); - } - } - - async function readChatTranscriptEventsSince( - transcriptPath: string, - startOffset: number, - ): Promise<{ events: AgentChatEventEnvelope[]; nextOffset: number }> { - let fh: fs.promises.FileHandle | null = null; - try { - fh = await fs.promises.open(transcriptPath, "r"); - const stat = await fh.stat(); - const size = stat.size; - const normalizedStart = Math.max(0, Math.min(startOffset, size)); - if (size <= normalizedStart) { - return { events: [], nextOffset: size }; - } - - const out = Buffer.alloc(size - normalizedStart); - await fh.read(out, 0, out.length, normalizedStart); - const lastNewline = out.lastIndexOf(0x0a); - if (lastNewline < 0) { - return { events: [], nextOffset: normalizedStart }; - } - - const completeSlice = out.subarray(0, lastNewline + 1); - const raw = completeSlice.toString("utf8"); - return { - events: parseAgentChatTranscript(raw), - nextOffset: normalizedStart + completeSlice.length, - }; - } catch { - return { events: [], nextOffset: Math.max(0, startOffset) }; - } finally { - await fh?.close().catch(() => {}); - } - } - - function chatEventDeliveryKey(event: AgentChatEventEnvelope): string { - return `${event.sessionId}:${event.sequence ?? -1}:${event.timestamp}:${event.event.type}`; - } - - function rememberChatEventSent(peer: PeerState, event: AgentChatEventEnvelope): boolean { - const key = chatEventDeliveryKey(event); - let sent = peer.chatEventIdsSent.get(event.sessionId); - if (!sent) { - sent = new Set(); - peer.chatEventIdsSent.set(event.sessionId, sent); - } - if (sent.has(key)) return false; - sent.add(key); - if (sent.size > 800) { - const overflow = sent.size - 800; - let removed = 0; - for (const existingKey of sent) { - sent.delete(existingKey); - removed += 1; - if (removed >= overflow) break; - } - } - return true; - } - - async function pumpChatEvents(): Promise { - if (disposed) return; - - for (const peer of peers) { - if (!peer.authenticated || peer.ws.readyState !== WebSocket.OPEN) continue; - if (isPeerBackpressured(peer)) continue; - for (const sessionId of peer.subscribedChatSessionIds) { - const session = args.sessionService.get(sessionId); - if (!session?.transcriptPath) continue; - - const startOffset = peer.chatTranscriptOffsets.get(sessionId) ?? 0; - const { events, nextOffset } = await readChatTranscriptEventsSince(session.transcriptPath, startOffset); - if (nextOffset !== startOffset) { - peer.chatTranscriptOffsets.set(sessionId, nextOffset); - } - for (const event of events) { - if (!rememberChatEventSent(peer, event)) continue; - send(peer.ws, "chat_event", event); - } - } - } - } - - function broadcastChatEvent(event: AgentChatEventEnvelope): void { - for (const peer of peers) { - if (!peer.authenticated || peer.ws.readyState !== WebSocket.OPEN) continue; - if (isPeerBackpressured(peer)) continue; - if (!peer.subscribedChatSessionIds.has(event.sessionId)) continue; - if (!rememberChatEventSent(peer, event)) continue; - send(peer.ws, "chat_event", event); - } - } - - async function pumpChanges(): Promise { - if (disposed) return; - const currentDbVersion = args.db.sync.getDbVersion(); - const nowMs = Date.now(); - for (const peer of peers) { - if (!peer.authenticated || !peer.metadata || peer.ws.readyState !== WebSocket.OPEN) continue; - if (isPeerBackpressured(peer)) continue; - if (peer.pendingChangesetBatch) { - if (nowMs - peer.pendingChangesetBatch.sentAtMs >= CHANGESET_ACK_TIMEOUT_MS) { - const pending = peer.pendingChangesetBatch; - if (pending.retryCount >= MAX_CHANGESET_ACK_RETRIES) { - args.logger.warn("sync_host.changeset_ack_timeout", { - peerDeviceId: peer.metadata.deviceId, - batchId: pending.batchId, - fromDbVersion: pending.fromDbVersion, - toDbVersion: pending.toDbVersion, - retryCount: pending.retryCount, - }); - try { - peer.ws.close(4000, "Changeset acknowledgement timed out"); - } catch { - // ignore close failures - } - continue; - } - const resent = resendPendingChangesetBatch(peer); - args.logger.debug("sync_host.changeset_ack_retry", { - peerDeviceId: peer.metadata.deviceId, - batchId: pending.batchId, - fromDbVersion: pending.fromDbVersion, - toDbVersion: pending.toDbVersion, - retryCount: pending.retryCount, - resent, - }); - } - continue; - } - if (currentDbVersion <= peer.lastKnownServerDbVersion) continue; - const changes = args.db.sync - .exportChangesSince(peer.lastKnownServerDbVersion) - .filter((change: CrsqlChangeRow) => change.site_id !== peer.metadata?.siteId); - const pending = sendNextChangesetBatch(peer, "broadcast", peer.lastKnownServerDbVersion, currentDbVersion, changes); - if (pending) { - if (peerSupportsChangesetAck(peer)) { - peer.pendingChangesetBatch = pending; - } else { - peer.lastKnownServerDbVersion = Math.max(peer.lastKnownServerDbVersion, pending.toDbVersion); - } - lastBroadcastAt = nowIso(); - } else { - args.logger.debug("sync_host.changeset_deferred_backpressure", { - peerDeviceId: peer.metadata?.deviceId ?? null, - fromDbVersion: peer.lastKnownServerDbVersion, - toDbVersion: currentDbVersion, - bufferedAmount: peer.ws.bufferedAmount, - }); - } - } - } - - function handleChangesetAck(peer: PeerState, payload: SyncChangesetAckPayload | null | undefined): void { - const pending = peer.pendingChangesetBatch; - if (!pending || !payload) return; - if (payload.batchId !== pending.batchId) { - args.logger.debug("sync_host.changeset_ack_ignored", { - peerDeviceId: peer.metadata?.deviceId ?? null, - expectedBatchId: pending.batchId, - receivedBatchId: payload.batchId, - }); - return; - } - if (!payload.ok) { - pending.retryCount += 1; - pending.sentAtMs = Date.now(); - args.logger.warn("sync_host.changeset_ack_failed", { - peerDeviceId: peer.metadata?.deviceId ?? null, - batchId: pending.batchId, - fromDbVersion: pending.fromDbVersion, - toDbVersion: pending.toDbVersion, - retryCount: pending.retryCount, - error: payload.error?.message ?? "Changeset apply failed.", - }); - if (pending.retryCount >= MAX_CHANGESET_ACK_RETRIES) { - try { - peer.ws.close(4000, "Changeset apply failed repeatedly"); - } catch { - // ignore close failures - } - } - return; - } - if (payload.toDbVersion < pending.toDbVersion) return; - peer.lastKnownServerDbVersion = Math.max(peer.lastKnownServerDbVersion, pending.toDbVersion); - peer.pendingChangesetBatch = null; - peer.lastAppliedAt = nowIso(); - lastChangesetAckLatencyMs = Math.max(0, Date.now() - pending.sentAtMs); - args.logger.debug("sync_host.changeset_ack_applied", { - peerDeviceId: peer.metadata?.deviceId ?? null, - batchId: pending.batchId, - fromDbVersion: pending.fromDbVersion, - toDbVersion: pending.toDbVersion, - latencyMs: lastChangesetAckLatencyMs, - }); - broadcastBrainStatus(); - } - - function resolveArtifactPath(request: Extract["args"]): string { - const artifactId = toOptionalString(request.artifactId); - const explicitUri = toOptionalString(request.uri) ?? toOptionalString(request.path); - let candidate = explicitUri; - if (artifactId) { - const artifact = args.computerUseArtifactBrokerService.listArtifacts({ artifactId })[0] ?? null; - candidate = artifact?.uri ?? candidate; - } - if (!candidate) { - throw new Error("Artifact request requires artifactId, uri, or path."); - } - if (/^https?:\/\//i.test(candidate)) { - throw new Error("Remote artifact URLs are not supported by the desktop sync host."); - } - if (/^file:\/\//i.test(candidate)) { - try { - candidate = fileURLToPath(candidate); - } catch { - throw new Error("Artifact file URL is invalid."); - } - } - const absolute = path.isAbsolute(candidate) - ? candidate - : path.resolve(args.projectRoot, candidate); - let resolvedArtifactPath: string; - try { - resolvedArtifactPath = resolvePathWithinRoot(layout.artifactsDir, absolute); - } catch { - throw new Error("Artifact path must resolve within .ade/artifacts."); - } - if (!fs.existsSync(resolvedArtifactPath) || !fs.statSync(resolvedArtifactPath).isFile()) { - throw new Error("Artifact file does not exist."); - } - return resolvedArtifactPath; - } - - function isMobilePeer(peer: PeerState): boolean { - return peer.metadata?.platform === "iOS" || peer.metadata?.deviceType === "phone"; - } - - function assertMobileFileMutationAllowed(peer: PeerState, payload: SyncFileRequest): void { - if (!MOBILE_MUTATING_FILE_ACTIONS.has(payload.action)) return; - if (!isMobilePeer(peer)) return; - - const workspaceId = toOptionalString((payload as { args?: { workspaceId?: unknown } }).args?.workspaceId); - if (!workspaceId) return; - const workspace = args.fileService.listWorkspaces({ includeArchived: true }) - .find((entry) => entry.id === workspaceId); - if (!workspace || workspace.mobileReadOnly === true || workspace.isReadOnlyByDefault) { - throw new Error("Mobile file access is read-only for this workspace."); - } - } - - function isMobileLaneFileMutationBlocked(payload: SyncCommandPayload): boolean { - const laneId = toOptionalString((payload.args as Record | null | undefined)?.laneId); - if (!laneId) return false; - const workspace = args.fileService.listWorkspaces({ includeArchived: true }) - .find((entry) => entry.laneId === laneId); - return workspace ? workspace.mobileReadOnly === true || workspace.isReadOnlyByDefault : true; - } - - async function handleFileRequest(peer: PeerState, requestId: string | null, payload: SyncFileRequest): Promise { - const respond = (response: SyncFileResponsePayload) => { - sendRequired(peer, "file_response", response, requestId); - }; - - try { - assertMobileFileMutationAllowed(peer, payload); - let result: - | FilesWorkspace[] - | FileTreeNode[] - | FileContent - | FilesQuickOpenItem[] - | FilesSearchTextMatch[] - | SyncFileBlob - | { ok: true } = { ok: true }; - - switch (payload.action) { - case "listWorkspaces": - result = args.fileService.listWorkspaces(payload.args ?? {}); - break; - case "listTree": - result = await args.fileService.listTree(payload.args); - break; - case "readFile": - result = fileContentToBlob(payload.args.path, args.fileService.readFile(payload.args)); - break; - case "writeText": - args.fileService.writeWorkspaceText(payload.args); - result = { ok: true }; - break; - case "createFile": - args.fileService.createFile(payload.args); - result = { ok: true }; - break; - case "createDirectory": - args.fileService.createDirectory(payload.args); - result = { ok: true }; - break; - case "rename": - args.fileService.rename(payload.args); - result = { ok: true }; - break; - case "deletePath": - args.fileService.deletePath(payload.args); - result = { ok: true }; - break; - case "quickOpen": - result = await args.fileService.quickOpen(payload.args); - break; - case "searchText": - result = await args.fileService.searchText(payload.args); - break; - case "readArtifact": { - const artifactPath = resolveArtifactPath(payload.args); - result = createBlobFromBuffer(normalizeRelative(path.relative(args.projectRoot, artifactPath)), fs.readFileSync(artifactPath)); - break; - } - default: - throw new Error(`Unsupported file action: ${(payload as { action?: string }).action ?? "unknown"}`); - } - - respond({ - ok: true, - action: payload.action, - result, - }); - } catch (error) { - respond({ - ok: false, - action: payload.action, - error: { - code: "file_request_failed", - message: error instanceof Error ? error.message : String(error), - }, - }); - } - } - - async function handleCommand(peer: PeerState, requestId: string | null, payload: SyncCommandPayload): Promise { - const commandId = toOptionalString(payload.commandId) ?? requestId ?? `cmd-${Date.now()}`; - const commandCacheKey = mobileCommandCacheKey(args.projectRoot, peer, commandId); - const commandArgsKey = stableJsonKey(payload.args ?? {}); - const commandArgsFingerprint = mobileCommandArgsFingerprint(commandArgsKey); - pruneMobileCommandResultCache(); - - const sendResult = (record: CachedMobileCommand | null, result: SyncCommandResultPayload) => { - if (!record) { - sendRequired(peer, "command_result", result, requestId); - return; - } - record.result = result; - record.completedAtMs = Date.now(); - lastCommandResultLatencyMs = Math.max(0, record.completedAtMs - record.acceptedAtMs); - const waiters = record.waiters.splice(0); - for (const waiter of waiters) { - sendRequired(waiter.peer, "command_result", result, waiter.requestId); - } - pruneMobileCommandResultCache(); - try { - writePersistedCommandLedger(); - } catch (error) { - args.logger.warn("sync_host.command_ledger_write_failed", { - error: error instanceof Error ? error.message : String(error), - }); - } - }; - const startCommandRecord = (ack: SyncCommandAckPayload): CachedMobileCommand | null => { - sendRequired(peer, "command_ack", ack, requestId); - if (!commandCacheKey) return null; - const record: CachedMobileCommand = { - commandId, - action: payload.action, - argsKey: commandArgsKey, - argsFingerprint: commandArgsFingerprint, - ack, - result: null, - waiters: [{ peer, requestId }], - acceptedAtMs: Date.now(), - completedAtMs: null, - }; - mobileCommandResultCache.set(commandCacheKey, record); - return record; - }; - const existingCommand = commandCacheKey ? mobileCommandResultCache.get(commandCacheKey) : null; - if (existingCommand) { - if (existingCommand.action !== payload.action || existingCommand.argsFingerprint !== commandArgsFingerprint) { - commandConflictCount += 1; - const mismatchResult: SyncCommandResultPayload = { - commandId, - ok: false, - error: { - code: "duplicate_command_mismatch", - message: "A command with this id already exists for a different action or payload.", - }, - }; - sendRequired(peer, "command_ack", { - commandId, - accepted: false, - status: "rejected", - message: mismatchResult.error?.message ?? null, - }, requestId); - sendRequired(peer, "command_result", mismatchResult, requestId); - return; - } - commandReplayCount += 1; - sendRequired(peer, "command_ack", existingCommand.ack, requestId); - if (existingCommand.result) { - sendRequired(peer, "command_result", existingCommand.result, requestId); - } else { - addMobileCommandWaiter(existingCommand, peer, requestId); - } - return; - } - - const reject = (message: string, code = "unsupported_command") => { - const ack: SyncCommandAckPayload = { - commandId, - accepted: false, - status: "rejected", - message, - }; - const result: SyncCommandResultPayload = { - commandId, - ok: false, - error: { - code, - message, - }, - }; - sendResult(startCommandRecord(ack), result); - }; - - const policy = remoteCommandService.getPolicy(payload.action); - if (payload.action === "notification_prefs") { - // iOS bridges `SyncService.setMutePush` through the command envelope - // rather than a second `notification_prefs` envelope. We translate by - // merging `{ muteUntil }` into the device's existing prefs (or the - // default prefs if none have been uploaded yet) so the notification - // bus starts gating immediately — the same `isAllowedByPrefs` path the - // envelope-based update feeds. - const deviceId = peer.metadata?.deviceId; - if (!deviceId) { - reject("notification_prefs requires an authenticated device.", "invalid_command"); - return; - } - const rawArgs = (payload.args as Record | null | undefined) ?? {}; - const rawMute = rawArgs.muteUntil; - const muteUntil = typeof rawMute === "string" && rawMute.length > 0 ? rawMute : null; - const existing = readNotificationPrefsForDevice(deviceId); - storeNotificationPrefsForDevice(deviceId, { ...existing, muteUntil }); - const ack: SyncCommandAckPayload = { - commandId, - accepted: true, - status: "accepted", - message: muteUntil ? `Muted pushes until ${muteUntil}.` : "Cleared push mute.", - }; - sendResult(startCommandRecord(ack), { - commandId, - ok: true, - result: { ok: true, muteUntil }, - }); - return; - } - if (payload.action === "lanes.presence.announce" || payload.action === "lanes.presence.release") { - const laneId = normalizeLaneId((payload.args as Record | null | undefined)?.laneId as string | null); - if (!laneId) { - reject(`${payload.action} requires laneId.`, "invalid_command"); - return; - } - const marker = buildRemotePresenceMarker(peer); - if (!marker) { - reject("Lane presence requires authenticated peer metadata.", "invalid_command"); - return; - } - const changed = payload.action === "lanes.presence.announce" - ? upsertLanePresence({ laneId, marker, source: "remote" }) - : removeLanePresence(laneId, marker.deviceId); - if (changed) { - args.onStateChanged?.(); - broadcastBrainStatus(); - } - const ack: SyncCommandAckPayload = { - commandId, - accepted: true, - status: "accepted", - message: payload.action === "lanes.presence.announce" - ? `Marked ${laneId} as open on ${marker.displayName}.` - : `Released ${laneId} on ${marker.displayName}.`, - }; - sendResult(startCommandRecord(ack), { - commandId, - ok: true, - result: { ok: true }, - }); - return; - } - if (!policy) { - reject(`Unsupported remote command: ${payload.action}.`); - return; - } - if (!policy.viewerAllowed) { - reject(`Remote command ${payload.action} is not available to paired controller devices.`, "forbidden_command"); - return; - } - if (payload.action === "files.writeTextAtomic" && isMobilePeer(peer) && isMobileLaneFileMutationBlocked(payload)) { - reject("Mobile file access is read-only for this workspace.", "mobile_read_only"); - return; - } - if (policy.localOnly || policy.requiresApproval) { - reject(`Remote command ${payload.action} requires approval on the desktop.`, "approval_required"); - return; - } - - const acceptedRecord = startCommandRecord({ - commandId, - accepted: true, - status: "accepted", - message: `Executing ${payload.action}.`, - }); - - try { - const created = await remoteCommandService.execute(payload); - sendResult(acceptedRecord, { - commandId, - ok: true, - result: decorateCommandResult(payload.action, created), - }); - } catch (error) { - sendResult(acceptedRecord, { - commandId, - ok: false, - error: { - code: "command_failed", - message: error instanceof Error ? error.message : String(error), - }, - }); - } - } - - async function handleMessage(peer: PeerState, raw: RawData): Promise { - const rawText = wsDataToText(raw); - const envelope = parseSyncEnvelope(rawText); - const heartbeatAwaitedAt = peer.awaitingHeartbeatAt; - peer.lastSeenAt = nowIso(); - peer.awaitingHeartbeatAt = null; - peer.missedHeartbeatCount = 0; - - if (!peer.authenticated) { - if (envelope.type !== "hello" && envelope.type !== "pairing_request") { - send(peer.ws, "hello_error", { - code: "invalid_hello", - message: "Authenticate with hello or pairing_request before sending other messages.", - }, envelope.requestId); - try { - peer.ws.close(4003, "Authentication required"); - } catch { - // ignore - } - return; - } - if (envelope.type === "pairing_request") { - const pairing = parsePairingRequestPayload(envelope.payload); - if (!pairing) { - send(peer.ws, "pairing_result", { - ok: false, - error: { - code: "pairing_failed", - message: "Invalid pairing request payload.", - }, - }, envelope.requestId); - try { peer.ws.close(4003, "Pairing failed"); } catch { /* ignore */ } - return; - } - const cooldownMs = pairingCooldownMsRemaining(peer.remoteAddress); - if (cooldownMs > 0) { - const minutes = Math.ceil(cooldownMs / 60_000); - send(peer.ws, "pairing_result", { - ok: false, - error: { - code: "pairing_failed", - message: `Too many failed PIN attempts. Try again in ${minutes} minute${minutes === 1 ? "" : "s"}.`, - }, - }, envelope.requestId); - try { peer.ws.close(4004, "Pairing cooldown"); } catch { /* ignore */ } - return; - } - try { - const result = pairingStore.pairPeer(pairing.peer, pairing.code); - if (peer.remoteAddress) { - pairFailures.delete(peer.remoteAddress); - } - args.deviceRegistryService?.upsertPeerMetadata(pairing.peer, { - lastSeenAt: nowIso(), - lastHost: peer.remoteAddress, - lastPort: peer.remotePort, - }); - send(peer.ws, "pairing_result", { - ok: true, - deviceId: result.deviceId, - secret: result.secret, - }, envelope.requestId); - } catch (error) { - const message = error instanceof Error ? error.message : String(error); - const thrownCode = (error as { code?: string } | null)?.code ?? null; - const resultCode: "pin_not_set" | "invalid_pin" | "pairing_failed" = - thrownCode === "pin_not_set" || thrownCode === "invalid_pin" - ? thrownCode - : "pairing_failed"; - send(peer.ws, "pairing_result", { - ok: false, - error: { - code: resultCode, - message, - }, - }, envelope.requestId); - // Drop the socket after any failed pair so brute-forcing the 6-digit - // PIN requires a new TCP+WS handshake per attempt, and track per-IP - // failures so sustained guessers hit a cooldown. - if (resultCode === "invalid_pin" || resultCode === "pairing_failed") { - registerPairFailure(peer.remoteAddress); - } - try { peer.ws.close(4003, "Pairing failed"); } catch { /* ignore */ } - } - return; - } - const hello = parseHelloPayload(envelope.payload); - if (!hello) { - send(peer.ws, "hello_error", { - code: "invalid_hello", - message: "Invalid hello payload.", - }, envelope.requestId); - try { - peer.ws.close(4003, "Authentication failed"); - } catch { - // ignore - } - return; - } - const authFailed = (() => { - if (hello.auth?.kind === "bootstrap") { - return hello.auth.token !== bootstrapToken; - } - if (hello.auth?.kind === "paired") { - if (hello.auth.deviceId !== hello.peer.deviceId) return true; - return !pairingStore.authenticate(hello.auth.deviceId, hello.auth.secret); - } - return true; - })(); - if (authFailed) { - send(peer.ws, "hello_error", { - code: "auth_failed", - message: "Sync authentication failed.", - }, envelope.requestId); - try { - peer.ws.close(4003, "Authentication failed"); - } catch { - // ignore - } - return; - } - - closeExistingPeersForDevice(hello.peer.deviceId, peer); - peer.authenticated = true; - peer.metadata = hello.peer; - const auth = hello.auth ?? { kind: "bootstrap", token: "" }; - peer.authKind = auth.kind; - peer.pairedDeviceId = auth.kind === "paired" ? auth.deviceId : null; - peer.lastKnownServerDbVersion = Math.max(0, Math.floor(hello.peer.dbVersion)); - args.deviceRegistryService?.upsertPeerMetadata(hello.peer, { - lastSeenAt: nowIso(), - lastHost: peer.remoteAddress, - lastPort: peer.remotePort, - }); - const projectCatalog = await buildProjectCatalogPayload(); - send(peer.ws, "hello_ok", { - peer: hello.peer, - brain: readBrainMetadata(), - serverDbVersion: args.db.sync.getDbVersion(), - heartbeatIntervalMs, - pollIntervalMs, - projects: projectsForHello(projectCatalog), - features: { - fileAccess: true, - terminalStreaming: true, - chatStreaming: { - enabled: true, - }, - projectCatalog: { - enabled: Boolean(args.projectCatalogProvider), - }, - changesetAck: { - enabled: true, - }, - bootstrapAuth: true, - pairingAuth: { - enabled: true, - pinDigits: 6, - }, - commandRouting: { - mode: "allowlisted", - supportedActions: [ - ...remoteCommandService.getSupportedActions(), - ...localPresenceCommandDescriptors.map((entry) => entry.action), - ], - actions: [ - ...remoteCommandService.getDescriptors(), - ...localPresenceCommandDescriptors, - ], - }, - }, - }, envelope.requestId); - args.onStateChanged?.(); - await pumpChanges(); - broadcastBrainStatus(); - return; - } - - switch (envelope.type) { - case "project_catalog_request": { - sendProjectCatalog(peer, await buildProjectCatalogPayload(), envelope.requestId); - break; - } - case "project_switch_request": { - await handleProjectSwitchRequest(peer, envelope.requestId, envelope.payload as SyncProjectSwitchRequestPayload); - break; - } - case "heartbeat": { - const payload = envelope.payload as { kind?: string; sentAt?: string } | null; - if (payload?.kind === "ping") { - send(peer.ws, "heartbeat", { - kind: "pong", - sentAt: payload.sentAt ?? nowIso(), - dbVersion: args.db.sync.getDbVersion(), - }, envelope.requestId); - } else if (payload?.kind === "pong" && heartbeatAwaitedAt) { - const now = Date.now(); - const sentAtMs = Date.parse(heartbeatAwaitedAt); - peer.latencyMs = Number.isFinite(sentAtMs) ? Math.max(0, now - sentAtMs) : null; - peer.awaitingHeartbeatAt = null; - } - break; - } - case "changeset_batch": { - const payload = (envelope.payload ?? {}) as SyncChangesetBatchPayload; - const batchId = payload.batchId || envelope.requestId || ""; - const changes = Array.isArray(payload.changes) ? payload.changes as CrsqlChangeRow[] : []; - try { - let appliedCount = 0; - if (changes.length > 0) { - args.db.sync.applyChanges(changes); - appliedCount = changes.length; - peer.lastAppliedAt = nowIso(); - lastBroadcastAt = nowIso(); - args.onStateChanged?.(); - broadcastBrainStatus(); - } - sendRequired(peer, "changeset_ack", { - batchId, - fromDbVersion: Number(payload.fromDbVersion ?? 0), - toDbVersion: Number(payload.toDbVersion ?? 0), - appliedDbVersion: args.db.sync.getDbVersion(), - appliedCount, - ok: true, - } satisfies SyncChangesetAckPayload, envelope.requestId); - } catch (error) { - sendRequired(peer, "changeset_ack", { - batchId, - fromDbVersion: Number(payload.fromDbVersion ?? 0), - toDbVersion: Number(payload.toDbVersion ?? 0), - appliedDbVersion: args.db.sync.getDbVersion(), - appliedCount: 0, - ok: false, - error: { - code: "changeset_apply_failed", - message: error instanceof Error ? error.message : String(error), - }, - } satisfies SyncChangesetAckPayload, envelope.requestId); - throw error; - } - break; - } - case "changeset_ack": { - handleChangesetAck(peer, envelope.payload as SyncChangesetAckPayload); - break; - } - case "file_request": - await handleFileRequest(peer, envelope.requestId, envelope.payload as SyncFileRequest); - break; - case "terminal_subscribe": { - const payload = envelope.payload as { sessionId?: string; maxBytes?: number } | null; - const sessionId = toOptionalString(payload?.sessionId); - if (!sessionId) break; - peer.subscribedSessionIds.add(sessionId); - const session = args.sessionService.get(sessionId); - const transcript = session - ? await args.sessionService.readTranscriptTail( - session.transcriptPath, - Math.max(1_024, Math.min(2_000_000, Math.floor(payload?.maxBytes ?? DEFAULT_TERMINAL_SNAPSHOT_BYTES))), - { raw: true, alignToLineBoundary: true }, - ) - : ""; - const snapshot: SyncTerminalSnapshotPayload = { - sessionId, - transcript, - status: session?.status ?? null, - runtimeState: session?.runtimeState ?? null, - lastOutputPreview: session?.lastOutputPreview ?? null, - capturedAt: nowIso(), - }; - sendRequired(peer, "terminal_snapshot", snapshot, envelope.requestId); - break; - } - case "terminal_unsubscribe": { - const payload = envelope.payload as { sessionId?: string } | null; - const sessionId = toOptionalString(payload?.sessionId); - if (sessionId) { - peer.subscribedSessionIds.delete(sessionId); - } - break; - } - case "terminal_input": { - // Forward keystrokes / pasted text from a mobile client into the - // active PTY for the named session. We require a prior subscribe so - // only an attached peer can drive the shell — protects against an - // attacker who acquired a session id but is not actively viewing. - const payload = envelope.payload as { sessionId?: string; data?: string } | null; - const sessionId = toOptionalString(payload?.sessionId); - const data = typeof payload?.data === "string" ? payload.data : null; - if (!sessionId || data == null) break; - if (!peer.subscribedSessionIds.has(sessionId)) { - args.logger.warn("sync.terminal_input_unsubscribed_session", { sessionId }); - break; - } - const accepted = args.ptyService.writeBySessionId(sessionId, data); - if (!accepted) { - args.logger.info("sync.terminal_input_no_active_pty", { sessionId }); - } - break; - } - case "terminal_resize": { - // Mobile clients re-emit this whenever their visible viewport - // changes (rotation, split view, dynamic font). We forward to the - // active PTY so command-line apps re-flow correctly. Out-of-bound - // values are clamped inside ptyService. - const payload = envelope.payload as { sessionId?: string; cols?: number; rows?: number } | null; - const sessionId = toOptionalString(payload?.sessionId); - const cols = typeof payload?.cols === "number" ? Math.floor(payload.cols) : null; - const rows = typeof payload?.rows === "number" ? Math.floor(payload.rows) : null; - if (!sessionId || cols == null || rows == null) break; - if (!peer.subscribedSessionIds.has(sessionId)) break; - args.ptyService.resizeBySessionId(sessionId, cols, rows); - break; - } - case "chat_subscribe": { - const payload = envelope.payload as { sessionId?: string; maxBytes?: number } | null; - const sessionId = toOptionalString(payload?.sessionId); - if (!sessionId) break; - peer.subscribedChatSessionIds.add(sessionId); - - const session = args.sessionService.get(sessionId); - const maxBytes = Math.max( - 1_024, - Math.min(2_000_000, Math.floor(typeof payload?.maxBytes === "number" ? payload.maxBytes : DEFAULT_TERMINAL_SNAPSHOT_BYTES)), - ); - const raw = session?.transcriptPath - ? await args.sessionService.readTranscriptTail( - session.transcriptPath, - maxBytes, - { raw: true, alignToLineBoundary: true }, - ) - : ""; - const events = parseAgentChatTranscript(raw).filter((event) => event.sessionId === sessionId); - const transcriptSize = session?.transcriptPath && fs.existsSync(session.transcriptPath) - ? fs.statSync(session.transcriptPath).size - : 0; - peer.chatTranscriptOffsets.set(sessionId, transcriptSize); - const snapshot: SyncChatSubscribeSnapshotPayload = { - sessionId, - capturedAt: nowIso(), - truncated: transcriptSize > maxBytes, - events, - }; - sendRequired(peer, "chat_subscribe", snapshot, envelope.requestId); - break; - } - case "chat_unsubscribe": { - const payload = envelope.payload as SyncChatUnsubscribePayload | null; - const sessionId = toOptionalString(payload?.sessionId); - if (sessionId) { - peer.subscribedChatSessionIds.delete(sessionId); - peer.chatTranscriptOffsets.delete(sessionId); - peer.chatEventIdsSent.delete(sessionId); - } - break; - } - case "command": - await handleCommand(peer, envelope.requestId, envelope.payload as SyncCommandPayload); - break; - case "register_push_token": { - const payload = envelope.payload as SyncRegisterPushTokenPayload | null; - handleRegisterPushToken(peer, envelope.requestId, payload); - break; - } - case "notification_prefs": { - const payload = envelope.payload as SyncNotificationPrefsPayload | null; - handleNotificationPrefs(peer, payload); - break; - } - case "send_test_push": { - const payload = envelope.payload as SyncSendTestPushPayload | null; - await handleSendTestPush(peer, envelope.requestId, payload); - break; - } - default: - break; - } - } - - function handleRegisterPushToken( - peer: PeerState, - requestId: string | null | undefined, - payload: SyncRegisterPushTokenPayload | null, - ): void { - const deviceId = peer.metadata?.deviceId; - if (!deviceId) { - args.logger.warn("sync_host.push_token_missing_device", {}); - sendRequired(peer, "command_ack", { - commandId: "push-token:unknown", - accepted: false, - status: "missing_device_id", - message: "Cannot store push token before device registration completes.", - }, requestId ?? null); - return; - } - if (!payload || typeof payload.token !== "string" || payload.token.trim().length === 0) { - args.logger.warn("sync_host.push_token_missing", { deviceId }); - sendRequired(peer, "command_ack", { - commandId: `push-token:${deviceId}:unknown`, - accepted: false, - status: "invalid_payload", - message: "Push token registration did not include a token.", - }, requestId ?? null); - return; - } - const kind: ApnsPushTokenKind = - payload.kind === "alert" || payload.kind === "activity-start" || payload.kind === "activity-update" - ? payload.kind - : "alert"; - if (kind === "activity-update" && !payload.activityId?.trim()) { - args.logger.warn("sync_host.push_token_missing_activity_id", { deviceId }); - sendRequired(peer, "command_ack", { - commandId: `push-token:${deviceId}:${kind}`, - accepted: false, - status: "missing_activity_id", - message: "Live Activity update tokens require an activity id.", - }, requestId ?? null); - return; - } - const env: ApnsEnvironment = payload.env === "production" ? "production" : "sandbox"; - const stored = args.deviceRegistryService?.setApnsToken?.(deviceId, payload.token.trim(), kind, env, { - bundleId: payload.bundleId, - activityId: payload.activityId, - }); - if (!stored) { - sendRequired(peer, "command_ack", { - commandId: `push-token:${deviceId}:${kind}`, - accepted: false, - status: "device_not_found", - message: `Could not store ${kind} push token for ${deviceId}.`, - }, requestId ?? null); - return; - } - // Optional ack so the client can retry on failure. - sendRequired(peer, "command_ack", { - commandId: `push-token:${deviceId}:${kind}`, - accepted: true, - status: "accepted", - message: `Stored ${kind} push token for ${deviceId}.`, - }, requestId ?? null); - } - - function handleNotificationPrefs(peer: PeerState, payload: SyncNotificationPrefsPayload | null): void { - const deviceId = peer.metadata?.deviceId; - if (!deviceId || !payload || !payload.prefs) return; - storeNotificationPrefsForDevice(deviceId, normalizeNotificationPreferences(payload.prefs)); - } - - async function handleSendTestPush( - peer: PeerState, - requestId: string | null | undefined, - payload: SyncSendTestPushPayload | null, - ): Promise { - const deviceId = peer.metadata?.deviceId; - if (!deviceId) return; - const kind = payload?.kind === "activity" ? "activity" : "alert"; - const result = args.notificationEventBus - ? await args.notificationEventBus.sendTestPush(deviceId, kind) - : { ok: false, reason: "notification_bus_unavailable" as const }; - sendRequired(peer, "command_result", { - commandId: `push-test:${deviceId}:${kind}`, - ok: result.ok, - ...(result.ok ? {} : { error: { code: "test_push_failed", message: result.reason ?? "unknown" } }), - }, requestId ?? null); - } - - /** - * Deliver a foreground-only notification to a specific iOS peer over the - * existing WebSocket. Used by the notification bus when the device is - * currently connected, in place of (or alongside) an APNs alert. - */ - function sendInAppNotification( - deviceId: string, - payload: Omit, - ): void { - const fullPayload: SyncInAppNotificationPayload = { - ...payload, - generatedAt: nowIso(), - }; - for (const peer of peers) { - if (!peer.authenticated || peer.ws.readyState !== WebSocket.OPEN) continue; - if (peer.metadata?.deviceId !== deviceId) continue; - send(peer.ws, "in_app_notification", fullPayload); - } - } - - function getNotificationPrefsForDevice(deviceId: string): NotificationPreferences | null { - return readNotificationPrefsForDevice(deviceId); - } - - function isIosPeerConnected(deviceId: string): boolean { - for (const peer of peers) { - if (peer.metadata?.deviceId !== deviceId) continue; - if (!peer.authenticated || peer.ws.readyState !== WebSocket.OPEN) continue; - return true; - } - return false; - } - - const getLanePresenceSnapshot = (): Array<{ laneId: string; devicesOpen: DeviceMarker[] }> => { - return [...lanePresenceByLaneId.keys()] - .sort((left, right) => left.localeCompare(right)) - .map((laneId) => ({ - laneId, - devicesOpen: listLanePresenceMarkers(laneId), - })) - .filter((entry) => entry.devicesOpen.length > 0); - }; - - return { - async waitUntilListening(): Promise { - if (startupError) { - throw startupError; - } - if (server.address()) { - const address = server.address(); - const port = typeof address === "object" && address ? address.port : DEFAULT_SYNC_HOST_PORT; - publishLanDiscovery(port); - publishTailnetDiscovery(port); - return port; - } - await new Promise((resolve, reject) => { - const onListening = () => { - cleanup(); - resolve(); - }; - const onError = (error: unknown) => { - cleanup(); - const normalized = error instanceof Error ? error : new Error(String(error)); - startupError = normalized; - reject(normalized); - }; - const cleanup = () => { - server.off("listening", onListening); - server.off("error", onError); - }; - server.on("listening", onListening); - server.on("error", onError); - if (startupError) { - cleanup(); - reject(startupError); - return; - } - if (server.address()) { - cleanup(); - resolve(); - } - }); - const address = server.address(); - const port = typeof address === "object" && address ? address.port : DEFAULT_SYNC_HOST_PORT; - publishLanDiscovery(port); - publishTailnetDiscovery(port); - return port; - }, - - getPort(): number | null { - const address = server.address(); - return typeof address === "object" && address ? address.port : null; - }, - - getBootstrapToken(): string { - return bootstrapToken; - }, - - setLocalActiveLanePresence(laneIds: string[]): void { - setLocalActiveLanePresence(laneIds); - }, - - refreshLanDiscovery(options?: { forceTailnet?: boolean }): void { - const address = server.address(); - if (typeof address === "object" && address) { - publishLanDiscovery(address.port); - publishTailnetDiscovery(address.port, { force: options?.forceTailnet }); - } - }, - - setDiscoveryEnabled(enabled: boolean): void { - if (discoveryEnabled === enabled) return; - discoveryEnabled = enabled; - const address = server.address(); - if (!enabled) { - unpublishLanDiscovery(); - void unpublishTailnetDiscovery(); - updateTailnetDiscoveryStatus({ - state: "disabled", - serviceName: SYNC_TAILNET_DISCOVERY_SERVICE_NAME, - servicePort: SYNC_TAILNET_DISCOVERY_SERVICE_PORT, - target: null, - updatedAt: nowIso(), - error: "Tailnet discovery is disabled for this background project context.", - stderr: null, - }); - return; - } - if (typeof address === "object" && address) { - publishLanDiscovery(address.port); - publishTailnetDiscovery(address.port, { force: true }); - } - }, - - revokePairedDevice(deviceId: string): void { - pairingStore.revoke(deviceId); - let revokedConnectedPeer = false; - for (const peer of peers) { - if (!peer.authenticated || peer.authKind !== "paired" || peer.pairedDeviceId !== deviceId) continue; - revokedConnectedPeer = true; - peer.authenticated = false; - peer.metadata = null; - peer.authKind = null; - peer.pairedDeviceId = null; - try { - peer.ws.close(4003, "Pairing revoked"); - } catch { - // ignore close failures - } - } - if (revokedConnectedPeer) { - args.onStateChanged?.(); - broadcastBrainStatus(); - } - }, - - getPeerStates(): SyncPeerConnectionState[] { - const dbVersion = args.db.sync.getDbVersion(); - const latestByDevice = new Map(); - for (const peer of [...peers] - .map((peer) => toSyncPeerConnectionState(peer, dbVersion)) - .filter((peer): peer is SyncPeerConnectionState => peer != null)) { - const existing = latestByDevice.get(peer.deviceId); - if (!existing || peer.connectedAt > existing.connectedAt) { - latestByDevice.set(peer.deviceId, peer); - } - } - return [...latestByDevice.values()]; - }, - - getTailnetDiscoveryStatus(): SyncTailnetDiscoveryStatus { - return { ...tailnetDiscoveryStatus }; - }, - - getLanePresenceSnapshot(): Array<{ laneId: string; devicesOpen: DeviceMarker[] }> { - return getLanePresenceSnapshot(); - }, - - getChatSubscriptionSnapshot(): Array<{ deviceId: string; subscribedChatSessionIds: string[] }> { - return [...peers] - .map((peer) => { - if (!peer.metadata) return null; - return { - deviceId: peer.metadata.deviceId, - subscribedChatSessionIds: [...peer.subscribedChatSessionIds].sort(), - }; - }) - .filter((peer): peer is { deviceId: string; subscribedChatSessionIds: string[] } => peer != null); - }, - - getBrainStatusSnapshot(): SyncBrainStatusPayload { - return buildBrainStatus(); - }, - - async broadcastProjectCatalog(): Promise { - const payload = await buildProjectCatalogPayload(); - for (const peer of peers) { - if (!peer.authenticated || peer.ws.readyState !== WebSocket.OPEN) continue; - sendProjectCatalog(peer, payload); - } - }, - - /** - * Push an in-app notification to a specific iOS peer over the WebSocket. - * Used by the notification event bus as the foreground-delivery path. - */ - sendInAppNotification( - deviceId: string, - payload: Omit, - ): void { - sendInAppNotification(deviceId, payload); - }, - - /** Returns the latest announced notification prefs for a device, or null. */ - getNotificationPrefsForDevice(deviceId: string): NotificationPreferences | null { - return getNotificationPrefsForDevice(deviceId); - }, - - /** Whether a given device is currently connected + authenticated. */ - isIosPeerConnected(deviceId: string): boolean { - return isIosPeerConnected(deviceId); - }, - - handlePtyData(event: PtyDataEvent): void { - const payload = { - sessionId: event.sessionId, - ptyId: event.ptyId, - data: event.data, - at: nowIso(), - }; - for (const peer of peers) { - if (!peer.authenticated || !peer.subscribedSessionIds.has(event.sessionId) || peer.ws.readyState !== WebSocket.OPEN) continue; - if (isPeerBackpressured(peer)) continue; - send(peer.ws, "terminal_data", payload); - } - }, - - handlePtyExit(event: PtyExitEvent): void { - const payload = { - sessionId: event.sessionId, - ptyId: event.ptyId, - exitCode: event.exitCode, - at: nowIso(), - }; - for (const peer of peers) { - if (!peer.authenticated || !peer.subscribedSessionIds.has(event.sessionId) || peer.ws.readyState !== WebSocket.OPEN) continue; - if (isPeerBackpressured(peer)) continue; - send(peer.ws, "terminal_exit", payload); - } - }, - - async dispose(): Promise { - if (disposed) return; - disposed = true; - localActiveLaneIds = new Set(); - lanePresenceByLaneId.clear(); - dropInFlightCommandRecordsForProject(); - chatEventSubscription?.(); - clearInterval(pollTimer); - clearInterval(heartbeatTimer); - clearInterval(brainStatusTimer); - unpublishLanDiscovery(); - try { - await unpublishTailnetDiscovery(); - } catch { - // Never throw from dispose. - } - await new Promise((resolve) => { - const finish = () => resolve(); - for (const peer of peers) { - try { - peer.ws.close(); - } catch { - // ignore - } - } - if (!server.address()) { - finish(); - return; - } - try { - server.close(() => finish()); - } catch { - finish(); - } - }); - if (bonjourAnnouncement) { - try { - bonjourAnnouncement.stop?.(); - } catch { - // ignore cleanup failures - } - bonjourAnnouncement = null; - } - bonjourPort = null; - bonjourSignature = null; - if (bonjourInstance) { - try { - bonjourInstance.destroy(); - } catch { - // ignore cleanup failures - } - bonjourInstance = null; - } - }, - }; -} - -export type SyncHostService = ReturnType; +export * from "../../../../../ade-cli/src/services/sync/syncHostService"; diff --git a/apps/desktop/src/main/services/sync/syncPairingStore.ts b/apps/desktop/src/main/services/sync/syncPairingStore.ts index f398ee351..d5a17f65f 100644 --- a/apps/desktop/src/main/services/sync/syncPairingStore.ts +++ b/apps/desktop/src/main/services/sync/syncPairingStore.ts @@ -1,93 +1 @@ -import fs from "node:fs"; -import path from "node:path"; -import { createHash, randomBytes } from "node:crypto"; -import type { SyncPeerMetadata } from "../../../shared/types"; -import { nowIso, safeJsonParse, writeTextAtomic } from "../shared/utils"; -import type { SyncPinStore } from "./syncPinStore"; - -type PairingRecord = { - secretHash: string; - createdAt: string; - lastUsedAt: string | null; - peerName: string; - peerPlatform: string; - peerDeviceType: string; -}; - -type PairingSecretsFile = Record; - -type SyncPairingStoreArgs = { - filePath: string; - pinStore: SyncPinStore; -}; - -function hashSecret(secret: string): string { - return createHash("sha256").update(secret).digest("hex"); -} - -function pairingError(code: "pin_not_set" | "invalid_pin", message: string): Error { - const err = new Error(message) as Error & { code?: string }; - err.code = code; - return err; -} - -export function createSyncPairingStore(args: SyncPairingStoreArgs) { - fs.mkdirSync(path.dirname(args.filePath), { recursive: true }); - - const readRecords = (): PairingSecretsFile => { - if (!fs.existsSync(args.filePath)) return {}; - return safeJsonParse(fs.readFileSync(args.filePath, "utf8"), {}); - }; - - const writeRecords = (records: PairingSecretsFile): void => { - writeTextAtomic(args.filePath, `${JSON.stringify(records, null, 2)}\n`); - }; - - return { - pairPeer(peer: SyncPeerMetadata, pin: string): { deviceId: string; secret: string } { - if (!args.pinStore.hasPin()) { - throw pairingError("pin_not_set", "No pairing PIN is set on this computer."); - } - if (!args.pinStore.verifyPin(pin)) { - throw pairingError("invalid_pin", "Incorrect pairing PIN."); - } - const secret = randomBytes(24).toString("hex"); - const records = readRecords(); - const existing = records[peer.deviceId] ?? null; - records[peer.deviceId] = { - secretHash: hashSecret(secret), - createdAt: existing?.createdAt ?? nowIso(), - lastUsedAt: null, - peerName: peer.deviceName, - peerPlatform: peer.platform, - peerDeviceType: peer.deviceType, - }; - writeRecords(records); - return { - deviceId: peer.deviceId, - secret, - }; - }, - - authenticate(deviceId: string, secret: string): boolean { - const records = readRecords(); - const entry = records[deviceId]; - if (!entry) return false; - if (entry.secretHash !== hashSecret(secret)) return false; - entry.lastUsedAt = nowIso(); - writeRecords(records); - return true; - }, - - revoke(deviceId: string): void { - const normalized = deviceId.trim(); - if (!normalized) return; - const records = readRecords(); - if (!(normalized in records)) return; - delete records[normalized]; - writeRecords(records); - }, - }; -} - -export type SyncPairingStore = ReturnType; +export * from "../../../../../ade-cli/src/services/sync/syncPairingStore"; diff --git a/apps/desktop/src/main/services/sync/syncPeerService.ts b/apps/desktop/src/main/services/sync/syncPeerService.ts index b67af24ba..7b2c82d7b 100644 --- a/apps/desktop/src/main/services/sync/syncPeerService.ts +++ b/apps/desktop/src/main/services/sync/syncPeerService.ts @@ -1,579 +1 @@ -import { WebSocket, type RawData } from "ws"; -import type { - SyncBrainStatusPayload, - SyncChangesetAckPayload, - SyncChangesetBatchPayload, - SyncClientStatus, - SyncCommandAckPayload, - SyncCommandResultPayload, - SyncDesktopConnectionDraft, - SyncRemoteCommandAction, - SyncPeerMetadata, - SyncRunQuickCommandArgs, -} from "../../../shared/types"; -import type { Logger } from "../logging/logger"; -import type { AdeDb } from "../state/kvDb"; -import { nowIso } from "../shared/utils"; -import type { DeviceRegistryService } from "./deviceRegistryService"; -import { DEFAULT_SYNC_COMPRESSION_THRESHOLD_BYTES, encodeSyncEnvelope, parseSyncEnvelope, wsDataToText } from "./syncProtocol"; - -type SyncPeerServiceArgs = { - db: AdeDb; - logger: Logger; - deviceRegistryService: DeviceRegistryService; - onStatusChange?: (status: SyncClientStatus) => void; - onBrainStatus?: (payload: SyncBrainStatusPayload) => void; - onRemoteChangesApplied?: () => void; -}; - -type PendingRequest = { - resolve: (value: unknown) => void; - reject: (error: Error) => void; - timer: ReturnType; -}; - -type InternalStatus = SyncClientStatus; -type PendingChangesetBatch = { - batchId: string; - payload: SyncChangesetBatchPayload; - sentAtMs: number; - retryCount: number; -}; - -const CHANGESET_ACK_TIMEOUT_MS = 10_000; -const MAX_CHANGESET_ACK_RETRIES = 6; - -export function createSyncPeerService(args: SyncPeerServiceArgs) { - let ws: WebSocket | null = null; - let disposed = false; - let relayTimer: NodeJS.Timeout | null = null; - let heartbeatTimer: NodeJS.Timeout | null = null; - let connectionDraft: SyncDesktopConnectionDraft | null = null; - let latestBrainStatus: SyncBrainStatusPayload | null = null; - let outboundLocalDbVersion = args.db.sync.getDbVersion(); - let latestRemoteDbVersion = 0; - let pendingOutboundChangeset: PendingChangesetBatch | null = null; - const pendingRequests = new Map(); - let pendingConnect: { resolve: () => void; reject: (error: Error) => void } | null = null; - - const status: InternalStatus = { - state: "disconnected", - host: null, - port: null, - connectedAt: null, - lastSeenAt: null, - latencyMs: null, - syncLag: null, - lastRemoteDbVersion: 0, - brainDeviceId: null, - hostName: null, - error: null, - message: null, - savedDraft: null, - }; - - const emitStatus = () => { - status.lastRemoteDbVersion = latestRemoteDbVersion; - status.savedDraft = connectionDraft - ? { - host: connectionDraft.host, - port: connectionDraft.port, - authKind: connectionDraft.authKind ?? "bootstrap", - pairedDeviceId: connectionDraft.pairedDeviceId ?? null, - lastRemoteDbVersion: connectionDraft.lastRemoteDbVersion ?? latestRemoteDbVersion, - } - : null; - args.onStatusChange?.({ ...status }); - }; - - const stopTimers = () => { - if (relayTimer) { - clearInterval(relayTimer); - relayTimer = null; - } - if (heartbeatTimer) { - clearInterval(heartbeatTimer); - heartbeatTimer = null; - } - }; - - const clearPendingRequests = (message: string) => { - for (const [requestId, pending] of pendingRequests) { - clearTimeout(pending.timer); - pending.reject(new Error(message)); - pendingRequests.delete(requestId); - } - }; - - const applyDraft = (draft: SyncDesktopConnectionDraft | null) => { - connectionDraft = draft - ? { - host: draft.host.trim(), - port: Math.max(1, Math.floor(draft.port)), - token: draft.token, - authKind: draft.authKind ?? "bootstrap", - pairedDeviceId: draft.pairedDeviceId ?? null, - lastRemoteDbVersion: Math.max(0, Math.floor(draft.lastRemoteDbVersion ?? 0)), - } - : null; - emitStatus(); - }; - - const currentLocalPeerMetadata = (): SyncPeerMetadata => { - const localDevice = args.deviceRegistryService.ensureLocalDevice(); - return { - deviceId: localDevice.deviceId, - deviceName: localDevice.name, - platform: localDevice.platform, - deviceType: localDevice.deviceType, - siteId: localDevice.siteId, - dbVersion: latestRemoteDbVersion, - capabilities: ["changesetAck"], - }; - }; - - const sendChangesetAck = ( - batch: SyncChangesetBatchPayload, - ok: boolean, - appliedDbVersion: number, - appliedCount: number, - error?: unknown, - ) => { - if (!ws || ws.readyState !== WebSocket.OPEN) return; - const payload: SyncChangesetAckPayload = { - batchId: batch.batchId, - fromDbVersion: Number(batch.fromDbVersion ?? 0), - toDbVersion: Number(batch.toDbVersion ?? 0), - appliedDbVersion, - appliedCount, - ok, - ...(error - ? { error: { code: "changeset_apply_failed", message: error instanceof Error ? error.message : String(error) } } - : {}), - }; - ws.send( - encodeSyncEnvelope({ - type: "changeset_ack", - requestId: batch.batchId, - payload, - compressionThresholdBytes: DEFAULT_SYNC_COMPRESSION_THRESHOLD_BYTES, - }), - ); - }; - - const sendOutboundChangeset = (pending: PendingChangesetBatch) => { - if (!ws || ws.readyState !== WebSocket.OPEN) return false; - ws.send( - encodeSyncEnvelope({ - type: "changeset_batch", - requestId: pending.batchId, - payload: pending.payload, - compressionThresholdBytes: DEFAULT_SYNC_COMPRESSION_THRESHOLD_BYTES, - }), - ); - return true; - }; - - const sendLocalChanges = () => { - if (!ws || ws.readyState !== WebSocket.OPEN) return; - const nowMs = Date.now(); - if (pendingOutboundChangeset) { - if (nowMs - pendingOutboundChangeset.sentAtMs >= CHANGESET_ACK_TIMEOUT_MS) { - if (pendingOutboundChangeset.retryCount >= MAX_CHANGESET_ACK_RETRIES) { - args.logger.warn("sync_peer.changeset_ack_timeout_exhausted", { - batchId: pendingOutboundChangeset.batchId, - retryCount: pendingOutboundChangeset.retryCount, - }); - disconnectInternal("error", null, "Changeset acknowledgement timed out."); - return; - } - pendingOutboundChangeset.sentAtMs = nowMs; - pendingOutboundChangeset.retryCount += 1; - sendOutboundChangeset(pendingOutboundChangeset); - } - return; - } - const currentDbVersion = args.db.sync.getDbVersion(); - if (currentDbVersion <= outboundLocalDbVersion) return; - const localSiteId = args.deviceRegistryService.getLocalSiteId(); - const changes = args.db.sync - .exportChangesSince(outboundLocalDbVersion) - .filter((change) => change.site_id === localSiteId); - const previousDbVersion = outboundLocalDbVersion; - if (!changes.length) { - outboundLocalDbVersion = currentDbVersion; - return; - } - const batchId = `changeset:${currentLocalPeerMetadata().deviceId}:${previousDbVersion}:${currentDbVersion}:${Date.now()}:${Math.random().toString(16).slice(2)}`; - pendingOutboundChangeset = { - batchId, - payload: { - batchId, - reason: "relay", - fromDbVersion: previousDbVersion, - toDbVersion: currentDbVersion, - changes, - }, - sentAtMs: nowMs, - retryCount: 0, - }; - sendOutboundChangeset(pendingOutboundChangeset); - }; - - const startRelay = () => { - stopTimers(); - relayTimer = setInterval(() => { - try { - sendLocalChanges(); - } catch (error) { - args.logger.warn("sync_peer.relay_failed", { - error: error instanceof Error ? error.message : String(error), - }); - } - }, 400); - }; - - const startHeartbeatFallback = () => { - heartbeatTimer = setInterval(() => { - if (!ws || ws.readyState !== WebSocket.OPEN) return; - ws.send( - encodeSyncEnvelope({ - type: "heartbeat", - payload: { - kind: "ping", - sentAt: nowIso(), - dbVersion: latestRemoteDbVersion, - }, - }), - ); - }, 30_000); - }; - - const disconnectInternal = (state: SyncClientStatus["state"], message: string | null, error: string | null) => { - stopTimers(); - if (ws) { - try { - ws.removeAllListeners(); - ws.close(); - } catch { - // ignore - } - } - ws = null; - pendingOutboundChangeset = null; - latestBrainStatus = null; - status.state = state; - status.connectedAt = null; - status.lastSeenAt = null; - status.latencyMs = null; - status.syncLag = null; - status.brainDeviceId = null; - status.hostName = null; - status.message = message; - status.error = error; - clearPendingRequests(error ?? message ?? "Sync peer disconnected."); - emitStatus(); - }; - - const handleMessage = (raw: RawData) => { - const envelope = parseSyncEnvelope(wsDataToText(raw)); - status.lastSeenAt = nowIso(); - switch (envelope.type) { - case "hello_ok": { - const payload = envelope.payload as { - brain: SyncPeerMetadata; - serverDbVersion: number; - }; - latestRemoteDbVersion = Math.max(0, Math.floor(payload.serverDbVersion ?? 0)); - status.state = "connected"; - status.connectedAt = nowIso(); - status.message = `Connected to host ${payload.brain.deviceName}.`; - status.error = null; - status.brainDeviceId = payload.brain.deviceId; - status.hostName = payload.brain.deviceName; - if (connectionDraft) { - connectionDraft.lastRemoteDbVersion = latestRemoteDbVersion; - } - outboundLocalDbVersion = Math.min(outboundLocalDbVersion, args.db.sync.getDbVersion()); - emitStatus(); - startRelay(); - startHeartbeatFallback(); - pendingConnect?.resolve(); - pendingConnect = null; - break; - } - case "hello_error": { - const payload = envelope.payload as { message?: string }; - pendingConnect?.reject(new Error(payload?.message ?? "Sync peer authentication failed.")); - pendingConnect = null; - disconnectInternal("error", null, payload?.message ?? "Sync peer authentication failed."); - break; - } - case "changeset_batch": { - const payload = (envelope.payload ?? {}) as SyncChangesetBatchPayload; - const changes = Array.isArray(payload.changes) ? payload.changes : []; - try { - if (changes.length) { - args.db.sync.applyChanges(changes); - args.onRemoteChangesApplied?.(); - } - latestRemoteDbVersion = Math.max(latestRemoteDbVersion, Math.floor(payload.toDbVersion ?? latestRemoteDbVersion)); - if (connectionDraft) connectionDraft.lastRemoteDbVersion = latestRemoteDbVersion; - sendChangesetAck(payload, true, args.db.sync.getDbVersion(), changes.length); - emitStatus(); - } catch (error) { - sendChangesetAck(payload, false, args.db.sync.getDbVersion(), 0, error); - throw error; - } - break; - } - case "changeset_ack": { - const payload = envelope.payload as SyncChangesetAckPayload; - if (!pendingOutboundChangeset || payload.batchId !== pendingOutboundChangeset.batchId) break; - if (!payload.ok) { - if (pendingOutboundChangeset.retryCount >= MAX_CHANGESET_ACK_RETRIES) { - const message = payload.error?.message ?? "Changeset apply failed repeatedly."; - args.logger.warn("sync_peer.changeset_ack_failed_exhausted", { - batchId: pendingOutboundChangeset.batchId, - retryCount: pendingOutboundChangeset.retryCount, - error: message, - }); - disconnectInternal("error", null, message); - break; - } - pendingOutboundChangeset.sentAtMs = Date.now(); - pendingOutboundChangeset.retryCount += 1; - args.logger.warn("sync_peer.changeset_ack_failed", { - batchId: pendingOutboundChangeset.batchId, - error: payload.error?.message ?? "Changeset apply failed.", - }); - break; - } - if (payload.toDbVersion < pendingOutboundChangeset.payload.toDbVersion) break; - const acknowledgedRemoteVersion = Math.max( - latestRemoteDbVersion, - pendingOutboundChangeset.payload.toDbVersion, - Math.floor(payload.toDbVersion ?? 0), - ); - latestRemoteDbVersion = acknowledgedRemoteVersion; - if (connectionDraft) { - connectionDraft.lastRemoteDbVersion = acknowledgedRemoteVersion; - } - outboundLocalDbVersion = Math.max(outboundLocalDbVersion, pendingOutboundChangeset.payload.toDbVersion); - pendingOutboundChangeset = null; - emitStatus(); - break; - } - case "brain_status": { - const payload = envelope.payload as SyncBrainStatusPayload; - latestBrainStatus = payload; - status.brainDeviceId = payload.brain.deviceId; - status.hostName = payload.brain.deviceName; - const localDeviceId = args.deviceRegistryService.getLocalDeviceId(); - const localPeer = payload.connectedPeers.find((peer) => peer.deviceId === localDeviceId) ?? null; - status.latencyMs = localPeer?.latencyMs ?? null; - status.syncLag = localPeer?.syncLag ?? 0; - args.onBrainStatus?.(payload); - emitStatus(); - break; - } - case "heartbeat": { - const payload = envelope.payload as { kind?: string; sentAt?: string }; - if (payload?.kind === "ping" && ws && ws.readyState === WebSocket.OPEN) { - ws.send( - encodeSyncEnvelope({ - type: "heartbeat", - requestId: envelope.requestId ?? null, - payload: { - kind: "pong", - sentAt: payload.sentAt ?? nowIso(), - dbVersion: latestRemoteDbVersion, - }, - }), - ); - } - break; - } - case "command_ack": - case "command_result": { - const requestId = envelope.requestId ?? null; - if (!requestId) break; - const pending = pendingRequests.get(requestId); - if (!pending) break; - if (envelope.type === "command_result") { - clearTimeout(pending.timer); - pendingRequests.delete(requestId); - const payload = envelope.payload as SyncCommandResultPayload; - if (payload.ok) { - pending.resolve(payload.result ?? null); - } else { - pending.reject(new Error(payload.error?.message ?? "Remote command failed.")); - } - } else { - const payload = envelope.payload as SyncCommandAckPayload; - if (!payload.accepted) { - clearTimeout(pending.timer); - pendingRequests.delete(requestId); - pending.reject(new Error(payload.message ?? "Remote command rejected.")); - } - } - break; - } - default: - break; - } - }; - - return { - setSavedDraft(draft: SyncDesktopConnectionDraft | null): void { - applyDraft(draft); - }, - - async connect(draft: SyncDesktopConnectionDraft): Promise { - if (disposed) { - throw new Error("Sync peer service is disposed."); - } - this.disconnect({ preserveDraft: true }); - applyDraft(draft); - latestRemoteDbVersion = Math.max(0, Math.floor(draft.lastRemoteDbVersion ?? 0)); - status.state = "connecting"; - status.host = draft.host.trim(); - status.port = Math.max(1, Math.floor(draft.port)); - status.message = `Connecting to ${status.host}:${String(status.port)}...`; - status.error = null; - emitStatus(); - - await new Promise((resolve, reject) => { - const socket = new WebSocket(`ws://${status.host}:${String(status.port)}`); - ws = socket; - pendingConnect = { resolve, reject }; - - const cleanup = () => { - socket.removeListener("open", onOpen); - socket.removeListener("error", onError); - }; - - const onOpen = () => { - cleanup(); - const peer = currentLocalPeerMetadata(); - const auth = draft.authKind === "paired" && draft.pairedDeviceId - ? { - kind: "paired" as const, - deviceId: draft.pairedDeviceId, - secret: draft.token, - } - : { - kind: "bootstrap" as const, - token: draft.token, - }; - socket.send( - encodeSyncEnvelope({ - type: "hello", - requestId: "hello", - payload: { - peer, - auth, - }, - compressionThresholdBytes: DEFAULT_SYNC_COMPRESSION_THRESHOLD_BYTES, - }), - ); - }; - - const onError = (error: Error) => { - cleanup(); - pendingConnect?.reject(error); - pendingConnect = null; - disconnectInternal("error", null, error.message); - }; - - socket.once("open", onOpen); - socket.once("error", onError); - socket.on("message", (raw) => { - try { - handleMessage(raw); - } catch (error) { - args.logger.warn("sync_peer.message_failed", { - error: error instanceof Error ? error.message : String(error), - }); - } - }); - socket.on("close", () => { - if (disposed) return; - if (pendingConnect) { - pendingConnect.reject(new Error("Connection closed before authentication completed.")); - pendingConnect = null; - } - disconnectInternal("disconnected", "Disconnected from host.", null); - }); - }); - }, - - disconnect(options: { preserveDraft?: boolean } = {}): void { - const nextDraft = options.preserveDraft ? connectionDraft : null; - disconnectInternal("disconnected", connectionDraft ? "Disconnected from host." : null, null); - if (!options.preserveDraft) { - applyDraft(null); - } else { - applyDraft(nextDraft); - } - }, - - getStatus(): SyncClientStatus { - return { ...status }; - }, - - getLatestBrainStatus(): SyncBrainStatusPayload | null { - return latestBrainStatus ? { ...latestBrainStatus, connectedPeers: [...latestBrainStatus.connectedPeers] } : null; - }, - - getConnectionDraft(): SyncDesktopConnectionDraft | null { - return connectionDraft ? { ...connectionDraft } : null; - }, - - isConnected(): boolean { - return status.state === "connected" && Boolean(ws) && ws?.readyState === WebSocket.OPEN; - }, - - flushLocalChanges(): void { - sendLocalChanges(); - }, - - async executeRemoteCommand(action: SyncRemoteCommandAction | (string & {}), commandArgs: Record): Promise { - if (!ws || ws.readyState !== WebSocket.OPEN) { - throw new Error("Not connected to a host device."); - } - const requestId = `sync-command-${Date.now()}-${Math.random().toString(16).slice(2)}`; - const promise = new Promise((resolve, reject) => { - const timer = setTimeout(() => { - pendingRequests.delete(requestId); - reject(new Error("Timed out waiting for remote command result.")); - }, 20_000); - pendingRequests.set(requestId, { resolve, reject, timer }); - }); - ws.send( - encodeSyncEnvelope({ - type: "command", - requestId, - payload: { - commandId: requestId, - action, - args: commandArgs, - }, - compressionThresholdBytes: DEFAULT_SYNC_COMPRESSION_THRESHOLD_BYTES, - }), - ); - return await promise; - }, - - async runQuickCommand(argsIn: SyncRunQuickCommandArgs): Promise { - return await this.executeRemoteCommand("work.runQuickCommand", argsIn); - }, - - async dispose(): Promise { - disposed = true; - this.disconnect(); - }, - }; -} - -export type SyncPeerService = ReturnType; +export * from "../../../../../ade-cli/src/services/sync/syncPeerService"; diff --git a/apps/desktop/src/main/services/sync/syncPinStore.ts b/apps/desktop/src/main/services/sync/syncPinStore.ts index 6401a31dc..a1720f95c 100644 --- a/apps/desktop/src/main/services/sync/syncPinStore.ts +++ b/apps/desktop/src/main/services/sync/syncPinStore.ts @@ -1,147 +1 @@ -import fs from "node:fs"; -import path from "node:path"; -import { pbkdf2Sync, randomBytes, timingSafeEqual } from "node:crypto"; -import { safeJsonParse, writeTextAtomic } from "../shared/utils"; - -type SyncPinStoreArgs = { - filePath: string; -}; - -type LegacySyncPinFile = { - pin: string; - updatedAt: string; -}; - -type HashedSyncPinFile = { - version: 2; - algorithm: "pbkdf2-sha256"; - iterations: number; - salt: string; - hash: string; - updatedAt: string; -}; - -type SyncPinFile = LegacySyncPinFile | HashedSyncPinFile; - -const PIN_PATTERN = /^\d{6}$/; -const PIN_HASH_ITERATIONS = 120_000; -const PIN_HASH_BYTES = 32; - -function derivePinHash(pin: string, salt: string, iterations: number): string { - return pbkdf2Sync(pin, salt, iterations, PIN_HASH_BYTES, "sha256").toString("hex"); -} - -function safeEqualHex(left: string, right: string): boolean { - const leftBuffer = Buffer.from(left, "hex"); - const rightBuffer = Buffer.from(right, "hex"); - return leftBuffer.length === rightBuffer.length && timingSafeEqual(leftBuffer, rightBuffer); -} - -function createHashedPinFile(pin: string, updatedAt = new Date().toISOString()): HashedSyncPinFile { - const salt = randomBytes(16).toString("hex"); - return { - version: 2, - algorithm: "pbkdf2-sha256", - iterations: PIN_HASH_ITERATIONS, - salt, - hash: derivePinHash(pin, salt, PIN_HASH_ITERATIONS), - updatedAt, - }; -} - -function isHashedPinFile(value: SyncPinFile | null): value is HashedSyncPinFile { - if (!value || !("version" in value)) return false; - return value.version === 2 - && value.algorithm === "pbkdf2-sha256" - && Number.isInteger(value.iterations) - && value.iterations > 0 - && typeof value.salt === "string" - && /^[0-9a-f]+$/i.test(value.salt) - && typeof value.hash === "string" - && /^[0-9a-f]+$/i.test(value.hash); -} - -export function createSyncPinStore(args: SyncPinStoreArgs) { - fs.mkdirSync(path.dirname(args.filePath), { recursive: true }); - - let cachedPlainPin: string | null = null; - let cachedRecord: HashedSyncPinFile | null | undefined; - - const writeRecord = (record: HashedSyncPinFile): void => { - writeTextAtomic(args.filePath, `${JSON.stringify(record, null, 2)}\n`); - try { - fs.chmodSync(args.filePath, 0o600); - } catch { - // ignore chmod failures on platforms that don't support it - } - }; - - const readFromDisk = (): HashedSyncPinFile | null => { - if (!fs.existsSync(args.filePath)) return null; - const parsed = safeJsonParse( - fs.readFileSync(args.filePath, "utf8"), - null, - ); - if (isHashedPinFile(parsed)) return parsed; - - const pin = typeof (parsed as LegacySyncPinFile | null)?.pin === "string" - ? (parsed as LegacySyncPinFile).pin.trim() - : ""; - if (!PIN_PATTERN.test(pin)) return null; - - const migrated = createHashedPinFile(pin, (parsed as LegacySyncPinFile).updatedAt); - writeRecord(migrated); - cachedPlainPin = pin; - return migrated; - }; - - const loadRecord = (): HashedSyncPinFile | null => { - if (cachedRecord !== undefined) return cachedRecord; - cachedRecord = readFromDisk(); - return cachedRecord; - }; - - return { - getPin(): string | null { - if (cachedPlainPin !== null) return cachedPlainPin; - loadRecord(); - return cachedPlainPin; - }, - - hasPin(): boolean { - return loadRecord() !== null; - }, - - verifyPin(pin: string): boolean { - const trimmed = pin.trim(); - if (!PIN_PATTERN.test(trimmed)) return false; - const record = loadRecord(); - if (!record) return false; - const hash = derivePinHash(trimmed, record.salt, record.iterations); - return safeEqualHex(hash, record.hash); - }, - - setPin(pin: string): void { - const trimmed = pin.trim(); - if (!PIN_PATTERN.test(trimmed)) { - throw new Error("PIN must be 6 digits."); - } - const payload = createHashedPinFile(trimmed); - writeRecord(payload); - cachedRecord = payload; - cachedPlainPin = trimmed; - }, - - clearPin(): void { - try { - fs.rmSync(args.filePath, { force: true }); - } catch { - // ignore cleanup failures - } - cachedRecord = null; - cachedPlainPin = null; - }, - }; -} - -export type SyncPinStore = ReturnType; +export * from "../../../../../ade-cli/src/services/sync/syncPinStore"; diff --git a/apps/desktop/src/main/services/sync/syncProtocol.test.ts b/apps/desktop/src/main/services/sync/syncProtocol.test.ts index 380db8966..70b4d956d 100644 --- a/apps/desktop/src/main/services/sync/syncProtocol.test.ts +++ b/apps/desktop/src/main/services/sync/syncProtocol.test.ts @@ -5,6 +5,7 @@ describe("syncProtocol", () => { it("preserves request ids and leaves small payloads uncompressed", () => { const encoded = encodeSyncEnvelope({ type: "heartbeat", + projectId: " project-1 ", requestId: "req-1", payload: { kind: "ping", @@ -16,6 +17,7 @@ describe("syncProtocol", () => { const parsed = parseSyncEnvelope(encoded); expect(parsed.type).toBe("heartbeat"); + expect(parsed.projectId).toBe("project-1"); expect(parsed.requestId).toBe("req-1"); expect(parsed.compression).toBe("none"); expect(parsed.payload).toEqual({ @@ -55,6 +57,7 @@ describe("syncProtocol", () => { expect(wire.payloadEncoding).toBe("base64"); const parsed = parseSyncEnvelope(encoded); + expect(parsed.projectId).toBe(null); expect(parsed.requestId).toBe("req-large"); expect(parsed.compression).toBe("gzip"); expect(parsed.payload).toEqual(payload); diff --git a/apps/desktop/src/main/services/sync/syncProtocol.ts b/apps/desktop/src/main/services/sync/syncProtocol.ts index a1e9dcb32..6be409cc1 100644 --- a/apps/desktop/src/main/services/sync/syncProtocol.ts +++ b/apps/desktop/src/main/services/sync/syncProtocol.ts @@ -1,120 +1 @@ -import { gunzipSync, gzipSync } from "node:zlib"; -import type { SyncCompressionCodec, SyncEnvelope, SyncPeerPlatform, SyncProtocolVersion } from "../../../shared/types"; -import { safeJsonParse } from "../shared/utils"; - -export const SYNC_PROTOCOL_VERSION: SyncProtocolVersion = 1; -export const DEFAULT_SYNC_HOST_PORT = 8787; -export const DEFAULT_SYNC_COMPRESSION_THRESHOLD_BYTES = 4 * 1024; - -export function mapPlatform(platform: NodeJS.Platform): SyncPeerPlatform { - switch (platform) { - case "darwin": - return "macOS"; - case "linux": - return "linux"; - case "win32": - return "windows"; - default: - return "unknown"; - } -} - -export function wsDataToText(data: unknown): string { - if (typeof data === "string") return data; - if (Buffer.isBuffer(data)) return data.toString("utf8"); - if (Array.isArray(data)) return Buffer.concat(data).toString("utf8"); - return String(data); -} - -export type ParsedSyncEnvelope = { - version: SyncProtocolVersion; - type: SyncEnvelope["type"]; - requestId: string | null; - compression: SyncCompressionCodec; - payload: unknown; - raw: SyncEnvelope; -}; - -type EncodeEnvelopeArgs = { - type: SyncEnvelope["type"]; - requestId?: string | null; - payload: unknown; - compressionThresholdBytes?: number; -}; - -function asSyncEnvelope(value: unknown): SyncEnvelope { - return value as SyncEnvelope; -} - -export function encodeSyncEnvelope(args: EncodeEnvelopeArgs): string { - const payloadJson = JSON.stringify(args.payload ?? null); - const payloadBytes = Buffer.byteLength(payloadJson, "utf8"); - const requestId = typeof args.requestId === "string" && args.requestId.trim().length > 0 - ? args.requestId.trim() - : null; - const threshold = Math.max(0, Math.floor(args.compressionThresholdBytes ?? DEFAULT_SYNC_COMPRESSION_THRESHOLD_BYTES)); - - if (payloadBytes >= threshold) { - const compressed = gzipSync(Buffer.from(payloadJson, "utf8")); - return JSON.stringify(asSyncEnvelope({ - version: SYNC_PROTOCOL_VERSION, - type: args.type, - requestId, - compression: "gzip", - payloadEncoding: "base64", - payload: compressed.toString("base64"), - uncompressedBytes: payloadBytes, - })); - } - - return JSON.stringify(asSyncEnvelope({ - version: SYNC_PROTOCOL_VERSION, - type: args.type, - requestId, - compression: "none", - payloadEncoding: "json", - payload: args.payload ?? null, - })); -} - -export function parseSyncEnvelope(rawText: string): ParsedSyncEnvelope { - const decoded = safeJsonParse(rawText, null); - if (!decoded || typeof decoded !== "object") { - throw new Error("Invalid sync envelope JSON."); - } - if (decoded.version !== SYNC_PROTOCOL_VERSION) { - throw new Error(`Unsupported sync protocol version: ${String((decoded as { version?: unknown }).version ?? "unknown")}`); - } - - const requestId = typeof decoded.requestId === "string" && decoded.requestId.trim().length > 0 - ? decoded.requestId.trim() - : null; - - if (decoded.compression === "gzip") { - if (decoded.payloadEncoding !== "base64" || typeof decoded.payload !== "string") { - throw new Error("Compressed sync envelopes must use base64 payload encoding."); - } - const uncompressed = gunzipSync(Buffer.from(decoded.payload, "base64")).toString("utf8"); - return { - version: decoded.version, - type: decoded.type, - requestId, - compression: "gzip", - payload: safeJsonParse(uncompressed, null), - raw: decoded, - }; - } - - if (decoded.payloadEncoding !== "json") { - throw new Error("Uncompressed sync envelopes must use JSON payload encoding."); - } - - return { - version: decoded.version, - type: decoded.type, - requestId, - compression: "none", - payload: decoded.payload, - raw: decoded, - }; -} +export * from "../../../../../ade-cli/src/services/sync/syncProtocol"; diff --git a/apps/desktop/src/main/services/sync/syncRemoteCommandService.test.ts b/apps/desktop/src/main/services/sync/syncRemoteCommandService.test.ts index 088a1a387..192c0aa08 100644 --- a/apps/desktop/src/main/services/sync/syncRemoteCommandService.test.ts +++ b/apps/desktop/src/main/services/sync/syncRemoteCommandService.test.ts @@ -609,6 +609,7 @@ describe("createSyncRemoteCommandService", () => { expect(descriptors).toHaveLength(actions.length); for (const desc of descriptors) { expect(desc).toHaveProperty("action"); + expect(desc.scope).toBe("project"); expect(desc).toHaveProperty("policy"); expect(desc.policy).toHaveProperty("viewerAllowed"); } @@ -641,6 +642,21 @@ describe("createSyncRemoteCommandService", () => { }); }); + describe("getDescriptor", () => { + it("returns scope and policy for a known action", () => { + const descriptor = service.getDescriptor("lanes.list"); + expect(descriptor).toEqual(expect.objectContaining({ + action: "lanes.list", + scope: "project", + policy: expect.objectContaining({ viewerAllowed: true }), + })); + }); + + it("returns null for an unknown action", () => { + expect(service.getDescriptor("totally.unknown.action")).toBeNull(); + }); + }); + // --------------------------------------------------------------- // execute: unknown action // --------------------------------------------------------------- diff --git a/apps/desktop/src/main/services/sync/syncRemoteCommandService.ts b/apps/desktop/src/main/services/sync/syncRemoteCommandService.ts index e4b4c4452..2c2731ce2 100644 --- a/apps/desktop/src/main/services/sync/syncRemoteCommandService.ts +++ b/apps/desktop/src/main/services/sync/syncRemoteCommandService.ts @@ -1,2514 +1 @@ -import { randomUUID } from "node:crypto"; -import type { - AgentChatCreateArgs, - AgentChatArchiveArgs, - AgentChatApproveArgs, - AgentChatDisposeArgs, - AgentChatFileRef, - AgentChatGetSummaryArgs, - AgentChatListArgs, - AgentChatProvider, - AgentChatRespondToInputArgs, - AgentChatResumeArgs, - AgentChatSendArgs, - AgentChatSession, - AgentChatSessionSummary, - AgentChatSteerArgs, - AgentChatCancelSteerArgs, - AgentChatEditSteerArgs, - AgentChatDispatchSteerArgs, - AgentChatCancelDispatchedSteerArgs, - AgentChatInterruptArgs, - AgentChatUpdateSessionArgs, - AgentStatus, - AddPrCommentArgs, - AiReviewSummaryArgs, - ApplyLaneTemplateArgs, - ArchiveLaneArgs, - AttachLaneArgs, - ClosePrArgs, - CancelQueueAutomationArgs, - CtoCoreMemory, - CtoIdentity, - CtoTriggerAgentWakeupArgs, - CreateChildLaneArgs, - CreateLaneArgs, - CreateLaneFromUnstagedArgs, - CreatePrFromLaneArgs, - CreateIntegrationLaneForProposalArgs, - ConvergenceRuntimeState, - CleanupIntegrationWorkflowArgs, - DeleteLaneArgs, - DeleteIntegrationProposalArgs, - DismissIntegrationCleanupArgs, - DraftPrDescriptionArgs, - GetDiffChangesArgs, - GetFileDiffArgs, - GitBatchFileActionArgs, - GitCherryPickArgs, - GitCommitArgs, - GitFileActionArgs, - GitGenerateCommitMessageArgs, - GitGetCommitMessageArgs, - GitGetFileHistoryArgs, - GitCheckoutBranchArgs, - GitListBranchesArgs, - GitListCommitFilesArgs, - GitPushArgs, - GitRevertArgs, - GitStashPushArgs, - GitStashRefArgs, - GitSyncArgs, - ImportBranchLaneArgs, - LandPrArgs, - LandQueueNextArgs, - PauseQueueAutomationArgs, - PipelineSettings, - PrConvergenceStatePatch, - LaneEnvInitConfig, - LaneEnvInitProgress, - LaneDetailPayload, - LaneListSnapshot, - LaneOverlayOverrides, - LaneStateSnapshotSummary, - ListLanesArgs, - ListIntegrationWorkflowsArgs, - ListSessionsArgs, - LinkPrToLaneArgs, - RebasePushArgs, - RebaseStartArgs, - RenameLaneArgs, - ReopenPrArgs, - RecheckIntegrationStepArgs, - ReactToPrCommentArgs, - ReplyToPrReviewThreadArgs, - ReparentLaneArgs, - RequestPrReviewersArgs, - ReorderQueuePrsArgs, - ResumeQueueAutomationArgs, - RerunPrChecksArgs, - SetPrLabelsArgs, - SetPrReviewThreadResolvedArgs, - StartIntegrationResolutionArgs, - SubmitPrReviewArgs, - SyncCommandPayload, - SyncRemoteCommandAction, - SyncRemoteCommandDescriptor, - SyncRemoteCommandPolicy, - SyncStartCliSessionArgs, - SyncStartCliSessionResult, - SyncRunQuickCommandArgs, - TerminalSessionSummary, - UpdateSessionMetaArgs, - UpdateIntegrationProposalArgs, - TerminalToolType, - UpdateLaneAppearanceArgs, - UpdatePrBodyArgs, - UpdatePrTitleArgs, - WriteTextAtomicArgs, -} from "../../../shared/types"; -import { - buildTrackedCliLaunchCommand, - buildTrackedCliResumeCommand, - isLaunchProfile, - isTrackedCliPermissionMode, - LAUNCH_PROFILE_TITLE, - LAUNCH_PROFILE_TOOL_TYPE, - launchProfileForTerminalSession, - resolveTrackedCliResumeCommand, - validateLaunchProfilePermissionMode, -} from "../../../shared/cliLaunch"; -import { normalizePrCreationStrategy } from "../../../shared/prStrategy"; -import type { createAgentChatService } from "../chat/agentChatService"; -import type { createCtoStateService } from "../cto/ctoStateService"; -import type { createFlowPolicyService } from "../cto/flowPolicyService"; -import type { createLinearCredentialService } from "../cto/linearCredentialService"; -import type { createLinearIngressService } from "../cto/linearIngressService"; -import type { createLinearIssueTracker } from "../cto/linearIssueTracker"; -import type { createLinearSyncService } from "../cto/linearSyncService"; -import type { createWorkerAgentService } from "../cto/workerAgentService"; -import type { createWorkerBudgetService } from "../cto/workerBudgetService"; -import type { createWorkerHeartbeatService } from "../cto/workerHeartbeatService"; -import type { createWorkerRevisionService } from "../cto/workerRevisionService"; -import { matchLaneOverlayPolicies } from "../config/laneOverlayMatcher"; -import type { createProjectConfigService } from "../config/projectConfigService"; -import type { createConflictService } from "../conflicts/conflictService"; -import type { createDiffService } from "../diffs/diffService"; -import type { createFileService } from "../files/fileService"; -import type { createGitOperationsService } from "../git/gitOperationsService"; -import type { createAutoRebaseService } from "../lanes/autoRebaseService"; -import type { createLaneEnvironmentService } from "../lanes/laneEnvironmentService"; -import type { createLaneService } from "../lanes/laneService"; -import type { createLaneTemplateService } from "../lanes/laneTemplateService"; -import type { createPortAllocationService } from "../lanes/portAllocationService"; -import type { createRebaseSuggestionService } from "../lanes/rebaseSuggestionService"; -import type { createProcessService } from "../processes/processService"; -import type { Logger } from "../logging/logger"; -import type { createPrService } from "../prs/prService"; -import type { createIssueInventoryService } from "../prs/issueInventoryService"; -import type { PathToMergeOrchestrator } from "../prs/pathToMergeOrchestrator"; -import type { createQueueLandingService } from "../prs/queueLandingService"; -import type { createPtyService } from "../pty/ptyService"; -import type { createSessionService } from "../sessions/sessionService"; - -type SyncRemoteCommandServiceArgs = { - laneService: ReturnType; - prService: ReturnType; - issueInventoryService?: ReturnType | null; - /** - * Optional Path-to-Merge orchestrator. When present, iOS callers can start - * and stop the convergence loop via the `prs.pathToMerge.start` / - * `prs.pathToMerge.stop` sync commands. Optional so older builds (without - * the orchestrator wired) keep compiling and degrade gracefully on iOS. - */ - pathToMergeOrchestrator?: PathToMergeOrchestrator | null; - queueLandingService?: ReturnType | null; - ptyService: ReturnType; - sessionService: ReturnType; - fileService: ReturnType; - gitService?: ReturnType; - diffService?: ReturnType; - conflictService?: ReturnType; - agentChatService?: ReturnType; - workerAgentService?: ReturnType | null; - workerBudgetService?: ReturnType | null; - workerHeartbeatService?: ReturnType | null; - workerRevisionService?: ReturnType | null; - ctoStateService?: ReturnType | null; - flowPolicyService?: ReturnType | null; - linearCredentialService?: ReturnType | null; - /** - * Resolvers for services created after createSyncService in main.ts. - * Router handlers read them lazily so init order is not load-bearing. - */ - getLinearIngressService?: () => ReturnType | null; - getLinearIssueTracker?: () => ReturnType | null; - getLinearSyncService?: () => ReturnType | null; - projectConfigService?: ReturnType; - processService?: ReturnType | null; - portAllocationService?: ReturnType | null; - laneEnvironmentService?: ReturnType | null; - laneTemplateService?: ReturnType | null; - rebaseSuggestionService?: ReturnType | null; - autoRebaseService?: ReturnType | null; - logger: Logger; -}; - -type RegisteredRemoteCommand = { - descriptor: SyncRemoteCommandDescriptor; - handler: (args: Record) => Promise; -}; - -function isRecord(value: unknown): value is Record { - return Boolean(value) && typeof value === "object" && !Array.isArray(value); -} - -function asTrimmedString(value: unknown): string | null { - return typeof value === "string" && value.trim().length > 0 ? value.trim() : null; -} - -function asOptionalBoolean(value: unknown): boolean | undefined { - return typeof value === "boolean" ? value : undefined; -} - -function asOptionalNumber(value: unknown): number | undefined { - return typeof value === "number" && Number.isFinite(value) ? value : undefined; -} - -function asStringArray(value: unknown): string[] { - if (!Array.isArray(value)) return []; - return value.map((entry) => asTrimmedString(entry)).filter((entry): entry is string => Boolean(entry)); -} - -function parseAgentChatFileRefs(value: unknown): AgentChatFileRef[] | undefined { - if (!Array.isArray(value)) return undefined; - const attachments: AgentChatFileRef[] = []; - for (const entry of value) { - if (!isRecord(entry)) continue; - const path = asTrimmedString(entry.path); - const type = entry.type === "image" ? "image" : entry.type === "file" ? "file" : null; - if (!path || !type) continue; - attachments.push({ path, type }); - } - return attachments; -} - -function parseCursorConfigValues( - value: unknown, -): AgentChatUpdateSessionArgs["cursorConfigValues"] | AgentChatCreateArgs["cursorConfigValues"] { - if (value == null) return null; - if (!isRecord(value)) return {}; - return Object.fromEntries( - Object.entries(value) - .filter((entry): entry is [string, string | boolean | number] => ( - typeof entry[1] === "string" - || typeof entry[1] === "boolean" - || (typeof entry[1] === "number" && Number.isFinite(entry[1])) - )) - .map(([key, entryValue]): [string, string | boolean | number] => [key.trim(), entryValue]) - .filter(([key]) => key.length > 0), - ); -} - -function requireString(value: unknown, message: string): string { - const parsed = asTrimmedString(value); - if (!parsed) throw new Error(message); - return parsed; -} - -function requireStringArray(value: unknown, message: string): string[] { - const parsed = asStringArray(value); - if (parsed.length === 0) throw new Error(message); - return parsed; -} - -function requireService(value: T | null | undefined, message: string): T { - if (value == null) throw new Error(message); - return value; -} - -function parseProcessLaneArgs(payload: Record, action: string): { laneId: string } { - return { - laneId: requireString(payload.laneId, `${action} requires laneId.`), - }; -} - -function parseProcessActionArgs(payload: Record, action: string): { laneId: string; processId: string; runId?: string } { - const parsed = { - laneId: requireString(payload.laneId, `${action} requires laneId.`), - processId: requireString(payload.processId, `${action} requires processId.`), - }; - const runId = asTrimmedString(payload.runId); - return runId ? { ...parsed, runId } : parsed; -} - -async function summarizeChatSessionForRemote( - agentChatService: ReturnType, - session: AgentChatSession, -): Promise { - const summary = await agentChatService.getSessionSummary(session.id); - if (summary) return summary; - - return { - sessionId: session.id, - laneId: session.laneId, - provider: session.provider, - model: session.model, - ...(session.modelId ? { modelId: session.modelId } : {}), - ...(session.sessionProfile ? { sessionProfile: session.sessionProfile } : {}), - reasoningEffort: session.reasoningEffort ?? null, - codexFastMode: session.codexFastMode === true, - executionMode: session.executionMode ?? null, - ...(session.permissionMode ? { permissionMode: session.permissionMode } : {}), - ...(session.interactionMode !== undefined ? { interactionMode: session.interactionMode } : {}), - ...(session.claudePermissionMode ? { claudePermissionMode: session.claudePermissionMode } : {}), - ...(session.codexApprovalPolicy ? { codexApprovalPolicy: session.codexApprovalPolicy } : {}), - ...(session.codexSandbox ? { codexSandbox: session.codexSandbox } : {}), - ...(session.codexConfigSource ? { codexConfigSource: session.codexConfigSource } : {}), - ...(session.opencodePermissionMode ? { opencodePermissionMode: session.opencodePermissionMode } : {}), - ...(session.droidPermissionMode ? { droidPermissionMode: session.droidPermissionMode } : {}), - ...(session.cursorModeSnapshot ? { cursorModeSnapshot: session.cursorModeSnapshot } : {}), - ...(session.cursorModeId !== undefined ? { cursorModeId: session.cursorModeId } : {}), - ...(session.cursorConfigValues ? { cursorConfigValues: session.cursorConfigValues } : {}), - ...(session.identityKey ? { identityKey: session.identityKey } : {}), - ...(session.surface ? { surface: session.surface } : {}), - automationId: session.automationId ?? null, - automationRunId: session.automationRunId ?? null, - ...(session.capabilityMode ? { capabilityMode: session.capabilityMode } : {}), - completion: session.completion ?? null, - status: session.status, - idleSinceAt: session.idleSinceAt ?? null, - startedAt: session.createdAt, - endedAt: null, - lastActivityAt: session.lastActivityAt, - lastOutputPreview: null, - summary: null, - ...(session.threadId ? { threadId: session.threadId } : {}), - ...(session.requestedCwd !== undefined ? { requestedCwd: session.requestedCwd } : {}), - }; -} - -function parseListLanesArgs(value: Record): ListLanesArgs { - return { - includeArchived: asOptionalBoolean(value.includeArchived), - includeStatus: asOptionalBoolean(value.includeStatus), - }; -} - -function parseCreateLaneArgs(value: Record): CreateLaneArgs { - return { - name: requireString(value.name, "lanes.create requires name."), - ...(asTrimmedString(value.description) ? { description: asTrimmedString(value.description)! } : {}), - ...(asTrimmedString(value.parentLaneId) ? { parentLaneId: asTrimmedString(value.parentLaneId)! } : {}), - ...(asTrimmedString(value.baseBranch) ? { baseBranch: asTrimmedString(value.baseBranch)! } : {}), - }; -} - -function parseCreateChildLaneArgs(value: Record): CreateChildLaneArgs { - return { - name: requireString(value.name, "lanes.createChild requires name."), - parentLaneId: requireString(value.parentLaneId, "lanes.createChild requires parentLaneId."), - ...(asTrimmedString(value.description) ? { description: asTrimmedString(value.description)! } : {}), - ...(asTrimmedString(value.folder) ? { folder: asTrimmedString(value.folder)! } : {}), - }; -} - -function parseCreateLaneFromUnstagedArgs(value: Record): CreateLaneFromUnstagedArgs { - return { - name: requireString(value.name, "lanes.createFromUnstaged requires name."), - sourceLaneId: requireString(value.sourceLaneId, "lanes.createFromUnstaged requires sourceLaneId."), - }; -} - -function parseImportBranchArgs(value: Record): ImportBranchLaneArgs { - return { - branchRef: requireString(value.branchRef, "lanes.importBranch requires branchRef."), - ...(asTrimmedString(value.name) ? { name: asTrimmedString(value.name)! } : {}), - ...(asTrimmedString(value.description) ? { description: asTrimmedString(value.description)! } : {}), - ...(asTrimmedString(value.baseBranch) ? { baseBranch: asTrimmedString(value.baseBranch)! } : {}), - }; -} - -function parseAttachLaneArgs(value: Record): AttachLaneArgs { - return { - name: requireString(value.name, "lanes.attach requires name."), - attachedPath: requireString(value.attachedPath, "lanes.attach requires attachedPath."), - ...(asTrimmedString(value.description) ? { description: asTrimmedString(value.description)! } : {}), - }; -} - -function parseArchiveLaneArgs(value: Record, action: string): ArchiveLaneArgs { - return { - laneId: requireString(value.laneId, `${action} requires laneId.`), - }; -} - -function parseDeleteLaneArgs(value: Record): DeleteLaneArgs { - return { - laneId: requireString(value.laneId, "lanes.delete requires laneId."), - deleteBranch: asOptionalBoolean(value.deleteBranch), - deleteRemoteBranch: asOptionalBoolean(value.deleteRemoteBranch), - ...(asTrimmedString(value.remoteName) ? { remoteName: asTrimmedString(value.remoteName)! } : {}), - force: asOptionalBoolean(value.force), - }; -} - -function parseRenameLaneArgs(value: Record): RenameLaneArgs { - return { - laneId: requireString(value.laneId, "lanes.rename requires laneId."), - name: requireString(value.name, "lanes.rename requires name."), - }; -} - -function parseReparentLaneArgs(value: Record): ReparentLaneArgs { - return { - laneId: requireString(value.laneId, "lanes.reparent requires laneId."), - newParentLaneId: requireString(value.newParentLaneId, "lanes.reparent requires newParentLaneId."), - }; -} - -function parseUpdateLaneAppearanceArgs(value: Record): UpdateLaneAppearanceArgs { - const parsed: UpdateLaneAppearanceArgs = { - laneId: requireString(value.laneId, "lanes.updateAppearance requires laneId."), - }; - if ("color" in value) { - parsed.color = value.color == null ? null : asTrimmedString(value.color) ?? null; - } - if ("icon" in value) { - parsed.icon = value.icon == null ? null : (asTrimmedString(value.icon) as UpdateLaneAppearanceArgs["icon"]); - } - if ("tags" in value) { - parsed.tags = value.tags == null ? null : asStringArray(value.tags); - } - return parsed; -} - -function parseRebaseStartArgs(value: Record): RebaseStartArgs { - return { - laneId: requireString(value.laneId, "lanes.rebaseStart requires laneId."), - ...(asTrimmedString(value.scope) ? { scope: value.scope as RebaseStartArgs["scope"] } : {}), - ...(asTrimmedString(value.pushMode) ? { pushMode: value.pushMode as RebaseStartArgs["pushMode"] } : {}), - ...(asTrimmedString(value.actor) ? { actor: asTrimmedString(value.actor)! } : {}), - ...(asTrimmedString(value.reason) ? { reason: asTrimmedString(value.reason)! } : {}), - ...(asTrimmedString(value.baseBranchOverride) ? { baseBranchOverride: asTrimmedString(value.baseBranchOverride)! } : {}), - }; -} - -function parseRebasePushArgs(value: Record): RebasePushArgs { - return { - runId: requireString(value.runId, "lanes.rebasePush requires runId."), - laneIds: requireStringArray(value.laneIds, "lanes.rebasePush requires laneIds."), - }; -} - -function parseRunIdArgs(value: Record, action: string): { runId: string } { - return { - runId: requireString(value.runId, `${action} requires runId.`), - }; -} - -function parseListSessionsArgs(value: Record): ListSessionsArgs { - const laneId = asTrimmedString(value.laneId); - const status = asTrimmedString(value.status) as ListSessionsArgs["status"]; - const limit = asOptionalNumber(value.limit); - return { - ...(laneId ? { laneId } : {}), - ...(status ? { status } : {}), - ...(typeof limit === "number" ? { limit } : {}), - }; -} - -function parseUpdateSessionMetaArgs(value: Record): UpdateSessionMetaArgs { - const parsed: UpdateSessionMetaArgs = { - sessionId: requireString(value.sessionId, "work.updateSessionMeta requires sessionId."), - }; - - if ("pinned" in value) parsed.pinned = value.pinned === true; - if ("manuallyNamed" in value) parsed.manuallyNamed = value.manuallyNamed === true; - if ("title" in value) parsed.title = value.title == null ? undefined : requireString(value.title, "work.updateSessionMeta requires a non-empty title when title is provided."); - if ("goal" in value) parsed.goal = value.goal == null ? null : asTrimmedString(value.goal) ?? null; - if ("toolType" in value) { - parsed.toolType = value.toolType == null - ? null - : asTrimmedString(value.toolType) as UpdateSessionMetaArgs["toolType"]; - } - if ("resumeCommand" in value) { - parsed.resumeCommand = value.resumeCommand == null ? null : asTrimmedString(value.resumeCommand) ?? null; - } - - return parsed; -} - -function parseQuickCommandArgs(value: Record): SyncRunQuickCommandArgs { - const laneId = requireString(value.laneId, "work.runQuickCommand requires laneId."); - const title = requireString(value.title, "work.runQuickCommand requires title."); - const toolType = asTrimmedString(value.toolType); - const startupCommand = asTrimmedString(value.startupCommand); - if (!startupCommand && toolType !== "shell") { - throw new Error("work.runQuickCommand requires startupCommand unless toolType is shell."); - } - return { - laneId, - title, - ...(startupCommand ? { startupCommand } : {}), - cols: asOptionalNumber(value.cols), - rows: asOptionalNumber(value.rows), - toolType, - tracked: asOptionalBoolean(value.tracked), - }; -} - -const DEFAULT_CLI_COLS = 120; -const DEFAULT_CLI_ROWS = 36; - -function clampCliDimension(value: number | undefined, fallback: number, min: number, max: number): number { - return Math.max(min, Math.min(max, Math.floor(value ?? fallback))); -} - -function parseCliProvider(value: unknown): SyncStartCliSessionArgs["provider"] { - const provider = asTrimmedString(value)?.toLowerCase(); - if (!isLaunchProfile(provider)) throw new Error("work.startCliSession requires provider."); - return provider; -} - -function parseCliPermissionMode(value: unknown): SyncStartCliSessionArgs["permissionMode"] { - const mode = asTrimmedString(value); - return isTrackedCliPermissionMode(mode) ? mode : "default"; -} - -function parseStartCliSessionArgs(value: Record): SyncStartCliSessionArgs { - const laneId = requireString(value.laneId, "work.startCliSession requires laneId."); - const provider = parseCliProvider(value.provider); - const initialInput = typeof value.initialInput === "string" && value.initialInput.trim().length > 0 - ? value.initialInput.slice(0, 20_000) - : null; - return { - laneId, - provider, - permissionMode: parseCliPermissionMode(value.permissionMode), - title: asTrimmedString(value.title), - initialInput, - cols: asOptionalNumber(value.cols), - rows: asOptionalNumber(value.rows), - resumeSessionId: asTrimmedString(value.resumeSessionId), - }; -} - -function requireResumeSessionForProvider( - sessionService: ReturnType, - sessionId: string, - provider: SyncStartCliSessionArgs["provider"], -): TerminalSessionSummary { - const session = sessionService.get(sessionId) as TerminalSessionSummary | null; - if (!session) throw new Error(`work.startCliSession resumeSessionId '${sessionId}' was not found.`); - const existingProvider = launchProfileForTerminalSession(session); - if (existingProvider && existingProvider !== provider) { - throw new Error(`work.startCliSession resumeSessionId '${sessionId}' belongs to ${existingProvider}, not ${provider}.`); - } - return session; -} - -function isChatToolType(toolType: string | null | undefined): boolean { - if (!toolType) return false; - const t = toolType.trim().toLowerCase(); - return t === "cursor" || t.endsWith("-chat"); -} - -async function listRemoteWorkSessions( - args: SyncRemoteCommandServiceArgs, - filters: ListSessionsArgs, -) { - const sessions = args.ptyService.enrichSessions(args.sessionService.list(filters)); - const laneId = typeof filters.laneId === "string" ? filters.laneId.trim() : ""; - const allChats = await args.agentChatService - ?.listSessions(laneId || undefined, { includeIdentity: true }) - .catch(() => [] as AgentChatSessionSummary[]) ?? []; - - const identitySessionIds = new Set( - allChats.filter((chat) => Boolean(chat.identityKey)).map((chat) => chat.sessionId), - ); - const visibleSessions = identitySessionIds.size > 0 - ? sessions.filter((session) => !identitySessionIds.has(session.id)) - : sessions; - - const chatSummaryBySessionId = new Map( - allChats.filter((chat) => !chat.identityKey).map((chat) => [chat.sessionId, chat] as const), - ); - if (chatSummaryBySessionId.size === 0) return visibleSessions; - - return visibleSessions.map((session) => { - if (!isChatToolType(session.toolType) || session.status !== "running") return session; - const chat = chatSummaryBySessionId.get(session.id); - if (!chat) return session; - if (chat.awaitingInput) return { ...session, runtimeState: "waiting-input" as const, chatIdleSinceAt: null }; - if (chat.status === "active") return { ...session, runtimeState: "running" as const, chatIdleSinceAt: null }; - if (chat.status === "idle") return { ...session, runtimeState: "idle" as const, chatIdleSinceAt: chat.idleSinceAt ?? null }; - return session; - }); -} - -function parseCloseSessionArgs(value: Record): { sessionId: string } { - return { - sessionId: requireString(value.sessionId, "work.closeSession requires sessionId."), - }; -} - -function parseAgentChatListArgs(value: Record): AgentChatListArgs { - return { - ...(asTrimmedString(value.laneId) ? { laneId: asTrimmedString(value.laneId)! } : {}), - includeAutomation: asOptionalBoolean(value.includeAutomation), - }; -} - -function parseAgentChatGetSummaryArgs(value: Record): AgentChatGetSummaryArgs { - return { - sessionId: requireString(value.sessionId, "chat.getSummary requires sessionId."), - }; -} - -function parseAgentChatCreateArgs(value: Record): AgentChatCreateArgs { - const parsed: AgentChatCreateArgs = { - laneId: requireString(value.laneId, "chat.create requires laneId."), - provider: (asTrimmedString(value.provider) ?? "codex") as AgentChatCreateArgs["provider"], - model: asTrimmedString(value.model) ?? "", - ...(asTrimmedString(value.modelId) ? { modelId: asTrimmedString(value.modelId)! } : {}), - ...(asTrimmedString(value.reasoningEffort) ? { reasoningEffort: asTrimmedString(value.reasoningEffort)! } : {}), - }; - - if ("sessionProfile" in value) parsed.sessionProfile = value.sessionProfile == null ? undefined : asTrimmedString(value.sessionProfile) as AgentChatCreateArgs["sessionProfile"]; - if ("permissionMode" in value) parsed.permissionMode = value.permissionMode == null ? undefined : asTrimmedString(value.permissionMode) as AgentChatCreateArgs["permissionMode"]; - if ("interactionMode" in value) parsed.interactionMode = value.interactionMode == null ? null : asTrimmedString(value.interactionMode) as AgentChatCreateArgs["interactionMode"]; - if ("claudePermissionMode" in value) parsed.claudePermissionMode = value.claudePermissionMode == null ? undefined : asTrimmedString(value.claudePermissionMode) as AgentChatCreateArgs["claudePermissionMode"]; - if ("codexApprovalPolicy" in value) parsed.codexApprovalPolicy = value.codexApprovalPolicy == null ? undefined : asTrimmedString(value.codexApprovalPolicy) as AgentChatCreateArgs["codexApprovalPolicy"]; - if ("codexSandbox" in value) parsed.codexSandbox = value.codexSandbox == null ? undefined : asTrimmedString(value.codexSandbox) as AgentChatCreateArgs["codexSandbox"]; - if ("codexConfigSource" in value) parsed.codexConfigSource = value.codexConfigSource == null ? undefined : asTrimmedString(value.codexConfigSource) as AgentChatCreateArgs["codexConfigSource"]; - if ("codexFastMode" in value) parsed.codexFastMode = asOptionalBoolean(value.codexFastMode); - if ("opencodePermissionMode" in value) parsed.opencodePermissionMode = value.opencodePermissionMode == null ? undefined : asTrimmedString(value.opencodePermissionMode) as AgentChatCreateArgs["opencodePermissionMode"]; - if ("droidPermissionMode" in value) parsed.droidPermissionMode = value.droidPermissionMode == null ? undefined : (asTrimmedString(value.droidPermissionMode) ?? undefined) as AgentChatCreateArgs["droidPermissionMode"]; - if ("cursorModeId" in value) parsed.cursorModeId = value.cursorModeId == null ? null : asTrimmedString(value.cursorModeId) ?? null; - if ("cursorConfigValues" in value) parsed.cursorConfigValues = parseCursorConfigValues(value.cursorConfigValues); - if ("requestedCwd" in value) parsed.requestedCwd = value.requestedCwd == null ? undefined : requireString(value.requestedCwd, "chat.create requires a non-empty requestedCwd when provided."); - - return parsed; -} - -function parseAgentChatSendArgs(value: Record): AgentChatSendArgs { - const attachments = parseAgentChatFileRefs(value.attachments); - return { - sessionId: requireString(value.sessionId, "chat.send requires sessionId."), - text: requireString(value.text, "chat.send requires text."), - ...(asTrimmedString(value.displayText) ? { displayText: asTrimmedString(value.displayText)! } : {}), - ...(attachments?.length ? { attachments } : {}), - ...(asTrimmedString(value.reasoningEffort) ? { reasoningEffort: asTrimmedString(value.reasoningEffort)! } : {}), - ...(asTrimmedString(value.executionMode) ? { executionMode: asTrimmedString(value.executionMode)! as AgentChatSendArgs["executionMode"] } : {}), - ...(asTrimmedString(value.interactionMode) ? { interactionMode: asTrimmedString(value.interactionMode)! as AgentChatSendArgs["interactionMode"] } : {}), - }; -} - -function parseAgentChatSteerArgs(value: Record): AgentChatSteerArgs { - const attachments = parseAgentChatFileRefs(value.attachments); - return { - sessionId: requireString(value.sessionId, "chat.steer requires sessionId."), - text: requireString(value.text, "chat.steer requires text."), - ...(attachments?.length ? { attachments } : {}), - }; -} - -function parseAgentChatCancelSteerArgs(value: Record): AgentChatCancelSteerArgs { - return { - sessionId: requireString(value.sessionId, "chat.cancelSteer requires sessionId."), - steerId: requireString(value.steerId, "chat.cancelSteer requires steerId."), - }; -} - -function parseAgentChatEditSteerArgs(value: Record): AgentChatEditSteerArgs { - return { - sessionId: requireString(value.sessionId, "chat.editSteer requires sessionId."), - steerId: requireString(value.steerId, "chat.editSteer requires steerId."), - text: requireString(value.text, "chat.editSteer requires text."), - }; -} - -function parseAgentChatDispatchSteerArgs(value: Record): AgentChatDispatchSteerArgs { - const mode = value.mode; - if (mode !== "inline" && mode !== "interrupt") { - throw new Error("chat.dispatchSteer requires mode of 'inline' or 'interrupt'."); - } - return { - sessionId: requireString(value.sessionId, "chat.dispatchSteer requires sessionId."), - steerId: requireString(value.steerId, "chat.dispatchSteer requires steerId."), - mode, - }; -} - -function parseAgentChatCancelDispatchedSteerArgs(value: Record): AgentChatCancelDispatchedSteerArgs { - return { - sessionId: requireString(value.sessionId, "chat.cancelDispatchedSteer requires sessionId."), - steerId: requireString(value.steerId, "chat.cancelDispatchedSteer requires steerId."), - }; -} - -function parseAgentChatInterruptArgs(value: Record): AgentChatInterruptArgs { - return { - sessionId: requireString(value.sessionId, "chat.interrupt requires sessionId."), - }; -} - -function parseAgentChatResumeArgs(value: Record): AgentChatResumeArgs { - return { - sessionId: requireString(value.sessionId, "chat.resume requires sessionId."), - }; -} - -function parseAgentChatApproveArgs(value: Record): AgentChatApproveArgs { - return { - sessionId: requireString(value.sessionId, "chat.approve requires sessionId."), - itemId: requireString(value.itemId, "chat.approve requires itemId."), - decision: requireString(value.decision, "chat.approve requires decision.") as AgentChatApproveArgs["decision"], - ...(asTrimmedString(value.responseText) ? { responseText: asTrimmedString(value.responseText)! } : {}), - }; -} - -function parseAgentChatRespondToInputArgs(value: Record): AgentChatRespondToInputArgs { - const parsed: AgentChatRespondToInputArgs = { - sessionId: requireString(value.sessionId, "chat.respondToInput requires sessionId."), - itemId: requireString(value.itemId, "chat.respondToInput requires itemId."), - }; - - if (typeof value.decision === "string" && value.decision.trim().length > 0) { - parsed.decision = value.decision.trim() as AgentChatRespondToInputArgs["decision"]; - } - if (isRecord(value.answers)) { - parsed.answers = Object.fromEntries( - Object.entries(value.answers).map(([key, entry]) => { - if (Array.isArray(entry)) { - return [key, entry.map((item) => String(item))]; - } - return [key, String(entry)]; - }), - ); - } - if (typeof value.responseText === "string" && value.responseText.trim().length > 0) { - parsed.responseText = value.responseText.trim(); - } - return parsed; -} - -function parseAgentChatUpdateSessionArgs(value: Record): AgentChatUpdateSessionArgs { - const parsed: AgentChatUpdateSessionArgs = { - sessionId: requireString(value.sessionId, "chat.updateSession requires sessionId."), - }; - - if ("title" in value) parsed.title = value.title == null ? null : asTrimmedString(value.title) ?? null; - if ("modelId" in value) parsed.modelId = value.modelId == null ? undefined : asTrimmedString(value.modelId) as AgentChatUpdateSessionArgs["modelId"]; - if ("reasoningEffort" in value) parsed.reasoningEffort = value.reasoningEffort == null ? null : asTrimmedString(value.reasoningEffort) ?? null; - if ("permissionMode" in value) parsed.permissionMode = value.permissionMode == null ? undefined : asTrimmedString(value.permissionMode) as AgentChatUpdateSessionArgs["permissionMode"]; - if ("interactionMode" in value) parsed.interactionMode = value.interactionMode == null ? null : asTrimmedString(value.interactionMode) as AgentChatUpdateSessionArgs["interactionMode"]; - if ("claudePermissionMode" in value) parsed.claudePermissionMode = value.claudePermissionMode == null ? undefined : asTrimmedString(value.claudePermissionMode) as AgentChatUpdateSessionArgs["claudePermissionMode"]; - if ("codexApprovalPolicy" in value) parsed.codexApprovalPolicy = value.codexApprovalPolicy == null ? undefined : asTrimmedString(value.codexApprovalPolicy) as AgentChatUpdateSessionArgs["codexApprovalPolicy"]; - if ("codexSandbox" in value) parsed.codexSandbox = value.codexSandbox == null ? undefined : asTrimmedString(value.codexSandbox) as AgentChatUpdateSessionArgs["codexSandbox"]; - if ("codexConfigSource" in value) parsed.codexConfigSource = value.codexConfigSource == null ? undefined : asTrimmedString(value.codexConfigSource) as AgentChatUpdateSessionArgs["codexConfigSource"]; - if ("codexFastMode" in value) parsed.codexFastMode = asOptionalBoolean(value.codexFastMode); - if ("opencodePermissionMode" in value) parsed.opencodePermissionMode = value.opencodePermissionMode == null ? undefined : asTrimmedString(value.opencodePermissionMode) as AgentChatUpdateSessionArgs["opencodePermissionMode"]; - if ("droidPermissionMode" in value) parsed.droidPermissionMode = value.droidPermissionMode == null ? undefined : asTrimmedString(value.droidPermissionMode) as AgentChatUpdateSessionArgs["droidPermissionMode"]; - if ("cursorModeId" in value) parsed.cursorModeId = value.cursorModeId == null ? null : asTrimmedString(value.cursorModeId) ?? null; - if ("cursorConfigValues" in value) { - parsed.cursorConfigValues = parseCursorConfigValues(value.cursorConfigValues); - } - if ("manuallyNamed" in value) parsed.manuallyNamed = value.manuallyNamed === true; - return parsed; -} - -function parseAgentChatDisposeArgs(value: Record): AgentChatDisposeArgs { - return { - sessionId: requireString(value.sessionId, "chat.dispose requires sessionId."), - }; -} - -function parseAgentChatArchiveArgs(value: Record, action: string): AgentChatArchiveArgs { - return { - sessionId: requireString(value.sessionId, `${action} requires sessionId.`), - }; -} - -function parseGetTranscriptArgs(value: Record): { - sessionId: string; - limit?: number; - maxChars?: number; -} { - return { - sessionId: requireString(value.sessionId, "chat.getTranscript requires sessionId."), - limit: asOptionalNumber(value.limit), - maxChars: asOptionalNumber(value.maxChars), - }; -} - -function parseGitFileActionArgs(value: Record, action: string): GitFileActionArgs { - return { - laneId: requireString(value.laneId, `${action} requires laneId.`), - path: requireString(value.path, `${action} requires path.`), - }; -} - -function parseGitBatchFileActionArgs(value: Record, action: string): GitBatchFileActionArgs { - return { - laneId: requireString(value.laneId, `${action} requires laneId.`), - paths: requireStringArray(value.paths, `${action} requires paths.`), - }; -} - -function parseWriteTextAtomicArgs(value: Record): WriteTextAtomicArgs { - if (typeof value.text !== "string") { - throw new Error("files.writeTextAtomic requires text."); - } - return { - laneId: requireString(value.laneId, "files.writeTextAtomic requires laneId."), - path: requireString(value.path, "files.writeTextAtomic requires path."), - text: value.text, - }; -} - -function parseGitCommitArgs(value: Record): GitCommitArgs { - return { - laneId: requireString(value.laneId, "git.commit requires laneId."), - message: requireString(value.message, "git.commit requires message."), - amend: asOptionalBoolean(value.amend), - }; -} - -function parseGitGenerateCommitMessageArgs(value: Record): GitGenerateCommitMessageArgs { - return { - laneId: requireString(value.laneId, "git.generateCommitMessage requires laneId."), - amend: asOptionalBoolean(value.amend), - }; -} - -function parseGitListRecentCommitsArgs(value: Record): { laneId: string; limit?: number } { - return { - laneId: requireString(value.laneId, "git.listRecentCommits requires laneId."), - limit: asOptionalNumber(value.limit), - }; -} - -function parseGitListCommitFilesArgs(value: Record): GitListCommitFilesArgs { - return { - laneId: requireString(value.laneId, "git.listCommitFiles requires laneId."), - commitSha: requireString(value.commitSha, "git.listCommitFiles requires commitSha."), - }; -} - -function parseGitGetCommitMessageArgs(value: Record): GitGetCommitMessageArgs { - return { - laneId: requireString(value.laneId, "git.getCommitMessage requires laneId."), - commitSha: requireString(value.commitSha, "git.getCommitMessage requires commitSha."), - }; -} - -function parseGitGetFileHistoryArgs(value: Record): GitGetFileHistoryArgs { - return { - laneId: requireString(value.laneId, "git.getFileHistory requires laneId."), - path: requireString(value.path, "git.getFileHistory requires path."), - limit: asOptionalNumber(value.limit), - }; -} - -function parseGitRevertArgs(value: Record): GitRevertArgs { - return { - laneId: requireString(value.laneId, "git.revertCommit requires laneId."), - commitSha: requireString(value.commitSha, "git.revertCommit requires commitSha."), - }; -} - -function parseGitCherryPickArgs(value: Record): GitCherryPickArgs { - return { - laneId: requireString(value.laneId, "git.cherryPickCommit requires laneId."), - commitSha: requireString(value.commitSha, "git.cherryPickCommit requires commitSha."), - }; -} - -function parseGitStashPushArgs(value: Record): GitStashPushArgs { - return { - laneId: requireString(value.laneId, "git.stashPush requires laneId."), - ...(asTrimmedString(value.message) ? { message: asTrimmedString(value.message)! } : {}), - includeUntracked: asOptionalBoolean(value.includeUntracked), - }; -} - -function parseGitStashRefArgs(value: Record, action: string): GitStashRefArgs { - return { - laneId: requireString(value.laneId, `${action} requires laneId.`), - stashRef: requireString(value.stashRef, `${action} requires stashRef.`), - }; -} - -function parseGitSyncArgs(value: Record): GitSyncArgs { - return { - laneId: requireString(value.laneId, "git.sync requires laneId."), - ...(asTrimmedString(value.mode) ? { mode: value.mode as GitSyncArgs["mode"] } : {}), - ...(asTrimmedString(value.baseRef) ? { baseRef: asTrimmedString(value.baseRef)! } : {}), - }; -} - -function parseGitPushArgs(value: Record): GitPushArgs { - return { - laneId: requireString(value.laneId, "git.push requires laneId."), - forceWithLease: asOptionalBoolean(value.forceWithLease), - }; -} - -function parseGetDiffChangesArgs(value: Record): GetDiffChangesArgs { - return { - laneId: requireString(value.laneId, "git.getChanges requires laneId."), - }; -} - -function parseGetFileDiffArgs(value: Record): GetFileDiffArgs { - return { - laneId: requireString(value.laneId, "git.getFile requires laneId."), - path: requireString(value.path, "git.getFile requires path."), - mode: requireString(value.mode, "git.getFile requires mode.") as GetFileDiffArgs["mode"], - ...(asTrimmedString(value.compareRef) ? { compareRef: asTrimmedString(value.compareRef)! } : {}), - ...(asTrimmedString(value.compareTo) ? { compareTo: value.compareTo as GetFileDiffArgs["compareTo"] } : {}), - }; -} - -function parseGitListBranchesArgs(value: Record): GitListBranchesArgs { - return { - laneId: requireString(value.laneId, "git.listBranches requires laneId."), - }; -} - -function parseGitCheckoutBranchArgs(value: Record): GitCheckoutBranchArgs { - return { - laneId: requireString(value.laneId, "git.checkoutBranch requires laneId."), - branchName: requireString(value.branchName, "git.checkoutBranch requires branchName."), - ...(asTrimmedString(value.mode) ? { mode: value.mode as GitCheckoutBranchArgs["mode"] } : {}), - ...(asTrimmedString(value.startPoint) ? { startPoint: asTrimmedString(value.startPoint)! } : {}), - ...(asTrimmedString(value.baseRef) ? { baseRef: asTrimmedString(value.baseRef)! } : {}), - ...(asOptionalBoolean(value.acknowledgeActiveWork) !== undefined - ? { acknowledgeActiveWork: asOptionalBoolean(value.acknowledgeActiveWork) } - : {}), - }; -} - -function parseConflictLaneArgs(value: Record, action: string): { laneId: string } { - return { - laneId: requireString(value.laneId, `${action} requires laneId.`), - }; -} - -function parseChatModelsArgs(value: Record): { provider: AgentChatProvider; activateRuntime?: boolean } { - return { - provider: (asTrimmedString(value.provider) ?? "codex") as AgentChatProvider, - ...(value.activateRuntime === true ? { activateRuntime: true } : {}), - }; -} - -function requirePrId(value: Record, action: string): string { - return requireString(value.prId, `${action} requires prId.`); -} - -function parseCreatePrArgs(value: Record): CreatePrFromLaneArgs { - const laneId = asTrimmedString(value.laneId); - const title = asTrimmedString(value.title); - const body = typeof value.body === "string" ? value.body : ""; - if (!laneId || !title) throw new Error("prs.createFromLane requires laneId and title."); - const strategy: CreatePrFromLaneArgs["strategy"] = - normalizePrCreationStrategy(asTrimmedString(value.strategy)) ?? undefined; - return { - laneId, - title, - body, - draft: value.draft === true, - ...(asTrimmedString(value.baseBranch) ? { baseBranch: asTrimmedString(value.baseBranch)! } : {}), - ...(asStringArray(value.labels).length ? { labels: asStringArray(value.labels) } : {}), - ...(asStringArray(value.reviewers).length ? { reviewers: asStringArray(value.reviewers) } : {}), - ...(typeof value.allowDirtyWorktree === "boolean" ? { allowDirtyWorktree: value.allowDirtyWorktree } : {}), - ...(typeof value.closeLinearIssueOnMerge === "boolean" ? { closeLinearIssueOnMerge: value.closeLinearIssueOnMerge } : {}), - ...(strategy ? { strategy } : {}), - }; -} - -function parseLinkPrToLaneArgs(value: Record): LinkPrToLaneArgs { - return { - laneId: requireString(value.laneId, "prs.linkToLane requires laneId."), - prUrlOrNumber: requireString(value.prUrlOrNumber, "prs.linkToLane requires prUrlOrNumber."), - }; -} - -function parseDraftPrDescriptionArgs(value: Record): DraftPrDescriptionArgs { - return { - laneId: requireString(value.laneId, "prs.draftDescription requires laneId."), - ...(asTrimmedString(value.model) ? { model: asTrimmedString(value.model)! } : {}), - ...("reasoningEffort" in value - ? { reasoningEffort: value.reasoningEffort == null ? null : asTrimmedString(value.reasoningEffort) ?? null } - : {}), - ...(asTrimmedString(value.baseBranch) ? { baseBranch: asTrimmedString(value.baseBranch)! } : {}), - ...(typeof value.closeLinearIssueOnMerge === "boolean" ? { closeLinearIssueOnMerge: value.closeLinearIssueOnMerge } : {}), - }; -} - -function parseLandPrArgs(value: Record): LandPrArgs { - const prId = requirePrId(value, "prs.land"); - const method = asTrimmedString(value.method) as LandPrArgs["method"]; - if (!method || !["merge", "squash", "rebase"].includes(method)) { - throw new Error("prs.land requires method to be merge, squash, or rebase."); - } - return { prId, method }; -} - -function parseClosePrArgs(value: Record): ClosePrArgs { - return { - prId: requirePrId(value, "prs.close"), - ...(typeof value.comment === "string" ? { comment: value.comment } : {}), - }; -} - -function parseReopenPrArgs(value: Record): ReopenPrArgs { - return { - prId: requirePrId(value, "prs.reopen"), - }; -} - -function parseRequestReviewersArgs(value: Record): RequestPrReviewersArgs { - const prId = requirePrId(value, "prs.requestReviewers"); - const reviewers = asStringArray(value.reviewers); - if (reviewers.length === 0) throw new Error("prs.requestReviewers requires at least one reviewer."); - return { prId, reviewers }; -} - -function parseRerunPrChecksArgs(value: Record): RerunPrChecksArgs { - const checkRunIds = (() => { - if (value.checkRunIds == null) return undefined; - if (!Array.isArray(value.checkRunIds)) { - throw new Error("prs.rerunChecks requires checkRunIds to be an array of numbers when provided."); - } - return value.checkRunIds.map((entry) => { - if (typeof entry !== "number" || !Number.isSafeInteger(entry) || entry <= 0) { - throw new Error("prs.rerunChecks requires checkRunIds to be an array of numbers when provided."); - } - return entry; - }); - })(); - return { - prId: requirePrId(value, "prs.rerunChecks"), - ...(checkRunIds?.length ? { checkRunIds } : {}), - }; -} - -function parseAddPrCommentArgs(value: Record): AddPrCommentArgs { - return { - prId: requirePrId(value, "prs.addComment"), - body: requireString(value.body, "prs.addComment requires body."), - ...(asTrimmedString(value.inReplyToCommentId) ? { inReplyToCommentId: asTrimmedString(value.inReplyToCommentId)! } : {}), - }; -} - -function parseUpdatePrTitleArgs(value: Record): UpdatePrTitleArgs { - return { - prId: requirePrId(value, "prs.updateTitle"), - title: requireString(value.title, "prs.updateTitle requires title."), - }; -} - -function parseUpdatePrBodyArgs(value: Record): UpdatePrBodyArgs { - return { - prId: requirePrId(value, "prs.updateBody"), - body: typeof value.body === "string" ? value.body : "", - }; -} - -function parseSetPrLabelsArgs(value: Record): SetPrLabelsArgs { - return { - prId: requirePrId(value, "prs.setLabels"), - labels: asStringArray(value.labels), - }; -} - -function parseSubmitPrReviewArgs(value: Record): SubmitPrReviewArgs { - const event = asTrimmedString(value.event); - if (event !== "APPROVE" && event !== "REQUEST_CHANGES" && event !== "COMMENT") { - throw new Error("prs.submitReview requires event to be APPROVE, REQUEST_CHANGES, or COMMENT."); - } - return { - prId: requirePrId(value, "prs.submitReview"), - event, - ...(typeof value.body === "string" ? { body: value.body } : {}), - }; -} - -function parseReplyToReviewThreadArgs(value: Record): ReplyToPrReviewThreadArgs { - return { - prId: requirePrId(value, "prs.replyToReviewThread"), - threadId: requireString(value.threadId, "prs.replyToReviewThread requires threadId."), - body: requireString(value.body, "prs.replyToReviewThread requires body."), - }; -} - -function parseSetReviewThreadResolvedArgs(value: Record): SetPrReviewThreadResolvedArgs { - return { - prId: requirePrId(value, "prs.setReviewThreadResolved"), - threadId: requireString(value.threadId, "prs.setReviewThreadResolved requires threadId."), - resolved: value.resolved === true, - }; -} - -function parseReactToCommentArgs(value: Record): ReactToPrCommentArgs { - const content = asTrimmedString(value.content); - if (!content) throw new Error("prs.reactToComment requires content."); - return { - prId: requirePrId(value, "prs.reactToComment"), - commentId: requireString(value.commentId, "prs.reactToComment requires commentId."), - content: content as ReactToPrCommentArgs["content"], - }; -} - -function parseAiReviewSummaryArgs(value: Record): AiReviewSummaryArgs { - return { - prId: requirePrId(value, "prs.aiReviewSummary"), - ...(asTrimmedString(value.model) ? { model: asTrimmedString(value.model)! } : {}), - }; -} - -function parseListIntegrationWorkflowsArgs(value: Record): ListIntegrationWorkflowsArgs { - const view = asTrimmedString(value.view); - return view ? { view: view as ListIntegrationWorkflowsArgs["view"] } : {}; -} - -function parseUpdateIntegrationProposalArgs(value: Record): UpdateIntegrationProposalArgs { - return { - proposalId: requireString(value.proposalId, "prs.updateIntegrationProposal requires proposalId."), - ...(typeof value.title === "string" ? { title: value.title } : {}), - ...(typeof value.body === "string" ? { body: value.body } : {}), - ...(typeof value.draft === "boolean" ? { draft: value.draft } : {}), - ...(typeof value.integrationLaneName === "string" ? { integrationLaneName: value.integrationLaneName } : {}), - ...(typeof value.preferredIntegrationLaneId === "string" || value.preferredIntegrationLaneId === null - ? { preferredIntegrationLaneId: value.preferredIntegrationLaneId } - : {}), - ...(typeof value.mergeIntoHeadSha === "string" || value.mergeIntoHeadSha === null - ? { mergeIntoHeadSha: value.mergeIntoHeadSha } - : {}), - }; -} - -function parseDeleteIntegrationProposalArgs(value: Record): DeleteIntegrationProposalArgs { - return { - proposalId: requireString(value.proposalId, "prs.deleteIntegrationProposal requires proposalId."), - ...(typeof value.deleteIntegrationLane === "boolean" ? { deleteIntegrationLane: value.deleteIntegrationLane } : {}), - }; -} - -function parseDismissIntegrationCleanupArgs(value: Record): DismissIntegrationCleanupArgs { - return { - proposalId: requireString(value.proposalId, "prs.dismissIntegrationCleanup requires proposalId."), - }; -} - -function parseCleanupIntegrationWorkflowArgs(value: Record): CleanupIntegrationWorkflowArgs { - const rawLaneIds = Array.isArray(value.archiveSourceLaneIds) ? value.archiveSourceLaneIds : []; - const archiveSourceLaneIds = rawLaneIds - .map((entry) => (typeof entry === "string" ? entry.trim() : "")) - .filter((entry) => entry.length > 0); - return { - proposalId: requireString(value.proposalId, "prs.cleanupIntegrationWorkflow requires proposalId."), - ...(typeof value.archiveIntegrationLane === "boolean" ? { archiveIntegrationLane: value.archiveIntegrationLane } : {}), - ...(archiveSourceLaneIds.length > 0 ? { archiveSourceLaneIds } : {}), - }; -} - -function parseCreateIntegrationLaneForProposalArgs(value: Record): CreateIntegrationLaneForProposalArgs { - return { - proposalId: requireString(value.proposalId, "prs.createIntegrationLaneForProposal requires proposalId."), - }; -} - -function parseStartIntegrationResolutionArgs(value: Record): StartIntegrationResolutionArgs { - return { - proposalId: requireString(value.proposalId, "prs.startIntegrationResolution requires proposalId."), - laneId: requireString(value.laneId, "prs.startIntegrationResolution requires laneId."), - }; -} - -function parseRecheckIntegrationStepArgs(value: Record): RecheckIntegrationStepArgs { - return { - proposalId: requireString(value.proposalId, "prs.recheckIntegrationStep requires proposalId."), - laneId: requireString(value.laneId, "prs.recheckIntegrationStep requires laneId."), - }; -} - -function parseLandQueueNextArgs(value: Record): LandQueueNextArgs { - const method = asTrimmedString(value.method) as LandQueueNextArgs["method"]; - if (!method || !["merge", "squash", "rebase"].includes(method)) { - throw new Error("prs.landQueueNext requires method to be merge, squash, or rebase."); - } - return { - groupId: requireString(value.groupId, "prs.landQueueNext requires groupId."), - method, - ...(typeof value.archiveLane === "boolean" ? { archiveLane: value.archiveLane } : {}), - ...(typeof value.autoResolve === "boolean" ? { autoResolve: value.autoResolve } : {}), - ...(asOptionalNumber(value.confidenceThreshold) != null ? { confidenceThreshold: asOptionalNumber(value.confidenceThreshold)! } : {}), - }; -} - -function parseReorderQueuePrsArgs(value: Record): ReorderQueuePrsArgs { - return { - groupId: requireString(value.groupId, "prs.reorderQueue requires groupId."), - prIds: requireStringArray(value.prIds, "prs.reorderQueue requires prIds."), - }; -} - -function parsePauseQueueAutomationArgs(value: Record): PauseQueueAutomationArgs { - return { - queueId: requireString(value.queueId, "prs.pauseQueueAutomation requires queueId."), - }; -} - -function parseResumeQueueAutomationArgs(value: Record): ResumeQueueAutomationArgs { - const method = asTrimmedString(value.method); - if (method && !["merge", "squash", "rebase"].includes(method)) { - throw new Error("prs.resumeQueueAutomation requires method to be merge, squash, or rebase when provided."); - } - return { - queueId: requireString(value.queueId, "prs.resumeQueueAutomation requires queueId."), - ...(method ? { method: method as ResumeQueueAutomationArgs["method"] } : {}), - ...(typeof value.archiveLane === "boolean" ? { archiveLane: value.archiveLane } : {}), - ...(typeof value.autoResolve === "boolean" ? { autoResolve: value.autoResolve } : {}), - ...(typeof value.ciGating === "boolean" ? { ciGating: value.ciGating } : {}), - ...(asOptionalNumber(value.confidenceThreshold) != null ? { confidenceThreshold: asOptionalNumber(value.confidenceThreshold)! } : {}), - ...(asTrimmedString(value.originLabel) ? { originLabel: asTrimmedString(value.originLabel)! } : {}), - }; -} - -function parseCancelQueueAutomationArgs(value: Record): CancelQueueAutomationArgs { - return { - queueId: requireString(value.queueId, "prs.cancelQueueAutomation requires queueId."), - }; -} - -function parseIssueInventoryPrArgs(value: Record, action: string): { prId: string } { - return { - prId: requirePrId(value, action), - }; -} - -function parseIssueInventoryItemsArgs(value: Record, action: string): { prId: string; itemIds: string[] } { - return { - prId: requirePrId(value, action), - itemIds: requireStringArray(value.itemIds, `${action} requires itemIds.`), - }; -} - -function parseIssueInventoryDismissArgs(value: Record): { prId: string; itemIds: string[]; reason: string } { - return { - ...parseIssueInventoryItemsArgs(value, "prs.issueInventory.markDismissed"), - reason: typeof value.reason === "string" ? value.reason : "", - }; -} - -function parsePipelineSettingsPatch(value: Record): { prId: string; settings: Partial } { - const settings = isRecord(value.settings) ? value.settings : value; - const patch: Partial = {}; - if (typeof settings.autoMerge === "boolean") patch.autoMerge = settings.autoMerge; - const mergeMethod = asTrimmedString(settings.mergeMethod); - if (mergeMethod && ["merge", "squash", "rebase", "repo_default"].includes(mergeMethod)) { - patch.mergeMethod = mergeMethod as PipelineSettings["mergeMethod"]; - } - const maxRounds = asOptionalNumber(settings.maxRounds); - if (maxRounds != null && maxRounds >= 1) patch.maxRounds = Math.floor(maxRounds); - const onRebaseNeeded = asTrimmedString(settings.onRebaseNeeded); - if (onRebaseNeeded === "pause" || onRebaseNeeded === "auto_rebase") { - patch.onRebaseNeeded = onRebaseNeeded; - } - const conflictStrategy = asTrimmedString(settings.conflictStrategy); - if (conflictStrategy && ["pause", "rebase", "merge", "auto"].includes(conflictStrategy)) { - patch.conflictStrategy = conflictStrategy as PipelineSettings["conflictStrategy"]; - } - const forceFinalizeMode = asTrimmedString(settings.forceFinalizeMode); - if (forceFinalizeMode && ["off", "conditional", "unconditional"].includes(forceFinalizeMode)) { - patch.forceFinalizeMode = forceFinalizeMode as PipelineSettings["forceFinalizeMode"]; - } - if (typeof settings.forceFinalizeRequireNoCiFailures === "boolean") { - patch.forceFinalizeRequireNoCiFailures = settings.forceFinalizeRequireNoCiFailures; - } - if (typeof settings.earlyMergeOnGreen === "boolean") { - patch.earlyMergeOnGreen = settings.earlyMergeOnGreen; - } - const atCapPolicy = asTrimmedString(settings.atCapPolicy); - if (atCapPolicy && ["stop", "wait_for_ci", "ci_retry_once", "ci_retry_loop", "force_merge"].includes(atCapPolicy)) { - patch.atCapPolicy = atCapPolicy as PipelineSettings["atCapPolicy"]; - } - const atCapWaitMinutes = asOptionalNumber(settings.atCapWaitMinutes); - if (atCapWaitMinutes != null && atCapWaitMinutes >= 1) patch.atCapWaitMinutes = Math.floor(atCapWaitMinutes); - const atCapCiRetryMax = asOptionalNumber(settings.atCapCiRetryMax); - if (atCapCiRetryMax != null && atCapCiRetryMax >= 1) patch.atCapCiRetryMax = Math.floor(atCapCiRetryMax); - if (typeof settings.forceMergeRequiresConfirmation === "boolean") { - patch.forceMergeRequiresConfirmation = settings.forceMergeRequiresConfirmation; - } - if (isRecord(settings.autoAgentSettings)) { - const autoAgentSettings: Partial = {}; - const provider = settings.autoAgentSettings.provider; - if (provider === null || provider === "claude" || provider === "codex") autoAgentSettings.provider = provider; - for (const key of ["model", "reasoningEffort"] as const) { - const value = settings.autoAgentSettings[key]; - if (value === null || typeof value === "string") autoAgentSettings[key] = value; - } - const permissionMode = settings.autoAgentSettings.permissionMode; - if ( - permissionMode === null || - permissionMode === "read_only" || - permissionMode === "guarded_edit" || - permissionMode === "full_edit" || - permissionMode === "default" || - permissionMode === "plan" || - permissionMode === "edit" || - permissionMode === "full-auto" || - permissionMode === "config-toml" - ) { - autoAgentSettings.permissionMode = permissionMode; - } - const confidenceThreshold = asOptionalNumber(settings.autoAgentSettings.confidenceThreshold); - if (settings.autoAgentSettings.confidenceThreshold === null || (confidenceThreshold != null && confidenceThreshold >= 0 && confidenceThreshold <= 1)) { - autoAgentSettings.confidenceThreshold = settings.autoAgentSettings.confidenceThreshold === null ? null : confidenceThreshold; - } - if (Object.keys(autoAgentSettings).length > 0) patch.autoAgentSettings = autoAgentSettings as PipelineSettings["autoAgentSettings"]; - } - return { - prId: requirePrId(value, "prs.pipelineSettings.save"), - settings: patch, - }; -} - -function parseConvergenceStatePatch(value: Record): { prId: string; state: PrConvergenceStatePatch } { - const raw = isRecord(value.state) ? value.state : value; - const patch: PrConvergenceStatePatch = {}; - const statuses = new Set(["idle", "launching", "running", "polling", "paused", "converged", "merged", "failed", "cancelled", "stopped"]); - const pollerStatuses = new Set(["idle", "scheduled", "polling", "waiting_for_checks", "waiting_for_comments", "paused", "stopped"]); - if (typeof raw.autoConvergeEnabled === "boolean") patch.autoConvergeEnabled = raw.autoConvergeEnabled; - const status = asTrimmedString(raw.status); - if (status && statuses.has(status)) patch.status = status as ConvergenceRuntimeState["status"]; - const pollerStatus = asTrimmedString(raw.pollerStatus); - if (pollerStatus && pollerStatuses.has(pollerStatus)) patch.pollerStatus = pollerStatus as ConvergenceRuntimeState["pollerStatus"]; - const currentRound = asOptionalNumber(raw.currentRound); - if (currentRound != null && currentRound >= 0) patch.currentRound = Math.floor(currentRound); - if (typeof raw.forceFinalizeUsed === "boolean") patch.forceFinalizeUsed = raw.forceFinalizeUsed; - const ciRetryAttemptsUsed = asOptionalNumber(raw.ciRetryAttemptsUsed); - if (ciRetryAttemptsUsed != null && ciRetryAttemptsUsed >= 0) patch.ciRetryAttemptsUsed = Math.floor(ciRetryAttemptsUsed); - const pauseRepeatCount = asOptionalNumber(raw.pauseRepeatCount); - if (pauseRepeatCount != null && pauseRepeatCount >= 0) patch.pauseRepeatCount = Math.floor(pauseRepeatCount); - for (const key of [ - "activeSessionId", - "activeLaneId", - "activeHref", - "pauseReason", - "errorMessage", - "waitForCiStartedAt", - "lastDispatchHeadSha", - "lastPauseReasonHash", - "lastStartedAt", - "lastPolledAt", - "lastPausedAt", - "lastStoppedAt", - ] as const) { - const next = raw[key]; - if (next === null || typeof next === "string") { - (patch as Record)[key] = next; - } - } - return { - prId: requirePrId(value, "prs.convergenceState.save"), - state: patch, - }; -} - -function mergeLaneDockerConfig( - current: { composePath?: string; services?: string[]; projectPrefix?: string } | undefined, - next: { composePath?: string; services?: string[]; projectPrefix?: string } | undefined, -) { - if (!current && !next) return undefined; - if (!current) return next ? { ...next, ...(next.services ? { services: [...next.services] } : {}) } : undefined; - if (!next) return { ...current, ...(current.services ? { services: [...current.services] } : {}) }; - return { - ...current, - ...next, - ...(next.services != null - ? { services: [...next.services] } - : current.services != null - ? { services: [...current.services] } - : {}), - }; -} - -function mergeLaneEnvInitConfig( - current: LaneEnvInitConfig | undefined, - next: LaneEnvInitConfig | undefined, -): LaneEnvInitConfig | undefined { - if (!current && !next) return undefined; - if (!current) { - return next - ? { - ...(next.envFiles ? { envFiles: [...next.envFiles] } : {}), - ...(mergeLaneDockerConfig(undefined, next.docker) ? { docker: mergeLaneDockerConfig(undefined, next.docker) } : {}), - ...(next.dependencies ? { dependencies: [...next.dependencies] } : {}), - ...(next.mountPoints ? { mountPoints: [...next.mountPoints] } : {}), - ...(next.copyPaths ? { copyPaths: [...next.copyPaths] } : {}), - } - : undefined; - } - if (!next) { - return { - ...(current.envFiles ? { envFiles: [...current.envFiles] } : {}), - ...(mergeLaneDockerConfig(undefined, current.docker) ? { docker: mergeLaneDockerConfig(undefined, current.docker) } : {}), - ...(current.dependencies ? { dependencies: [...current.dependencies] } : {}), - ...(current.mountPoints ? { mountPoints: [...current.mountPoints] } : {}), - ...(current.copyPaths ? { copyPaths: [...current.copyPaths] } : {}), - }; - } - return { - envFiles: [...(current.envFiles ?? []), ...(next.envFiles ?? [])], - ...(mergeLaneDockerConfig(current.docker, next.docker) ? { docker: mergeLaneDockerConfig(current.docker, next.docker) } : {}), - dependencies: [...(current.dependencies ?? []), ...(next.dependencies ?? [])], - mountPoints: [...(current.mountPoints ?? []), ...(next.mountPoints ?? [])], - copyPaths: [...(current.copyPaths ?? []), ...(next.copyPaths ?? [])], - }; -} - -function mergeLaneOverrides(base: LaneOverlayOverrides, next: Partial): LaneOverlayOverrides { - return { - ...base, - ...next, - ...(base.env || next.env ? { env: { ...(base.env ?? {}), ...(next.env ?? {}) } } : {}), - ...(base.processIds || next.processIds ? { processIds: [...(next.processIds ?? base.processIds ?? [])] } : {}), - ...(base.testSuiteIds || next.testSuiteIds ? { testSuiteIds: [...(next.testSuiteIds ?? base.testSuiteIds ?? [])] } : {}), - ...(mergeLaneEnvInitConfig(base.envInit, next.envInit) ? { envInit: mergeLaneEnvInitConfig(base.envInit, next.envInit) } : {}), - }; -} - -function applyLeaseToOverrides( - overrides: LaneOverlayOverrides, - lease: { status: string; rangeStart: number; rangeEnd: number } | null, -): LaneOverlayOverrides { - if (!lease || lease.status !== "active" || overrides.portRange) { - return { ...overrides }; - } - return { - ...overrides, - portRange: { start: lease.rangeStart, end: lease.rangeEnd }, - }; -} - -/** - * Strict resolver for identity-pinned sessions (CTO + worker agents). Never - * slips a foreign lane through via a `lanes[0]` fallback — if no primary lane - * exists, the caller must error out rather than silently host the identity on - * a non-primary lane. - */ -async function resolvePrimaryLaneIdOnlyForSync(args: SyncRemoteCommandServiceArgs): Promise { - await args.laneService.ensurePrimaryLane?.().catch(() => {}); - const lanes = await args.laneService.list({ includeArchived: false, includeStatus: false }); - return lanes.find((lane) => lane.laneType === "primary")?.id ?? ""; -} - -async function resolveLaneOverlayContext(args: SyncRemoteCommandServiceArgs, laneId: string) { - const projectConfigService = requireService(args.projectConfigService, "Project config service not available."); - const lanes = await args.laneService.list({ includeStatus: false }); - const lane = lanes.find((entry) => entry.id === laneId); - if (!lane) throw new Error(`Lane not found: ${laneId}`); - - const config = projectConfigService.getEffective(); - const overlayOverrides = matchLaneOverlayPolicies(lane, config.laneOverlayPolicies ?? []); - const lease = args.portAllocationService?.getLease(lane.id) ?? null; - const overrides = applyLeaseToOverrides(overlayOverrides, lease); - const envInitConfig = args.laneEnvironmentService?.resolveEnvInitConfig(config.laneEnvInit, overrides); - - return { - lane, - overrides, - envInitConfig, - }; -} - -async function resolveChatCreateArgs( - service: ReturnType, - payload: AgentChatCreateArgs, -): Promise { - if (payload.model.trim().length > 0) return payload; - const available = await service.getAvailableModels({ - provider: payload.provider, - ...(payload.provider === "opencode" ? { activateRuntime: true } : {}), - }); - const chosen = available[0]; - if (!chosen) { - throw new Error(`No configured ${payload.provider} chat model is available on the host.`); - } - return { - ...payload, - model: chosen.id, - ...(!payload.modelId && chosen.modelId ? { modelId: chosen.modelId } : {}), - }; -} - -function sessionStatusBucket(argsIn: { - status: string; - lastOutputPreview: string | null | undefined; - runtimeState?: string | null; -}): "running" | "awaiting-input" | "ended" { - if (argsIn.status === "running") { - if (argsIn.runtimeState === "waiting-input") return "awaiting-input"; - const preview = argsIn.lastOutputPreview ?? ""; - if (/\b(?:waiting|awaiting)\b.{0,28}\b(?:input|confirmation|response|prompt)\b/i.test(preview)) { - return "awaiting-input"; - } - if (/\((?:y\/n|yes\/no)\)/i.test(preview) || /\[(?:y\/n|yes\/no)\]/i.test(preview)) { - return "awaiting-input"; - } - return "running"; - } - return "ended"; -} - -function summarizeLaneRuntime( - laneId: string, - sessions: Array<{ - laneId: string; - status: string; - lastOutputPreview: string | null; - runtimeState?: string | null; - }>, -): LaneListSnapshot["runtime"] { - let runningCount = 0; - let awaitingInputCount = 0; - let endedCount = 0; - let sessionCount = 0; - for (const session of sessions) { - if (session.laneId !== laneId) continue; - sessionCount += 1; - const bucket = sessionStatusBucket(session); - if (bucket === "running") runningCount += 1; - else if (bucket === "awaiting-input") awaitingInputCount += 1; - else endedCount += 1; - } - const bucket = runningCount > 0 - ? "running" - : awaitingInputCount > 0 - ? "awaiting-input" - : endedCount > 0 - ? "ended" - : "none"; - return { - bucket, - runningCount, - awaitingInputCount, - endedCount, - sessionCount, - }; -} - -async function buildLaneListSnapshots( - args: SyncRemoteCommandServiceArgs, - lanes: Awaited["list"]>>, -): Promise { - const [sessions, rebaseSuggestions, autoRebaseStatuses, stateSnapshots, batchAssessment] = await Promise.all([ - Promise.resolve(args.sessionService.list({ limit: 500 })), - Promise.resolve(args.rebaseSuggestionService?.listSuggestions() ?? []), - Promise.resolve(args.autoRebaseService?.listStatuses() ?? []), - Promise.resolve(args.laneService.listStateSnapshots()), - args.conflictService?.getBatchAssessment({ lanes }).catch(() => null) ?? Promise.resolve(null), - ]); - - const rebaseByLaneId = new Map(rebaseSuggestions.map((entry) => [entry.laneId, entry] as const)); - const autoRebaseByLaneId = new Map(autoRebaseStatuses.map((entry) => [entry.laneId, entry] as const)); - const stateByLaneId = new Map(stateSnapshots.map((entry) => [entry.laneId, entry] as const)); - const conflictByLaneId = new Map((batchAssessment?.lanes ?? []).map((entry) => [entry.laneId, entry] as const)); - - return lanes.map((lane) => ({ - lane, - runtime: summarizeLaneRuntime(lane.id, sessions), - rebaseSuggestion: rebaseByLaneId.get(lane.id) ?? null, - autoRebaseStatus: autoRebaseByLaneId.get(lane.id) ?? null, - conflictStatus: conflictByLaneId.get(lane.id) ?? null, - stateSnapshot: stateByLaneId.get(lane.id) ?? null, - adoptableAttached: lane.laneType === "attached" && lane.archivedAt == null, - })); -} - -async function buildLaneDetailPayload(args: SyncRemoteCommandServiceArgs, laneId: string): Promise { - const lane = (await args.laneService.list({ includeArchived: true, includeStatus: true })).find((entry) => entry.id === laneId) ?? null; - if (!lane) throw new Error(`Lane not found: ${laneId}`); - - const [ - stackChain, - children, - sessions, - chatSessions, - rebaseSuggestions, - autoRebaseStatuses, - stateSnapshot, - recentCommits, - diffChanges, - stashes, - syncStatus, - conflictState, - conflictStatus, - overlaps, - envInitProgress, - ] = await Promise.all([ - args.laneService.getStackChain(laneId), - args.laneService.getChildren(laneId), - Promise.resolve(args.sessionService.list({ laneId, limit: 200 })), - args.agentChatService?.listSessions(laneId, { includeAutomation: true }) ?? Promise.resolve([]), - Promise.resolve(args.rebaseSuggestionService?.listSuggestions() ?? []), - Promise.resolve(args.autoRebaseService?.listStatuses() ?? []), - Promise.resolve(args.laneService.getStateSnapshot(laneId)), - args.gitService?.listRecentCommits({ laneId, limit: 20 }) ?? Promise.resolve([]), - args.diffService?.getChanges(laneId).catch(() => null) ?? Promise.resolve(null), - args.gitService?.listStashes({ laneId }) ?? Promise.resolve([]), - args.gitService?.getSyncStatus({ laneId }).catch(() => null) ?? Promise.resolve(null), - args.gitService?.getConflictState({ laneId }).catch(() => null) ?? Promise.resolve(null), - args.conflictService?.getLaneStatus({ laneId }).catch(() => null) ?? Promise.resolve(null), - args.conflictService?.listOverlaps({ laneId }).catch(() => []) ?? Promise.resolve([]), - Promise.resolve(args.laneEnvironmentService?.getProgress(laneId) ?? null), - ]); - - return { - lane, - runtime: summarizeLaneRuntime(laneId, sessions), - stackChain, - children, - stateSnapshot: stateSnapshot as LaneStateSnapshotSummary | null, - rebaseSuggestion: rebaseSuggestions.find((entry) => entry.laneId === laneId) ?? null, - autoRebaseStatus: autoRebaseStatuses.find((entry) => entry.laneId === laneId) ?? null, - conflictStatus, - overlaps, - syncStatus, - conflictState, - recentCommits, - diffChanges, - stashes, - envInitProgress, - sessions, - chatSessions, - }; -} - -export function createSyncRemoteCommandService(args: SyncRemoteCommandServiceArgs) { - const registry = new Map(); - - const register = ( - action: SyncRemoteCommandAction, - policy: SyncRemoteCommandPolicy, - handler: (payload: Record) => Promise, - ) => { - registry.set(action, { - descriptor: { action, policy }, - handler, - }); - }; - - register("lanes.list", { viewerAllowed: true }, async (payload) => args.laneService.list(parseListLanesArgs(payload))); - register("lanes.refreshSnapshots", { viewerAllowed: true }, async (payload) => { - const refreshed = await args.laneService.refreshSnapshots(parseListLanesArgs(payload)); - return { - ...refreshed, - snapshots: await buildLaneListSnapshots(args, refreshed.lanes), - }; - }); - register("lanes.getDetail", { viewerAllowed: true }, async (payload) => - buildLaneDetailPayload(args, requireString(payload.laneId, "lanes.getDetail requires laneId."))); - register("lanes.create", { viewerAllowed: true, queueable: true }, async (payload) => args.laneService.create(parseCreateLaneArgs(payload))); - register("lanes.createChild", { viewerAllowed: true, queueable: true }, async (payload) => args.laneService.createChild(parseCreateChildLaneArgs(payload))); - register("lanes.createFromUnstaged", { viewerAllowed: true, queueable: true }, async (payload) => - args.laneService.createFromUnstaged(parseCreateLaneFromUnstagedArgs(payload))); - register("lanes.importBranch", { viewerAllowed: true, queueable: true }, async (payload) => - args.laneService.importBranch(parseImportBranchArgs(payload))); - register("lanes.previewBranchSwitch", { viewerAllowed: true }, async (payload) => - args.laneService.previewBranchSwitch(parseGitCheckoutBranchArgs(payload))); - register("lanes.attach", { viewerAllowed: true, queueable: true }, async (payload) => args.laneService.attach(parseAttachLaneArgs(payload))); - register("lanes.adoptAttached", { viewerAllowed: true, queueable: true }, async (payload) => - args.laneService.adoptAttached({ laneId: requireString(payload.laneId, "lanes.adoptAttached requires laneId.") })); - register("lanes.rename", { viewerAllowed: true, queueable: true }, async (payload) => { - args.laneService.rename(parseRenameLaneArgs(payload)); - return { ok: true }; - }); - register("lanes.reparent", { viewerAllowed: true, queueable: true }, async (payload) => - args.laneService.reparent(parseReparentLaneArgs(payload))); - register("lanes.updateAppearance", { viewerAllowed: true, queueable: true }, async (payload) => { - args.laneService.updateAppearance(parseUpdateLaneAppearanceArgs(payload)); - return { ok: true }; - }); - register("lanes.archive", { viewerAllowed: true, queueable: true }, async (payload) => { - await args.laneService.archive(parseArchiveLaneArgs(payload, "lanes.archive")); - return { ok: true }; - }); - register("lanes.unarchive", { viewerAllowed: true, queueable: true }, async (payload) => { - await args.laneService.unarchive(parseArchiveLaneArgs(payload, "lanes.unarchive")); - return { ok: true }; - }); - register("lanes.delete", { viewerAllowed: true, queueable: true }, async (payload) => { - await args.laneService.delete(parseDeleteLaneArgs(payload)); - return { ok: true }; - }); - register("lanes.getStackChain", { viewerAllowed: true }, async (payload) => - args.laneService.getStackChain(requireString(payload.laneId, "lanes.getStackChain requires laneId."))); - register("lanes.getChildren", { viewerAllowed: true }, async (payload) => - args.laneService.getChildren(requireString(payload.laneId, "lanes.getChildren requires laneId."))); - register("lanes.rebaseStart", { viewerAllowed: true, queueable: true }, async (payload) => args.laneService.rebaseStart(parseRebaseStartArgs(payload))); - register("lanes.rebasePush", { viewerAllowed: true, queueable: true }, async (payload) => args.laneService.rebasePush(parseRebasePushArgs(payload))); - register("lanes.rebaseRollback", { viewerAllowed: true, queueable: true }, async (payload) => args.laneService.rebaseRollback(parseRunIdArgs(payload, "lanes.rebaseRollback"))); - register("lanes.rebaseAbort", { viewerAllowed: true, queueable: true }, async (payload) => args.laneService.rebaseAbort(parseRunIdArgs(payload, "lanes.rebaseAbort"))); - register("lanes.listRebaseSuggestions", { viewerAllowed: true }, async () => args.rebaseSuggestionService?.listSuggestions() ?? []); - register("lanes.dismissRebaseSuggestion", { viewerAllowed: true, queueable: true }, async (payload) => { - const laneId = requireString(payload.laneId, "lanes.dismissRebaseSuggestion requires laneId."); - args.conflictService?.dismissRebase(laneId); - if (args.rebaseSuggestionService) { - await args.rebaseSuggestionService.dismiss({ laneId }); - } - return { ok: true }; - }); - register("lanes.deferRebaseSuggestion", { viewerAllowed: true, queueable: true }, async (payload) => { - const laneId = requireString(payload.laneId, "lanes.deferRebaseSuggestion requires laneId."); - const minutes = Math.max(5, Math.min(7 * 24 * 60, Math.floor(asOptionalNumber(payload.minutes) ?? 60))); - const until = new Date(Date.now() + minutes * 60_000).toISOString(); - args.conflictService?.deferRebase(laneId, until); - if (args.rebaseSuggestionService) { - await args.rebaseSuggestionService.defer({ - laneId, - minutes, - }); - } - return { ok: true }; - }); - register("lanes.listAutoRebaseStatuses", { viewerAllowed: true }, async () => args.autoRebaseService?.listStatuses() ?? []); - register("lanes.dismissAutoRebaseStatus", { viewerAllowed: true, queueable: true }, async (payload) => { - if (!args.autoRebaseService) return { ok: true }; - await args.autoRebaseService.dismissStatus({ - laneId: requireString(payload.laneId, "lanes.dismissAutoRebaseStatus requires laneId."), - }); - return { ok: true }; - }); - register("lanes.listTemplates", { viewerAllowed: true }, async () => args.laneTemplateService?.listTemplates() ?? []); - register("lanes.getDefaultTemplate", { viewerAllowed: true }, async () => args.laneTemplateService?.getDefaultTemplateId() ?? null); - register("lanes.getEnvStatus", { viewerAllowed: true }, async (payload) => args.laneEnvironmentService?.getProgress(requireString(payload.laneId, "lanes.getEnvStatus requires laneId.")) ?? null); - register("lanes.initEnv", { viewerAllowed: true, queueable: true }, async (payload) => { - const laneEnvironmentService = requireService(args.laneEnvironmentService, "Lane environment service not available."); - const laneId = requireString(payload.laneId, "lanes.initEnv requires laneId."); - const context = await resolveLaneOverlayContext(args, laneId); - if (!context.envInitConfig) { - const now = new Date().toISOString(); - return { - laneId, - steps: [], - startedAt: now, - completedAt: now, - overallStatus: "completed", - } satisfies LaneEnvInitProgress; - } - return await laneEnvironmentService.initLaneEnvironment(context.lane, context.envInitConfig, context.overrides); - }); - register("lanes.applyTemplate", { viewerAllowed: true, queueable: true }, async (payload) => { - const laneTemplateService = requireService(args.laneTemplateService, "Lane template service not available."); - const laneEnvironmentService = requireService(args.laneEnvironmentService, "Lane environment service not available."); - const parsed = { - laneId: requireString(payload.laneId, "lanes.applyTemplate requires laneId."), - templateId: requireString(payload.templateId, "lanes.applyTemplate requires templateId."), - } satisfies ApplyLaneTemplateArgs; - const context = await resolveLaneOverlayContext(args, parsed.laneId); - const template = laneTemplateService.getTemplate(parsed.templateId); - if (!template) throw new Error(`Template not found: ${parsed.templateId}`); - const templateEnvInit = laneTemplateService.resolveTemplateAsEnvInit(template); - const mergedOverrides = mergeLaneOverrides(context.overrides, { - ...(template.envVars ? { env: template.envVars } : {}), - ...(!context.overrides.portRange && template.portRange ? { portRange: template.portRange } : {}), - envInit: templateEnvInit, - }); - const mergedEnvInitConfig = mergeLaneEnvInitConfig(context.envInitConfig, templateEnvInit) ?? templateEnvInit; - return await laneEnvironmentService.initLaneEnvironment(context.lane, mergedEnvInitConfig, mergedOverrides); - }); - - register("work.listSessions", { viewerAllowed: true }, async (payload) => listRemoteWorkSessions(args, parseListSessionsArgs(payload))); - register("work.updateSessionMeta", { viewerAllowed: true, queueable: true }, async (payload) => { - args.sessionService.updateMeta(parseUpdateSessionMetaArgs(payload)); - return { ok: true }; - }); - register("work.runQuickCommand", { viewerAllowed: true, queueable: true }, async (payload) => { - const parsed = parseQuickCommandArgs(payload); - return await args.ptyService.create({ - laneId: parsed.laneId, - title: parsed.title, - ...(parsed.toolType === "shell" || !parsed.startupCommand ? {} : { startupCommand: parsed.startupCommand }), - tracked: parsed.tracked ?? true, - cols: parsed.cols ?? 120, - rows: parsed.rows ?? 36, - toolType: (parsed.toolType ?? "run-shell") as TerminalToolType, - }); - }); - register("work.startCliSession", { viewerAllowed: true, queueable: true }, async (payload) => { - const parsed = parseStartCliSessionArgs(payload); - const cols = clampCliDimension(parsed.cols, DEFAULT_CLI_COLS, 20, 240); - const rows = clampCliDimension(parsed.rows, DEFAULT_CLI_ROWS, 4, 120); - const resumeSessionId = parsed.resumeSessionId?.trim() || undefined; - const { provider } = parsed; - const permissionMode = parsed.permissionMode ?? "default"; - validateLaunchProfilePermissionMode(provider, permissionMode); - const resumeSession = resumeSessionId - ? requireResumeSessionForProvider(args.sessionService, resumeSessionId, provider) - : null; - const toolType = LAUNCH_PROFILE_TOOL_TYPE[provider] as TerminalToolType; - const title = parsed.title?.trim() || LAUNCH_PROFILE_TITLE[provider]; - const preassignedSessionId = provider === "claude" && !resumeSessionId ? randomUUID() : undefined; - - function resolveLaunch(): { startupCommand?: string; command?: string; args?: string[]; env?: Record } { - if (provider === "shell") return {}; - if (resumeSessionId) { - if (!resumeSession) throw new Error(`work.startCliSession resumeSessionId '${resumeSessionId}' was not found.`); - const startupCommand = resolveTrackedCliResumeCommand(resumeSession) - ?? buildTrackedCliResumeCommand({ - provider, - targetKind: "session", - targetId: null, - launch: { permissionMode }, - }); - return { startupCommand }; - } - return buildTrackedCliLaunchCommand({ provider, permissionMode, sessionId: preassignedSessionId }); - } - - const sessionId = resumeSessionId ?? preassignedSessionId; - const result = await args.ptyService.create({ - ...(sessionId ? { sessionId } : {}), - allowNewSessionId: Boolean(preassignedSessionId), - laneId: parsed.laneId, - title, - tracked: true, - toolType, - cols, - rows, - ...resolveLaunch(), - }); - - if (parsed.initialInput && provider !== "shell") { - const written = args.ptyService.writeBySessionId(result.sessionId, `${parsed.initialInput}\r`); - if (!written) { - try { - args.ptyService.dispose({ ptyId: result.ptyId, sessionId: result.sessionId }); - } catch (err) { - args.logger.warn("sync_remote.start_cli_session_initial_input_cleanup_failed", { - sessionId: result.sessionId, - err: String(err), - }); - } - throw new Error("work.startCliSession created a terminal session but could not write initialInput."); - } - } - - const session = args.sessionService.get(result.sessionId); - const enriched = session ? args.ptyService.enrichSessions([session])[0] ?? session : null; - return { - sessionId: result.sessionId, - ptyId: result.ptyId, - session: enriched, - } satisfies SyncStartCliSessionResult; - }); - register("work.closeSession", { viewerAllowed: true, queueable: true }, async (payload) => { - const { sessionId } = parseCloseSessionArgs(payload); - const session = args.sessionService.get(sessionId); - if (session?.ptyId) { - await args.ptyService.dispose({ ptyId: session.ptyId, sessionId }); - } - return { ok: true }; - }); - - register("processes.listDefinitions", { viewerAllowed: true }, async () => - requireService(args.processService, "Process service not available.").listDefinitions()); - register("processes.listRuntime", { viewerAllowed: true }, async (payload) => - requireService(args.processService, "Process service not available.").listRuntime( - parseProcessLaneArgs(payload, "processes.listRuntime").laneId, - )); - register("processes.start", { viewerAllowed: true, queueable: true }, async (payload) => - requireService(args.processService, "Process service not available.").start( - parseProcessActionArgs(payload, "processes.start"), - )); - register("processes.stop", { viewerAllowed: true, queueable: true }, async (payload) => - requireService(args.processService, "Process service not available.").stop( - parseProcessActionArgs(payload, "processes.stop"), - )); - register("processes.kill", { viewerAllowed: true, queueable: false }, async (payload) => - requireService(args.processService, "Process service not available.").kill( - parseProcessActionArgs(payload, "processes.kill"), - )); - - register("chat.listSessions", { viewerAllowed: true }, async (payload) => { - const agentChatService = requireService(args.agentChatService, "Agent chat service not available."); - const parsed = parseAgentChatListArgs(payload); - return agentChatService.listSessions(parsed.laneId, { includeAutomation: parsed.includeAutomation }); - }); - register("chat.getSummary", { viewerAllowed: true }, async (payload) => - requireService(args.agentChatService, "Agent chat service not available.").getSessionSummary(parseAgentChatGetSummaryArgs(payload).sessionId)); - register("chat.getTranscript", { viewerAllowed: true }, async (payload) => - requireService(args.agentChatService, "Agent chat service not available.").getChatTranscript(parseGetTranscriptArgs(payload))); - register("chat.create", { viewerAllowed: true, queueable: true }, async (payload) => { - const agentChatService = requireService(args.agentChatService, "Agent chat service not available."); - const parsed = parseAgentChatCreateArgs(payload); - const session = await agentChatService.createSession(await resolveChatCreateArgs(agentChatService, parsed)); - return summarizeChatSessionForRemote(agentChatService, session); - }); - register("chat.send", { viewerAllowed: true, queueable: true }, async (payload) => { - await requireService(args.agentChatService, "Agent chat service not available.").sendMessage( - parseAgentChatSendArgs(payload), - { awaitDispatch: true }, - ); - return { ok: true }; - }); - register("chat.interrupt", { viewerAllowed: true, queueable: false }, async (payload) => { - await requireService(args.agentChatService, "Agent chat service not available.").interrupt(parseAgentChatInterruptArgs(payload)); - return { ok: true }; - }); - register("chat.steer", { viewerAllowed: true, queueable: false }, async (payload) => { - await requireService(args.agentChatService, "Agent chat service not available.").steer(parseAgentChatSteerArgs(payload)); - return { ok: true }; - }); - register("chat.cancelSteer", { viewerAllowed: true, queueable: false }, async (payload) => { - await requireService(args.agentChatService, "Agent chat service not available.").cancelSteer(parseAgentChatCancelSteerArgs(payload)); - return { ok: true }; - }); - register("chat.editSteer", { viewerAllowed: true, queueable: false }, async (payload) => { - await requireService(args.agentChatService, "Agent chat service not available.").editSteer(parseAgentChatEditSteerArgs(payload)); - return { ok: true }; - }); - register("chat.dispatchSteer", { viewerAllowed: true, queueable: false }, async (payload) => { - const result = await requireService(args.agentChatService, "Agent chat service not available.").dispatchSteer(parseAgentChatDispatchSteerArgs(payload)); - return { ok: true, dispatchedAt: result.dispatchedAt }; - }); - register("chat.cancelDispatchedSteer", { viewerAllowed: true, queueable: false }, async (payload) => { - const result = await requireService(args.agentChatService, "Agent chat service not available.").cancelDispatchedSteer(parseAgentChatCancelDispatchedSteerArgs(payload)); - return { ok: true, cancelled: result.cancelled }; - }); - register("chat.approve", { viewerAllowed: true, queueable: false }, async (payload) => { - await requireService(args.agentChatService, "Agent chat service not available.").approveToolUse(parseAgentChatApproveArgs(payload)); - return { ok: true }; - }); - register("chat.respondToInput", { viewerAllowed: true, queueable: false }, async (payload) => { - await requireService(args.agentChatService, "Agent chat service not available.").respondToInput(parseAgentChatRespondToInputArgs(payload)); - return { ok: true }; - }); - register("chat.resume", { viewerAllowed: true, queueable: true }, async (payload) => - requireService(args.agentChatService, "Agent chat service not available.").resumeSession(parseAgentChatResumeArgs(payload))); - // Restart: fired by iOS Live Activity + Attention Drawer "Restart" pill on - // a failed agent. Alias to resumeSession — same runtime-rewire behaviour. - // Keep as a distinct action name so telemetry can distinguish explicit - // restart intent from ordinary resume. - register("chat.restart", { viewerAllowed: true, queueable: true }, async (payload) => - requireService(args.agentChatService, "Agent chat service not available.").resumeSession(parseAgentChatResumeArgs(payload))); - register("chat.updateSession", { viewerAllowed: true, queueable: true }, async (payload) => - requireService(args.agentChatService, "Agent chat service not available.").updateSession(parseAgentChatUpdateSessionArgs(payload))); - register("chat.dispose", { viewerAllowed: true, queueable: true }, async (payload) => { - await requireService(args.agentChatService, "Agent chat service not available.").dispose(parseAgentChatDisposeArgs(payload)); - return { ok: true }; - }); - register("chat.archive", { viewerAllowed: true, queueable: true }, async (payload) => { - await requireService(args.agentChatService, "Agent chat service not available.").archiveSession(parseAgentChatArchiveArgs(payload, "chat.archive")); - return { ok: true }; - }); - register("chat.unarchive", { viewerAllowed: true, queueable: true }, async (payload) => { - await requireService(args.agentChatService, "Agent chat service not available.").unarchiveSession(parseAgentChatArchiveArgs(payload, "chat.unarchive")); - return { ok: true }; - }); - register("chat.delete", { viewerAllowed: true, queueable: true }, async (payload) => { - await requireService(args.agentChatService, "Agent chat service not available.").deleteSession(parseAgentChatArchiveArgs(payload, "chat.delete")); - return { ok: true }; - }); - register("chat.models", { viewerAllowed: true }, async (payload) => - requireService(args.agentChatService, "Agent chat service not available.").getAvailableModels(parseChatModelsArgs(payload))); - register("chat.modelCatalog", { viewerAllowed: true }, async () => - requireService(args.agentChatService, "Agent chat service not available.").getModelCatalog()); - - register("cto.getRoster", { viewerAllowed: true }, async () => { - const agentChatService = requireService(args.agentChatService, "Agent chat service not available."); - const workerAgentService = requireService(args.workerAgentService, "Worker agent service not available."); - const sessions = await agentChatService.listSessions(undefined, { includeIdentity: true }); - const activityTimestamp = (value: string | null | undefined): number => { - if (!value) return 0; - const parsed = Date.parse(value); - return Number.isFinite(parsed) ? parsed : 0; - }; - const sortedByRecency = [...sessions].sort( - (a, b) => activityTimestamp(b.lastActivityAt) - activityTimestamp(a.lastActivityAt), - ); - const ctoSummary = sortedByRecency.find((entry) => entry.identityKey === "cto") ?? null; - const agents = workerAgentService.listAgents(); - const knownAgentIds = new Set(agents.map((agent) => agent.id)); - const liveWorkers = agents.map((agent) => { - const sessionSummary = sortedByRecency.find( - (entry) => entry.identityKey === `agent:${agent.id}`, - ) ?? null; - return { - agentId: agent.id, - name: agent.name, - avatarSeed: agent.slug || null, - status: agent.status as string, - sessionSummary, - }; - }); - // Include agent: sessions whose identity is no longer in the roster - // so mobile users can still see / resume orphan chats. These are marked - // with a synthetic "orphaned" status and no avatar seed. - const orphanPrefix = "agent:"; - const orphanWorkers: typeof liveWorkers = []; - const seenOrphanIds = new Set(); - for (const entry of sortedByRecency) { - const key = entry.identityKey ?? ""; - if (!key.startsWith(orphanPrefix)) continue; - const agentId = key.slice(orphanPrefix.length); - if (!agentId.length) continue; - if (knownAgentIds.has(agentId)) continue; - if (seenOrphanIds.has(agentId)) continue; - seenOrphanIds.add(agentId); - orphanWorkers.push({ - agentId, - name: agentId, - avatarSeed: null, - status: "orphaned", - sessionSummary: entry, - }); - } - liveWorkers.sort((a, b) => a.name.localeCompare(b.name)); - orphanWorkers.sort((a, b) => a.name.localeCompare(b.name)); - const workers = [...liveWorkers, ...orphanWorkers]; - return { cto: ctoSummary, workers }; - }); - register("cto.ensureSession", { viewerAllowed: true }, async (payload) => { - const agentChatService = requireService(args.agentChatService, "Agent chat service not available."); - const laneId = await resolvePrimaryLaneIdOnlyForSync(args); - if (!laneId) throw new Error("No primary lane is available to host the CTO chat session."); - const modelId = asTrimmedString(payload.modelId); - const reasoningEffort = asTrimmedString(payload.reasoningEffort); - const session = await agentChatService.ensureIdentitySession({ - identityKey: "cto", - laneId, - modelId: modelId ?? null, - reasoningEffort: reasoningEffort ?? null, - permissionMode: "full-auto", - }); - return summarizeChatSessionForRemote(agentChatService, session); - }); - register("cto.ensureAgentSession", { viewerAllowed: true }, async (payload) => { - const agentChatService = requireService(args.agentChatService, "Agent chat service not available."); - const workerAgentService = requireService(args.workerAgentService, "Worker agent service not available."); - const agentId = requireString(payload.agentId, "cto.ensureAgentSession requires agentId."); - // Reject unknown agentIds before we spin up an identity-bound session — - // otherwise clients could spawn orphan `agent:` sessions for agents - // that don't exist. - const agent = typeof workerAgentService.getAgent === "function" - ? workerAgentService.getAgent(agentId) - : workerAgentService.listAgents().find((entry) => entry.id === agentId) ?? null; - if (!agent) { - throw new Error(`cto.ensureAgentSession: unknown agentId '${agentId}'`); - } - const laneId = await resolvePrimaryLaneIdOnlyForSync(args); - if (!laneId) throw new Error("No primary lane is available to host the agent chat session."); - const modelId = asTrimmedString(payload.modelId); - const reasoningEffort = asTrimmedString(payload.reasoningEffort); - const session = await agentChatService.ensureIdentitySession({ - identityKey: `agent:${agentId}`, - laneId, - modelId: modelId ?? null, - reasoningEffort: reasoningEffort ?? null, - permissionMode: "full-auto", - }); - return summarizeChatSessionForRemote(agentChatService, session); - }); - - register("cto.getState", { viewerAllowed: true }, async (payload) => { - const ctoStateService = requireService(args.ctoStateService, "CTO state service not available."); - const recentLimit = asOptionalNumber(payload.recentLimit); - return ctoStateService.getSnapshot(recentLimit ?? 20); - }); - register("cto.listAgents", { viewerAllowed: true }, async (payload) => { - const workerAgentService = requireService(args.workerAgentService, "Worker agent service not available."); - const includeDeleted = asOptionalBoolean(payload.includeDeleted); - return workerAgentService.listAgents(includeDeleted === undefined ? {} : { includeDeleted }); - }); - register("cto.getBudgetSnapshot", { viewerAllowed: true }, async (payload) => { - const workerBudgetService = requireService(args.workerBudgetService, "Worker budget service not available."); - const monthKey = asTrimmedString(payload.monthKey); - return workerBudgetService.getBudgetSnapshot(monthKey ? { monthKey } : {}); - }); - register("cto.getAgentCoreMemory", { viewerAllowed: true }, async (payload) => { - const workerHeartbeatService = requireService(args.workerHeartbeatService, "Worker heartbeat service not available."); - const agentId = requireString(payload.agentId, "cto.getAgentCoreMemory requires agentId."); - return workerHeartbeatService.getAgentCoreMemory(agentId); - }); - register("cto.listAgentRuns", { viewerAllowed: true }, async (payload) => { - const workerHeartbeatService = requireService(args.workerHeartbeatService, "Worker heartbeat service not available."); - const agentId = requireString(payload.agentId, "cto.listAgentRuns requires agentId."); - const limit = asOptionalNumber(payload.limit); - return workerHeartbeatService.listRuns({ agentId, ...(typeof limit === "number" ? { limit } : {}) }); - }); - register("cto.listAgentSessionLogs", { viewerAllowed: true }, async (payload) => { - const workerHeartbeatService = requireService(args.workerHeartbeatService, "Worker heartbeat service not available."); - const agentId = requireString(payload.agentId, "cto.listAgentSessionLogs requires agentId."); - const limit = asOptionalNumber(payload.limit); - return workerHeartbeatService.listAgentSessionLogs(agentId, limit ?? 40); - }); - register("cto.listAgentRevisions", { viewerAllowed: true }, async (payload) => { - const workerRevisionService = requireService(args.workerRevisionService, "Worker revision service not available."); - const agentId = requireString(payload.agentId, "cto.listAgentRevisions requires agentId."); - const limit = asOptionalNumber(payload.limit); - return workerRevisionService.listAgentRevisions(agentId, limit ?? 20); - }); - register("cto.getFlowPolicy", { viewerAllowed: true }, async () => { - const flowPolicyService = requireService(args.flowPolicyService, "Flow policy service not available."); - return flowPolicyService.getPolicy(); - }); - register("cto.getLinearConnectionStatus", { viewerAllowed: true }, async () => { - const linearCredentialService = requireService(args.linearCredentialService, "Linear credential service not available."); - const credentialStatus = linearCredentialService.getStatus(); - const tokenStored = Boolean(credentialStatus.tokenStored); - const checkedAt = new Date().toISOString(); - const linearIssueTracker = args.getLinearIssueTracker?.() ?? null; - if (!linearIssueTracker || !tokenStored) { - return { - tokenStored, - connected: false, - viewerId: null, - viewerName: null, - checkedAt, - authMode: credentialStatus.authMode, - oauthAvailable: credentialStatus.oauthConfigured, - tokenExpiresAt: credentialStatus.tokenExpiresAt, - message: tokenStored ? "Linear tracker service unavailable." : "Linear token not configured.", - }; - } - const status = await linearIssueTracker.getConnectionStatus(); - return { - tokenStored, - connected: status.connected, - viewerId: status.viewerId, - viewerName: status.viewerName, - organizationId: status.organizationId, - organizationName: status.organizationName, - organizationUrlKey: status.organizationUrlKey, - organizationLogoUrl: status.organizationLogoUrl, - checkedAt, - authMode: credentialStatus.authMode, - oauthAvailable: credentialStatus.oauthConfigured, - tokenExpiresAt: credentialStatus.tokenExpiresAt, - message: status.message, - }; - }); - register("cto.getLinearSyncDashboard", { viewerAllowed: true }, async () => { - const linearSyncService = requireService(args.getLinearSyncService?.() ?? null, "Linear sync service not available."); - return linearSyncService.getDashboard(); - }); - register("cto.listLinearSyncQueue", { viewerAllowed: true }, async () => { - const linearSyncService = requireService(args.getLinearSyncService?.() ?? null, "Linear sync service not available."); - return linearSyncService.listQueue({ limit: 300 }); - }); - register("cto.listLinearIngressEvents", { viewerAllowed: true }, async (payload) => { - const linearIngressService = requireService(args.getLinearIngressService?.() ?? null, "Linear ingress service not available."); - const limit = asOptionalNumber(payload.limit); - return linearIngressService.listRecentEvents(limit ?? 20); - }); - register("cto.updateIdentity", { viewerAllowed: true, queueable: true }, async (payload) => { - const ctoStateService = requireService(args.ctoStateService, "CTO state service not available."); - const patch = isRecord(payload.patch) ? (payload.patch as Partial) : {}; - return ctoStateService.updateIdentity(patch); - }); - register("cto.updateCoreMemory", { viewerAllowed: true, queueable: true }, async (payload) => { - const ctoStateService = requireService(args.ctoStateService, "CTO state service not available."); - const patch = isRecord(payload.patch) ? (payload.patch as Partial) : {}; - return ctoStateService.updateCoreMemory(patch); - }); - register("cto.setAgentStatus", { viewerAllowed: true, queueable: true }, async (payload) => { - const workerAgentService = requireService(args.workerAgentService, "Worker agent service not available."); - const agentId = requireString(payload.agentId, "cto.setAgentStatus requires agentId."); - const status = requireString(payload.status, "cto.setAgentStatus requires status.") as AgentStatus; - workerAgentService.setAgentStatus(agentId, status); - return {}; - }); - register("cto.triggerAgentWakeup", { viewerAllowed: true, queueable: true }, async (payload) => { - const workerHeartbeatService = requireService(args.workerHeartbeatService, "Worker heartbeat service not available."); - const agentId = requireString(payload.agentId, "cto.triggerAgentWakeup requires agentId."); - const reason = asTrimmedString(payload.reason); - const context = isRecord(payload.context) ? payload.context : undefined; - return workerHeartbeatService.triggerWakeup({ - agentId, - ...(reason ? { reason: reason as CtoTriggerAgentWakeupArgs["reason"] } : {}), - ...(context ? { context } : {}), - }); - }); - register("cto.rollbackAgentRevision", { viewerAllowed: true, queueable: true }, async (payload) => { - const workerRevisionService = requireService(args.workerRevisionService, "Worker revision service not available."); - const agentId = requireString(payload.agentId, "cto.rollbackAgentRevision requires agentId."); - const revisionId = requireString(payload.revisionId, "cto.rollbackAgentRevision requires revisionId."); - await workerRevisionService.rollbackAgentRevision(agentId, revisionId, "user"); - return {}; - }); - - register("git.getChanges", { viewerAllowed: true }, async (payload) => - requireService(args.diffService, "Diff service not available.").getChanges(parseGetDiffChangesArgs(payload).laneId)); - register("git.getFile", { viewerAllowed: true }, async (payload) => { - const diffService = requireService(args.diffService, "Diff service not available."); - const parsed = parseGetFileDiffArgs(payload); - return await diffService.getFileDiff({ - laneId: parsed.laneId, - filePath: parsed.path, - mode: parsed.mode, - compareRef: parsed.compareRef, - compareTo: parsed.compareTo, - }); - }); - register("files.writeTextAtomic", { viewerAllowed: true, queueable: true }, async (payload) => { - const parsed = parseWriteTextAtomicArgs(payload); - args.fileService.writeTextAtomic({ laneId: parsed.laneId, relPath: parsed.path, text: parsed.text }); - return { ok: true }; - }); - register("git.stageFile", { viewerAllowed: true, queueable: true }, async (payload) => - requireService(args.gitService, "Git service not available.").stageFile(parseGitFileActionArgs(payload, "git.stageFile"))); - register("git.stageAll", { viewerAllowed: true, queueable: true }, async (payload) => - requireService(args.gitService, "Git service not available.").stageAll(parseGitBatchFileActionArgs(payload, "git.stageAll"))); - register("git.unstageFile", { viewerAllowed: true, queueable: true }, async (payload) => - requireService(args.gitService, "Git service not available.").unstageFile(parseGitFileActionArgs(payload, "git.unstageFile"))); - register("git.unstageAll", { viewerAllowed: true, queueable: true }, async (payload) => - requireService(args.gitService, "Git service not available.").unstageAll(parseGitBatchFileActionArgs(payload, "git.unstageAll"))); - register("git.discardFile", { viewerAllowed: true, queueable: true }, async (payload) => - requireService(args.gitService, "Git service not available.").discardFile(parseGitFileActionArgs(payload, "git.discardFile"))); - register("git.restoreStagedFile", { viewerAllowed: true, queueable: true }, async (payload) => - requireService(args.gitService, "Git service not available.").restoreStagedFile(parseGitFileActionArgs(payload, "git.restoreStagedFile"))); - register("git.commit", { viewerAllowed: true, queueable: true }, async (payload) => - requireService(args.gitService, "Git service not available.").commit(parseGitCommitArgs(payload))); - register("git.generateCommitMessage", { viewerAllowed: true }, async (payload) => - requireService(args.gitService, "Git service not available.").generateCommitMessage(parseGitGenerateCommitMessageArgs(payload))); - register("git.listRecentCommits", { viewerAllowed: true }, async (payload) => - requireService(args.gitService, "Git service not available.").listRecentCommits(parseGitListRecentCommitsArgs(payload))); - register("git.listCommitFiles", { viewerAllowed: true }, async (payload) => - requireService(args.gitService, "Git service not available.").listCommitFiles(parseGitListCommitFilesArgs(payload))); - register("git.getFileHistory", { viewerAllowed: true }, async (payload) => - requireService(args.gitService, "Git service not available.").getFileHistory(parseGitGetFileHistoryArgs(payload))); - register("git.getCommitMessage", { viewerAllowed: true }, async (payload) => - requireService(args.gitService, "Git service not available.").getCommitMessage(parseGitGetCommitMessageArgs(payload))); - register("git.revertCommit", { viewerAllowed: true, queueable: true }, async (payload) => - requireService(args.gitService, "Git service not available.").revertCommit(parseGitRevertArgs(payload))); - register("git.cherryPickCommit", { viewerAllowed: true, queueable: true }, async (payload) => - requireService(args.gitService, "Git service not available.").cherryPickCommit(parseGitCherryPickArgs(payload))); - register("git.stashPush", { viewerAllowed: true, queueable: true }, async (payload) => - requireService(args.gitService, "Git service not available.").stashPush(parseGitStashPushArgs(payload))); - register("git.stashList", { viewerAllowed: true }, async (payload) => - requireService(args.gitService, "Git service not available.").listStashes(parseConflictLaneArgs(payload, "git.stashList"))); - register("git.stashApply", { viewerAllowed: true, queueable: true }, async (payload) => - requireService(args.gitService, "Git service not available.").stashApply(parseGitStashRefArgs(payload, "git.stashApply"))); - register("git.stashPop", { viewerAllowed: true, queueable: true }, async (payload) => - requireService(args.gitService, "Git service not available.").stashPop(parseGitStashRefArgs(payload, "git.stashPop"))); - register("git.stashDrop", { viewerAllowed: true, queueable: true }, async (payload) => - requireService(args.gitService, "Git service not available.").stashDrop(parseGitStashRefArgs(payload, "git.stashDrop"))); - register("git.fetch", { viewerAllowed: true, queueable: true }, async (payload) => - requireService(args.gitService, "Git service not available.").fetch(parseConflictLaneArgs(payload, "git.fetch"))); - register("git.pull", { viewerAllowed: true, queueable: true }, async (payload) => - requireService(args.gitService, "Git service not available.").pull(parseConflictLaneArgs(payload, "git.pull"))); - register("git.getSyncStatus", { viewerAllowed: true }, async (payload) => - requireService(args.gitService, "Git service not available.").getSyncStatus(parseConflictLaneArgs(payload, "git.getSyncStatus"))); - register("git.sync", { viewerAllowed: true, queueable: true }, async (payload) => - requireService(args.gitService, "Git service not available.").sync(parseGitSyncArgs(payload))); - register("git.push", { viewerAllowed: true, queueable: true }, async (payload) => - requireService(args.gitService, "Git service not available.").push(parseGitPushArgs(payload))); - register("git.getConflictState", { viewerAllowed: true }, async (payload) => - requireService(args.gitService, "Git service not available.").getConflictState(parseConflictLaneArgs(payload, "git.getConflictState"))); - register("git.rebaseContinue", { viewerAllowed: true, queueable: true }, async (payload) => - requireService(args.gitService, "Git service not available.").rebaseContinue(parseConflictLaneArgs(payload, "git.rebaseContinue"))); - register("git.rebaseAbort", { viewerAllowed: true, queueable: true }, async (payload) => - requireService(args.gitService, "Git service not available.").rebaseAbort(parseConflictLaneArgs(payload, "git.rebaseAbort"))); - register("git.listBranches", { viewerAllowed: true }, async (payload) => - requireService(args.gitService, "Git service not available.").listBranches(parseGitListBranchesArgs(payload))); - register("git.checkoutBranch", { viewerAllowed: true, queueable: true }, async (payload) => - requireService(args.gitService, "Git service not available.").checkoutBranch(parseGitCheckoutBranchArgs(payload))); - - register("conflicts.getLaneStatus", { viewerAllowed: true }, async (payload) => - requireService(args.conflictService, "Conflict service not available.").getLaneStatus(parseConflictLaneArgs(payload, "conflicts.getLaneStatus"))); - register("conflicts.listOverlaps", { viewerAllowed: true }, async (payload) => - requireService(args.conflictService, "Conflict service not available.").listOverlaps(parseConflictLaneArgs(payload, "conflicts.listOverlaps"))); - register("conflicts.getBatchAssessment", { viewerAllowed: true }, async () => - requireService(args.conflictService, "Conflict service not available.").getBatchAssessment()); - - register("prs.list", { viewerAllowed: true }, async () => args.prService.listAll()); - register("prs.refresh", { viewerAllowed: true }, async (payload) => { - const prId = asTrimmedString(payload.prId); - const prIds = asStringArray(payload.prIds); - await args.prService.refresh(prId ? { prId } : prIds.length > 0 ? { prIds } : {}); - const prs = await args.prService.listAll(); - return { - refreshedCount: prId ? 1 : prIds.length > 0 ? prIds.length : prs.length, - prs, - snapshots: args.prService.listSnapshots(), - }; - }); - register("prs.getDetail", { viewerAllowed: true }, async (payload) => args.prService.getDetail(requirePrId(payload, "prs.getDetail"))); - register("prs.getStatus", { viewerAllowed: true }, async (payload) => args.prService.getStatus(requirePrId(payload, "prs.getStatus"))); - register("prs.getChecks", { viewerAllowed: true }, async (payload) => args.prService.getChecks(requirePrId(payload, "prs.getChecks"))); - register("prs.getReviews", { viewerAllowed: true }, async (payload) => args.prService.getReviews(requirePrId(payload, "prs.getReviews"))); - register("prs.getComments", { viewerAllowed: true }, async (payload) => args.prService.getComments(requirePrId(payload, "prs.getComments"))); - register("prs.getFiles", { viewerAllowed: true }, async (payload) => args.prService.getFiles(requirePrId(payload, "prs.getFiles"))); - register("prs.getGitHubSnapshot", { viewerAllowed: true }, async (payload) => - args.prService.getGithubSnapshot({ force: payload.force === true })); - register("prs.getReviewThreads", { viewerAllowed: true }, async (payload) => args.prService.getReviewThreads(requirePrId(payload, "prs.getReviewThreads"))); - register("prs.getActionRuns", { viewerAllowed: true }, async (payload) => args.prService.getActionRuns(requirePrId(payload, "prs.getActionRuns"))); - register("prs.getActivity", { viewerAllowed: true }, async (payload) => args.prService.getActivity(requirePrId(payload, "prs.getActivity"))); - register("prs.getDeployments", { viewerAllowed: true }, async (payload) => args.prService.getDeployments(requirePrId(payload, "prs.getDeployments"))); - register("prs.createFromLane", { viewerAllowed: true, queueable: true }, async (payload) => args.prService.createFromLane(parseCreatePrArgs(payload))); - register("prs.linkToLane", { viewerAllowed: true, queueable: true }, async (payload) => args.prService.linkToLane(parseLinkPrToLaneArgs(payload))); - register("prs.draftDescription", { viewerAllowed: true, queueable: true }, async (payload) => - args.prService.draftDescription(parseDraftPrDescriptionArgs(payload))); - register("prs.land", { viewerAllowed: true, queueable: true }, async (payload) => args.prService.land(parseLandPrArgs(payload))); - register("prs.close", { viewerAllowed: true, queueable: true }, async (payload) => { - await args.prService.closePr(parseClosePrArgs(payload)); - return { ok: true }; - }); - register("prs.reopen", { viewerAllowed: true, queueable: true }, async (payload) => { - await args.prService.reopenPr(parseReopenPrArgs(payload)); - return { ok: true }; - }); - register("prs.requestReviewers", { viewerAllowed: true, queueable: true }, async (payload) => { - await args.prService.requestReviewers(parseRequestReviewersArgs(payload)); - return { ok: true }; - }); - register("prs.rerunChecks", { viewerAllowed: true, queueable: true }, async (payload) => { - await args.prService.rerunChecks(parseRerunPrChecksArgs(payload)); - return { ok: true }; - }); - register("prs.addComment", { viewerAllowed: true, queueable: true }, async (payload) => - args.prService.addComment(parseAddPrCommentArgs(payload))); - register("prs.updateTitle", { viewerAllowed: true, queueable: true }, async (payload) => { - await args.prService.updateTitle(parseUpdatePrTitleArgs(payload)); - return { ok: true }; - }); - register("prs.updateBody", { viewerAllowed: true, queueable: true }, async (payload) => { - await args.prService.updateBody(parseUpdatePrBodyArgs(payload)); - return { ok: true }; - }); - register("prs.setLabels", { viewerAllowed: true, queueable: true }, async (payload) => { - await args.prService.setLabels(parseSetPrLabelsArgs(payload)); - return { ok: true }; - }); - register("prs.submitReview", { viewerAllowed: true, queueable: true }, async (payload) => { - await args.prService.submitReview(parseSubmitPrReviewArgs(payload)); - return { ok: true }; - }); - register("prs.replyToReviewThread", { viewerAllowed: true, queueable: true }, async (payload) => - args.prService.replyToReviewThread(parseReplyToReviewThreadArgs(payload))); - register("prs.setReviewThreadResolved", { viewerAllowed: true, queueable: true }, async (payload) => - args.prService.setReviewThreadResolved(parseSetReviewThreadResolvedArgs(payload))); - register("prs.reactToComment", { viewerAllowed: true, queueable: true }, async (payload) => { - await args.prService.reactToComment(parseReactToCommentArgs(payload)); - return { ok: true }; - }); - register("prs.aiReviewSummary", { viewerAllowed: true, queueable: true }, async (payload) => - args.prService.aiReviewSummary(parseAiReviewSummaryArgs(payload))); - register("prs.listIntegrationWorkflows", { viewerAllowed: true }, async (payload) => - args.prService.listIntegrationWorkflows(parseListIntegrationWorkflowsArgs(payload))); - register("prs.updateIntegrationProposal", { viewerAllowed: true, queueable: true }, async (payload) => { - args.prService.updateIntegrationProposal(parseUpdateIntegrationProposalArgs(payload)); - return { ok: true }; - }); - register("prs.deleteIntegrationProposal", { viewerAllowed: true, queueable: true }, async (payload) => - args.prService.deleteIntegrationProposal(parseDeleteIntegrationProposalArgs(payload))); - register("prs.dismissIntegrationCleanup", { viewerAllowed: true, queueable: true }, async (payload) => - args.prService.dismissIntegrationCleanup(parseDismissIntegrationCleanupArgs(payload))); - register("prs.cleanupIntegrationWorkflow", { viewerAllowed: true, queueable: true }, async (payload) => - args.prService.cleanupIntegrationWorkflow(parseCleanupIntegrationWorkflowArgs(payload))); - register("prs.createIntegrationLaneForProposal", { viewerAllowed: true, queueable: true }, async (payload) => - args.prService.createIntegrationLaneForProposal(parseCreateIntegrationLaneForProposalArgs(payload))); - register("prs.startIntegrationResolution", { viewerAllowed: true, queueable: true }, async (payload) => - args.prService.startIntegrationResolution(parseStartIntegrationResolutionArgs(payload))); - register("prs.recheckIntegrationStep", { viewerAllowed: true, queueable: true }, async (payload) => - args.prService.recheckIntegrationStep(parseRecheckIntegrationStepArgs(payload))); - register("prs.landQueueNext", { viewerAllowed: true, queueable: true }, async (payload) => - args.prService.landQueueNext(parseLandQueueNextArgs(payload))); - register("prs.pauseQueueAutomation", { viewerAllowed: true, queueable: true }, async (payload) => { - if (!args.queueLandingService) throw new Error("Queue automation is not available."); - return args.queueLandingService.pauseQueue(parsePauseQueueAutomationArgs(payload).queueId); - }); - register("prs.resumeQueueAutomation", { viewerAllowed: true, queueable: true }, async (payload) => { - if (!args.queueLandingService) throw new Error("Queue automation is not available."); - return args.queueLandingService.resumeQueue(parseResumeQueueAutomationArgs(payload)); - }); - register("prs.cancelQueueAutomation", { viewerAllowed: true, queueable: true }, async (payload) => { - if (!args.queueLandingService) throw new Error("Queue automation is not available."); - return args.queueLandingService.cancelQueue(parseCancelQueueAutomationArgs(payload).queueId); - }); - register("prs.reorderQueue", { viewerAllowed: true, queueable: true }, async (payload) => { - await args.prService.reorderQueuePrs(parseReorderQueuePrsArgs(payload)); - return { ok: true }; - }); - register("prs.issueInventory.sync", { viewerAllowed: true, queueable: true }, async (payload) => { - if (!args.issueInventoryService) throw new Error("Issue inventory is not available."); - const { prId } = parseIssueInventoryPrArgs(payload, "prs.issueInventory.sync"); - const [checks, reviewThreads, comments] = await Promise.all([ - args.prService.getChecks(prId), - args.prService.getReviewThreads(prId), - args.prService.getComments(prId).catch(() => []), - ]); - return args.issueInventoryService.syncFromPrData(prId, checks, reviewThreads, comments); - }); - register("prs.issueInventory.get", { viewerAllowed: true }, async (payload) => { - if (!args.issueInventoryService) throw new Error("Issue inventory is not available."); - return args.issueInventoryService.getInventory(parseIssueInventoryPrArgs(payload, "prs.issueInventory.get").prId); - }); - register("prs.issueInventory.getNew", { viewerAllowed: true }, async (payload) => { - if (!args.issueInventoryService) throw new Error("Issue inventory is not available."); - return args.issueInventoryService.getNewItems(parseIssueInventoryPrArgs(payload, "prs.issueInventory.getNew").prId); - }); - register("prs.issueInventory.markFixed", { viewerAllowed: true, queueable: true }, async (payload) => { - if (!args.issueInventoryService) throw new Error("Issue inventory is not available."); - const parsed = parseIssueInventoryItemsArgs(payload, "prs.issueInventory.markFixed"); - args.issueInventoryService.markFixed(parsed.prId, parsed.itemIds); - return { ok: true }; - }); - register("prs.issueInventory.markDismissed", { viewerAllowed: true, queueable: true }, async (payload) => { - if (!args.issueInventoryService) throw new Error("Issue inventory is not available."); - const parsed = parseIssueInventoryDismissArgs(payload); - args.issueInventoryService.markDismissed(parsed.prId, parsed.itemIds, parsed.reason); - return { ok: true }; - }); - register("prs.issueInventory.markEscalated", { viewerAllowed: true, queueable: true }, async (payload) => { - if (!args.issueInventoryService) throw new Error("Issue inventory is not available."); - const parsed = parseIssueInventoryItemsArgs(payload, "prs.issueInventory.markEscalated"); - args.issueInventoryService.markEscalated(parsed.prId, parsed.itemIds); - return { ok: true }; - }); - register("prs.issueInventory.getConvergence", { viewerAllowed: true }, async (payload) => { - if (!args.issueInventoryService) throw new Error("Issue inventory is not available."); - return args.issueInventoryService.getConvergenceStatus(parseIssueInventoryPrArgs(payload, "prs.issueInventory.getConvergence").prId); - }); - register("prs.issueInventory.reset", { viewerAllowed: true, queueable: true }, async (payload) => { - if (!args.issueInventoryService) throw new Error("Issue inventory is not available."); - args.issueInventoryService.resetInventory(parseIssueInventoryPrArgs(payload, "prs.issueInventory.reset").prId); - return { ok: true }; - }); - register("prs.convergenceState.get", { viewerAllowed: true }, async (payload) => { - if (!args.issueInventoryService) throw new Error("Issue inventory is not available."); - return args.issueInventoryService.getConvergenceRuntime(parseIssueInventoryPrArgs(payload, "prs.convergenceState.get").prId); - }); - register("prs.convergenceState.save", { viewerAllowed: true, queueable: true }, async (payload) => { - if (!args.issueInventoryService) throw new Error("Issue inventory is not available."); - const parsed = parseConvergenceStatePatch(payload); - return args.issueInventoryService.saveConvergenceRuntime(parsed.prId, parsed.state); - }); - register("prs.convergenceState.delete", { viewerAllowed: true, queueable: true }, async (payload) => { - if (!args.issueInventoryService) throw new Error("Issue inventory is not available."); - args.issueInventoryService.resetConvergenceRuntime(parseIssueInventoryPrArgs(payload, "prs.convergenceState.delete").prId); - return { ok: true }; - }); - register("prs.pipelineSettings.get", { viewerAllowed: true }, async (payload) => { - if (!args.issueInventoryService) throw new Error("Issue inventory is not available."); - return args.issueInventoryService.getPipelineSettings(parseIssueInventoryPrArgs(payload, "prs.pipelineSettings.get").prId); - }); - register("prs.pipelineSettings.save", { viewerAllowed: true, queueable: true }, async (payload) => { - if (!args.issueInventoryService) throw new Error("Issue inventory is not available."); - const parsed = parsePipelineSettingsPatch(payload); - args.issueInventoryService.savePipelineSettings(parsed.prId, parsed.settings); - return { ok: true }; - }); - register("prs.pipelineSettings.delete", { viewerAllowed: true, queueable: true }, async (payload) => { - if (!args.issueInventoryService) throw new Error("Issue inventory is not available."); - args.issueInventoryService.deletePipelineSettings(parseIssueInventoryPrArgs(payload, "prs.pipelineSettings.delete").prId); - return { ok: true }; - }); - register("prs.pathToMerge.start", { viewerAllowed: true, queueable: true }, async (payload) => { - if (!args.pathToMergeOrchestrator) { - throw new Error("Path to Merge orchestrator is not available in this build."); - } - const { prId } = parseIssueInventoryPrArgs(payload, "prs.pathToMerge.start"); - const modelId = typeof payload?.modelId === "string" ? payload.modelId : null; - const reasoning = typeof payload?.reasoning === "string" ? payload.reasoning : null; - const additionalInstructions = typeof payload?.additionalInstructions === "string" - ? payload.additionalInstructions - : null; - const rawScope = payload?.scope; - const scope = rawScope === "checks" || rawScope === "comments" || rawScope === "both" - ? rawScope - : undefined; - return args.pathToMergeOrchestrator.startPathToMerge({ - prId, - modelId, - reasoning, - scope, - additionalInstructions, - }); - }); - register("prs.pathToMerge.stop", { viewerAllowed: true, queueable: true }, async (payload) => { - if (!args.pathToMergeOrchestrator) { - throw new Error("Path to Merge orchestrator is not available in this build."); - } - const { prId } = parseIssueInventoryPrArgs(payload, "prs.pathToMerge.stop"); - const reason = typeof payload?.reason === "string" ? payload.reason : null; - return args.pathToMergeOrchestrator.stopPathToMerge({ prId, reason }); - }); - register("prs.getMobileSnapshot", { viewerAllowed: true }, async () => args.prService.getMobileSnapshot()); - - return { - getSupportedActions(): SyncRemoteCommandAction[] { - return [...registry.keys()]; - }, - - getDescriptors(): SyncRemoteCommandDescriptor[] { - return [...registry.values()].map((entry) => entry.descriptor); - }, - - getPolicy(action: string): SyncRemoteCommandPolicy | null { - return registry.get(action as SyncRemoteCommandAction)?.descriptor.policy ?? null; - }, - - async execute(payload: SyncCommandPayload): Promise { - const handler = registry.get(payload.action as SyncRemoteCommandAction); - if (!handler) { - throw new Error(`Unsupported remote command: ${payload.action}`); - } - const commandArgs = isRecord(payload.args) ? payload.args : {}; - args.logger.debug?.("sync.remote_command.execute", { - action: payload.action, - policy: handler.descriptor.policy, - }); - return await handler.handler(commandArgs); - }, - }; -} - -export type SyncRemoteCommandService = ReturnType; +export * from "../../../../../ade-cli/src/services/sync/syncRemoteCommandService"; diff --git a/apps/desktop/src/main/services/sync/syncService.test.ts b/apps/desktop/src/main/services/sync/syncService.test.ts index ff3481117..d0c41975f 100644 --- a/apps/desktop/src/main/services/sync/syncService.test.ts +++ b/apps/desktop/src/main/services/sync/syncService.test.ts @@ -44,7 +44,7 @@ const { createSyncHostServiceMock } = vi.hoisted(() => ({ // Prevent real WebSocket servers from binding to port 8787 during tests. // Tests only exercise role/transfer/pairing logic, not the sync transport. -vi.mock("./syncHostService", () => ({ +vi.mock("../../../../../ade-cli/src/services/sync/syncHostService", () => ({ createSyncHostService: createSyncHostServiceMock, SYNC_TAILNET_DISCOVERY_SERVICE_NAME: "svc:ade-sync", SYNC_TAILNET_DISCOVERY_SERVICE_PORT: 8787, @@ -175,6 +175,10 @@ describe.skipIf(!isCrsqliteAvailable())("syncService", () => { // serviceB sees the same on-disk pin file but only the host that performed // the migration retains the plaintext PIN in memory; serviceB should not. expect(serviceB.getPin()).toBeNull(); + + const generated = await serviceA.generatePin(); + expect(generated.pairingPin).toMatch(/^\d{6}$/); + expect(serviceA.getPin()).toBe(generated.pairingPin); }); it("reports W3 transfer blockers while keeping paused and idle state survivable", async () => { @@ -387,7 +391,7 @@ describe.skipIf(!isCrsqliteAvailable())("syncService", () => { expect(transferred.transferReadiness.ready).toBe(true); }, 30_000); - it("builds pairing QR payloads with LAN-first address candidates and tailscale fallback", async () => { + it("builds pairing runtime addresses with LAN-first address candidates and tailscale fallback", async () => { const projectRoot = makeProjectRoot("ade-sync-service-pairing-"); const db = await openKvDb( path.join(projectRoot, ".ade", "ade.db"), @@ -498,16 +502,8 @@ describe.skipIf(!isCrsqliteAvailable())("syncService", () => { expect(refreshedCandidates[0]?.kind).toBe("saved"); expect(refreshedCandidates[0]?.host).toBe(refreshedStatus.localDevice.lastHost); - const encodedPayload = - status.pairingConnectInfo?.qrPayloadText.split("payload=")[1] ?? ""; - const parsedPayload = JSON.parse(decodeURIComponent(encodedPayload)) as { - version: number; - hostIdentity: { deviceId: string }; - addressCandidates: Array<{ host: string; kind: string }>; - }; - expect(parsedPayload.version).toBe(2); - expect(parsedPayload.hostIdentity.deviceId).toBe(localDeviceId); - expect(parsedPayload.addressCandidates.some((c) => c.kind === "loopback" && c.host === "127.0.0.1")).toBe(true); + expect(status.pairingConnectInfo?.hostIdentity.deviceId).toBe(localDeviceId); + expect(addressCandidates.some((c) => c.kind === "loopback" && c.host === "127.0.0.1")).toBe(true); }, 30_000); it("does not start the sync host or expose pairing details when host startup is disabled", async () => { diff --git a/apps/desktop/src/main/services/sync/syncService.ts b/apps/desktop/src/main/services/sync/syncService.ts index 0c7e54d4c..f29ba5d05 100644 --- a/apps/desktop/src/main/services/sync/syncService.ts +++ b/apps/desktop/src/main/services/sync/syncService.ts @@ -1,1064 +1 @@ -import fs from "node:fs"; -import path from "node:path"; -import { resolveAdeLayout } from "../../../shared/adeLayout"; -import type { - SyncAddressCandidate, - SyncDesktopConnectionDraft, - SyncDeviceRuntimeState, - SyncGetStatusArgs, - SyncPairingConnectInfo, - SyncPairingQrPayload, - SyncProjectCatalogPayload, - SyncProjectSwitchRequestPayload, - SyncProjectSwitchResultPayload, - SyncRoleSnapshot, - SyncTailnetDiscoveryStatus, - SyncTransferBlocker, - SyncTransferReadiness, -} from "../../../shared/types"; -import type { Logger } from "../logging/logger"; -import type { createAgentChatService } from "../chat/agentChatService"; -import type { createCtoStateService } from "../cto/ctoStateService"; -import type { createFlowPolicyService } from "../cto/flowPolicyService"; -import type { createLinearCredentialService } from "../cto/linearCredentialService"; -import type { createLinearIngressService } from "../cto/linearIngressService"; -import type { createLinearIssueTracker } from "../cto/linearIssueTracker"; -import type { createLinearSyncService } from "../cto/linearSyncService"; -import type { createWorkerAgentService } from "../cto/workerAgentService"; -import type { createWorkerBudgetService } from "../cto/workerBudgetService"; -import type { createWorkerHeartbeatService } from "../cto/workerHeartbeatService"; -import type { createWorkerRevisionService } from "../cto/workerRevisionService"; -import type { createComputerUseArtifactBrokerService } from "../computerUse/computerUseArtifactBrokerService"; -import type { createProjectConfigService } from "../config/projectConfigService"; -import type { createFileService } from "../files/fileService"; -import type { createDiffService } from "../diffs/diffService"; -import type { createGitOperationsService } from "../git/gitOperationsService"; -import type { createConflictService } from "../conflicts/conflictService"; -import type { createLaneEnvironmentService } from "../lanes/laneEnvironmentService"; -import type { createLaneService } from "../lanes/laneService"; -import type { createLaneTemplateService } from "../lanes/laneTemplateService"; -import type { createAutoRebaseService } from "../lanes/autoRebaseService"; -import type { createPortAllocationService } from "../lanes/portAllocationService"; -import type { createRebaseSuggestionService } from "../lanes/rebaseSuggestionService"; -import type { createMissionService } from "../missions/missionService"; -import type { createProcessService } from "../processes/processService"; -import type { createIssueInventoryService } from "../prs/issueInventoryService"; -import type { PathToMergeOrchestrator } from "../prs/pathToMergeOrchestrator"; -import type { createPrService } from "../prs/prService"; -import type { createQueueLandingService } from "../prs/queueLandingService"; -import type { createPtyService } from "../pty/ptyService"; -import type { createSessionService } from "../sessions/sessionService"; -import type { NotificationEventBus } from "../notifications/notificationEventBus"; -import type { AdeDb } from "../state/kvDb"; -import { nowIso, safeJsonParse, sleep, writeTextAtomic } from "../shared/utils"; -import { createDeviceRegistryService } from "./deviceRegistryService"; -import { - createSyncHostService, - SYNC_TAILNET_DISCOVERY_SERVICE_NAME, - SYNC_TAILNET_DISCOVERY_SERVICE_PORT, - type SyncHostService, -} from "./syncHostService"; -import { createSyncPeerService } from "./syncPeerService"; -import { createSyncPinStore } from "./syncPinStore"; -import { DEFAULT_SYNC_HOST_PORT } from "./syncProtocol"; - -type SyncServiceArgs = { - db: AdeDb; - logger: Logger; - projectRoot: string; - localDeviceIdPath?: string; - phonePairingStateDir?: string; - fileService: ReturnType; - laneService: ReturnType; - gitService?: ReturnType; - diffService?: ReturnType; - conflictService?: ReturnType; - prService: ReturnType; - issueInventoryService?: ReturnType | null; - /** - * Optional Path-to-Merge orchestrator forwarded to the embedded sync host so - * iOS callers can drive the convergence loop via remote commands. - */ - pathToMergeOrchestrator?: PathToMergeOrchestrator | null; - queueLandingService?: ReturnType | null; - sessionService: ReturnType; - ptyService: ReturnType; - projectConfigService?: ReturnType; - portAllocationService?: ReturnType; - laneEnvironmentService?: ReturnType; - laneTemplateService?: ReturnType; - rebaseSuggestionService?: ReturnType< - typeof createRebaseSuggestionService - > | null; - autoRebaseService?: ReturnType | null; - computerUseArtifactBrokerService: ReturnType< - typeof createComputerUseArtifactBrokerService - >; - missionService: ReturnType; - agentChatService: ReturnType; - workerAgentService?: ReturnType | null; - workerBudgetService?: ReturnType | null; - workerHeartbeatService?: ReturnType | null; - workerRevisionService?: ReturnType | null; - ctoStateService?: ReturnType | null; - flowPolicyService?: ReturnType | null; - linearCredentialService?: ReturnType | null; - /** - * Resolvers for services that are constructed AFTER createSyncService in - * main.ts. Using lazy getters lets the sync router forward remote commands - * to them without requiring a specific init order. - */ - getLinearIngressService?: () => ReturnType | null; - getLinearIssueTracker?: () => ReturnType | null; - getLinearSyncService?: () => ReturnType | null; - processService: ReturnType; - hostStartupEnabled?: boolean; - hostDiscoveryEnabled?: boolean; - /** - * Phone sync is hosted by the local desktop app. When enabled, legacy - * desktop-to-desktop viewer state stored in a project DB cannot demote the - * phone sync surface into viewer mode. - */ - forceHostRole?: boolean; - onStatusChanged?: (snapshot: SyncRoleSnapshot) => void; - /** - * Optional notification bus forwarded to the sync host. The host publishes - * chat/PR/mission/system events and invokes `sendInAppNotification` for - * connected iOS peers. - */ - notificationEventBus?: NotificationEventBus | null; - projectCatalogProvider?: { - listProjects: () => Promise; - prepareProjectConnection: (args: SyncProjectSwitchRequestPayload) => Promise; - completeProjectConnection?: ( - args: SyncProjectSwitchRequestPayload, - result: SyncProjectSwitchResultPayload, - ) => Promise; - }; -}; - -const DRAFT_FILE = "sync-peer-draft.json"; -const TOKEN_FILE = "sync-bootstrap-token"; -const PIN_FILE = "sync-pin.json"; -const PAIRED_DEVICES_FILE = "sync-paired-devices.json"; - -function migrateLegacySyncSecretFile(args: { - legacyPath: string; - appPath: string; - logger: Logger; - label: string; -}): void { - if (args.legacyPath === args.appPath) return; - if (fs.existsSync(args.appPath) || !fs.existsSync(args.legacyPath)) return; - try { - fs.mkdirSync(path.dirname(args.appPath), { recursive: true }); - fs.copyFileSync(args.legacyPath, args.appPath, fs.constants.COPYFILE_EXCL); - args.logger.info("sync.app_pairing_state_migrated", { - label: args.label, - legacyPath: args.legacyPath, - appPath: args.appPath, - }); - } catch (error) { - if ((error as NodeJS.ErrnoException | null | undefined)?.code === "EEXIST") return; - args.logger.warn("sync.app_pairing_state_migration_failed", { - label: args.label, - legacyPath: args.legacyPath, - appPath: args.appPath, - error: error instanceof Error ? error.message : String(error), - }); - } -} -const RUNNING_PROCESS_STATES = new Set(["starting", "running", "degraded"]); -const CHAT_TOOL_TYPES = new Set(["codex-chat", "claude-chat", "opencode-chat"]); -const SYNC_HOST_PORT_RETRY_WINDOW = 12; -const LOCAL_LANE_PRESENCE_HEARTBEAT_MS = 30_000; -const TRANSFER_READINESS_CACHE_MS = 15_000; - -function buildSkippedTransferReadiness(): SyncTransferReadiness { - return { - ready: false, - blockers: [], - survivableState: [ - "Transfer readiness was skipped for this lightweight sync status request.", - ], - }; -} - -function sanitizeDraft( - raw: unknown, - token: string | null, -): SyncDesktopConnectionDraft | null { - if (!raw || typeof raw !== "object" || !token) return null; - const row = raw as Record; - const host = typeof row.host === "string" ? row.host.trim() : ""; - const port = Number(row.port ?? 0); - if (!host || !Number.isFinite(port) || port <= 0) return null; - return { - host, - port: Math.floor(port), - token, - authKind: row.authKind === "paired" ? "paired" : "bootstrap", - pairedDeviceId: - typeof row.pairedDeviceId === "string" ? row.pairedDeviceId : null, - lastRemoteDbVersion: Number.isFinite(row.lastRemoteDbVersion) - ? Number(row.lastRemoteDbVersion) - : 0, - }; -} - -function normalizeHost(host: string | null | undefined): string | null { - if (!host) return null; - const normalized = host.trim().toLowerCase(); - return normalized.length > 0 ? normalized : null; -} - -function tailscaleDnsNameFromDevice( - localDevice: SyncRoleSnapshot["localDevice"], -): string | null { - const value = localDevice.metadata?.tailscaleDnsName; - return typeof value === "string" && value.trim().toLowerCase().endsWith(".ts.net") - ? value.trim().replace(/\.$/, "").toLowerCase() - : null; -} - -function buildAddressCandidates( - localDevice: SyncRoleSnapshot["localDevice"], -): SyncAddressCandidate[] { - const candidates: SyncAddressCandidate[] = []; - const seen = new Set(); - const append = ( - host: string | null | undefined, - kind: SyncAddressCandidate["kind"], - ) => { - const normalized = normalizeHost(host); - if (!normalized || seen.has(normalized)) return; - seen.add(normalized); - candidates.push({ host: normalized, kind }); - }; - const preferredSavedHost = normalizeHost(localDevice.lastHost); - const preferredSavedHostIsCurrent = preferredSavedHost != null && ( - localDevice.ipAddresses.some((host) => normalizeHost(host) === preferredSavedHost) - || normalizeHost(localDevice.tailscaleIp) === preferredSavedHost - || tailscaleDnsNameFromDevice(localDevice) === preferredSavedHost - ); - if (preferredSavedHostIsCurrent) { - append(localDevice.lastHost, "saved"); - } - for (const lanAddress of localDevice.ipAddresses) { - append(lanAddress, "lan"); - } - if (!preferredSavedHostIsCurrent) { - append(localDevice.lastHost, "saved"); - } - append(tailscaleDnsNameFromDevice(localDevice), "tailscale"); - append(localDevice.tailscaleIp, "tailscale"); - append("127.0.0.1", "loopback"); - return candidates; -} - -function buildPairingConnectInfo(argsIn: { - localDevice: SyncRoleSnapshot["localDevice"]; -}): SyncPairingConnectInfo { - const port = argsIn.localDevice.lastPort ?? DEFAULT_SYNC_HOST_PORT; - const addressCandidates = buildAddressCandidates(argsIn.localDevice); - const hostIdentity = { - deviceId: argsIn.localDevice.deviceId, - siteId: argsIn.localDevice.siteId, - name: argsIn.localDevice.name, - platform: argsIn.localDevice.platform, - deviceType: argsIn.localDevice.deviceType, - }; - const qrPayload: SyncPairingQrPayload = { - version: 2, - hostIdentity, - port, - addressCandidates, - }; - const qrPayloadText = `ade-sync://pair?payload=${encodeURIComponent(JSON.stringify(qrPayload))}`; - return { - hostIdentity, - port, - addressCandidates, - qrPayload, - qrPayloadText, - }; -} - -function isRetryableHostBindError(error: unknown): boolean { - const code = (error as NodeJS.ErrnoException | null | undefined)?.code ?? ""; - return code === "EADDRINUSE" || code === "EACCES"; -} - -function createInactiveTailnetDiscoveryStatus( - error: string, -): SyncTailnetDiscoveryStatus { - return { - state: "disabled", - serviceName: SYNC_TAILNET_DISCOVERY_SERVICE_NAME, - servicePort: SYNC_TAILNET_DISCOVERY_SERVICE_PORT, - target: null, - updatedAt: null, - error, - stderr: null, - }; -} - -function buildHostPortCandidates(preferredPort: number | null | undefined): number[] { - const preferred = Number.isFinite(preferredPort) - ? Math.max(0, Math.min(65_535, Math.floor(Number(preferredPort)))) - : DEFAULT_SYNC_HOST_PORT; - const candidates: number[] = []; - const seen = new Set(); - const add = (port: number) => { - const normalized = Math.max(0, Math.min(65_535, Math.floor(port))); - if (seen.has(normalized)) return; - seen.add(normalized); - candidates.push(normalized); - }; - add(preferred); - if (preferred !== DEFAULT_SYNC_HOST_PORT) { - add(DEFAULT_SYNC_HOST_PORT); - } - for (let offset = 1; offset <= SYNC_HOST_PORT_RETRY_WINDOW; offset += 1) { - if (preferred + offset <= 65_535) { - add(preferred + offset); - } - } - if (preferred !== DEFAULT_SYNC_HOST_PORT) { - for (let offset = 1; offset <= Math.min(4, SYNC_HOST_PORT_RETRY_WINDOW); offset += 1) { - if (DEFAULT_SYNC_HOST_PORT + offset <= 65_535) { - add(DEFAULT_SYNC_HOST_PORT + offset); - } - } - } - add(0); - return candidates; -} - -export function createSyncService(args: SyncServiceArgs) { - const layout = resolveAdeLayout(args.projectRoot); - const pairingStateDir = args.phonePairingStateDir ?? layout.secretsDir; - const draftPath = path.join(pairingStateDir, DRAFT_FILE); - const tokenPath = path.join(pairingStateDir, TOKEN_FILE); - const pinPath = path.join(pairingStateDir, PIN_FILE); - const pairingSecretsPath = path.join(pairingStateDir, PAIRED_DEVICES_FILE); - migrateLegacySyncSecretFile({ - legacyPath: path.join(layout.secretsDir, DRAFT_FILE), - appPath: draftPath, - logger: args.logger, - label: DRAFT_FILE, - }); - migrateLegacySyncSecretFile({ - legacyPath: path.join(layout.secretsDir, TOKEN_FILE), - appPath: tokenPath, - logger: args.logger, - label: TOKEN_FILE, - }); - migrateLegacySyncSecretFile({ - legacyPath: path.join(layout.secretsDir, PIN_FILE), - appPath: pinPath, - logger: args.logger, - label: PIN_FILE, - }); - migrateLegacySyncSecretFile({ - legacyPath: path.join(layout.secretsDir, PAIRED_DEVICES_FILE), - appPath: pairingSecretsPath, - logger: args.logger, - label: PAIRED_DEVICES_FILE, - }); - fs.mkdirSync(path.dirname(draftPath), { recursive: true }); - - const pinStore = createSyncPinStore({ filePath: pinPath }); - - const deviceRegistryService = createDeviceRegistryService({ - db: args.db, - logger: args.logger, - projectRoot: args.projectRoot, - localDeviceIdPath: args.localDeviceIdPath, - }); - - let hostService: SyncHostService | null = null; - let refreshRunning = false; - let refreshQueued = false; - let disposed = false; - // Mobile project switch can fire `sync.initialize` as a background task and - // then immediately await `service.initialize()` from the dialog handler. - // Coalesce concurrent calls so the second await rides the first promise - // rather than re-running ensureLocalDevice/refreshRoleState in parallel. - let initializingPromise: Promise | null = null; - let initialized = false; - let hostStartupEnabled = args.hostStartupEnabled !== false; - let hostDiscoveryEnabled = args.hostDiscoveryEnabled !== false; - let transferReadinessCache: { value: SyncTransferReadiness; expiresAtMs: number } | null = null; - let transferReadinessInFlight: Promise | null = null; - const forceHostRole = args.forceHostRole === true; - const isCrdtSyncAvailable = (): boolean => args.db.sync.isAvailable?.() !== false; - const assertPhonePairingAvailable = (): void => { - if (!hostStartupEnabled) { - throw new Error( - "Phone pairing is unavailable because the sync host is disabled for this ADE process.", - ); - } - if (!isCrdtSyncAvailable()) { - throw new Error( - "Phone pairing is unavailable because the CRDT database extension is unavailable on this platform.", - ); - } - }; - let activeLocalLanePresenceIds: string[] = []; - const localLanePresenceHeartbeatTimer = setInterval(() => { - if (disposed || !hostService || activeLocalLanePresenceIds.length === 0) return; - hostService.setLocalActiveLanePresence?.(activeLocalLanePresenceIds); - }, LOCAL_LANE_PRESENCE_HEARTBEAT_MS); - - const readToken = (): string | null => { - if (!fs.existsSync(tokenPath)) return null; - const value = fs.readFileSync(tokenPath, "utf8").trim(); - return value.length > 0 ? value : null; - }; - - const writeToken = (token: string): void => { - writeTextAtomic(tokenPath, `${token.trim()}\n`); - }; - - const readSavedDraft = (): SyncDesktopConnectionDraft | null => { - if (forceHostRole) return null; - if (!fs.existsSync(draftPath)) return null; - const token = readToken(); - return sanitizeDraft( - safeJsonParse(fs.readFileSync(draftPath, "utf8"), null), - token, - ); - }; - - const writeSavedDraft = (draft: SyncDesktopConnectionDraft | null): void => { - if (!draft) { - try { - fs.rmSync(draftPath, { force: true }); - } catch { - // ignore - } - return; - } - writeToken(draft.token); - writeTextAtomic( - draftPath, - `${JSON.stringify( - { - host: draft.host, - port: draft.port, - authKind: draft.authKind ?? "bootstrap", - pairedDeviceId: draft.pairedDeviceId ?? null, - lastRemoteDbVersion: draft.lastRemoteDbVersion ?? 0, - }, - null, - 2, - )}\n`, - ); - }; - - const syncPeerService = createSyncPeerService({ - db: args.db, - logger: args.logger, - deviceRegistryService, - onStatusChange: (status) => { - if (forceHostRole) return; - if (status.savedDraft) { - const token = readToken(); - if (token) { - writeSavedDraft({ - host: status.savedDraft.host, - port: status.savedDraft.port, - token, - authKind: status.savedDraft.authKind ?? "bootstrap", - pairedDeviceId: status.savedDraft.pairedDeviceId ?? null, - lastRemoteDbVersion: status.savedDraft.lastRemoteDbVersion ?? 0, - }); - } - } - void emitStatus(); - }, - onBrainStatus: (payload) => { - deviceRegistryService.applyBrainStatus(payload); - void emitStatus(); - }, - onRemoteChangesApplied: () => { - void refreshRoleState(); - }, - }); - - const emitStatus = async (): Promise => { - if (disposed) return; - args.onStatusChanged?.(await service.getStatus()); - }; - - const startHostIfNeeded = async (): Promise => { - if (!hostStartupEnabled || !isCrdtSyncAvailable()) { - if (hostService) { - await stopHostIfRunning(); - } - const currentLocalDevice = deviceRegistryService.ensureLocalDevice(); - deviceRegistryService.touchLocalDevice({ - lastSeenAt: nowIso(), - lastHost: currentLocalDevice.ipAddresses[0] ?? currentLocalDevice.tailscaleIp ?? currentLocalDevice.lastHost, - }); - return; - } - if (hostService) { - const currentLocalDevice = deviceRegistryService.ensureLocalDevice(); - deviceRegistryService.touchLocalDevice({ - lastSeenAt: nowIso(), - lastHost: currentLocalDevice.ipAddresses[0] ?? currentLocalDevice.tailscaleIp ?? currentLocalDevice.lastHost, - lastPort: hostService.getPort(), - }); - hostService.refreshLanDiscovery?.(); - return; - } - const localDevice = deviceRegistryService.ensureLocalDevice(); - const preferredPort = localDevice.lastPort ?? DEFAULT_SYNC_HOST_PORT; - let lastError: unknown = null; - for (const attemptedPort of buildHostPortCandidates(preferredPort)) { - const candidateHostService = createSyncHostService({ - db: args.db, - logger: args.logger, - projectRoot: args.projectRoot, - fileService: args.fileService, - laneService: args.laneService, - gitService: args.gitService, - diffService: args.diffService, - conflictService: args.conflictService, - prService: args.prService, - issueInventoryService: args.issueInventoryService, - pathToMergeOrchestrator: args.pathToMergeOrchestrator, - queueLandingService: args.queueLandingService, - sessionService: args.sessionService, - ptyService: args.ptyService, - processService: args.processService, - agentChatService: args.agentChatService, - workerAgentService: args.workerAgentService, - workerBudgetService: args.workerBudgetService, - workerHeartbeatService: args.workerHeartbeatService, - workerRevisionService: args.workerRevisionService, - ctoStateService: args.ctoStateService, - flowPolicyService: args.flowPolicyService, - linearCredentialService: args.linearCredentialService, - getLinearIngressService: args.getLinearIngressService, - getLinearIssueTracker: args.getLinearIssueTracker, - getLinearSyncService: args.getLinearSyncService, - projectConfigService: args.projectConfigService, - portAllocationService: args.portAllocationService, - laneEnvironmentService: args.laneEnvironmentService, - laneTemplateService: args.laneTemplateService, - rebaseSuggestionService: args.rebaseSuggestionService ?? undefined, - autoRebaseService: args.autoRebaseService ?? undefined, - computerUseArtifactBrokerService: args.computerUseArtifactBrokerService, - pinStore, - bootstrapTokenPath: tokenPath, - pairingSecretsPath, - port: attemptedPort, - discoveryEnabled: hostDiscoveryEnabled, - deviceRegistryService, - notificationEventBus: args.notificationEventBus ?? null, - projectCatalogProvider: args.projectCatalogProvider, - onStateChanged: () => { - void refreshRoleState(); - }, - }); - try { - const resolvedPort = await candidateHostService.waitUntilListening(); - hostService = candidateHostService; - hostService.setLocalActiveLanePresence?.(activeLocalLanePresenceIds); - deviceRegistryService.touchLocalDevice({ - lastSeenAt: nowIso(), - lastHost: localDevice.ipAddresses[0] ?? localDevice.tailscaleIp ?? localDevice.lastHost, - lastPort: resolvedPort, - }); - return; - } catch (error) { - lastError = error; - await candidateHostService.dispose().catch(() => {}); - const retryable = isRetryableHostBindError(error) && attemptedPort !== 0; - args.logger.warn( - retryable ? "sync.host_start_port_conflict" : "sync.host_start_failed", - { - preferredPort, - attemptedPort, - error: error instanceof Error ? error.message : String(error), - code: (error as NodeJS.ErrnoException | null | undefined)?.code ?? null, - }, - ); - if (!retryable) { - throw error; - } - } - } - throw lastError instanceof Error - ? lastError - : new Error("Unable to start the sync host."); - }; - - const stopHostIfRunning = async (): Promise => { - if (!hostService) return; - const current = hostService; - hostService = null; - await current.dispose(); - }; - - const resolveViewerDraftFromRegistry = - (): SyncDesktopConnectionDraft | null => { - if (forceHostRole) return null; - const cluster = deviceRegistryService.getClusterState(); - const token = readToken(); - if (!cluster || !token) return null; - const brain = deviceRegistryService.getDevice(cluster.brainDeviceId); - const host = - brain != null ? buildAddressCandidates(brain)[0]?.host ?? null : null; - const port = brain?.lastPort ?? DEFAULT_SYNC_HOST_PORT; - if (!host) return null; - return { - host, - port, - token, - lastRemoteDbVersion: - syncPeerService.getStatus().lastRemoteDbVersion ?? 0, - }; - }; - - const refreshRoleState = async (): Promise => { - if (disposed) return; - if (refreshRunning) { - refreshQueued = true; - return; - } - refreshRunning = true; - try { - do { - refreshQueued = false; - const savedDraft = readSavedDraft(); - syncPeerService.setSavedDraft(savedDraft); - const localDevice = deviceRegistryService.ensureLocalDevice(); - let cluster = deviceRegistryService.getClusterState(); - if (forceHostRole) { - if (!cluster || cluster.brainDeviceId !== localDevice.deviceId) { - cluster = deviceRegistryService.setClusterState({ - brainDeviceId: localDevice.deviceId, - brainEpoch: (cluster?.brainEpoch ?? 0) + 1, - updatedByDeviceId: localDevice.deviceId, - }); - } - } else if (!cluster && !savedDraft) { - cluster = deviceRegistryService.bootstrapLocalBrainIfNeeded(); - } - const isLocalBrain = forceHostRole || (cluster - ? cluster.brainDeviceId === localDevice.deviceId - : !savedDraft); - if (isLocalBrain) { - if (syncPeerService.isConnected()) { - syncPeerService.disconnect({ preserveDraft: true }); - } - await startHostIfNeeded(); - } else { - await stopHostIfRunning(); - if (!isCrdtSyncAvailable()) { - if (syncPeerService.isConnected()) { - syncPeerService.disconnect({ preserveDraft: true }); - } - continue; - } - const draft = savedDraft ?? resolveViewerDraftFromRegistry(); - if (draft && !syncPeerService.isConnected()) { - syncPeerService.setSavedDraft(draft); - try { - await syncPeerService.connect(draft); - deviceRegistryService.touchLocalDevice({ lastSeenAt: nowIso() }); - syncPeerService.flushLocalChanges(); - } catch (error) { - args.logger.warn("sync.role.viewer_connect_failed", { - error: error instanceof Error ? error.message : String(error), - }); - } - } - } - } while (refreshQueued); - } finally { - refreshRunning = false; - await emitStatus(); - } - }; - - const listRuntimeDevices = async (): Promise => { - const devices = deviceRegistryService.listDevices(); - const cluster = deviceRegistryService.getClusterState(); - const currentBrainId = cluster?.brainDeviceId ?? null; - const peerStates = hostService - ? hostService.getPeerStates() - : (syncPeerService.getLatestBrainStatus()?.connectedPeers ?? []); - const localDeviceId = deviceRegistryService.getLocalDeviceId(); - return devices.map((device) => { - const peer = - peerStates.find((entry) => entry.deviceId === device.deviceId) ?? null; - const isLocal = device.deviceId === localDeviceId; - return { - ...device, - isLocal, - isBrain: device.deviceId === currentBrainId, - connectionState: isLocal ? "self" : peer ? "connected" : "disconnected", - connectedAt: peer?.connectedAt ?? null, - lastAppliedAt: peer?.lastAppliedAt ?? null, - remoteAddress: peer?.remoteAddress ?? null, - remotePort: peer?.remotePort ?? null, - latencyMs: peer?.latencyMs ?? null, - syncLag: peer?.syncLag ?? null, - }; - }); - }; - - const computeTransferReadiness = async (): Promise => { - const blockers: SyncTransferBlocker[] = []; - - for (const mission of args.missionService.list({ - status: "active", - limit: 200, - })) { - blockers.push({ - kind: "mission_run", - id: mission.id, - label: mission.title || mission.id, - detail: `Mission is ${mission.status}. Paused missions can transfer, but active mission work cannot.`, - }); - } - - const chats = await args.agentChatService.listSessions(undefined, { - includeIdentity: true, - includeAutomation: true, - }); - const chatSummaries = new Map( - chats.map((chat) => [chat.sessionId, chat] as const), - ); - - for (const session of args.sessionService.list({ - status: "running", - limit: 500, - })) { - if (CHAT_TOOL_TYPES.has(session.toolType ?? "")) { - const chat = chatSummaries.get(session.id); - const isCto = chat?.identityKey === "cto"; - blockers.push({ - kind: "chat_runtime", - id: session.id, - label: chat?.title || (isCto ? "CTO thread" : session.title), - detail: isCto - ? "A running CTO turn must stop before handoff. CTO history and idle threads still transfer." - : "Live chat runtimes do not hot-transfer. Let the turn finish or interrupt it first.", - }); - continue; - } - blockers.push({ - kind: "terminal_session", - id: session.id, - label: session.title, - detail: - "Running terminal sessions must stop before the host role can move.", - }); - } - - const lanes = args.db.all<{ id: string }>( - "select id from lanes where status != 'archived'", - ); - for (const lane of lanes) { - for (const runtime of args.processService.listRuntime(lane.id)) { - if (!RUNNING_PROCESS_STATES.has(runtime.status)) continue; - blockers.push({ - kind: "managed_process", - id: `${lane.id}:${runtime.processId}`, - label: runtime.processId, - detail: - "Managed run processes must stop before the host role can move.", - }); - } - } - - return { - ready: blockers.length === 0, - blockers, - survivableState: [ - "Paused missions remain paused and can resume on the new host.", - "CTO history and idle threads remain available on the new host.", - "Idle and ended agent chats remain available and resumable on the new host.", - ], - }; - }; - - const getTransferReadiness = async (options?: { force?: boolean }): Promise => { - const now = Date.now(); - if (!options?.force && transferReadinessCache && transferReadinessCache.expiresAtMs > now) { - return transferReadinessCache.value; - } - // `force` should skip the cached value but still share the in-flight - // promise — otherwise overlapping forced callers each spawn their own - // computeTransferReadiness() run. - if (transferReadinessInFlight) return transferReadinessInFlight; - transferReadinessInFlight = computeTransferReadiness() - .then((value) => { - transferReadinessCache = { - value, - expiresAtMs: Date.now() + TRANSFER_READINESS_CACHE_MS, - }; - return value; - }) - .finally(() => { - transferReadinessInFlight = null; - }); - return transferReadinessInFlight; - }; - - const service = { - async initialize(): Promise { - if (initialized) return; - if (initializingPromise) return initializingPromise; - initializingPromise = (async () => { - deviceRegistryService.ensureLocalDevice(); - await refreshRoleState(); - initialized = true; - })().finally(() => { - initializingPromise = null; - }); - return initializingPromise; - }, - - async getStatus(options?: SyncGetStatusArgs): Promise { - const localDevice = deviceRegistryService.ensureLocalDevice(); - const cluster = deviceRegistryService.getClusterState(); - const savedDraft = readSavedDraft(); - const currentBrain = cluster - ? deviceRegistryService.getDevice(cluster.brainDeviceId) - : localDevice; - const isLocalBrain = forceHostRole || (cluster - ? cluster.brainDeviceId === localDevice.deviceId - : !savedDraft && !syncPeerService.isConnected()); - const role = isLocalBrain ? "brain" : "viewer"; - const crdtSyncAvailable = isCrdtSyncAvailable(); - const canHostPhonePairing = role === "brain" && hostStartupEnabled && crdtSyncAvailable; - const client = syncPeerService.getStatus(); - const mode = - role === "viewer" - ? "viewer" - : client.state === "connected" - ? "brain" - : "standalone"; - return { - mode, - role, - localDevice, - currentBrain, - clusterState: cluster, - bootstrapToken: - canHostPhonePairing ? readToken() : null, - pairingPin: canHostPhonePairing ? pinStore.getPin() : null, - pairingPinConfigured: canHostPhonePairing ? pinStore.hasPin() : false, - pairingConnectInfo: - canHostPhonePairing - ? buildPairingConnectInfo({ localDevice }) - : null, - connectedPeers: hostService - ? hostService.getPeerStates() - : (syncPeerService.getLatestBrainStatus()?.connectedPeers ?? []), - tailnetDiscovery: canHostPhonePairing && hostService - ? hostService.getTailnetDiscoveryStatus() - : createInactiveTailnetDiscoveryStatus( - canHostPhonePairing - ? "Tailnet discovery is waiting for the desktop sync host to start." - : "Tailnet discovery is only published by the host desktop.", - ), - client, - transferReadiness: options?.includeTransferReadiness === false - ? (transferReadinessCache?.value ?? buildSkippedTransferReadiness()) - : await getTransferReadiness({ force: options?.forceTransferReadiness === true }), - survivableStateText: - crdtSyncAvailable - ? "Paused and idle state will remain available on the new host." - : "Desktop sync is disabled because the CRDT database extension is unavailable on this platform.", - blockingStateText: - crdtSyncAvailable - ? "Live missions, chats, terminals, or run processes must stop first." - : "Install a Windows cr-sqlite runtime before pairing or syncing devices.", - }; - }, - - async listDevices(): Promise { - return await listRuntimeDevices(); - }, - - async refreshDiscovery(): Promise { - hostService?.refreshLanDiscovery?.({ forceTailnet: true }); - const snapshot = await this.getStatus(); - args.onStatusChanged?.(snapshot); - return snapshot; - }, - - setHostDiscoveryEnabled(enabled: boolean): void { - if (hostDiscoveryEnabled === enabled) return; - hostDiscoveryEnabled = enabled; - hostService?.setDiscoveryEnabled(enabled); - void emitStatus(); - }, - - async setHostStartupEnabled(enabled: boolean): Promise { - if (hostStartupEnabled === enabled) return; - hostStartupEnabled = enabled; - await refreshRoleState(); - }, - - async updateLocalDevice(argsIn: { - name?: string; - deviceType?: "desktop" | "phone" | "vps" | "unknown"; - }) { - const updated = deviceRegistryService.updateLocalDevice(argsIn); - hostService?.setLocalActiveLanePresence(activeLocalLanePresenceIds); - await emitStatus(); - return updated; - }, - - async connectToBrain( - draft: SyncDesktopConnectionDraft, - ): Promise { - if (!isCrdtSyncAvailable()) { - throw new Error("Desktop sync is unavailable because the CRDT database extension is not loaded."); - } - await stopHostIfRunning(); - deviceRegistryService.clearClusterRegistryForViewerJoin(); - writeSavedDraft(draft); - syncPeerService.setSavedDraft(draft); - try { - await syncPeerService.connect(draft); - deviceRegistryService.touchLocalDevice({ lastSeenAt: nowIso() }); - syncPeerService.flushLocalChanges(); - await sleep(150); - await refreshRoleState(); - return await this.getStatus(); - } catch (error) { - writeSavedDraft(null); - syncPeerService.setSavedDraft(null); - await refreshRoleState(); - throw error; - } - }, - - async disconnectFromBrain(): Promise { - syncPeerService.disconnect(); - writeSavedDraft(null); - deviceRegistryService.clearClusterRegistryForViewerJoin(); - await refreshRoleState(); - return await this.getStatus(); - }, - - getPin(): string | null { - return pinStore.getPin(); - }, - - async setPin(pin: string): Promise { - assertPhonePairingAvailable(); - const current = await service.getStatus(); - if (current.role !== "brain") { - throw new Error("Phone pairing PINs can only be managed on the host desktop."); - } - pinStore.setPin(pin); - const snapshot = await service.getStatus(); - args.onStatusChanged?.(snapshot); - return snapshot; - }, - - async clearPin(): Promise { - assertPhonePairingAvailable(); - const current = await service.getStatus(); - if (current.role !== "brain") { - throw new Error("Phone pairing PINs can only be managed on the host desktop."); - } - pinStore.clearPin(); - const snapshot = await service.getStatus(); - args.onStatusChanged?.(snapshot); - return snapshot; - }, - - async setActiveLanePresence(laneIds: string[]): Promise { - const normalized = Array.isArray(laneIds) - ? [...new Set( - laneIds - .map((laneId) => (typeof laneId === "string" ? laneId.trim() : "")) - .filter((laneId) => laneId.length > 0), - )] - : []; - activeLocalLanePresenceIds = normalized; - hostService?.setLocalActiveLanePresence(activeLocalLanePresenceIds); - }, - - async forgetDevice(deviceId: string): Promise { - hostService?.revokePairedDevice(deviceId); - deviceRegistryService.forgetDevice(deviceId); - await emitStatus(); - return await this.getStatus(); - }, - - async getTransferReadiness(): Promise { - return await getTransferReadiness({ force: true }); - }, - - async transferBrainToLocal(): Promise { - const current = await this.getStatus({ forceTransferReadiness: true }); - if (current.role === "brain") return current; - if (!current.transferReadiness.ready) { - throw new Error( - "Stop live missions, chats, terminals, and run processes before transferring the host role.", - ); - } - const localDevice = deviceRegistryService.ensureLocalDevice(); - const currentCluster = deviceRegistryService.getClusterState(); - deviceRegistryService.touchLocalDevice({ - lastSeenAt: nowIso(), - lastHost: localDevice.lastHost, - lastPort: localDevice.lastPort ?? DEFAULT_SYNC_HOST_PORT, - }); - deviceRegistryService.setClusterState({ - brainDeviceId: localDevice.deviceId, - brainEpoch: (currentCluster?.brainEpoch ?? 0) + 1, - updatedByDeviceId: localDevice.deviceId, - }); - syncPeerService.flushLocalChanges(); - await sleep(300); - await refreshRoleState(); - return await this.getStatus(); - }, - - handlePtyData( - event: Parameters[0], - ): void { - hostService?.handlePtyData(event); - }, - - handlePtyExit( - event: Parameters[0], - ): void { - hostService?.handlePtyExit(event); - }, - - getHostService(): SyncHostService | null { - return hostService; - }, - - getDeviceRegistryService() { - return deviceRegistryService; - }, - - async dispose(): Promise { - disposed = true; - syncPeerService.disconnect(); - clearInterval(localLanePresenceHeartbeatTimer); - await stopHostIfRunning(); - await syncPeerService.dispose(); - }, - }; - - return service; -} - -export type SyncService = ReturnType; +export * from "../../../../../ade-cli/src/services/sync/syncService"; diff --git a/apps/desktop/src/preload/global.d.ts b/apps/desktop/src/preload/global.d.ts index ad235d767..4a7e3e717 100644 --- a/apps/desktop/src/preload/global.d.ts +++ b/apps/desktop/src/preload/global.d.ts @@ -218,13 +218,6 @@ import type { CtoGetLinearOAuthSessionArgs, CtoGetLinearOAuthSessionResult, CtoRunProjectScanResult, - CtoGetOpenclawStateResult, - CtoUpdateOpenclawConfigArgs, - CtoTestOpenclawConnectionArgs, - CtoTestOpenclawConnectionResult, - CtoListOpenclawMessagesArgs, - CtoListOpenclawMessagesResult, - CtoSendOpenclawMessageArgs, LinearConnectionStatus, CtoSetLinearTokenArgs, CtoSaveFlowPolicyArgs, @@ -244,7 +237,6 @@ import type { CtoEnsureLinearWebhookArgs, CtoListLinearIngressEventsArgs, LinearWorkflowConfig, - OpenclawBridgeStatus, AddMissionArtifactArgs, AddMissionInterventionArgs, KeybindingOverride, @@ -413,6 +405,7 @@ import type { ProjectConfigTrust, ProjectConfigValidationResult, ProjectInfo, + OpenProjectBinding, CreateProjectInput, CreateProjectResult, CloneProjectInput, @@ -700,6 +693,17 @@ import type { MacosVmStopArgs, MacosVmTypeTextArgs, MacosVmWindowTarget, + RemoteRuntimeActionRequest, + RemoteRuntimeActionResult, + RemoteRuntimeConnectionSnapshot, + RemoteRuntimeConnectResult, + RemoteRuntimeDiscoveredMachine, + RemoteRuntimeLocalWorkCheckResult, + RemoteRuntimeProjectRecord, + RemoteRuntimeStreamEventsRequest, + RemoteRuntimeStreamEventsResult, + RemoteRuntimeTarget, + RemoteRuntimeTargetInput, ChatTerminalActiveForChatArgs, ChatTerminalListArgs, ChatTerminalReadArgs, @@ -726,6 +730,7 @@ declare global { getWindowSession: () => Promise<{ windowId: number | null; project: ProjectInfo | null; + binding: OpenProjectBinding | null; }>; newWindow: () => Promise<{ windowId: number | null }>; openProjectInNewWindow: ( @@ -735,15 +740,20 @@ declare global { onProjectChanged: ( cb: (project: ProjectInfo | null) => void, ) => () => void; - onNavigate: ( - cb: (request: AppNavigationRequest) => void, + onProjectBindingChanged: ( + cb: (binding: OpenProjectBinding | null) => void, ) => () => void; + onNavigate: (cb: (request: AppNavigationRequest) => void) => () => void; openExternal: (url: string) => Promise; revealPath: (path: string) => Promise; openPath: (path: string) => Promise; writeClipboardText: (text: string) => Promise; hasClipboardImage: () => Promise; - readClipboardImage: () => Promise<{ data: string; filename: string; mimeType: string } | null>; + readClipboardImage: () => Promise<{ + data: string; + filename: string; + mimeType: string; + } | null>; getImageDataUrl: (path: string) => Promise<{ dataUrl: string }>; writeClipboardImage: (path: string) => Promise; openPathInEditor: (args: { @@ -781,7 +791,9 @@ declare global { reorderRecent: ( orderedPaths: string[], ) => Promise; - createLocal: (input: CreateProjectInput) => Promise; + createLocal: ( + input: CreateProjectInput, + ) => Promise; clone: (input: CloneProjectInput) => Promise; getDefaultParentDir: () => Promise; getSnapshot: () => Promise; @@ -790,12 +802,73 @@ declare global { onMissing: (cb: (data: { rootPath: string }) => void) => () => void; onStateEvent: (cb: (event: AdeProjectEvent) => void) => () => void; }; + remoteRuntime: { + listTargets: () => Promise; + getConnectionSnapshot: () => Promise; + onConnectionSnapshotChanged: ( + cb: (snapshot: RemoteRuntimeConnectionSnapshot) => void, + ) => () => void; + listDiscoveredMachines: () => Promise; + saveTarget: ( + input: RemoteRuntimeTargetInput, + ) => Promise; + removeTarget: (id: string) => Promise<{ removed: boolean }>; + connect: (id: string) => Promise; + listProjects: (id: string) => Promise; + addProject: ( + id: string, + rootPath: string, + ) => Promise; + browseDirectories: ( + id: string, + args?: ProjectBrowseInput, + ) => Promise; + getProjectDetail: ( + id: string, + rootPath: string, + ) => Promise; + getDefaultParentDir: (id: string) => Promise; + createProject: ( + id: string, + input: CreateProjectInput, + ) => Promise; + cloneProject: ( + id: string, + input: CloneProjectInput, + ) => Promise; + listMyGitHubRepos: ( + id: string, + input?: ListMyGitHubReposInput, + ) => Promise; + openProject: ( + id: string, + projectId: string, + ) => Promise; + callAction: ( + id: string, + projectId: string, + request: RemoteRuntimeActionRequest, + ) => Promise; + streamEvents: ( + id: string, + projectId: string, + request?: RemoteRuntimeStreamEventsRequest, + ) => Promise; + checkLocalWork: ( + id: string, + project: RemoteRuntimeProjectRecord, + ) => Promise; + disconnect: (id: string) => Promise<{ disconnected: boolean }>; + }; keybindings: { get: () => Promise; set: (overrides: KeybindingOverride[]) => Promise; }; ai: { - getStatus: (args?: { force?: boolean; refreshOpenCodeInventory?: boolean }) => Promise; + getStatus: (args?: { + force?: boolean; + refreshOpenCodeInventory?: boolean; + }) => Promise; getOpenCodeRuntimeDiagnostics: () => Promise; storeApiKey: (provider: string, key: string) => Promise; deleteApiKey: (provider: string) => Promise; @@ -813,17 +886,35 @@ declare global { limit?: number; cursor?: string | null; }) => Promise; - cursorCloudCreateRun: (args: CursorCloudCreateRunRequest) => Promise; + cursorCloudCreateRun: ( + args: CursorCloudCreateRunRequest, + ) => Promise; cursorCloudArchiveAgent: (agentId: string) => Promise; cursorCloudUnarchiveAgent: (agentId: string) => Promise; cursorCloudDeleteAgent: (agentId: string) => Promise; - cursorCloudGetAgent: (agentId: string) => Promise; - cursorCloudStreamRun: (args: CursorCloudStreamRunRequest) => Promise; - cursorCloudCancelRun: (args: { agentId: string; runId: string }) => Promise; - cursorCloudFollowUp: (args: CursorCloudFollowUpRequest) => Promise; - cursorCloudListArtifacts: (agentId: string) => Promise; - cursorCloudDownloadArtifact: (args: { agentId: string; path: string }) => Promise; - cursorCloudOpenChat: (args: CursorCloudOpenChatRequest) => Promise; + cursorCloudGetAgent: ( + agentId: string, + ) => Promise; + cursorCloudStreamRun: ( + args: CursorCloudStreamRunRequest, + ) => Promise; + cursorCloudCancelRun: (args: { + agentId: string; + runId: string; + }) => Promise; + cursorCloudFollowUp: ( + args: CursorCloudFollowUpRequest, + ) => Promise; + cursorCloudListArtifacts: ( + agentId: string, + ) => Promise; + cursorCloudDownloadArtifact: (args: { + agentId: string; + path: string; + }) => Promise; + cursorCloudOpenChat: ( + args: CursorCloudOpenChatRequest, + ) => Promise; }; sync: { getStatus: (args?: SyncGetStatusArgs) => Promise; @@ -842,17 +933,20 @@ declare global { transferBrainToLocal: () => Promise; getPin: () => Promise<{ pin: string | null }>; setPin: (pin: string) => Promise; + generatePin: () => Promise; clearPin: () => Promise; - setActiveLanePresence: (args: { - laneIds: string[]; - }) => Promise; + setActiveLanePresence: (args: { laneIds: string[] }) => Promise; onEvent: (cb: (event: SyncStatusEventPayload) => void) => () => void; }; notifications: { apns: { getStatus: () => Promise; - saveConfig: (args: ApnsBridgeSaveConfigArgs) => Promise; - uploadKey: (args: ApnsBridgeUploadKeyArgs) => Promise; + saveConfig: ( + args: ApnsBridgeSaveConfigArgs, + ) => Promise; + uploadKey: ( + args: ApnsBridgeUploadKeyArgs, + ) => Promise; clearKey: () => Promise; sendTestPush: ( args: ApnsBridgeSendTestPushArgs, @@ -880,8 +974,13 @@ declare global { markWizardDismissed: () => Promise; markTourCompleted: (tourId: string) => Promise; markTourDismissed: (tourId: string) => Promise; - updateTourStep: (tourId: string, index: number) => Promise; - markGlossaryTermSeen: (termId: string) => Promise; + updateTourStep: ( + tourId: string, + index: number, + ) => Promise; + markGlossaryTermSeen: ( + termId: string, + ) => Promise; resetTourProgress: (tourId?: string) => Promise; markTourCompletedVariant: ( tourId: string, @@ -959,7 +1058,9 @@ declare global { args?: import("../shared/types").ReviewListSuppressionsArgs, ) => Promise; deleteSuppression: (suppressionId: string) => Promise; - qualityReport: () => Promise; + qualityReport: () => Promise< + import("../shared/types").ReviewQualityReport + >; onEvent: (cb: (ev: ReviewEventPayload) => void) => () => void; }; actions: { @@ -1205,7 +1306,9 @@ declare global { updateAppearance: (args: UpdateLaneAppearanceArgs) => Promise; archive: (args: ArchiveLaneArgs) => Promise; delete: (args: DeleteLaneArgs) => Promise; - cancelDelete: (args: { laneId: string }) => Promise<{ cancelled: boolean; reason?: string }>; + cancelDelete: (args: { + laneId: string; + }) => Promise<{ cancelled: boolean; reason?: string }>; getDeleteRisk: (args: { laneId: string }) => Promise; onDeleteEvent: (cb: (ev: LaneDeleteEvent) => void) => () => void; getStackChain: (laneId: string) => Promise; @@ -1301,10 +1404,14 @@ declare global { list: (args?: ListSessionsArgs) => Promise; get: (sessionId: string) => Promise; delete: (args: DeleteSessionArgs) => Promise; - updateMeta: (args: UpdateSessionMetaArgs) => Promise; + updateMeta: ( + args: UpdateSessionMetaArgs, + ) => Promise; readTranscriptTail: (args: ReadTranscriptTailArgs) => Promise; getDelta: (sessionId: string) => Promise; - onChanged: (cb: (ev: TerminalSessionChangedEvent) => void) => () => void; + onChanged: ( + cb: (ev: TerminalSessionChangedEvent) => void, + ) => () => void; }; agentChat: { list: (args?: AgentChatListArgs) => Promise; @@ -1312,9 +1419,13 @@ declare global { args: AgentChatGetSummaryArgs, ) => Promise; create: (args: AgentChatCreateArgs) => Promise; - suggestLaneName: (args: AgentChatSuggestLaneNameArgs) => Promise; + suggestLaneName: ( + args: AgentChatSuggestLaneNameArgs, + ) => Promise; parallelLaunchState: { - get: (args: AgentChatParallelLaunchStateArgs) => Promise; + get: ( + args: AgentChatParallelLaunchStateArgs, + ) => Promise; set: (args: AgentChatSetParallelLaunchStateArgs) => Promise; }; handoff: ( @@ -1324,8 +1435,12 @@ declare global { steer: (args: AgentChatSteerArgs) => Promise; cancelSteer: (args: AgentChatCancelSteerArgs) => Promise; editSteer: (args: AgentChatEditSteerArgs) => Promise; - dispatchSteer: (args: AgentChatDispatchSteerArgs) => Promise; - cancelDispatchedSteer: (args: AgentChatCancelDispatchedSteerArgs) => Promise; + dispatchSteer: ( + args: AgentChatDispatchSteerArgs, + ) => Promise; + cancelDispatchedSteer: ( + args: AgentChatCancelDispatchedSteerArgs, + ) => Promise; interrupt: (args: AgentChatInterruptArgs) => Promise; resume: (args: AgentChatResumeArgs) => Promise; approve: (args: AgentChatApproveArgs) => Promise; @@ -1390,43 +1505,98 @@ declare global { iosSimulator: { getStatus: () => Promise; listDevices: () => Promise; - listLaunchTargets: (args?: IosSimulatorListLaunchTargetsArgs) => Promise; + listLaunchTargets: ( + args?: IosSimulatorListLaunchTargetsArgs, + ) => Promise; launch: (args?: IosSimulatorLaunchArgs) => Promise; - attachToChatSession: (args: { chatSessionId: string | null; callerChatSessionId?: string | null }) => Promise; - shutdown: (args?: IosSimulatorShutdownArgs) => Promise; - screenshot: (args?: { deviceUdid?: string | null }) => Promise; - getScreenSnapshot: (args?: IosScreenSnapshotArgs) => Promise; - getInspectorSnapshot: (args?: { deviceUdid?: string | null }) => Promise; - inspectPoint: (args: IosSimulatorInspectPointArgs) => Promise; - getPreviewCapability: (args?: IosSimulatorListPreviewsArgs) => Promise; - listPreviewTargets: (args?: IosSimulatorListPreviewsArgs) => Promise; - renderPreview: (args: IosSimulatorRenderPreviewArgs) => Promise; - openPreviewWorkspace: (args?: IosSimulatorOpenPreviewWorkspaceArgs) => Promise<{ ok: true; path: string }>; - startStream: (args?: IosSimulatorStartStreamArgs) => Promise; + attachToChatSession: (args: { + chatSessionId: string | null; + callerChatSessionId?: string | null; + }) => Promise; + shutdown: ( + args?: IosSimulatorShutdownArgs, + ) => Promise; + screenshot: (args?: { + deviceUdid?: string | null; + }) => Promise; + getScreenSnapshot: ( + args?: IosScreenSnapshotArgs, + ) => Promise; + getInspectorSnapshot: (args?: { + deviceUdid?: string | null; + }) => Promise; + inspectPoint: ( + args: IosSimulatorInspectPointArgs, + ) => Promise; + getPreviewCapability: ( + args?: IosSimulatorListPreviewsArgs, + ) => Promise; + listPreviewTargets: ( + args?: IosSimulatorListPreviewsArgs, + ) => Promise; + renderPreview: ( + args: IosSimulatorRenderPreviewArgs, + ) => Promise; + openPreviewWorkspace: ( + args?: IosSimulatorOpenPreviewWorkspaceArgs, + ) => Promise<{ ok: true; path: string }>; + startStream: ( + args?: IosSimulatorStartStreamArgs, + ) => Promise; stopStream: () => Promise; getStreamStatus: () => Promise; getSimulatorWindowState: () => Promise; listSimulatorWindowSources: () => Promise; - tap: (args: { deviceUdid?: string | null; projectRoot?: string | null; x: number; y: number }) => Promise<{ ok: true }>; - typeText: (args: { deviceUdid?: string | null; projectRoot?: string | null; text: string }) => Promise<{ ok: true }>; + tap: (args: { + deviceUdid?: string | null; + projectRoot?: string | null; + x: number; + y: number; + }) => Promise<{ ok: true }>; + typeText: (args: { + deviceUdid?: string | null; + projectRoot?: string | null; + text: string; + }) => Promise<{ ok: true }>; drag: (args: IosSimulatorDragArgs) => Promise<{ ok: true }>; swipe: (args: IosSimulatorDragArgs) => Promise<{ ok: true }>; - selectPoint: (args: { deviceUdid?: string | null; projectRoot?: string | null; x: number; y: number }) => Promise; + selectPoint: (args: { + deviceUdid?: string | null; + projectRoot?: string | null; + x: number; + y: number; + }) => Promise; onEvent: (cb: (ev: IosSimulatorEventPayload) => void) => () => void; }; appControl: { getStatus: () => Promise; launch: (args?: AppControlLaunchArgs) => Promise; - launchInTerminal: (args?: AppControlLaunchArgs) => Promise; + launchInTerminal: ( + args?: AppControlLaunchArgs, + ) => Promise; connect: (args: AppControlConnectArgs) => Promise; - stop: (args?: AppControlStopArgs) => Promise<{ ok: true; previousSession: AppControlSession | null }>; + stop: ( + args?: AppControlStopArgs, + ) => Promise<{ ok: true; previousSession: AppControlSession | null }>; screenshot: () => Promise; - getSnapshot: (args?: AppControlSnapshotArgs) => Promise; - inspectPoint: (args: AppControlInspectPointArgs) => Promise; - selectPoint: (args: AppControlInspectPointArgs) => Promise; + getSnapshot: ( + args?: AppControlSnapshotArgs, + ) => Promise; + inspectPoint: ( + args: AppControlInspectPointArgs, + ) => Promise; + selectPoint: ( + args: AppControlInspectPointArgs, + ) => Promise; click: (args: AppControlClickArgs) => Promise<{ ok: true }>; typeText: (args: AppControlTypeTextArgs) => Promise<{ ok: true }>; - scroll: (args: { x: number; y: number; deltaX: number; deltaY: number; scale?: number | null }) => Promise<{ ok: true }>; + scroll: (args: { + x: number; + y: number; + deltaX: number; + deltaY: number; + scale?: number | null; + }) => Promise<{ ok: true }>; dispatchKey: (args: { type: "keyDown" | "keyUp" | "rawKeyDown" | "char"; key?: string | null; @@ -1435,18 +1605,34 @@ declare global { modifiers?: number | null; }) => Promise<{ ok: true }>; listTargets: () => Promise; - attachToTarget: (args: { targetId: string }) => Promise; + attachToTarget: (args: { + targetId: string; + }) => Promise; onEvent: (cb: (ev: AppControlEventPayload) => void) => () => void; }; builtInBrowser: { getStatus: () => Promise; - showPanel: (args?: BuiltInBrowserOpenPanelArgs) => Promise; - setBounds: (args: BuiltInBrowserBoundsArgs) => Promise; - attachWebview: (args: BuiltInBrowserAttachWebviewArgs) => Promise; - navigate: (args: BuiltInBrowserNavigateArgs) => Promise; - createTab: (args?: BuiltInBrowserCreateTabArgs) => Promise; - switchTab: (args: BuiltInBrowserTabArgs) => Promise; - closeTab: (args: BuiltInBrowserTabArgs) => Promise; + showPanel: ( + args?: BuiltInBrowserOpenPanelArgs, + ) => Promise; + setBounds: ( + args: BuiltInBrowserBoundsArgs, + ) => Promise; + attachWebview: ( + args: BuiltInBrowserAttachWebviewArgs, + ) => Promise; + navigate: ( + args: BuiltInBrowserNavigateArgs, + ) => Promise; + createTab: ( + args?: BuiltInBrowserCreateTabArgs, + ) => Promise; + switchTab: ( + args: BuiltInBrowserTabArgs, + ) => Promise; + closeTab: ( + args: BuiltInBrowserTabArgs, + ) => Promise; reload: () => Promise; goBack: () => Promise; goForward: () => Promise; @@ -1454,7 +1640,9 @@ declare global { startInspect: () => Promise; stopInspect: () => Promise; captureScreenshot: () => Promise; - selectPoint: (args: BuiltInBrowserSelectPointArgs) => Promise; + selectPoint: ( + args: BuiltInBrowserSelectPointArgs, + ) => Promise; selectCurrent: () => Promise; clearSelection: () => Promise<{ ok: true }>; onEvent: (cb: (ev: BuiltInBrowserEventPayload) => void) => () => void; @@ -1464,13 +1652,32 @@ declare global { provision: (args: MacosVmProvisionArgs) => Promise; start: (args: MacosVmStartArgs) => Promise; stop: (args: MacosVmStopArgs) => Promise; - delete: (args: MacosVmDeleteArgs) => Promise<{ deleted: boolean; previous: MacosVmRecord | null }>; - getAgentGuide: (args: MacosVmAgentGuideArgs) => Promise; - focusWindow: (args: MacosVmFocusWindowArgs) => Promise; - captureScreenshot: (args: MacosVmCaptureScreenshotArgs) => Promise; - selectPoint: (args: MacosVmSelectPointArgs) => Promise; - click: (args: MacosVmClickArgs) => Promise<{ ok: true; window: MacosVmWindowTarget; x: number; y: number }>; - typeText: (args: MacosVmTypeTextArgs) => Promise<{ ok: true; window: MacosVmWindowTarget }>; + delete: ( + args: MacosVmDeleteArgs, + ) => Promise<{ deleted: boolean; previous: MacosVmRecord | null }>; + getAgentGuide: ( + args: MacosVmAgentGuideArgs, + ) => Promise; + focusWindow: ( + args: MacosVmFocusWindowArgs, + ) => Promise; + captureScreenshot: ( + args: MacosVmCaptureScreenshotArgs, + ) => Promise; + selectPoint: ( + args: MacosVmSelectPointArgs, + ) => Promise; + click: ( + args: MacosVmClickArgs, + ) => Promise<{ + ok: true; + window: MacosVmWindowTarget; + x: number; + y: number; + }>; + typeText: ( + args: MacosVmTypeTextArgs, + ) => Promise<{ ok: true; window: MacosVmWindowTarget }>; onEvent: (cb: (ev: MacosVmEventPayload) => void) => () => void; }; terminal: { @@ -1478,7 +1685,9 @@ declare global { read: (args?: ChatTerminalReadArgs) => Promise; write: (args: ChatTerminalWriteArgs) => Promise<{ ok: true }>; signal: (args: ChatTerminalSignalArgs) => Promise<{ ok: true }>; - activeForChat: (args: ChatTerminalActiveForChatArgs) => Promise; + activeForChat: ( + args: ChatTerminalActiveForChatArgs, + ) => Promise; }; pty: { create: (args: PtyCreateArgs) => Promise; @@ -1549,8 +1758,18 @@ declare global { getSyncStatus: (args: { laneId: string; }) => Promise; - getOriginRemote: (args: { laneId: string }) => Promise<{ remoteUrl: string | null; branch: string | null }>; - getOpenPrForBranch: (args: { laneId: string; branch?: string }) => Promise<{ prUrl: string | null; prNumber: number | null; title: string | null; headRefName: string | null }>; + getOriginRemote: (args: { + laneId: string; + }) => Promise<{ remoteUrl: string | null; branch: string | null }>; + getOpenPrForBranch: (args: { + laneId: string; + branch?: string; + }) => Promise<{ + prUrl: string | null; + prNumber: number | null; + title: string | null; + headRefName: string | null; + }>; sync: (args: GitSyncArgs) => Promise; push: (args: GitPushArgs) => Promise; getConflictState: (laneId: string) => Promise; @@ -1621,8 +1840,12 @@ declare global { onEvent: (cb: (ev: ConflictEventPayload) => void) => () => void; }; feedback: { - prepareDraft: (args: FeedbackPrepareDraftArgs) => Promise; - submitDraft: (args: FeedbackSubmitDraftArgs) => Promise; + prepareDraft: ( + args: FeedbackPrepareDraftArgs, + ) => Promise; + submitDraft: ( + args: FeedbackSubmitDraftArgs, + ) => Promise; list: () => Promise; onUpdate: (cb: (event: FeedbackSubmissionEvent) => void) => () => void; }; @@ -1631,10 +1854,20 @@ declare global { setToken: (token: string) => Promise; clearToken: () => Promise; detectRepo: () => Promise<{ owner: string; name: string } | null>; - listRepoLabels: (args: { owner: string; name: string }) => Promise>; - listRepoCollaborators: (args: { owner: string; name: string }) => Promise>; - listMyRepos: (input?: ListMyGitHubReposInput) => Promise; - publishCurrentProject: (input: PublishProjectInput) => Promise; + listRepoLabels: (args: { + owner: string; + name: string; + }) => Promise>; + listRepoCollaborators: (args: { + owner: string; + name: string; + }) => Promise>; + listMyRepos: ( + input?: ListMyGitHubReposInput, + ) => Promise; + publishCurrentProject: ( + input: PublishProjectInput, + ) => Promise; onStatusChanged: (cb: (status: GitHubStatus) => void) => () => void; }; prs: { @@ -1659,7 +1892,10 @@ declare global { ) => Promise<{ title: string; body: string }>; land: (args: LandPrArgs) => Promise; landStack: (args: LandStackArgs) => Promise; - retargetBase: (args: { prId: string; baseBranch: string }) => Promise; + retargetBase: (args: { + prId: string; + baseBranch: string; + }) => Promise; openInGitHub: (prId: string) => Promise; createQueue: ( args: CreateQueuePrsArgs, @@ -1757,7 +1993,9 @@ declare global { updateBody: (args: UpdatePrBodyArgs) => Promise; setLabels: (args: SetPrLabelsArgs) => Promise; requestReviewers: (args: RequestPrReviewersArgs) => Promise; - submitReview: (args: SubmitPrReviewArgs) => Promise; + submitReview: ( + args: SubmitPrReviewArgs, + ) => Promise; close: (args: ClosePrArgs) => Promise; reopen: (args: ReopenPrArgs) => Promise; rerunChecks: (args: RerunPrChecksArgs) => Promise; @@ -1998,22 +2236,6 @@ declare global { args?: CtoListSessionLogsArgs, ) => Promise; updateIdentity: (args: CtoUpdateIdentityArgs) => Promise; - getOpenclawState: () => Promise; - updateOpenclawConfig: ( - args: CtoUpdateOpenclawConfigArgs, - ) => Promise; - testOpenclawConnection: ( - args?: CtoTestOpenclawConnectionArgs, - ) => Promise; - listOpenclawMessages: ( - args?: CtoListOpenclawMessagesArgs, - ) => Promise; - sendOpenclawMessage: ( - args: CtoSendOpenclawMessageArgs, - ) => Promise; - onOpenclawConnectionStatus: ( - cb: (status: OpenclawBridgeStatus) => void, - ) => () => void; listAgents: (args?: CtoListAgentsArgs) => Promise; saveAgent: (args: CtoSaveAgentArgs) => Promise; removeAgent: (args: CtoRemoveAgentArgs) => Promise; diff --git a/apps/desktop/src/preload/preload.test.ts b/apps/desktop/src/preload/preload.test.ts index dc5e1095c..ba4466305 100644 --- a/apps/desktop/src/preload/preload.test.ts +++ b/apps/desktop/src/preload/preload.test.ts @@ -235,4 +235,1038 @@ describe("preload OAuth bridge", () => { expect(invoke).toHaveBeenCalledWith(IPC.aiVerifyApiKey, { provider: "cursor" }); expect(invoke.mock.calls.filter(([channel]) => channel === IPC.aiGetStatus)).toHaveLength(2); }); + + it("rejects lane folder opens for remote project bindings before local lane IPC", async () => { + const binding = { + kind: "remote", + key: "remote:target-1:project-1", + targetId: "target-1", + runtimeName: "Remote", + projectId: "project-1", + rootPath: "/remote/project", + displayName: "Project", + }; + const invoke = vi.fn(async (channel: string) => { + if (channel === IPC.appGetWindowSession) { + return { windowId: 1, project: null, binding }; + } + return undefined; + }); + const on = vi.fn(); + const removeListener = vi.fn(); + const exposeInMainWorld = vi.fn((name: string, value: unknown) => { + (globalThis as any).__bridgeName = name; + (globalThis as any).__adeBridge = value; + }); + + vi.doMock("electron", () => ({ + contextBridge: { exposeInMainWorld }, + ipcRenderer: { invoke, on, removeListener }, + webFrame: { + getZoomLevel: vi.fn(() => 0), + setZoomLevel: vi.fn(), + getZoomFactor: vi.fn(() => 1), + }, + })); + + await import("./preload"); + + const bridge = (globalThis as any).__adeBridge; + await expect(bridge.lanes.openFolder({ laneId: "lane-1" })).rejects.toThrow(/remote lane folders/i); + + expect(invoke).toHaveBeenCalledWith(IPC.appGetWindowSession); + expect(invoke).not.toHaveBeenCalledWith(IPC.lanesOpenFolder, { laneId: "lane-1" }); + }); + + it("keeps lane folder opens on local project bindings routed to local lane IPC", async () => { + const binding = { + kind: "local", + key: "local:/repo", + rootPath: "/repo", + displayName: "Project", + }; + const invoke = vi.fn(async (channel: string) => { + if (channel === IPC.appGetWindowSession) { + return { windowId: 1, project: { rootPath: "/repo", displayName: "Project" }, binding }; + } + return undefined; + }); + const on = vi.fn(); + const removeListener = vi.fn(); + const exposeInMainWorld = vi.fn((name: string, value: unknown) => { + (globalThis as any).__bridgeName = name; + (globalThis as any).__adeBridge = value; + }); + + vi.doMock("electron", () => ({ + contextBridge: { exposeInMainWorld }, + ipcRenderer: { invoke, on, removeListener }, + webFrame: { + getZoomLevel: vi.fn(() => 0), + setZoomLevel: vi.fn(), + getZoomFactor: vi.fn(() => 1), + }, + })); + + await import("./preload"); + + const bridge = (globalThis as any).__adeBridge; + await bridge.lanes.openFolder({ laneId: "lane-1" }); + + expect(invoke).toHaveBeenCalledWith(IPC.appGetWindowSession); + expect(invoke).toHaveBeenCalledWith(IPC.lanesOpenFolder, { laneId: "lane-1" }); + }); + + it("routes project local-data cleanup through a remote project runtime when bound", async () => { + const binding = { + kind: "remote", + key: "remote:target-1:project-1", + targetId: "target-1", + runtimeName: "Remote", + projectId: "project-1", + rootPath: "/remote/project", + displayName: "Project", + }; + const args = { packs: true, logs: true }; + const result = { + deletedPaths: ["/remote/project/.ade/artifacts", "/remote/project/.ade/transcripts/logs"], + clearedAt: "2026-05-10T12:00:00.000Z", + }; + const invoke = vi.fn(async (channel: string) => { + if (channel === IPC.appGetWindowSession) { + return { windowId: 1, project: null, binding }; + } + if (channel === IPC.remoteRuntimeCallAction) { + return { ok: true, domain: "ade_project", action: "clearLocalData", result, statusHints: {} }; + } + return undefined; + }); + const on = vi.fn(); + const removeListener = vi.fn(); + const exposeInMainWorld = vi.fn((name: string, value: unknown) => { + (globalThis as any).__bridgeName = name; + (globalThis as any).__adeBridge = value; + }); + + vi.doMock("electron", () => ({ + contextBridge: { exposeInMainWorld }, + ipcRenderer: { invoke, on, removeListener }, + webFrame: { + getZoomLevel: vi.fn(() => 0), + setZoomLevel: vi.fn(), + getZoomFactor: vi.fn(() => 1), + }, + })); + + await import("./preload"); + + const bridge = (globalThis as any).__adeBridge; + await expect(bridge.project.clearLocalData(args)).resolves.toEqual(result); + + expect(invoke).toHaveBeenCalledWith(IPC.remoteRuntimeCallAction, { + id: "target-1", + projectId: "project-1", + request: { + domain: "ade_project", + action: "clearLocalData", + args, + }, + }); + expect(invoke).not.toHaveBeenCalledWith(IPC.projectClearLocalData, args); + }); + + it("routes session deltas and artifact previews through a remote project runtime when bound", async () => { + const binding = { + kind: "remote", + key: "remote:target-1:project-1", + targetId: "target-1", + runtimeName: "Remote", + projectId: "project-1", + rootPath: "/remote/project", + displayName: "Project", + }; + const delta = { sessionId: "session-1", filesChanged: 2 }; + const preview = "data:image/png;base64,AAAA"; + const invoke = vi.fn(async (channel: string, payload?: unknown) => { + if (channel === IPC.appGetWindowSession) { + return { windowId: 1, project: null, binding }; + } + if (channel === IPC.remoteRuntimeCallAction) { + const request = (payload as { request?: { domain?: string; action?: string } } | undefined)?.request; + if (request?.domain === "session" && request.action === "getDelta") { + return { ok: true, domain: "session", action: "getDelta", result: delta, statusHints: {} }; + } + if (request?.domain === "computer_use_artifacts" && request.action === "readArtifactPreview") { + return { + ok: true, + domain: "computer_use_artifacts", + action: "readArtifactPreview", + result: preview, + statusHints: {}, + }; + } + } + return undefined; + }); + const on = vi.fn(); + const removeListener = vi.fn(); + const exposeInMainWorld = vi.fn((name: string, value: unknown) => { + (globalThis as any).__bridgeName = name; + (globalThis as any).__adeBridge = value; + }); + + vi.doMock("electron", () => ({ + contextBridge: { exposeInMainWorld }, + ipcRenderer: { invoke, on, removeListener }, + webFrame: { + getZoomLevel: vi.fn(() => 0), + setZoomLevel: vi.fn(), + getZoomFactor: vi.fn(() => 1), + }, + })); + + await import("./preload"); + + const bridge = (globalThis as any).__adeBridge; + await expect(bridge.sessions.getDelta("session-1")).resolves.toEqual(delta); + await expect(bridge.computerUse.readArtifactPreview({ uri: ".ade/artifacts/proof.png" })).resolves.toBe(preview); + + expect(invoke).toHaveBeenCalledWith(IPC.remoteRuntimeCallAction, { + id: "target-1", + projectId: "project-1", + request: { + domain: "session", + action: "getDelta", + args: { sessionId: "session-1" }, + }, + }); + expect(invoke).toHaveBeenCalledWith(IPC.remoteRuntimeCallAction, { + id: "target-1", + projectId: "project-1", + request: { + domain: "computer_use_artifacts", + action: "readArtifactPreview", + args: { uri: ".ade/artifacts/proof.png" }, + }, + }); + expect(invoke).not.toHaveBeenCalledWith(IPC.sessionsGetDelta, { sessionId: "session-1" }); + expect(invoke).not.toHaveBeenCalledWith(IPC.computerUseReadArtifactPreview, { uri: ".ade/artifacts/proof.png" }); + }); + + it("routes Linear CTO read-model calls through a remote project runtime when bound", async () => { + const binding = { + kind: "remote", + key: "remote:target-1:project-1", + targetId: "target-1", + runtimeName: "Remote", + projectId: "project-1", + rootPath: "/remote/project", + displayName: "Project", + }; + const catalog = { users: [{ id: "user-1" }], labels: [{ id: "label-1" }], states: [{ id: "state-1" }] }; + const ingressStatus = { configured: true, webhookUrl: "https://linear.example/webhook" }; + const projects = [{ id: "project-1", name: "ADE" }]; + const picker = { projects, users: catalog.users, states: catalog.states }; + const search = { issues: [{ id: "issue-1", title: "Fix routing" }], pageInfo: { hasNextPage: false, endCursor: null } }; + const connection = { tokenStored: true, connected: true, viewerId: "user-1", viewerName: "Arul", checkedAt: "2026-05-10T00:00:00.000Z", message: null }; + const quickView = { connection, organization: { id: "org-1" }, viewer: { id: "user-1" }, projects, teams: [], assignedIssues: [], recentIssues: [], fetchedAt: "2026-05-10T00:00:00.000Z", sdk: { packageName: "@linear/sdk", surfaces: [] } }; + const route = { workflowId: "workflow-1", reason: "matched" }; + const oauthStart = { sessionId: "linear-oauth-1", authUrl: "https://linear.app/oauth/authorize", redirectUri: "http://127.0.0.1:19836/oauth/callback" }; + const oauthSession = { status: "completed", connection }; + const invoke = vi.fn(async (channel: string, payload?: unknown) => { + if (channel === IPC.appGetWindowSession) { + return { windowId: 1, project: null, binding }; + } + if (channel === IPC.remoteRuntimeCallAction) { + const request = (payload as { request?: { domain?: string; action?: string } } | undefined)?.request; + if (request?.domain === "linear_issue_tracker" && request.action === "getWorkflowCatalog") { + return { ok: true, domain: request.domain, action: request.action, result: catalog, statusHints: {} }; + } + if ( + request?.domain === "linear_credentials" && + ( + request.action === "setToken" || + request.action === "clearToken" || + request.action === "setOAuthClientCredentials" || + request.action === "clearOAuthClientCredentials" + ) + ) { + return { ok: true, domain: request.domain, action: request.action, result: undefined, statusHints: {} }; + } + if (request?.domain === "linear_issue_tracker" && request.action === "getConnectionStatus") { + return { ok: true, domain: request.domain, action: request.action, result: connection, statusHints: {} }; + } + if (request?.domain === "linear_issue_tracker" && request.action === "getQuickView") { + return { ok: true, domain: request.domain, action: request.action, result: quickView, statusHints: {} }; + } + if (request?.domain === "linear_routing" && request.action === "simulateRoute") { + return { ok: true, domain: request.domain, action: request.action, result: route, statusHints: {} }; + } + if (request?.domain === "linear_oauth" && request.action === "startSession") { + return { ok: true, domain: request.domain, action: request.action, result: oauthStart, statusHints: {} }; + } + if (request?.domain === "linear_oauth" && request.action === "getSession") { + return { ok: true, domain: request.domain, action: request.action, result: oauthSession, statusHints: {} }; + } + if (request?.domain === "linear_ingress" && request.action === "ensureRelayWebhook") { + return { ok: true, domain: request.domain, action: request.action, result: undefined, statusHints: {} }; + } + if (request?.domain === "linear_ingress" && request.action === "getStatus") { + return { ok: true, domain: request.domain, action: request.action, result: ingressStatus, statusHints: {} }; + } + if (request?.domain === "linear_issue_tracker" && request.action === "listProjects") { + return { ok: true, domain: request.domain, action: request.action, result: projects, statusHints: {} }; + } + if (request?.domain === "linear_issue_tracker" && request.action === "getIssuePickerData") { + return { ok: true, domain: request.domain, action: request.action, result: picker, statusHints: {} }; + } + if (request?.domain === "linear_issue_tracker" && request.action === "searchIssues") { + return { ok: true, domain: request.domain, action: request.action, result: search, statusHints: {} }; + } + } + return undefined; + }); + const on = vi.fn(); + const removeListener = vi.fn(); + const exposeInMainWorld = vi.fn((name: string, value: unknown) => { + (globalThis as any).__bridgeName = name; + (globalThis as any).__adeBridge = value; + }); + + vi.doMock("electron", () => ({ + contextBridge: { exposeInMainWorld }, + ipcRenderer: { invoke, on, removeListener }, + webFrame: { + getZoomLevel: vi.fn(() => 0), + setZoomLevel: vi.fn(), + getZoomFactor: vi.fn(() => 1), + }, + })); + + await import("./preload"); + + const bridge = (globalThis as any).__adeBridge; + await expect(bridge.cto.getLinearWorkflowCatalog()).resolves.toEqual(catalog); + await expect(bridge.cto.getLinearConnectionStatus()).resolves.toEqual(connection); + await expect(bridge.cto.setLinearToken({ token: "lin-token" })).resolves.toEqual(connection); + await expect(bridge.cto.clearLinearToken()).resolves.toEqual(connection); + await expect(bridge.cto.setLinearOAuthClient({ clientId: "client-id", clientSecret: "secret" })).resolves.toEqual(connection); + await expect(bridge.cto.clearLinearOAuthClient()).resolves.toEqual(connection); + await expect(bridge.cto.getLinearQuickView()).resolves.toEqual(quickView); + await expect(bridge.cto.simulateFlowRoute({ issue: { title: "Fix routing" } })).resolves.toEqual(route); + await expect(bridge.cto.startLinearOAuth()).resolves.toEqual(oauthStart); + await expect(bridge.cto.getLinearOAuthSession({ sessionId: "linear-oauth-1" })).resolves.toEqual(oauthSession); + await expect(bridge.cto.ensureLinearWebhook({ force: true })).resolves.toEqual(ingressStatus); + await expect(bridge.cto.getLinearProjects()).resolves.toEqual(projects); + await expect(bridge.cto.getLinearIssuePickerData()).resolves.toEqual(picker); + await expect(bridge.cto.searchLinearIssues({ query: "routing" })).resolves.toEqual(search); + + const actions = invoke.mock.calls + .filter(([channel]) => channel === IPC.remoteRuntimeCallAction) + .map(([, payload]) => (payload as { request: { domain: string; action: string; args?: unknown; arg?: unknown } }).request); + expect(actions).toEqual([ + { domain: "linear_issue_tracker", action: "getWorkflowCatalog" }, + { domain: "linear_issue_tracker", action: "getConnectionStatus" }, + { domain: "linear_credentials", action: "setToken", arg: "lin-token" }, + { domain: "linear_issue_tracker", action: "getConnectionStatus" }, + { domain: "linear_credentials", action: "clearToken" }, + { domain: "linear_issue_tracker", action: "getConnectionStatus" }, + { domain: "linear_credentials", action: "setOAuthClientCredentials", args: { clientId: "client-id", clientSecret: "secret" } }, + { domain: "linear_issue_tracker", action: "getConnectionStatus" }, + { domain: "linear_credentials", action: "clearOAuthClientCredentials" }, + { domain: "linear_issue_tracker", action: "getConnectionStatus" }, + { domain: "linear_issue_tracker", action: "getQuickView" }, + { domain: "linear_routing", action: "simulateRoute", args: { issue: { title: "Fix routing" } } }, + { domain: "linear_oauth", action: "startSession" }, + { domain: "linear_oauth", action: "getSession", arg: "linear-oauth-1" }, + { domain: "linear_ingress", action: "ensureRelayWebhook", arg: true }, + { domain: "linear_ingress", action: "getStatus" }, + { domain: "linear_issue_tracker", action: "listProjects" }, + { domain: "linear_issue_tracker", action: "getIssuePickerData" }, + { domain: "linear_issue_tracker", action: "searchIssues", args: { query: "routing" } }, + ]); + expect(invoke).not.toHaveBeenCalledWith(IPC.ctoGetLinearWorkflowCatalog); + expect(invoke).not.toHaveBeenCalledWith(IPC.ctoGetLinearConnectionStatus); + expect(invoke).not.toHaveBeenCalledWith(IPC.ctoSetLinearToken, { token: "lin-token" }); + expect(invoke).not.toHaveBeenCalledWith(IPC.ctoClearLinearToken); + expect(invoke).not.toHaveBeenCalledWith(IPC.ctoSetLinearOAuthClient, { clientId: "client-id", clientSecret: "secret" }); + expect(invoke).not.toHaveBeenCalledWith(IPC.ctoClearLinearOAuthClient); + expect(invoke).not.toHaveBeenCalledWith(IPC.ctoGetLinearQuickView); + expect(invoke).not.toHaveBeenCalledWith(IPC.ctoSimulateFlowRoute, { issue: { title: "Fix routing" } }); + expect(invoke).not.toHaveBeenCalledWith(IPC.ctoStartLinearOAuth); + expect(invoke).not.toHaveBeenCalledWith(IPC.ctoGetLinearOAuthSession, { sessionId: "linear-oauth-1" }); + expect(invoke).not.toHaveBeenCalledWith(IPC.ctoEnsureLinearWebhook, { force: true }); + expect(invoke).not.toHaveBeenCalledWith(IPC.ctoGetLinearProjects); + expect(invoke).not.toHaveBeenCalledWith(IPC.ctoGetLinearIssuePickerData); + expect(invoke).not.toHaveBeenCalledWith(IPC.ctoSearchLinearIssues, { query: "routing" }); + }); + + it("routes CTO identity session and project scan calls through a remote project runtime when bound", async () => { + const binding = { + kind: "remote", + key: "remote:target-1:project-1", + targetId: "target-1", + runtimeName: "Remote", + projectId: "project-1", + rootPath: "/remote/project", + displayName: "Project", + }; + const ctoSession = { id: "session-cto", identityKey: "cto" }; + const workerSession = { id: "session-worker", identityKey: "agent:worker-1" }; + const scan = { detection: null, coreMemoryPatch: { projectSummary: "Detected project setup." }, createdMemoryIds: ["mem-1"] }; + const invoke = vi.fn(async (channel: string, payload?: unknown) => { + if (channel === IPC.appGetWindowSession) { + return { windowId: 1, project: null, binding }; + } + if (channel === IPC.remoteRuntimeCallAction) { + const request = (payload as { request?: { domain?: string; action?: string } } | undefined)?.request; + if (request?.domain === "chat" && request.action === "ensureCtoSession") { + return { ok: true, domain: request.domain, action: request.action, result: ctoSession, statusHints: {} }; + } + if (request?.domain === "chat" && request.action === "ensureAgentIdentitySession") { + return { ok: true, domain: request.domain, action: request.action, result: workerSession, statusHints: {} }; + } + if (request?.domain === "cto_state" && request.action === "runProjectScan") { + return { ok: true, domain: request.domain, action: request.action, result: scan, statusHints: {} }; + } + } + return undefined; + }); + const exposeInMainWorld = vi.fn((name: string, value: unknown) => { + (globalThis as any).__bridgeName = name; + (globalThis as any).__adeBridge = value; + }); + + vi.doMock("electron", () => ({ + contextBridge: { exposeInMainWorld }, + ipcRenderer: { invoke, on: vi.fn(), removeListener: vi.fn() }, + webFrame: { + getZoomLevel: vi.fn(() => 0), + setZoomLevel: vi.fn(), + getZoomFactor: vi.fn(() => 1), + }, + })); + + await import("./preload"); + + const bridge = (globalThis as any).__adeBridge; + await expect(bridge.cto.ensureSession({ modelId: "claude-sonnet", reasoningEffort: "high" })).resolves.toEqual(ctoSession); + await expect(bridge.cto.ensureAgentSession({ agentId: "worker-1", modelId: "gpt-5.4-mini" })).resolves.toEqual(workerSession); + await expect(bridge.cto.runProjectScan()).resolves.toEqual(scan); + + const actions = invoke.mock.calls + .filter(([channel]) => channel === IPC.remoteRuntimeCallAction) + .map(([, payload]) => (payload as { request: { domain: string; action: string; args?: unknown } }).request); + expect(actions).toEqual([ + { domain: "chat", action: "ensureCtoSession", args: { modelId: "claude-sonnet", reasoningEffort: "high" } }, + { domain: "chat", action: "ensureAgentIdentitySession", args: { agentId: "worker-1", modelId: "gpt-5.4-mini" } }, + { domain: "cto_state", action: "runProjectScan" }, + ]); + expect(invoke).not.toHaveBeenCalledWith(IPC.ctoEnsureSession, { modelId: "claude-sonnet", reasoningEffort: "high" }); + expect(invoke).not.toHaveBeenCalledWith(IPC.ctoEnsureAgentSession, { agentId: "worker-1", modelId: "gpt-5.4-mini" }); + expect(invoke).not.toHaveBeenCalledWith(IPC.ctoRunProjectScan); + }); + + it("routes history list operations through a remote project runtime when bound", async () => { + const binding = { + kind: "remote", + key: "remote:target-1:project-1", + targetId: "target-1", + runtimeName: "Remote", + projectId: "project-1", + rootPath: "/remote/project", + displayName: "Project", + }; + const operation = { + id: "operation-1", + kind: "git", + status: "completed", + startedAt: "2026-05-10T12:00:00.000Z", + completedAt: "2026-05-10T12:00:01.000Z", + }; + const invoke = vi.fn(async (channel: string) => { + if (channel === IPC.appGetWindowSession) { + return { windowId: 1, project: null, binding }; + } + if (channel === IPC.remoteRuntimeCallAction) { + return { ok: true, domain: "operation", action: "list", result: [operation], statusHints: {} }; + } + return undefined; + }); + const on = vi.fn(); + const removeListener = vi.fn(); + const exposeInMainWorld = vi.fn((name: string, value: unknown) => { + (globalThis as any).__bridgeName = name; + (globalThis as any).__adeBridge = value; + }); + + vi.doMock("electron", () => ({ + contextBridge: { exposeInMainWorld }, + ipcRenderer: { invoke, on, removeListener }, + webFrame: { + getZoomLevel: vi.fn(() => 0), + setZoomLevel: vi.fn(), + getZoomFactor: vi.fn(() => 1), + }, + })); + + await import("./preload"); + + const bridge = (globalThis as any).__adeBridge; + await expect(bridge.history.listOperations({ limit: 10 })).resolves.toEqual([operation]); + + expect(invoke).toHaveBeenCalledWith(IPC.remoteRuntimeCallAction, { + id: "target-1", + projectId: "project-1", + request: { + domain: "operation", + action: "list", + args: { limit: 10 }, + }, + }); + expect(invoke).not.toHaveBeenCalledWith(IPC.historyListOperations, { limit: 10 }); + }); + + it("exports history using rows from a bound remote project runtime", async () => { + const binding = { + kind: "remote", + key: "remote:target-1:project-1", + targetId: "target-1", + runtimeName: "Remote", + projectId: "project-1", + rootPath: "/remote/project", + displayName: "Remote Project", + }; + const operation = { + id: "operation-1", + laneId: "lane-1", + laneName: "Lane 1", + kind: "git_push", + status: "succeeded", + startedAt: "2026-05-10T12:00:00.000Z", + endedAt: "2026-05-10T12:00:01.000Z", + preHeadSha: "abc", + postHeadSha: "def", + metadataJson: "{}", + }; + const exportResult = { + cancelled: false, + savedPath: "/tmp/ade-history.json", + bytesWritten: 120, + exportedAt: "2026-05-10T12:00:02.000Z", + rowCount: 1, + format: "json", + }; + const invoke = vi.fn(async (channel: string) => { + if (channel === IPC.appGetWindowSession) { + return { windowId: 1, project: null, binding }; + } + if (channel === IPC.remoteRuntimeCallAction) { + return { ok: true, domain: "operation", action: "list", result: [operation], statusHints: {} }; + } + if (channel === IPC.historyExportOperations) { + return exportResult; + } + return undefined; + }); + const on = vi.fn(); + const removeListener = vi.fn(); + const exposeInMainWorld = vi.fn((name: string, value: unknown) => { + (globalThis as any).__bridgeName = name; + (globalThis as any).__adeBridge = value; + }); + + vi.doMock("electron", () => ({ + contextBridge: { exposeInMainWorld }, + ipcRenderer: { invoke, on, removeListener }, + webFrame: { + getZoomLevel: vi.fn(() => 0), + setZoomLevel: vi.fn(), + getZoomFactor: vi.fn(() => 1), + }, + })); + + await import("./preload"); + + const bridge = (globalThis as any).__adeBridge; + await expect(bridge.history.exportOperations({ + format: "json", + status: "succeeded", + laneId: "lane-1", + limit: 25, + })).resolves.toEqual(exportResult); + + expect(invoke).toHaveBeenCalledWith(IPC.remoteRuntimeCallAction, { + id: "target-1", + projectId: "project-1", + request: { + domain: "operation", + action: "list", + args: { + laneId: "lane-1", + limit: 25, + }, + }, + }); + expect(invoke).toHaveBeenCalledWith(IPC.historyExportOperations, { + format: "json", + status: "succeeded", + laneId: "lane-1", + limit: 25, + rows: [operation], + project: { + rootPath: "/remote/project", + displayName: "Remote Project", + }, + }); + }); + + it("routes Phase 3 acceptance actions through a bound remote runtime", async () => { + const binding = { + kind: "remote", + key: "remote:target-1:project-1", + targetId: "target-1", + runtimeName: "Remote", + projectId: "project-1", + rootPath: "/remote/project", + displayName: "Project", + }; + const invoke = vi.fn(async (channel: string, payload?: unknown) => { + if (channel === IPC.appGetWindowSession) { + return { windowId: 1, project: null, binding }; + } + if (channel === IPC.remoteRuntimeCallAction) { + const request = (payload as { request?: { domain?: string; action?: string } } | undefined)?.request; + return { + ok: true, + domain: request?.domain, + action: request?.action, + result: { ok: true }, + statusHints: {}, + }; + } + return undefined; + }); + const on = vi.fn(); + const removeListener = vi.fn(); + const exposeInMainWorld = vi.fn((name: string, value: unknown) => { + (globalThis as any).__bridgeName = name; + (globalThis as any).__adeBridge = value; + }); + + vi.doMock("electron", () => ({ + contextBridge: { exposeInMainWorld }, + ipcRenderer: { invoke, on, removeListener }, + webFrame: { + getZoomLevel: vi.fn(() => 0), + setZoomLevel: vi.fn(), + getZoomFactor: vi.fn(() => 1), + }, + })); + + await import("./preload"); + + const bridge = (globalThis as any).__adeBridge; + await bridge.lanes.create({ name: "Remote lane" }); + await bridge.agentChat.create({ laneId: "lane-1", provider: "codex", model: "gpt-5.4" }); + await bridge.agentChat.send({ sessionId: "chat-1", text: "hello" }); + await bridge.agentChat.resume({ sessionId: "chat-1" }); + await bridge.git.commit({ laneId: "lane-1", message: "checkpoint" }); + await bridge.git.push({ laneId: "lane-1" }); + await bridge.prs.createFromLane({ laneId: "lane-1", title: "Remote PR", body: "Proof" }); + + const actions = invoke.mock.calls + .filter(([channel]) => channel === IPC.remoteRuntimeCallAction) + .map(([, payload]) => (payload as { request: { domain: string; action: string } }).request); + expect(actions.map((request) => `${request.domain}.${request.action}`)).toEqual([ + "lane.create", + "chat.createSession", + "chat.sendMessage", + "chat.resumeSession", + "git.commit", + "git.push", + "pr.createFromLane", + ]); + expect(invoke).not.toHaveBeenCalledWith(IPC.lanesCreate, expect.anything()); + expect(invoke).not.toHaveBeenCalledWith(IPC.agentChatCreate, expect.anything()); + expect(invoke).not.toHaveBeenCalledWith(IPC.gitCommit, expect.anything()); + expect(invoke).not.toHaveBeenCalledWith(IPC.gitPush, expect.anything()); + expect(invoke).not.toHaveBeenCalledWith(IPC.prsCreateFromLane, expect.anything()); + }); + + it("routes GitHub repo metadata through a remote project runtime when bound", async () => { + const binding = { + kind: "remote", + key: "remote:target-1:project-1", + targetId: "target-1", + runtimeName: "Remote", + projectId: "project-1", + rootPath: "/remote/project", + displayName: "Project", + }; + const labels = [{ name: "bug", color: "d73a4a" }]; + const collaborators = [{ login: "octocat", avatarUrl: "https://example.test/octocat.png" }]; + const invoke = vi.fn(async (channel: string, payload?: unknown) => { + if (channel === IPC.appGetWindowSession) { + return { windowId: 1, project: null, binding }; + } + if (channel === IPC.remoteRuntimeCallAction) { + const request = (payload as { request?: { action?: string } } | undefined)?.request; + if (request?.action === "listRepoLabels") { + return { ok: true, domain: "github", action: "listRepoLabels", result: labels, statusHints: {} }; + } + if (request?.action === "listRepoCollaborators") { + return { + ok: true, + domain: "github", + action: "listRepoCollaborators", + result: collaborators, + statusHints: {}, + }; + } + } + return undefined; + }); + const on = vi.fn(); + const removeListener = vi.fn(); + const exposeInMainWorld = vi.fn((name: string, value: unknown) => { + (globalThis as any).__bridgeName = name; + (globalThis as any).__adeBridge = value; + }); + + vi.doMock("electron", () => ({ + contextBridge: { exposeInMainWorld }, + ipcRenderer: { invoke, on, removeListener }, + webFrame: { + getZoomLevel: vi.fn(() => 0), + setZoomLevel: vi.fn(), + getZoomFactor: vi.fn(() => 1), + }, + })); + + await import("./preload"); + + const bridge = (globalThis as any).__adeBridge; + await expect(bridge.github.listRepoLabels({ owner: "acme", name: "repo" })).resolves.toEqual(labels); + await expect(bridge.github.listRepoCollaborators({ owner: "acme", name: "repo" })).resolves.toEqual(collaborators); + + expect(invoke).toHaveBeenCalledWith(IPC.remoteRuntimeCallAction, { + id: "target-1", + projectId: "project-1", + request: { + domain: "github", + action: "listRepoLabels", + args: { owner: "acme", name: "repo" }, + }, + }); + expect(invoke).toHaveBeenCalledWith(IPC.remoteRuntimeCallAction, { + id: "target-1", + projectId: "project-1", + request: { + domain: "github", + action: "listRepoCollaborators", + args: { owner: "acme", name: "repo" }, + }, + }); + expect(invoke).not.toHaveBeenCalledWith(IPC.githubListRepoLabels, { owner: "acme", name: "repo" }); + expect(invoke).not.toHaveBeenCalledWith(IPC.githubListRepoCollaborators, { owner: "acme", name: "repo" }); + }); + + it("routes GitHub publish through a remote project runtime when bound", async () => { + const binding = { + kind: "remote", + key: "remote:target-1:project-1", + targetId: "target-1", + runtimeName: "Remote", + projectId: "project-1", + rootPath: "/remote/project", + displayName: "Project", + }; + const result = { owner: "acme", name: "repo", url: "https://github.com/acme/repo" }; + const input = { name: "repo", private: true }; + const invoke = vi.fn(async (channel: string) => { + if (channel === IPC.appGetWindowSession) { + return { windowId: 1, project: null, binding }; + } + if (channel === IPC.remoteRuntimeCallAction) { + return { ok: true, domain: "github", action: "publishCurrentProject", result, statusHints: {} }; + } + return undefined; + }); + const on = vi.fn(); + const removeListener = vi.fn(); + const exposeInMainWorld = vi.fn((name: string, value: unknown) => { + (globalThis as any).__bridgeName = name; + (globalThis as any).__adeBridge = value; + }); + + vi.doMock("electron", () => ({ + contextBridge: { exposeInMainWorld }, + ipcRenderer: { invoke, on, removeListener }, + webFrame: { + getZoomLevel: vi.fn(() => 0), + setZoomLevel: vi.fn(), + getZoomFactor: vi.fn(() => 1), + }, + })); + + await import("./preload"); + + const bridge = (globalThis as any).__adeBridge; + await expect(bridge.github.publishCurrentProject(input)).resolves.toEqual(result); + + expect(invoke).toHaveBeenCalledWith(IPC.remoteRuntimeCallAction, { + id: "target-1", + projectId: "project-1", + request: { + domain: "github", + action: "publishCurrentProject", + args: input, + }, + }); + expect(invoke).not.toHaveBeenCalledWith(IPC.githubPublishCurrentProject, input); + }); + + it("routes PTY creation through a remote project runtime when bound", async () => { + const binding = { + kind: "remote", + key: "remote:target-1:project-1", + targetId: "target-1", + runtimeName: "Remote", + projectId: "project-1", + rootPath: "/remote/project", + displayName: "Project", + }; + const input = { + laneId: "lane-1", + startupCommand: "codex login", + tracked: true, + toolType: "shell", + }; + const result = { ptyId: "pty-1", sessionId: "session-1" }; + const invoke = vi.fn(async (channel: string) => { + if (channel === IPC.appGetWindowSession) { + return { windowId: 1, project: null, binding }; + } + if (channel === IPC.remoteRuntimeCallAction) { + return { ok: true, domain: "pty", action: "create", result, statusHints: {} }; + } + return undefined; + }); + const on = vi.fn(); + const removeListener = vi.fn(); + const exposeInMainWorld = vi.fn((name: string, value: unknown) => { + (globalThis as any).__bridgeName = name; + (globalThis as any).__adeBridge = value; + }); + + vi.doMock("electron", () => ({ + contextBridge: { exposeInMainWorld }, + ipcRenderer: { invoke, on, removeListener }, + webFrame: { + getZoomLevel: vi.fn(() => 0), + setZoomLevel: vi.fn(), + getZoomFactor: vi.fn(() => 1), + }, + })); + + await import("./preload"); + + const bridge = (globalThis as any).__adeBridge; + await expect(bridge.pty.create(input)).resolves.toEqual(result); + + expect(invoke).toHaveBeenCalledWith(IPC.remoteRuntimeCallAction, { + id: "target-1", + projectId: "project-1", + request: { + domain: "pty", + action: "create", + args: input, + }, + }); + expect(invoke).not.toHaveBeenCalledWith(IPC.ptyCreate, input); + }); + + it("fans out project state events from local IPC and remote runtime events", async () => { + const binding = { + kind: "remote", + key: "remote:target-1:project-1", + targetId: "target-1", + runtimeName: "Remote", + projectId: "project-1", + rootPath: "/remote/project", + displayName: "Project", + }; + const projectEvent = { + type: "config-changed", + at: "2026-05-10T12:00:00.000Z", + filePath: "/remote/project/.ade/ade.yaml", + snapshot: { + rootPath: "/remote/project", + adeDir: "/remote/project/.ade", + lastCheckedAt: "2026-05-10T12:00:00.000Z", + entries: [], + health: [], + cleanup: { changed: false, actions: [] }, + config: { + sharedPath: "/remote/project/.ade/ade.yaml", + localPath: "/remote/project/.ade/local.yaml", + secretPath: "/remote/project/.ade/local.secret.yaml", + trust: { + sharedHash: "shared", + localHash: "local", + approvedSharedHash: null, + requiresSharedTrust: false, + }, + }, + }, + }; + const invoke = vi.fn(async (channel: string) => { + if (channel === IPC.appGetWindowSession) { + return { windowId: 1, project: null, binding }; + } + if (channel === IPC.remoteRuntimeStreamEvents) { + return { events: [], nextCursor: 0, hasMore: false }; + } + return undefined; + }); + const on = vi.fn(); + const removeListener = vi.fn(); + const exposeInMainWorld = vi.fn((name: string, value: unknown) => { + (globalThis as any).__bridgeName = name; + (globalThis as any).__adeBridge = value; + }); + + vi.doMock("electron", () => ({ + contextBridge: { exposeInMainWorld }, + ipcRenderer: { invoke, on, removeListener }, + webFrame: { + getZoomLevel: vi.fn(() => 0), + setZoomLevel: vi.fn(), + getZoomFactor: vi.fn(() => 1), + }, + })); + + await import("./preload"); + + const bridge = (globalThis as any).__adeBridge; + await bridge.app.getWindowSession(); + + const callback = vi.fn(); + const unsubscribe = bridge.project.onStateEvent(callback); + + const projectStateListener = on.mock.calls.find(([channel]) => channel === IPC.projectStateEvent)?.[1]; + expect(typeof projectStateListener).toBe("function"); + projectStateListener({}, projectEvent); + expect(callback).toHaveBeenCalledWith(projectEvent); + + const runtimeListener = on.mock.calls.find(([channel]) => channel === IPC.runtimeEvent)?.[1]; + expect(typeof runtimeListener).toBe("function"); + runtimeListener({}, { + bindingKey: binding.key, + event: { + id: 1, + timestamp: "2026-05-10T12:00:01.000Z", + category: "runtime", + payload: { type: "project_state_event", event: projectEvent }, + }, + }); + expect(callback).toHaveBeenCalledTimes(2); + + unsubscribe(); + expect(removeListener).toHaveBeenCalledWith(IPC.projectStateEvent, projectStateListener); + + runtimeListener({}, { + bindingKey: binding.key, + event: { + id: 2, + timestamp: "2026-05-10T12:00:02.000Z", + category: "runtime", + payload: { type: "project_state_event", event: projectEvent }, + }, + }); + expect(callback).toHaveBeenCalledTimes(2); + }); + + it("fans out PR events from local IPC and remote runtime events", async () => { + const binding = { + kind: "remote", + key: "remote:target-1:project-1", + targetId: "target-1", + runtimeName: "Remote", + projectId: "project-1", + rootPath: "/remote/project", + displayName: "Project", + }; + const prEvent = { + type: "prs-updated", + polledAt: "2026-05-10T12:00:00.000Z", + prs: [], + }; + const invoke = vi.fn(async (channel: string) => { + if (channel === IPC.appGetWindowSession) { + return { windowId: 1, project: null, binding }; + } + if (channel === IPC.remoteRuntimeStreamEvents) { + return { events: [], nextCursor: 0, hasMore: false }; + } + return undefined; + }); + const on = vi.fn(); + const removeListener = vi.fn(); + const exposeInMainWorld = vi.fn((name: string, value: unknown) => { + (globalThis as any).__bridgeName = name; + (globalThis as any).__adeBridge = value; + }); + + vi.doMock("electron", () => ({ + contextBridge: { exposeInMainWorld }, + ipcRenderer: { invoke, on, removeListener }, + webFrame: { + getZoomLevel: vi.fn(() => 0), + setZoomLevel: vi.fn(), + getZoomFactor: vi.fn(() => 1), + }, + })); + + await import("./preload"); + + const bridge = (globalThis as any).__adeBridge; + await bridge.app.getWindowSession(); + + const callback = vi.fn(); + const unsubscribe = bridge.prs.onEvent(callback); + + const prListener = on.mock.calls.find(([channel]) => channel === IPC.prsEvent)?.[1]; + expect(typeof prListener).toBe("function"); + prListener({}, prEvent); + expect(callback).toHaveBeenCalledWith(prEvent); + + const runtimeListener = on.mock.calls.find(([channel]) => channel === IPC.runtimeEvent)?.[1]; + expect(typeof runtimeListener).toBe("function"); + runtimeListener({}, { + bindingKey: binding.key, + event: { + id: 1, + timestamp: "2026-05-10T12:00:01.000Z", + category: "runtime", + payload: { type: "pr_event", event: prEvent }, + }, + }); + expect(callback).toHaveBeenCalledTimes(2); + + unsubscribe(); + expect(removeListener).toHaveBeenCalledWith(IPC.prsEvent, prListener); + + runtimeListener({}, { + bindingKey: binding.key, + event: { + id: 2, + timestamp: "2026-05-10T12:00:02.000Z", + category: "runtime", + payload: { type: "pr_event", event: prEvent }, + }, + }); + expect(callback).toHaveBeenCalledTimes(2); + }); }); diff --git a/apps/desktop/src/preload/preload.ts b/apps/desktop/src/preload/preload.ts index f9c700fec..d79ccadf8 100644 --- a/apps/desktop/src/preload/preload.ts +++ b/apps/desktop/src/preload/preload.ts @@ -38,10 +38,15 @@ import type { AutomationSimulateRequest, AutomationSimulateResult, ReviewEventPayload, + ReviewFeedbackRecord, ReviewLaunchContext, ReviewListRunsArgs, + ReviewListSuppressionsArgs, + ReviewQualityReport, + ReviewRecordFeedbackArgs, ReviewRun, ReviewRunDetail, + ReviewSuppression, ReviewStartRunArgs, AdeActionRegistryEntry, AdeCliInstallResult, @@ -119,13 +124,6 @@ import type { CtoGetLinearOAuthSessionArgs, CtoGetLinearOAuthSessionResult, CtoRunProjectScanResult, - CtoGetOpenclawStateResult, - CtoUpdateOpenclawConfigArgs, - CtoTestOpenclawConnectionArgs, - CtoTestOpenclawConnectionResult, - CtoListOpenclawMessagesArgs, - CtoListOpenclawMessagesResult, - CtoSendOpenclawMessageArgs, LinearConnectionStatus, CtoSetLinearOAuthClientArgs, LinearIngressEventRecord, @@ -146,7 +144,6 @@ import type { CtoEnsureLinearWebhookArgs, CtoListLinearIngressEventsArgs, LinearWorkflowConfig, - OpenclawBridgeStatus, AddMissionArtifactArgs, AddMissionInterventionArgs, AutomationsEventPayload, @@ -349,6 +346,7 @@ import type { ProjectConfigTrust, ProjectConfigValidationResult, ProjectInfo, + OpenProjectBinding, CreateProjectInput, CreateProjectResult, CloneProjectInput, @@ -705,6 +703,19 @@ import type { MacosVmStopArgs, MacosVmTypeTextArgs, MacosVmWindowTarget, + RemoteRuntimeActionRequest, + RemoteRuntimeActionResult, + RemoteRuntimeBufferedEvent, + RemoteRuntimeConnectionSnapshot, + RemoteRuntimeConnectResult, + RemoteRuntimeDiscoveredMachine, + RemoteRuntimeEventNotificationPayload, + RemoteRuntimeLocalWorkCheckResult, + RemoteRuntimeProjectRecord, + RemoteRuntimeStreamEventsRequest, + RemoteRuntimeStreamEventsResult, + RemoteRuntimeTarget, + RemoteRuntimeTargetInput, ChatTerminalActiveForChatArgs, ChatTerminalListArgs, ChatTerminalReadArgs, @@ -724,7 +735,10 @@ type ShortIpcCache = { get: (opts?: { force?: boolean }) => Promise; }; -function createShortIpcCache(loader: () => Promise, ttlMs: number): ShortIpcCache { +function createShortIpcCache( + loader: () => Promise, + ttlMs: number, +): ShortIpcCache { let value: T | undefined; let promise: Promise | null = null; let expiresAt = 0; @@ -842,39 +856,54 @@ const aiStatusCache = (() => { }; const get = async (key: string): Promise => { - const args = parseIpcCacheArgs<{ refreshOpenCodeInventory?: boolean }>(key, {}); + const args = parseIpcCacheArgs<{ refreshOpenCodeInventory?: boolean }>( + key, + {}, + ); const wantsOpenCodeInventory = args.refreshOpenCodeInventory === true; const now = Date.now(); if ( - value !== undefined - && expiresAt > now - && (!wantsOpenCodeInventory || includesOpenCodeInventory) + value !== undefined && + expiresAt > now && + (!wantsOpenCodeInventory || includesOpenCodeInventory) ) { return value; } if ( - promise - && (!wantsOpenCodeInventory || promiseIncludesOpenCodeInventory) + promise && + (!wantsOpenCodeInventory || promiseIncludesOpenCodeInventory) ) { return promise; } promiseIncludesOpenCodeInventory = wantsOpenCodeInventory; - const request = ipcRenderer.invoke(IPC.aiGetStatus, { - refreshOpenCodeInventory: wantsOpenCodeInventory, - }).then((status: AiSettingsStatus) => { - if (promise === request) { - value = status; - expiresAt = Date.now() + 10_000; - includesOpenCodeInventory = wantsOpenCodeInventory; - } - return status; - }).finally(() => { - if (promise === request) { - promise = null; - promiseIncludesOpenCodeInventory = false; - } - }); + const request = callProjectRuntimeActionOr( + "ai", + "getStatus", + { + args: { + refreshOpenCodeInventory: wantsOpenCodeInventory, + }, + }, + () => + ipcRenderer.invoke(IPC.aiGetStatus, { + refreshOpenCodeInventory: wantsOpenCodeInventory, + }), + ) + .then((status: AiSettingsStatus) => { + if (promise === request) { + value = status; + expiresAt = Date.now() + 10_000; + includesOpenCodeInventory = wantsOpenCodeInventory; + } + return status; + }) + .finally(() => { + if (promise === request) { + promise = null; + promiseIncludesOpenCodeInventory = false; + } + }); promise = request; return request; }; @@ -888,37 +917,61 @@ const githubStatusCache = createShortIpcCache( ); const lanesListCache = createKeyedShortIpcCache( - (key) => ipcRenderer.invoke(IPC.lanesList, parseIpcCacheArgs(key, {})), + (key) => + ipcRenderer.invoke( + IPC.lanesList, + parseIpcCacheArgs(key, {}), + ), 2_000, ); const lanesListSnapshotsCache = createKeyedShortIpcCache( - (key) => ipcRenderer.invoke(IPC.lanesListSnapshots, parseIpcCacheArgs(key, {})), + (key) => + ipcRenderer.invoke( + IPC.lanesListSnapshots, + parseIpcCacheArgs(key, {}), + ), 2_000, ); const sessionDeltaCache = createKeyedShortIpcCache( - (sessionId) => ipcRenderer.invoke(IPC.sessionsGetDelta, { sessionId }), + (sessionId) => + callProjectRuntimeActionOr( + "session", + "getDelta", + { args: { sessionId } }, + () => ipcRenderer.invoke(IPC.sessionsGetDelta, { sessionId }), + ), 1_000, ); -const agentChatSummaryCache = createKeyedShortIpcCache( - (sessionId) => ipcRenderer.invoke(IPC.agentChatGetSummary, { sessionId }), - 1_000, -); +const agentChatSummaryCache = + createKeyedShortIpcCache( + (sessionId) => ipcRenderer.invoke(IPC.agentChatGetSummary, { sessionId }), + 1_000, + ); const iosSimulatorStatusCache = createShortIpcCache( - () => ipcRenderer.invoke(IPC.iosSimulatorGetStatus), + () => + callProjectRuntimeActionOr("ios_simulator", "getStatus", {}, () => + ipcRenderer.invoke(IPC.iosSimulatorGetStatus), + ), 2_000, ); const iosSimulatorDevicesCache = createShortIpcCache( - () => ipcRenderer.invoke(IPC.iosSimulatorListDevices), + () => + callProjectRuntimeActionOr("ios_simulator", "listDevices", {}, () => + ipcRenderer.invoke(IPC.iosSimulatorListDevices), + ), 2_000, ); const appControlStatusCache = createShortIpcCache( - () => ipcRenderer.invoke(IPC.appControlGetStatus), + () => + callProjectRuntimeActionOr("app_control", "getStatus", {}, () => + ipcRenderer.invoke(IPC.appControlGetStatus), + ), 1_000, ); @@ -927,18 +980,26 @@ const builtInBrowserStatusCache = createShortIpcCache( 500, ); -const macosVmStatusCache = createKeyedShortIpcCache( - (key) => ipcRenderer.invoke(IPC.macosVmGetStatus, parseIpcCacheArgs(key, {})), - 750, -); +const macosVmStatusCache = createKeyedShortIpcCache((key) => { + const args = parseIpcCacheArgs(key, {}); + return callProjectRuntimeActionOr("macos_vm", "getStatus", { args }, () => + ipcRenderer.invoke(IPC.macosVmGetStatus, args), + ); +}, 750); -const computerUseOwnerSnapshotCache = createKeyedShortIpcCache( - (key) => ipcRenderer.invoke( - IPC.computerUseGetOwnerSnapshot, - parseIpcCacheArgs(key, {} as ComputerUseOwnerSnapshotArgs), - ), - 2_000, -); +const computerUseOwnerSnapshotCache = + createKeyedShortIpcCache((key) => { + const args = parseIpcCacheArgs( + key, + {} as ComputerUseOwnerSnapshotArgs, + ); + return callProjectRuntimeActionOr( + "computer_use_artifacts", + "getOwnerSnapshot", + { args }, + () => ipcRenderer.invoke(IPC.computerUseGetOwnerSnapshot, args), + ); + }, 2_000); const imageDataUrlCache = createKeyedShortIpcCache<{ dataUrl: string }>( (path) => ipcRenderer.invoke(IPC.appGetImageDataUrl, { path }), @@ -951,15 +1012,1211 @@ const projectIconCache = createKeyedShortIpcCache( ); const diffChangesCache = createKeyedShortIpcCache( - (key) => ipcRenderer.invoke(IPC.diffGetChanges, parseIpcCacheArgs(key, {} as GetDiffChangesArgs)), + (key) => + ipcRenderer.invoke( + IPC.diffGetChanges, + parseIpcCacheArgs(key, {} as GetDiffChangesArgs), + ), 2_000, ); const gitBranchesCache = createKeyedShortIpcCache( - (key) => ipcRenderer.invoke(IPC.gitListBranches, parseIpcCacheArgs(key, {} as GitListBranchesArgs)), + (key) => + ipcRenderer.invoke( + IPC.gitListBranches, + parseIpcCacheArgs(key, {} as GitListBranchesArgs), + ), 2_000, ); +const allowLocalRuntimeFallback = + process.env.ADE_LOCAL_RUNTIME_FALLBACK !== "0" && + ( + process.env.ADE_LOCAL_RUNTIME_FALLBACK === "1" || + process.env.ADE_DISABLE_LOCAL_RUNTIME_DAEMON === "1" || + process.env.ADE_PACKAGE_CHANNEL === "alpha" + ); + +function isSafeLocalRuntimeFallbackError(error: unknown): boolean { + const message = error instanceof Error ? error.message : String(error); + return ( + /\b(ECONNREFUSED|ECONNRESET|EPIPE|ENOENT|ETIMEDOUT)\b/i.test(message) || + /Local runtime daemon is not available/i.test(message) || + /ADE service connection (?:closed|failed)/i.test(message) || + /Timed out connecting to ADE service socket/i.test(message) || + /Unsupported database value/i.test(message) || + /UNIQUE constraint failed: process_definitions\.id/i.test(message) || + /no such function: crsql_internal_sync_bit/i.test(message) || + /database is not open/i.test(message) + ); +} + +let currentProjectBinding: OpenProjectBinding | null = null; +let projectBindingGeneration = 0; + +function rememberProjectBinding(binding: OpenProjectBinding | null): void { + const previousKey = currentProjectBinding?.key ?? null; + const nextKey = binding?.key ?? null; + currentProjectBinding = binding; + if (previousKey !== nextKey) { + projectBindingGeneration += 1; + resetRemoteRuntimeEventDedup(nextKey); + } + if (binding?.kind === "remote" || binding?.kind === "local") { + ensureRemoteRuntimeEventPump(); + } +} + +async function getRemoteProjectBinding(): Promise | null> { + if (currentProjectBinding) { + return currentProjectBinding.kind === "remote" + ? currentProjectBinding + : null; + } + const session = (await ipcRenderer.invoke(IPC.appGetWindowSession)) as { + binding?: OpenProjectBinding | null; + } | null; + rememberProjectBinding(session?.binding ?? null); + return session?.binding?.kind === "remote" ? session.binding : null; +} + +async function getLocalProjectBinding(): Promise | null> { + if (currentProjectBinding) { + return currentProjectBinding.kind === "local" + ? currentProjectBinding + : null; + } + const session = (await ipcRenderer.invoke(IPC.appGetWindowSession)) as { + binding?: OpenProjectBinding | null; + } | null; + rememberProjectBinding(session?.binding ?? null); + return session?.binding?.kind === "local" ? session.binding : null; +} + +async function getProjectRuntimeBinding(): Promise { + if (currentProjectBinding) return currentProjectBinding; + const session = (await ipcRenderer.invoke(IPC.appGetWindowSession)) as { + binding?: OpenProjectBinding | null; + } | null; + rememberProjectBinding(session?.binding ?? null); + return session?.binding ?? null; +} + +async function callRemoteProjectActionIfBound( + domain: string, + action: string, + request: Omit = {}, +): Promise<{ handled: true; result: T } | { handled: false }> { + const binding = await getRemoteProjectBinding(); + if (!binding) return { handled: false }; + const response = (await ipcRenderer.invoke(IPC.remoteRuntimeCallAction, { + id: binding.targetId, + projectId: binding.projectId, + request: { domain, action, ...request }, + })) as RemoteRuntimeActionResult; + return { handled: true, result: response.result as T }; +} + +async function callLocalProjectActionIfBound( + domain: string, + action: string, + request: Omit = {}, +): Promise<{ handled: true; result: T } | { handled: false }> { + const binding = await getLocalProjectBinding(); + if (!binding) return { handled: false }; + try { + const response = (await ipcRenderer.invoke(IPC.localRuntimeCallAction, { + request: { domain, action, ...request }, + })) as RemoteRuntimeActionResult; + return { handled: true, result: response.result as T }; + } catch (error) { + if (!allowLocalRuntimeFallback || !isSafeLocalRuntimeFallbackError(error)) { + throw error; + } + console.warn( + "Local ADE service action failed; using in-process fallback.", + error, + ); + return { handled: false }; + } +} + +async function callProjectRuntimeActionIfBound( + domain: string, + action: string, + request: Omit = {}, +): Promise<{ handled: true; result: T } | { handled: false }> { + const remote = await callRemoteProjectActionIfBound( + domain, + action, + request, + ); + if (remote.handled) return remote; + return callLocalProjectActionIfBound(domain, action, request); +} + +async function callProjectRuntimeActionOr( + domain: string, + action: string, + request: Omit, + local: () => Promise, +): Promise { + const runtime = await callProjectRuntimeActionIfBound( + domain, + action, + request, + ); + return runtime.handled ? runtime.result : local(); +} + +async function callRemoteProjectSyncIfBound( + method: string, + params: Record = {}, +): Promise<{ handled: true; result: T } | { handled: false }> { + const binding = await getRemoteProjectBinding(); + if (!binding) return { handled: false }; + const result = (await ipcRenderer.invoke(IPC.remoteRuntimeCallSync, { + id: binding.targetId, + projectId: binding.projectId, + method, + params, + })) as T; + return { handled: true, result }; +} + +async function callLocalProjectSyncIfBound( + method: string, + params: Record = {}, +): Promise<{ handled: true; result: T } | { handled: false }> { + const binding = await getLocalProjectBinding(); + if (!binding) return { handled: false }; + try { + const result = (await ipcRenderer.invoke(IPC.localRuntimeCallSync, { + method, + params, + })) as T; + return { handled: true, result }; + } catch (error) { + if (!allowLocalRuntimeFallback || !isSafeLocalRuntimeFallbackError(error)) { + throw error; + } + console.warn( + "Local ADE service sync call failed; using in-process fallback.", + error, + ); + return { handled: false }; + } +} + +async function callProjectRuntimeSyncOr( + method: string, + params: Record, + local: () => Promise, +): Promise { + const remote = await callRemoteProjectSyncIfBound(method, params); + if (remote.handled) return remote.result; + const localRuntime = await callLocalProjectSyncIfBound(method, params); + return localRuntime.handled ? localRuntime.result : local(); +} + +const remoteAgentChatEventCallbacks = new Set< + (payload: AgentChatEventEnvelope) => void +>(); +const remoteSessionChangedCallbacks = new Set< + (payload: TerminalSessionChangedEvent) => void +>(); +const remoteLaneDeleteEventCallbacks = new Set< + (payload: LaneDeleteEvent) => void +>(); +const remoteLaneRebaseEventCallbacks = new Set< + (payload: RebaseRunEventPayload) => void +>(); +const remoteLaneRebaseSuggestionsEventCallbacks = new Set< + (payload: RebaseSuggestionsEventPayload) => void +>(); +const remoteLaneAutoRebaseEventCallbacks = new Set< + (payload: AutoRebaseEventPayload) => void +>(); +const remoteLaneEnvEventCallbacks = new Set< + (payload: LaneEnvInitEvent) => void +>(); +const remoteLanePortEventCallbacks = new Set< + (payload: PortAllocationEvent) => void +>(); +const remoteLaneProxyEventCallbacks = new Set< + (payload: LaneProxyEvent) => void +>(); +const remoteLaneOAuthEventCallbacks = new Set< + (payload: OAuthRedirectEvent) => void +>(); +const remoteLaneDiagnosticsEventCallbacks = new Set< + (payload: RuntimeDiagnosticsEvent) => void +>(); +const remotePtyDataEventCallbacks = new Set<(payload: PtyDataEvent) => void>(); +const remotePtyExitEventCallbacks = new Set<(payload: PtyExitEvent) => void>(); +const remoteProcessEventCallbacks = new Set<(payload: ProcessEvent) => void>(); +const remoteTestEventCallbacks = new Set<(payload: TestEvent) => void>(); +const remoteFileChangeEventCallbacks = new Set< + (payload: FileChangeEvent) => void +>(); +const remotePrEventCallbacks = new Set<(payload: PrEventPayload) => void>(); +const remotePrAiResolutionEventCallbacks = new Set< + (payload: PrAiResolutionEventPayload) => void +>(); +const remoteProjectStateEventCallbacks = new Set< + (payload: AdeProjectEvent) => void +>(); +const remoteMissionEventCallbacks = new Set< + (payload: MissionsEventPayload) => void +>(); +const remoteOrchestratorEventCallbacks = new Set< + (payload: OrchestratorRuntimeEvent) => void +>(); +const remoteOrchestratorThreadEventCallbacks = new Set< + (payload: OrchestratorThreadEvent) => void +>(); +const remoteDagMutationEventCallbacks = new Set< + (payload: DagMutationEvent) => void +>(); +const remoteSyncStatusEventCallbacks = new Set< + (payload: SyncStatusEventPayload) => void +>(); +const remoteReviewEventCallbacks = new Set< + (payload: ReviewEventPayload) => void +>(); +let remoteRuntimeEventTimer: ReturnType | null = null; +let remoteRuntimeEventInFlight = false; +let remoteRuntimeEventCursor = 0; +let remoteRuntimeEventBindingKey: string | null = null; +let remoteRuntimeEventGeneration = -1; +let remoteRuntimeEventStartedAtMs = 0; +let remoteRuntimeSeenEventBindingKey: string | null = null; +const remoteRuntimeSeenEventIds = new Set(); + +function resetRemoteRuntimeEventDedup(bindingKey: string | null): void { + remoteRuntimeSeenEventBindingKey = bindingKey; + remoteRuntimeSeenEventIds.clear(); +} + +function shouldDispatchRemoteRuntimeEvent( + bindingKey: string, + event: RemoteRuntimeBufferedEvent, +): boolean { + if (remoteRuntimeSeenEventBindingKey !== bindingKey) { + resetRemoteRuntimeEventDedup(bindingKey); + } + if (remoteRuntimeSeenEventIds.has(event.id)) return false; + remoteRuntimeSeenEventIds.add(event.id); + while (remoteRuntimeSeenEventIds.size > 1_000) { + const oldest = remoteRuntimeSeenEventIds.values().next().value; + if (typeof oldest !== "number") break; + remoteRuntimeSeenEventIds.delete(oldest); + } + remoteRuntimeEventCursor = Math.max(remoteRuntimeEventCursor, event.id); + return true; +} + +function hasRemoteRuntimeEventSubscribers(): boolean { + return ( + remoteAgentChatEventCallbacks.size > 0 || + remoteMissionEventCallbacks.size > 0 || + remoteOrchestratorEventCallbacks.size > 0 || + remoteOrchestratorThreadEventCallbacks.size > 0 || + remoteDagMutationEventCallbacks.size > 0 || + remoteSyncStatusEventCallbacks.size > 0 || + remoteReviewEventCallbacks.size > 0 || + remoteSessionChangedCallbacks.size > 0 || + remoteLaneDeleteEventCallbacks.size > 0 || + remoteLaneRebaseEventCallbacks.size > 0 || + remoteLaneRebaseSuggestionsEventCallbacks.size > 0 || + remoteLaneAutoRebaseEventCallbacks.size > 0 || + remoteLaneEnvEventCallbacks.size > 0 || + remoteLanePortEventCallbacks.size > 0 || + remoteLaneProxyEventCallbacks.size > 0 || + remoteLaneOAuthEventCallbacks.size > 0 || + remoteLaneDiagnosticsEventCallbacks.size > 0 || + remotePtyDataEventCallbacks.size > 0 || + remotePtyExitEventCallbacks.size > 0 || + remoteProcessEventCallbacks.size > 0 || + remoteTestEventCallbacks.size > 0 || + remoteFileChangeEventCallbacks.size > 0 || + remotePrEventCallbacks.size > 0 || + remoteProjectStateEventCallbacks.size > 0 || + remotePrAiResolutionEventCallbacks.size > 0 + ); +} + +function ensureRemoteRuntimeEventPump(): void { + if (!hasRemoteRuntimeEventSubscribers()) return; + if (remoteRuntimeEventTimer || remoteRuntimeEventInFlight) return; + remoteRuntimeEventTimer = setTimeout(() => { + remoteRuntimeEventTimer = null; + void pollRemoteRuntimeEvents(); + }, 0); +} + +function scheduleRemoteRuntimeEventPoll(delayMs: number): void { + if (!hasRemoteRuntimeEventSubscribers()) return; + if (remoteRuntimeEventTimer || remoteRuntimeEventInFlight) return; + remoteRuntimeEventTimer = setTimeout(() => { + remoteRuntimeEventTimer = null; + void pollRemoteRuntimeEvents(); + }, delayMs); +} + +async function pollRemoteRuntimeEvents(): Promise { + if (remoteRuntimeEventInFlight || !hasRemoteRuntimeEventSubscribers()) return; + remoteRuntimeEventInFlight = true; + let nextDelayMs: number | null = null; + try { + const binding = await getProjectRuntimeBinding(); + if (!binding || (binding.kind !== "remote" && binding.kind !== "local")) { + remoteRuntimeEventCursor = 0; + remoteRuntimeEventBindingKey = null; + remoteRuntimeEventGeneration = projectBindingGeneration; + remoteRuntimeEventStartedAtMs = 0; + resetRemoteRuntimeEventDedup(null); + return; + } + + if ( + remoteRuntimeEventBindingKey !== binding.key || + remoteRuntimeEventGeneration !== projectBindingGeneration + ) { + remoteRuntimeEventCursor = 0; + remoteRuntimeEventBindingKey = binding.key; + remoteRuntimeEventGeneration = projectBindingGeneration; + remoteRuntimeEventStartedAtMs = Date.now(); + resetRemoteRuntimeEventDedup(binding.key); + } + + const request = { + cursor: remoteRuntimeEventCursor, + limit: 100, + category: "runtime", + } satisfies RemoteRuntimeStreamEventsRequest; + const batch = + binding.kind === "remote" + ? ((await ipcRenderer.invoke(IPC.remoteRuntimeStreamEvents, { + id: binding.targetId, + projectId: binding.projectId, + request, + })) as RemoteRuntimeStreamEventsResult) + : ((await ipcRenderer.invoke(IPC.localRuntimeStreamEvents, { + request, + })) as RemoteRuntimeStreamEventsResult); + + remoteRuntimeEventCursor = Number.isFinite(batch.nextCursor) + ? Math.max(0, Math.floor(batch.nextCursor)) + : remoteRuntimeEventCursor; + + for (const event of batch.events) { + const eventTime = Date.parse(event.timestamp); + if ( + remoteRuntimeEventStartedAtMs > 0 && + Number.isFinite(eventTime) && + eventTime < remoteRuntimeEventStartedAtMs - 1_000 + ) { + continue; + } + if (!shouldDispatchRemoteRuntimeEvent(binding.key, event)) continue; + dispatchRemoteRuntimeEventPayload(event.payload); + } + nextDelayMs = batch.hasMore ? 50 : 750; + } catch (error) { + console.warn("Remote ADE service event polling failed", error); + nextDelayMs = 2_000; + } finally { + remoteRuntimeEventInFlight = false; + if ( + nextDelayMs != null && + hasRemoteRuntimeEventSubscribers() && + (currentProjectBinding?.kind === "remote" || + currentProjectBinding?.kind === "local") && + !remoteRuntimeEventTimer + ) { + scheduleRemoteRuntimeEventPoll(nextDelayMs); + } + } +} + +function handleRemoteRuntimeEventNotification(value: unknown): void { + const payload = toRemoteRuntimeEventNotificationPayload(value); + const binding = currentProjectBinding; + if (!payload || !binding || payload.bindingKey !== binding.key) return; + const eventTime = Date.parse(payload.event.timestamp); + if ( + remoteRuntimeEventStartedAtMs > 0 && + Number.isFinite(eventTime) && + eventTime < remoteRuntimeEventStartedAtMs - 1_000 + ) { + return; + } + if (!shouldDispatchRemoteRuntimeEvent(payload.bindingKey, payload.event)) + return; + dispatchRemoteRuntimeEventPayload(payload.event.payload); +} + +function toRemoteRuntimeEventNotificationPayload( + value: unknown, +): RemoteRuntimeEventNotificationPayload | null { + if (!isRecord(value)) return null; + const bindingKey = + typeof value.bindingKey === "string" ? value.bindingKey : ""; + const event = toRemoteRuntimeBufferedEvent(value.event); + if (!bindingKey || !event) return null; + return { bindingKey, event }; +} + +function toRemoteRuntimeBufferedEvent( + value: unknown, +): RemoteRuntimeBufferedEvent | null { + if (!isRecord(value)) return null; + if (typeof value.id !== "number" || !Number.isFinite(value.id)) return null; + if (typeof value.timestamp !== "string") return null; + const category = value.category; + if ( + category !== "orchestrator" && + category !== "dag_mutation" && + category !== "runtime" && + category !== "mission" + ) { + return null; + } + const payload = isRecord(value.payload) ? value.payload : {}; + return { + id: Math.max(0, Math.floor(value.id)), + timestamp: value.timestamp, + category, + payload, + }; +} + +ipcRenderer.on(IPC.runtimeEvent, (_event, payload: unknown) => { + handleRemoteRuntimeEventNotification(payload); +}); + +function dispatchRemoteRuntimeEventPayload( + payload: Record, +): void { + if (payload.type === "missions-updated") { + for (const cb of [...remoteMissionEventCallbacks]) { + try { + cb(payload as MissionsEventPayload); + } catch (error) { + console.error("preload remote mission listener failed", error); + } + } + } + + if (payload.type === "sync-status" && isRecord(payload.snapshot)) { + for (const cb of [...remoteSyncStatusEventCallbacks]) { + try { + cb(payload as SyncStatusEventPayload); + } catch (error) { + console.error("preload remote sync listener failed", error); + } + } + } + + const reviewEvent = toWrappedEvent( + payload, + "review_event", + ); + if (reviewEvent) { + for (const cb of [...remoteReviewEventCallbacks]) { + try { + cb(reviewEvent); + } catch (error) { + console.error("preload remote review listener failed", error); + } + } + } + + if ( + payload.type === "orchestrator-run-updated" || + payload.type === "orchestrator-step-updated" || + payload.type === "orchestrator-attempt-updated" || + payload.type === "orchestrator-claim-updated" + ) { + for (const cb of [...remoteOrchestratorEventCallbacks]) { + try { + cb(payload as OrchestratorRuntimeEvent); + } catch (error) { + console.error("preload remote orchestrator listener failed", error); + } + } + } + + if ( + payload.type === "thread_updated" || + payload.type === "message_appended" || + payload.type === "message_updated" || + payload.type === "metrics_updated" || + payload.type === "worker_digest_updated" || + payload.type === "worker_replay" + ) { + for (const cb of [...remoteOrchestratorThreadEventCallbacks]) { + try { + cb(payload as OrchestratorThreadEvent); + } catch (error) { + console.error( + "preload remote orchestrator thread listener failed", + error, + ); + } + } + } + + if ( + typeof payload.runId === "string" && + isRecord(payload.mutation) && + typeof payload.timestamp === "string" + ) { + for (const cb of [...remoteDagMutationEventCallbacks]) { + try { + cb(payload as DagMutationEvent); + } catch (error) { + console.error("preload remote DAG mutation listener failed", error); + } + } + } + + const chatEvent = toAgentChatEventEnvelope(payload); + if (chatEvent) { + agentChatSummaryCache.clear(); + for (const cb of [...remoteAgentChatEventCallbacks]) { + try { + cb(chatEvent); + } catch (error) { + console.error("preload remote agent chat listener failed", error); + } + } + } + + const sessionChanged = toTerminalSessionChangedEvent(payload); + if (sessionChanged) { + sessionDeltaCache.clear(); + for (const cb of [...remoteSessionChangedCallbacks]) { + try { + cb(sessionChanged); + } catch (error) { + console.error("preload remote session listener failed", error); + } + } + } + + const laneDeleteEvent = toWrappedEvent( + payload, + "lane_delete_event", + ); + if (laneDeleteEvent) { + clearGitReadCaches(); + for (const cb of [...remoteLaneDeleteEventCallbacks]) { + try { + cb(laneDeleteEvent); + } catch (error) { + console.error("preload remote lane delete listener failed", error); + } + } + } + + const laneRebaseEvent = toWrappedEvent( + payload, + "lane_rebase_event", + ); + if (laneRebaseEvent) { + clearGitReadCaches(); + for (const cb of [...remoteLaneRebaseEventCallbacks]) { + try { + cb(laneRebaseEvent); + } catch (error) { + console.error("preload remote lane rebase listener failed", error); + } + } + } + + const rebaseSuggestionsEvent = toWrappedEvent( + payload, + "lane_rebase_suggestions_event", + ); + if (rebaseSuggestionsEvent) { + for (const cb of [...remoteLaneRebaseSuggestionsEventCallbacks]) { + try { + cb(rebaseSuggestionsEvent); + } catch (error) { + console.error( + "preload remote rebase suggestions listener failed", + error, + ); + } + } + } + + const autoRebaseEvent = toWrappedEvent( + payload, + "lane_auto_rebase_event", + ); + if (autoRebaseEvent) { + for (const cb of [...remoteLaneAutoRebaseEventCallbacks]) { + try { + cb(autoRebaseEvent); + } catch (error) { + console.error("preload remote auto rebase listener failed", error); + } + } + } + + const envEvent = toWrappedEvent(payload, "lane_env_event"); + if (envEvent) { + for (const cb of [...remoteLaneEnvEventCallbacks]) { + try { + cb(envEvent); + } catch (error) { + console.error("preload remote lane env listener failed", error); + } + } + } + + const portEvent = toWrappedEvent( + payload, + "lane_port_event", + ); + if (portEvent) { + for (const cb of [...remoteLanePortEventCallbacks]) { + try { + cb(portEvent); + } catch (error) { + console.error("preload remote lane port listener failed", error); + } + } + } + + const proxyEvent = toWrappedEvent( + payload, + "lane_proxy_event", + ); + if (proxyEvent) { + for (const cb of [...remoteLaneProxyEventCallbacks]) { + try { + cb(proxyEvent); + } catch (error) { + console.error("preload remote lane proxy listener failed", error); + } + } + } + + const oauthEvent = toWrappedEvent( + payload, + "lane_oauth_event", + ); + if (oauthEvent) { + for (const cb of [...remoteLaneOAuthEventCallbacks]) { + try { + cb(oauthEvent); + } catch (error) { + console.error("preload remote lane OAuth listener failed", error); + } + } + } + + const diagnosticsEvent = toWrappedEvent( + payload, + "lane_diagnostics_event", + ); + if (diagnosticsEvent) { + for (const cb of [...remoteLaneDiagnosticsEventCallbacks]) { + try { + cb(diagnosticsEvent); + } catch (error) { + console.error("preload remote lane diagnostics listener failed", error); + } + } + } + + if (isRecord(payload) && payload.type === "lane_head_changed") { + clearGitReadCaches(); + } + + const ptyDataEvent = toWrappedEvent(payload, "pty_data"); + if (ptyDataEvent) { + for (const cb of [...remotePtyDataEventCallbacks]) { + try { + cb(ptyDataEvent); + } catch (error) { + console.error("preload remote pty data listener failed", error); + } + } + } + + const ptyExitEvent = toWrappedEvent(payload, "pty_exit"); + if (ptyExitEvent) { + for (const cb of [...remotePtyExitEventCallbacks]) { + try { + cb(ptyExitEvent); + } catch (error) { + console.error("preload remote pty exit listener failed", error); + } + } + } + + const processEvent = toProcessEvent(payload); + if (processEvent) { + for (const cb of [...remoteProcessEventCallbacks]) { + try { + cb(processEvent); + } catch (error) { + console.error("preload remote process listener failed", error); + } + } + } + + const testEvent = toTestEvent(payload); + if (testEvent) { + for (const cb of [...remoteTestEventCallbacks]) { + try { + cb(testEvent); + } catch (error) { + console.error("preload remote test listener failed", error); + } + } + } + + const fileChangeEvent = toWrappedEvent( + payload, + "file_change", + ); + if (fileChangeEvent) { + clearGitReadCaches(); + for (const cb of [...remoteFileChangeEventCallbacks]) { + try { + cb(fileChangeEvent); + } catch (error) { + console.error("preload remote file change listener failed", error); + } + } + } + + const prAiResolutionEvent = toWrappedEvent( + payload, + "pr_ai_resolution_event", + ); + if (prAiResolutionEvent) { + for (const cb of [...remotePrAiResolutionEventCallbacks]) { + try { + cb(prAiResolutionEvent); + } catch (error) { + console.error("preload remote PR AI resolution listener failed", error); + } + } + } + + const prEvent = toWrappedEvent(payload, "pr_event"); + if (prEvent) { + for (const cb of [...remotePrEventCallbacks]) { + try { + cb(prEvent); + } catch (error) { + console.error("preload remote PR listener failed", error); + } + } + } + + const projectStateEvent = toWrappedEvent( + payload, + "project_state_event", + ); + if (projectStateEvent) { + for (const cb of [...remoteProjectStateEventCallbacks]) { + try { + cb(projectStateEvent); + } catch (error) { + console.error("preload remote project state listener failed", error); + } + } + } +} + +function subscribeRemoteAgentChatEvents( + cb: (payload: AgentChatEventEnvelope) => void, +): () => void { + remoteAgentChatEventCallbacks.add(cb); + ensureRemoteRuntimeEventPump(); + return () => { + remoteAgentChatEventCallbacks.delete(cb); + }; +} + +function subscribeRemoteMissionEvents( + cb: (payload: MissionsEventPayload) => void, +): () => void { + remoteMissionEventCallbacks.add(cb); + ensureRemoteRuntimeEventPump(); + return () => { + remoteMissionEventCallbacks.delete(cb); + }; +} + +function subscribeRemoteOrchestratorEvents( + cb: (payload: OrchestratorRuntimeEvent) => void, +): () => void { + remoteOrchestratorEventCallbacks.add(cb); + ensureRemoteRuntimeEventPump(); + return () => { + remoteOrchestratorEventCallbacks.delete(cb); + }; +} + +function subscribeRemoteOrchestratorThreadEvents( + cb: (payload: OrchestratorThreadEvent) => void, +): () => void { + remoteOrchestratorThreadEventCallbacks.add(cb); + ensureRemoteRuntimeEventPump(); + return () => { + remoteOrchestratorThreadEventCallbacks.delete(cb); + }; +} + +function subscribeRemoteDagMutationEvents( + cb: (payload: DagMutationEvent) => void, +): () => void { + remoteDagMutationEventCallbacks.add(cb); + ensureRemoteRuntimeEventPump(); + return () => { + remoteDagMutationEventCallbacks.delete(cb); + }; +} + +function subscribeRemoteSyncStatusEvents( + cb: (payload: SyncStatusEventPayload) => void, +): () => void { + remoteSyncStatusEventCallbacks.add(cb); + ensureRemoteRuntimeEventPump(); + return () => { + remoteSyncStatusEventCallbacks.delete(cb); + }; +} + +function subscribeRemoteReviewEvents( + cb: (payload: ReviewEventPayload) => void, +): () => void { + remoteReviewEventCallbacks.add(cb); + ensureRemoteRuntimeEventPump(); + return () => { + remoteReviewEventCallbacks.delete(cb); + }; +} + +function subscribeRemoteSessionChangedEvents( + cb: (payload: TerminalSessionChangedEvent) => void, +): () => void { + remoteSessionChangedCallbacks.add(cb); + ensureRemoteRuntimeEventPump(); + return () => { + remoteSessionChangedCallbacks.delete(cb); + }; +} + +function subscribeRemoteLaneDeleteEvents( + cb: (payload: LaneDeleteEvent) => void, +): () => void { + remoteLaneDeleteEventCallbacks.add(cb); + ensureRemoteRuntimeEventPump(); + return () => { + remoteLaneDeleteEventCallbacks.delete(cb); + }; +} + +function subscribeRemoteLaneRebaseEvents( + cb: (payload: RebaseRunEventPayload) => void, +): () => void { + remoteLaneRebaseEventCallbacks.add(cb); + ensureRemoteRuntimeEventPump(); + return () => { + remoteLaneRebaseEventCallbacks.delete(cb); + }; +} + +function subscribeRemoteLaneRebaseSuggestionsEvents( + cb: (payload: RebaseSuggestionsEventPayload) => void, +): () => void { + remoteLaneRebaseSuggestionsEventCallbacks.add(cb); + ensureRemoteRuntimeEventPump(); + return () => { + remoteLaneRebaseSuggestionsEventCallbacks.delete(cb); + }; +} + +function subscribeRemoteLaneAutoRebaseEvents( + cb: (payload: AutoRebaseEventPayload) => void, +): () => void { + remoteLaneAutoRebaseEventCallbacks.add(cb); + ensureRemoteRuntimeEventPump(); + return () => { + remoteLaneAutoRebaseEventCallbacks.delete(cb); + }; +} + +function subscribeRemoteLaneEnvEvents( + cb: (payload: LaneEnvInitEvent) => void, +): () => void { + remoteLaneEnvEventCallbacks.add(cb); + ensureRemoteRuntimeEventPump(); + return () => { + remoteLaneEnvEventCallbacks.delete(cb); + }; +} + +function subscribeRemoteLanePortEvents( + cb: (payload: PortAllocationEvent) => void, +): () => void { + remoteLanePortEventCallbacks.add(cb); + ensureRemoteRuntimeEventPump(); + return () => { + remoteLanePortEventCallbacks.delete(cb); + }; +} + +function subscribeRemoteLaneProxyEvents( + cb: (payload: LaneProxyEvent) => void, +): () => void { + remoteLaneProxyEventCallbacks.add(cb); + ensureRemoteRuntimeEventPump(); + return () => { + remoteLaneProxyEventCallbacks.delete(cb); + }; +} + +function subscribeRemoteLaneOAuthEvents( + cb: (payload: OAuthRedirectEvent) => void, +): () => void { + remoteLaneOAuthEventCallbacks.add(cb); + ensureRemoteRuntimeEventPump(); + return () => { + remoteLaneOAuthEventCallbacks.delete(cb); + }; +} + +function subscribeRemoteLaneDiagnosticsEvents( + cb: (payload: RuntimeDiagnosticsEvent) => void, +): () => void { + remoteLaneDiagnosticsEventCallbacks.add(cb); + ensureRemoteRuntimeEventPump(); + return () => { + remoteLaneDiagnosticsEventCallbacks.delete(cb); + }; +} + +function subscribeRemotePtyDataEvents( + cb: (payload: PtyDataEvent) => void, +): () => void { + remotePtyDataEventCallbacks.add(cb); + ensureRemoteRuntimeEventPump(); + return () => { + remotePtyDataEventCallbacks.delete(cb); + }; +} + +function subscribeRemotePtyExitEvents( + cb: (payload: PtyExitEvent) => void, +): () => void { + remotePtyExitEventCallbacks.add(cb); + ensureRemoteRuntimeEventPump(); + return () => { + remotePtyExitEventCallbacks.delete(cb); + }; +} + +function subscribeRemoteProcessEvents( + cb: (payload: ProcessEvent) => void, +): () => void { + remoteProcessEventCallbacks.add(cb); + ensureRemoteRuntimeEventPump(); + return () => { + remoteProcessEventCallbacks.delete(cb); + }; +} + +function subscribeRemoteTestEvents( + cb: (payload: TestEvent) => void, +): () => void { + remoteTestEventCallbacks.add(cb); + ensureRemoteRuntimeEventPump(); + return () => { + remoteTestEventCallbacks.delete(cb); + }; +} + +function subscribeRemoteFileChangeEvents( + cb: (payload: FileChangeEvent) => void, +): () => void { + remoteFileChangeEventCallbacks.add(cb); + ensureRemoteRuntimeEventPump(); + return () => { + remoteFileChangeEventCallbacks.delete(cb); + }; +} + +function subscribeRemotePrAiResolutionEvents( + cb: (payload: PrAiResolutionEventPayload) => void, +): () => void { + remotePrAiResolutionEventCallbacks.add(cb); + ensureRemoteRuntimeEventPump(); + return () => { + remotePrAiResolutionEventCallbacks.delete(cb); + }; +} + +function subscribeRemotePrEvents( + cb: (payload: PrEventPayload) => void, +): () => void { + remotePrEventCallbacks.add(cb); + ensureRemoteRuntimeEventPump(); + return () => { + remotePrEventCallbacks.delete(cb); + }; +} + +function subscribeRemoteProjectStateEvents( + cb: (payload: AdeProjectEvent) => void, +): () => void { + remoteProjectStateEventCallbacks.add(cb); + ensureRemoteRuntimeEventPump(); + return () => { + remoteProjectStateEventCallbacks.delete(cb); + }; +} + +function subscribeAgentChatEvents( + cb: (payload: AgentChatEventEnvelope) => void, +): () => void { + const removeLocal = agentChatEventFanout(cb); + const removeRemote = subscribeRemoteAgentChatEvents(cb); + return () => { + removeRemote(); + removeLocal(); + }; +} + +function subscribePtyDataEvents( + cb: (payload: PtyDataEvent) => void, +): () => void { + const removeLocal = ptyDataEventFanout(cb); + const removeRemote = subscribeRemotePtyDataEvents(cb); + return () => { + removeRemote(); + removeLocal(); + }; +} + +function subscribePtyExitEvents( + cb: (payload: PtyExitEvent) => void, +): () => void { + const removeLocal = ptyExitEventFanout(cb); + const removeRemote = subscribeRemotePtyExitEvents(cb); + return () => { + removeRemote(); + removeLocal(); + }; +} + +function isRecord(value: unknown): value is Record { + return Boolean(value) && typeof value === "object" && !Array.isArray(value); +} + +function toAgentChatEventEnvelope( + payload: unknown, +): AgentChatEventEnvelope | null { + if (!isRecord(payload)) return null; + if (typeof payload.sessionId !== "string") return null; + if (typeof payload.timestamp !== "string") return null; + if (!isRecord(payload.event) || typeof payload.event.type !== "string") + return null; + return payload as unknown as AgentChatEventEnvelope; +} + +function toTerminalSessionChangedEvent( + payload: unknown, +): TerminalSessionChangedEvent | null { + if (!isRecord(payload) || payload.type !== "terminal_session_changed") + return null; + const event = payload.event; + if (!isRecord(event)) return null; + if (typeof event.sessionId !== "string") return null; + if ( + event.reason !== "meta-updated" && + event.reason !== "deleted" && + event.reason !== "created" + ) + return null; + return { + sessionId: event.sessionId, + reason: event.reason, + }; +} + +function toWrappedEvent(payload: unknown, type: string): T | null { + if (!isRecord(payload) || payload.type !== type || !isRecord(payload.event)) + return null; + return payload.event as T; +} + +function toProcessEvent(payload: unknown): ProcessEvent | null { + if (!isRecord(payload) || typeof payload.type !== "string") return null; + if (payload.type === "runtime") { + const runtime = payload.runtime; + if (!isRecord(runtime)) return null; + if ( + typeof runtime.laneId !== "string" || + typeof runtime.processId !== "string" + ) + return null; + return payload as unknown as ProcessEvent; + } + if (payload.type === "log") { + if (typeof payload.runId !== "string") return null; + if ( + typeof payload.laneId !== "string" || + typeof payload.processId !== "string" + ) + return null; + if (payload.stream !== "stdout" && payload.stream !== "stderr") return null; + if (typeof payload.chunk !== "string" || typeof payload.ts !== "string") + return null; + return payload as unknown as ProcessEvent; + } + return null; +} + +function toTestEvent(payload: unknown): TestEvent | null { + if (!isRecord(payload) || typeof payload.type !== "string") return null; + if (payload.type === "run") { + const run = payload.run; + if (!isRecord(run)) return null; + if (typeof run.id !== "string" || typeof run.suiteId !== "string") + return null; + return payload as unknown as TestEvent; + } + if (payload.type === "log") { + if ( + typeof payload.runId !== "string" || + typeof payload.suiteId !== "string" + ) + return null; + if (payload.stream !== "stdout" && payload.stream !== "stderr") return null; + if (typeof payload.chunk !== "string" || typeof payload.ts !== "string") + return null; + return payload as unknown as TestEvent; + } + return null; +} + function clearGitReadCaches(): void { diffChangesCache.clear(); gitBranchesCache.clear(); @@ -982,13 +2239,18 @@ function clearIosSimulatorStatusCaches(): void { iosSimulatorDevicesCache.clear(); } -function getAiStatusCacheKey(args?: { refreshOpenCodeInventory?: boolean }): string { +function getAiStatusCacheKey(args?: { + refreshOpenCodeInventory?: boolean; +}): string { return serializeIpcCacheArgs({ refreshOpenCodeInventory: args?.refreshOpenCodeInventory === true, }); } -async function clearAround(clear: () => void, action: () => Promise): Promise { +async function clearAround( + clear: () => void, + action: () => Promise, +): Promise { clear(); try { return await action(); @@ -1011,7 +2273,10 @@ function createIpcEventFanout( try { cb(payload); } catch (error) { - console.error(`preload IPC fanout listener failed for ${channel}`, error); + console.error( + `preload IPC fanout listener failed for ${channel}`, + error, + ); } } }; @@ -1050,14 +2315,18 @@ const appControlEventFanout = createIpcEventFanout( IPC.appControlEvent, () => appControlStatusCache.clear(), ); -const builtInBrowserEventFanout = createIpcEventFanout( - IPC.builtInBrowserEvent, - () => builtInBrowserStatusCache.clear(), -); +const builtInBrowserEventFanout = + createIpcEventFanout( + IPC.builtInBrowserEvent, + () => builtInBrowserStatusCache.clear(), + ); const macosVmEventFanout = createIpcEventFanout( IPC.macosVmEvent, () => macosVmStatusCache.clear(), ); +const projectStateEventFanout = createIpcEventFanout( + IPC.projectStateEvent, +); const ptyDataEventFanout = createIpcEventFanout(IPC.ptyData); const ptyExitEventFanout = createIpcEventFanout(IPC.ptyExit); @@ -1067,15 +2336,28 @@ contextBridge.exposeInMainWorld("ade", { getInfo: async (): Promise => ipcRenderer.invoke(IPC.appGetInfo), getProject: async (): Promise => ipcRenderer.invoke(IPC.appGetProject), - getWindowSession: async (): Promise<{ windowId: number | null; project: ProjectInfo | null }> => - ipcRenderer.invoke(IPC.appGetWindowSession), + getWindowSession: async (): Promise<{ + windowId: number | null; + project: ProjectInfo | null; + binding: OpenProjectBinding | null; + }> => { + const session = (await ipcRenderer.invoke(IPC.appGetWindowSession)) as { + windowId: number | null; + project: ProjectInfo | null; + binding: OpenProjectBinding | null; + }; + rememberProjectBinding(session.binding); + return session; + }, newWindow: async (): Promise<{ windowId: number | null }> => ipcRenderer.invoke(IPC.appNewWindow), openProjectInNewWindow: async ( rootPath: string, ): Promise<{ windowId: number | null; project: ProjectInfo | null }> => ipcRenderer.invoke(IPC.appOpenProjectInNewWindow, { rootPath }), - closeWindow: async (windowId?: number | null): Promise<{ closed: boolean }> => + closeWindow: async ( + windowId?: number | null, + ): Promise<{ closed: boolean }> => ipcRenderer.invoke(IPC.appCloseWindow, { windowId: windowId ?? null }), onProjectChanged: (cb: (project: ProjectInfo | null) => void) => { const listener = ( @@ -1088,6 +2370,21 @@ contextBridge.exposeInMainWorld("ade", { ipcRenderer.on(IPC.appProjectChanged, listener); return () => ipcRenderer.removeListener(IPC.appProjectChanged, listener); }, + onProjectBindingChanged: ( + cb: (binding: OpenProjectBinding | null) => void, + ) => { + const listener = ( + _event: Electron.IpcRendererEvent, + payload: OpenProjectBinding | null, + ) => { + rememberProjectBinding(payload); + clearProjectScopedReadCaches(); + cb(payload); + }; + ipcRenderer.on(IPC.appProjectBindingChanged, listener); + return () => + ipcRenderer.removeListener(IPC.appProjectBindingChanged, listener); + }, onNavigate: (cb: (request: AppNavigationRequest) => void) => { const listener = ( _event: Electron.IpcRendererEvent, @@ -1106,8 +2403,11 @@ contextBridge.exposeInMainWorld("ade", { ipcRenderer.invoke(IPC.appWriteClipboardText, { text }), hasClipboardImage: async (): Promise => ipcRenderer.invoke(IPC.appHasClipboardImage), - readClipboardImage: async (): Promise<{ data: string; filename: string; mimeType: string } | null> => - ipcRenderer.invoke(IPC.appReadClipboardImage), + readClipboardImage: async (): Promise<{ + data: string; + filename: string; + mimeType: string; + } | null> => ipcRenderer.invoke(IPC.appReadClipboardImage), getImageDataUrl: async (path: string): Promise<{ dataUrl: string }> => imageDataUrlCache.get(path), writeClipboardImage: async (path: string): Promise => @@ -1117,12 +2417,17 @@ contextBridge.exposeInMainWorld("ade", { relativePath?: string; target: "default" | "finder" | "vscode" | "cursor" | "zed"; }): Promise => ipcRenderer.invoke(IPC.appOpenPathInEditor, args), - logDebugEvent: (event: string, payload: Record = {}): void => - ipcRenderer.send(IPC.appLogDebugEvent, { event, payload }), + logDebugEvent: ( + event: string, + payload: Record = {}, + ): void => ipcRenderer.send(IPC.appLogDebugEvent, { event, payload }), }, project: { openRepo: async (): Promise => - clearAround(() => clearProjectScopedReadCaches(), () => ipcRenderer.invoke(IPC.projectOpenRepo)), + clearAround( + () => clearProjectScopedReadCaches(), + () => ipcRenderer.invoke(IPC.projectOpenRepo), + ), chooseDirectory: async ( args: { title?: string; defaultPath?: string } = {}, ): Promise => @@ -1136,15 +2441,21 @@ contextBridge.exposeInMainWorld("ade", { resolveIcon: async (rootPath: string): Promise => projectIconCache.get(rootPath), chooseIcon: async (rootPath: string): Promise => - clearAround(() => { - imageDataUrlCache.clear(); - projectIconCache.clear(rootPath); - }, () => ipcRenderer.invoke(IPC.projectChooseIcon, { rootPath })), + clearAround( + () => { + imageDataUrlCache.clear(); + projectIconCache.clear(rootPath); + }, + () => ipcRenderer.invoke(IPC.projectChooseIcon, { rootPath }), + ), removeIcon: async (rootPath: string): Promise => - clearAround(() => { - imageDataUrlCache.clear(); - projectIconCache.clear(rootPath); - }, () => ipcRenderer.invoke(IPC.projectRemoveIcon, { rootPath })), + clearAround( + () => { + imageDataUrlCache.clear(); + projectIconCache.clear(rootPath); + }, + () => ipcRenderer.invoke(IPC.projectRemoveIcon, { rootPath }), + ), getDroppedPath: (file: File): string => { try { return webUtils.getPathForFile(file); @@ -1157,31 +2468,60 @@ contextBridge.exposeInMainWorld("ade", { clearLocalData: async ( args: ClearLocalAdeDataArgs = {}, ): Promise => - clearAround(() => clearProjectScopedReadCaches(), () => ipcRenderer.invoke(IPC.projectClearLocalData, args)), + clearAround( + () => clearProjectScopedReadCaches(), + () => + callProjectRuntimeActionOr( + "ade_project", + "clearLocalData", + { args }, + () => ipcRenderer.invoke(IPC.projectClearLocalData, args), + ), + ), listRecent: async (): Promise => ipcRenderer.invoke(IPC.projectListRecent), closeCurrent: async (): Promise => - clearAround(() => clearProjectScopedReadCaches(), () => ipcRenderer.invoke(IPC.projectCloseCurrent)), + clearAround( + () => { + rememberProjectBinding(null); + clearProjectScopedReadCaches(); + }, + () => ipcRenderer.invoke(IPC.projectCloseCurrent), + ), switchToPath: async (rootPath: string): Promise => - clearAround(() => clearProjectScopedReadCaches(), () => ipcRenderer.invoke(IPC.projectSwitchToPath, { rootPath })), + clearAround( + () => { + rememberProjectBinding(null); + clearProjectScopedReadCaches(); + }, + () => ipcRenderer.invoke(IPC.projectSwitchToPath, { rootPath }), + ), forgetRecent: async (rootPath: string): Promise => ipcRenderer.invoke(IPC.projectForgetRecent, { rootPath }), reorderRecent: async ( orderedPaths: string[], ): Promise => ipcRenderer.invoke(IPC.projectReorderRecent, { orderedPaths }), - createLocal: async (input: CreateProjectInput): Promise => + createLocal: async ( + input: CreateProjectInput, + ): Promise => ipcRenderer.invoke(IPC.projectCreateLocal, input), clone: async (input: CloneProjectInput): Promise => ipcRenderer.invoke(IPC.projectClone, input), getDefaultParentDir: async (): Promise => ipcRenderer.invoke(IPC.projectGetDefaultParentDir), getSnapshot: async (): Promise => - ipcRenderer.invoke(IPC.projectStateGetSnapshot), + callProjectRuntimeActionOr("ade_project", "getSnapshot", {}, () => + ipcRenderer.invoke(IPC.projectStateGetSnapshot), + ), initializeOrRepair: async (): Promise => - ipcRenderer.invoke(IPC.projectStateInitializeOrRepair), + callProjectRuntimeActionOr("ade_project", "initializeOrRepair", {}, () => + ipcRenderer.invoke(IPC.projectStateInitializeOrRepair), + ), runIntegrityCheck: async (): Promise => - ipcRenderer.invoke(IPC.projectStateRunIntegrityCheck), + callProjectRuntimeActionOr("ade_project", "runIntegrityCheck", {}, () => + ipcRenderer.invoke(IPC.projectStateRunIntegrityCheck), + ), onMissing: (cb: (data: { rootPath: string }) => void) => { const listener = ( _event: Electron.IpcRendererEvent, @@ -1191,73 +2531,257 @@ contextBridge.exposeInMainWorld("ade", { return () => ipcRenderer.removeListener(IPC.projectMissing, listener); }, onStateEvent: (cb: (event: AdeProjectEvent) => void) => { + const removeLocal = projectStateEventFanout(cb); + const removeRemote = subscribeRemoteProjectStateEvents(cb); + return () => { + removeRemote(); + removeLocal(); + }; + }, + }, + remoteRuntime: { + listTargets: async (): Promise => + ipcRenderer.invoke(IPC.remoteRuntimeListTargets), + getConnectionSnapshot: async (): Promise => + ipcRenderer.invoke(IPC.remoteRuntimeGetConnectionSnapshot), + onConnectionSnapshotChanged: ( + cb: (snapshot: RemoteRuntimeConnectionSnapshot) => void, + ) => { const listener = ( _event: Electron.IpcRendererEvent, - payload: AdeProjectEvent, + payload: RemoteRuntimeConnectionSnapshot, ) => cb(payload); - ipcRenderer.on(IPC.projectStateEvent, listener); - return () => ipcRenderer.removeListener(IPC.projectStateEvent, listener); + ipcRenderer.on(IPC.remoteRuntimeConnectionSnapshotChanged, listener); + return () => + ipcRenderer.removeListener( + IPC.remoteRuntimeConnectionSnapshotChanged, + listener, + ); + }, + listDiscoveredMachines: async (): Promise< + RemoteRuntimeDiscoveredMachine[] + > => ipcRenderer.invoke(IPC.remoteRuntimeListDiscoveredMachines), + saveTarget: async ( + input: RemoteRuntimeTargetInput, + ): Promise => + ipcRenderer.invoke(IPC.remoteRuntimeSaveTarget, input), + removeTarget: async (id: string): Promise<{ removed: boolean }> => + ipcRenderer.invoke(IPC.remoteRuntimeRemoveTarget, { id }), + connect: async (id: string): Promise => + ipcRenderer.invoke(IPC.remoteRuntimeConnect, { id }), + listProjects: async (id: string): Promise => + ipcRenderer.invoke(IPC.remoteRuntimeListProjects, { id }), + addProject: async ( + id: string, + rootPath: string, + ): Promise => + ipcRenderer.invoke(IPC.remoteRuntimeAddProject, { id, rootPath }), + browseDirectories: async ( + id: string, + args: ProjectBrowseInput = {}, + ): Promise => + ipcRenderer.invoke(IPC.remoteRuntimeBrowseDirectories, { id, args }), + getProjectDetail: async ( + id: string, + rootPath: string, + ): Promise => + ipcRenderer.invoke(IPC.remoteRuntimeGetProjectDetail, { id, rootPath }), + getDefaultParentDir: async (id: string): Promise => + ipcRenderer.invoke(IPC.remoteRuntimeGetDefaultParentDir, { id }), + createProject: async ( + id: string, + input: CreateProjectInput, + ): Promise => + ipcRenderer.invoke(IPC.remoteRuntimeCreateProject, { id, input }), + cloneProject: async ( + id: string, + input: CloneProjectInput, + ): Promise => + ipcRenderer.invoke(IPC.remoteRuntimeCloneProject, { id, input }), + listMyGitHubRepos: async ( + id: string, + input: ListMyGitHubReposInput = {}, + ): Promise => + ipcRenderer.invoke(IPC.remoteRuntimeListMyGitHubRepos, { id, input }), + openProject: async ( + id: string, + projectId: string, + ): Promise => { + const binding = (await ipcRenderer.invoke(IPC.remoteRuntimeOpenProject, { + id, + projectId, + })) as OpenProjectBinding; + rememberProjectBinding(binding); + return binding; }, + callAction: async ( + id: string, + projectId: string, + request: RemoteRuntimeActionRequest, + ): Promise => + ipcRenderer.invoke(IPC.remoteRuntimeCallAction, { + id, + projectId, + request, + }), + streamEvents: async ( + id: string, + projectId: string, + request: RemoteRuntimeStreamEventsRequest = {}, + ): Promise => + ipcRenderer.invoke(IPC.remoteRuntimeStreamEvents, { + id, + projectId, + request, + }), + checkLocalWork: async ( + id: string, + project: RemoteRuntimeProjectRecord, + ): Promise => + ipcRenderer.invoke(IPC.remoteRuntimeCheckLocalWork, { id, project }), + disconnect: async (id: string): Promise<{ disconnected: boolean }> => + ipcRenderer.invoke(IPC.remoteRuntimeDisconnect, { id }), }, keybindings: { get: async (): Promise => - ipcRenderer.invoke(IPC.keybindingsGet), + callProjectRuntimeActionOr("keybindings", "get", {}, () => + ipcRenderer.invoke(IPC.keybindingsGet), + ), set: async ( overrides: KeybindingOverride[], ): Promise => - ipcRenderer.invoke(IPC.keybindingsSet, { overrides }), + callProjectRuntimeActionOr( + "keybindings", + "set", + { args: { overrides } }, + () => ipcRenderer.invoke(IPC.keybindingsSet, { overrides }), + ), }, ai: { - getStatus: async (args?: { force?: boolean; refreshOpenCodeInventory?: boolean }): Promise => { + getStatus: async (args?: { + force?: boolean; + refreshOpenCodeInventory?: boolean; + }): Promise => { const cacheKey = getAiStatusCacheKey(args); if (args?.force === true) { aiStatusCache.clear(); - return ipcRenderer.invoke(IPC.aiGetStatus, args); + return callProjectRuntimeActionOr("ai", "getStatus", { args }, () => + ipcRenderer.invoke(IPC.aiGetStatus, args), + ); } return aiStatusCache.get(cacheKey); }, getOpenCodeRuntimeDiagnostics: async (): Promise => ipcRenderer.invoke(IPC.aiGetOpenCodeRuntimeDiagnostics), storeApiKey: async (provider: string, key: string): Promise => - clearAround(() => aiStatusCache.clear(), () => ipcRenderer.invoke(IPC.aiStoreApiKey, { provider, key })), + clearAround( + () => aiStatusCache.clear(), + () => + callProjectRuntimeActionOr( + "ai", + "storeApiKey", + { args: { provider, key } }, + () => ipcRenderer.invoke(IPC.aiStoreApiKey, { provider, key }), + ), + ), deleteApiKey: async (provider: string): Promise => - clearAround(() => aiStatusCache.clear(), () => ipcRenderer.invoke(IPC.aiDeleteApiKey, { provider })), + clearAround( + () => aiStatusCache.clear(), + () => + callProjectRuntimeActionOr( + "ai", + "deleteApiKey", + { args: { provider } }, + () => ipcRenderer.invoke(IPC.aiDeleteApiKey, { provider }), + ), + ), listApiKeys: async (): Promise => - ipcRenderer.invoke(IPC.aiListApiKeys), + callProjectRuntimeActionOr("ai", "listApiKeys", {}, () => + ipcRenderer.invoke(IPC.aiListApiKeys), + ), verifyApiKey: async ( provider: string, ): Promise => - clearAround(() => aiStatusCache.clear(), () => ipcRenderer.invoke(IPC.aiVerifyApiKey, { provider })), + clearAround( + () => aiStatusCache.clear(), + () => + callProjectRuntimeActionOr( + "ai", + "verifyApiKeyConnection", + { args: { provider } }, + () => ipcRenderer.invoke(IPC.aiVerifyApiKey, { provider }), + ), + ), updateConfig: async (config: Partial): Promise => - clearAround(() => aiStatusCache.clear(), () => ipcRenderer.invoke(IPC.aiUpdateConfig, config)), + clearAround( + () => aiStatusCache.clear(), + () => + callProjectRuntimeActionOr( + "ai", + "updateConfig", + { args: config }, + () => ipcRenderer.invoke(IPC.aiUpdateConfig, config), + ), + ), cursorCloudListRepositories: async (): Promise => - ipcRenderer.invoke(IPC.aiCursorCloudListRepositories), + callProjectRuntimeActionOr("ai", "listCursorCloudRepositories", {}, () => + ipcRenderer.invoke(IPC.aiCursorCloudListRepositories), + ), cursorCloudListAgents: async (args?: { includeArchived?: boolean; limit?: number; cursor?: string | null; }): Promise => - ipcRenderer.invoke(IPC.aiCursorCloudListAgents, args ?? {}), + callProjectRuntimeActionOr( + "ai", + "listCursorCloudAgents", + { args: args ?? {} }, + () => ipcRenderer.invoke(IPC.aiCursorCloudListAgents, args ?? {}), + ), cursorCloudListRuns: async (args: { agentId: string; limit?: number; cursor?: string | null; }): Promise => - ipcRenderer.invoke(IPC.aiCursorCloudListRuns, args), + callProjectRuntimeActionOr("ai", "listCursorCloudRuns", { args }, () => + ipcRenderer.invoke(IPC.aiCursorCloudListRuns, args), + ), cursorCloudCreateRun: async ( args: CursorCloudCreateRunRequest, ): Promise => - ipcRenderer.invoke(IPC.aiCursorCloudCreateRun, args), + callProjectRuntimeActionOr("ai", "createCursorCloudRun", { args }, () => + ipcRenderer.invoke(IPC.aiCursorCloudCreateRun, args), + ), cursorCloudArchiveAgent: async (agentId: string): Promise => - ipcRenderer.invoke(IPC.aiCursorCloudArchiveAgent, { agentId }), + callProjectRuntimeActionOr( + "ai", + "archiveCursorCloudAgent", + { args: { agentId } }, + () => ipcRenderer.invoke(IPC.aiCursorCloudArchiveAgent, { agentId }), + ), cursorCloudUnarchiveAgent: async (agentId: string): Promise => - ipcRenderer.invoke(IPC.aiCursorCloudUnarchiveAgent, { agentId }), + callProjectRuntimeActionOr( + "ai", + "unarchiveCursorCloudAgent", + { args: { agentId } }, + () => ipcRenderer.invoke(IPC.aiCursorCloudUnarchiveAgent, { agentId }), + ), cursorCloudDeleteAgent: async (agentId: string): Promise => - ipcRenderer.invoke(IPC.aiCursorCloudDeleteAgent, { agentId }), + callProjectRuntimeActionOr( + "ai", + "deleteCursorCloudAgent", + { args: { agentId } }, + () => ipcRenderer.invoke(IPC.aiCursorCloudDeleteAgent, { agentId }), + ), cursorCloudGetAgent: async ( agentId: string, ): Promise => - ipcRenderer.invoke(IPC.aiCursorCloudGetAgent, { agentId }), + callProjectRuntimeActionOr( + "ai", + "getCursorCloudAgent", + { args: { agentId } }, + () => ipcRenderer.invoke(IPC.aiCursorCloudGetAgent, { agentId }), + ), cursorCloudStreamRun: async ( args: CursorCloudStreamRunRequest, ): Promise => @@ -1266,75 +2790,129 @@ contextBridge.exposeInMainWorld("ade", { agentId: string; runId: string; }): Promise => - ipcRenderer.invoke(IPC.aiCursorCloudCancelRun, args), + callProjectRuntimeActionOr("ai", "cancelCursorCloudRun", { args }, () => + ipcRenderer.invoke(IPC.aiCursorCloudCancelRun, args), + ), cursorCloudFollowUp: async ( args: CursorCloudFollowUpRequest, ): Promise => - ipcRenderer.invoke(IPC.aiCursorCloudFollowUp, args), + callProjectRuntimeActionOr("ai", "cursorCloudFollowUp", { args }, () => + ipcRenderer.invoke(IPC.aiCursorCloudFollowUp, args), + ), cursorCloudListArtifacts: async ( agentId: string, ): Promise => - ipcRenderer.invoke(IPC.aiCursorCloudListArtifacts, { agentId }), + callProjectRuntimeActionOr( + "ai", + "listCursorCloudArtifacts", + { args: { agentId } }, + () => ipcRenderer.invoke(IPC.aiCursorCloudListArtifacts, { agentId }), + ), cursorCloudDownloadArtifact: async (args: { agentId: string; path: string; }): Promise => - ipcRenderer.invoke(IPC.aiCursorCloudDownloadArtifact, args), + callProjectRuntimeActionOr( + "ai", + "downloadCursorCloudArtifact", + { args }, + () => ipcRenderer.invoke(IPC.aiCursorCloudDownloadArtifact, args), + ), cursorCloudOpenChat: async ( args: CursorCloudOpenChatRequest, ): Promise => - ipcRenderer.invoke(IPC.aiCursorCloudOpenChat, args), + callProjectRuntimeActionOr("ai", "openCursorCloudChat", { args }, () => + ipcRenderer.invoke(IPC.aiCursorCloudOpenChat, args), + ), }, sync: { getStatus: async (args?: SyncGetStatusArgs): Promise => - ipcRenderer.invoke(IPC.syncGetStatus, args), + callProjectRuntimeSyncOr("sync.getStatus", args ?? {}, () => + ipcRenderer.invoke(IPC.syncGetStatus, args), + ), refreshDiscovery: async (): Promise => - ipcRenderer.invoke(IPC.syncRefreshDiscovery), + callProjectRuntimeSyncOr("sync.refreshDiscovery", {}, () => + ipcRenderer.invoke(IPC.syncRefreshDiscovery), + ), listDevices: async (): Promise => - ipcRenderer.invoke(IPC.syncListDevices), + callProjectRuntimeSyncOr("sync.listDevices", {}, () => + ipcRenderer.invoke(IPC.syncListDevices), + ), updateLocalDevice: async (args: { name?: string; deviceType?: SyncPeerDeviceType; }): Promise => - ipcRenderer.invoke(IPC.syncUpdateLocalDevice, args), + callProjectRuntimeSyncOr("sync.updateLocalDevice", args, () => + ipcRenderer.invoke(IPC.syncUpdateLocalDevice, args), + ), connectToBrain: async ( draft: SyncDesktopConnectionDraft, ): Promise => - ipcRenderer.invoke(IPC.syncConnectToBrain, draft), + callProjectRuntimeSyncOr( + "sync.connectToBrain", + draft as unknown as Record, + () => ipcRenderer.invoke(IPC.syncConnectToBrain, draft), + ), disconnectFromBrain: async (): Promise => - ipcRenderer.invoke(IPC.syncDisconnectFromBrain), + callProjectRuntimeSyncOr("sync.disconnectFromBrain", {}, () => + ipcRenderer.invoke(IPC.syncDisconnectFromBrain), + ), forgetDevice: async (deviceId: string): Promise => - ipcRenderer.invoke(IPC.syncForgetDevice, { deviceId }), + callProjectRuntimeSyncOr("sync.forgetDevice", { deviceId }, () => + ipcRenderer.invoke(IPC.syncForgetDevice, { deviceId }), + ), getTransferReadiness: async (): Promise => - ipcRenderer.invoke(IPC.syncGetTransferReadiness), + callProjectRuntimeSyncOr("sync.getTransferReadiness", {}, () => + ipcRenderer.invoke(IPC.syncGetTransferReadiness), + ), transferBrainToLocal: async (): Promise => - ipcRenderer.invoke(IPC.syncTransferBrainToLocal), + callProjectRuntimeSyncOr("sync.transferBrainToLocal", {}, () => + ipcRenderer.invoke(IPC.syncTransferBrainToLocal), + ), getPin: async (): Promise<{ pin: string | null }> => - ipcRenderer.invoke(IPC.syncGetPin), + callProjectRuntimeSyncOr("sync.getPin", {}, () => + ipcRenderer.invoke(IPC.syncGetPin), + ), setPin: async (pin: string): Promise => - ipcRenderer.invoke(IPC.syncSetPin, pin), + callProjectRuntimeSyncOr("sync.setPin", { pin }, () => + ipcRenderer.invoke(IPC.syncSetPin, pin), + ), + generatePin: async (): Promise => + callProjectRuntimeSyncOr("sync.generatePin", {}, () => + ipcRenderer.invoke(IPC.syncGeneratePin), + ), clearPin: async (): Promise => - ipcRenderer.invoke(IPC.syncClearPin), - setActiveLanePresence: async (args: { - laneIds: string[]; - }): Promise => - ipcRenderer.invoke(IPC.syncSetActiveLanePresence, args), + callProjectRuntimeSyncOr("sync.clearPin", {}, () => + ipcRenderer.invoke(IPC.syncClearPin), + ), + setActiveLanePresence: async (args: { laneIds: string[] }): Promise => + callProjectRuntimeSyncOr("sync.setActiveLanePresence", args, () => + ipcRenderer.invoke(IPC.syncSetActiveLanePresence, args), + ), onEvent: (cb: (event: SyncStatusEventPayload) => void) => { const listener = ( _event: Electron.IpcRendererEvent, payload: SyncStatusEventPayload, ) => cb(payload); ipcRenderer.on(IPC.syncEvent, listener); - return () => ipcRenderer.removeListener(IPC.syncEvent, listener); + const removeRemote = subscribeRemoteSyncStatusEvents(cb); + return () => { + removeRemote(); + ipcRenderer.removeListener(IPC.syncEvent, listener); + }; }, }, notifications: { apns: { getStatus: async (): Promise => ipcRenderer.invoke(IPC.notificationsApnsGetStatus), - saveConfig: async (args: ApnsBridgeSaveConfigArgs): Promise => + saveConfig: async ( + args: ApnsBridgeSaveConfigArgs, + ): Promise => ipcRenderer.invoke(IPC.notificationsApnsSaveConfig, args), - uploadKey: async (args: ApnsBridgeUploadKeyArgs): Promise => + uploadKey: async ( + args: ApnsBridgeUploadKeyArgs, + ): Promise => ipcRenderer.invoke(IPC.notificationsApnsUploadKey, args), clearKey: async (): Promise => ipcRenderer.invoke(IPC.notificationsApnsClearKey), @@ -1360,114 +2938,290 @@ contextBridge.exposeInMainWorld("ade", { }, onboarding: { getStatus: async (): Promise => - ipcRenderer.invoke(IPC.onboardingGetStatus), + callProjectRuntimeActionOr("onboarding", "getStatus", {}, () => + ipcRenderer.invoke(IPC.onboardingGetStatus), + ), detectDefaults: async (): Promise => - ipcRenderer.invoke(IPC.onboardingDetectDefaults), + callProjectRuntimeActionOr("onboarding", "detectDefaults", {}, () => + ipcRenderer.invoke(IPC.onboardingDetectDefaults), + ), detectExistingLanes: async (): Promise => - ipcRenderer.invoke(IPC.onboardingDetectExistingLanes), + callProjectRuntimeActionOr("onboarding", "detectExistingLanes", {}, () => + ipcRenderer.invoke(IPC.onboardingDetectExistingLanes), + ), setDismissed: async (dismissed: boolean): Promise => - ipcRenderer.invoke(IPC.onboardingSetDismissed, { dismissed }), + callProjectRuntimeActionOr( + "onboarding", + "setDismissed", + { arg: dismissed }, + () => ipcRenderer.invoke(IPC.onboardingSetDismissed, { dismissed }), + ), complete: async (): Promise => - ipcRenderer.invoke(IPC.onboardingComplete), + callProjectRuntimeActionOr("onboarding", "complete", {}, () => + ipcRenderer.invoke(IPC.onboardingComplete), + ), getTourProgress: async (): Promise => - ipcRenderer.invoke(IPC.onboardingGetTourProgress), + callProjectRuntimeActionOr("onboarding", "getTourProgress", {}, () => + ipcRenderer.invoke(IPC.onboardingGetTourProgress), + ), markWizardCompleted: async (): Promise => - ipcRenderer.invoke(IPC.onboardingMarkWizardCompleted), + callProjectRuntimeActionOr("onboarding", "markWizardCompleted", {}, () => + ipcRenderer.invoke(IPC.onboardingMarkWizardCompleted), + ), markWizardDismissed: async (): Promise => - ipcRenderer.invoke(IPC.onboardingMarkWizardDismissed), - markTourCompleted: async (tourId: string): Promise => - ipcRenderer.invoke(IPC.onboardingMarkTourCompleted, { tourId }), - markTourDismissed: async (tourId: string): Promise => - ipcRenderer.invoke(IPC.onboardingMarkTourDismissed, { tourId }), - updateTourStep: async (tourId: string, index: number): Promise => - ipcRenderer.invoke(IPC.onboardingUpdateTourStep, { tourId, index }), - markGlossaryTermSeen: async (termId: string): Promise => - ipcRenderer.invoke(IPC.onboardingMarkGlossaryTermSeen, { termId }), - resetTourProgress: async (tourId?: string): Promise => - ipcRenderer.invoke(IPC.onboardingResetTourProgress, { tourId }), + callProjectRuntimeActionOr("onboarding", "markWizardDismissed", {}, () => + ipcRenderer.invoke(IPC.onboardingMarkWizardDismissed), + ), + markTourCompleted: async ( + tourId: string, + ): Promise => + callProjectRuntimeActionOr( + "onboarding", + "markTourCompleted", + { arg: tourId }, + () => ipcRenderer.invoke(IPC.onboardingMarkTourCompleted, { tourId }), + ), + markTourDismissed: async ( + tourId: string, + ): Promise => + callProjectRuntimeActionOr( + "onboarding", + "markTourDismissed", + { arg: tourId }, + () => ipcRenderer.invoke(IPC.onboardingMarkTourDismissed, { tourId }), + ), + updateTourStep: async ( + tourId: string, + index: number, + ): Promise => + callProjectRuntimeActionOr( + "onboarding", + "updateTourStep", + { argsList: [tourId, index] }, + () => + ipcRenderer.invoke(IPC.onboardingUpdateTourStep, { tourId, index }), + ), + markGlossaryTermSeen: async ( + termId: string, + ): Promise => + callProjectRuntimeActionOr( + "onboarding", + "markGlossaryTermSeen", + { arg: termId }, + () => + ipcRenderer.invoke(IPC.onboardingMarkGlossaryTermSeen, { termId }), + ), + resetTourProgress: async ( + tourId?: string, + ): Promise => + callProjectRuntimeActionOr( + "onboarding", + "resetTourProgress", + { arg: tourId }, + () => ipcRenderer.invoke(IPC.onboardingResetTourProgress, { tourId }), + ), markTourCompletedVariant: async ( tourId: string, variant: OnboardingTourVariant, ): Promise => - ipcRenderer.invoke(IPC.onboardingMarkTourCompletedVariant, { tourId, variant }), + callProjectRuntimeActionOr( + "onboarding", + "markTourCompleted", + { argsList: [tourId, variant] }, + () => + ipcRenderer.invoke(IPC.onboardingMarkTourCompletedVariant, { + tourId, + variant, + }), + ), markTourDismissedVariant: async ( tourId: string, variant: OnboardingTourVariant, ): Promise => - ipcRenderer.invoke(IPC.onboardingMarkTourDismissedVariant, { tourId, variant }), + callProjectRuntimeActionOr( + "onboarding", + "markTourDismissed", + { argsList: [tourId, variant] }, + () => + ipcRenderer.invoke(IPC.onboardingMarkTourDismissedVariant, { + tourId, + variant, + }), + ), updateTourStepVariant: async ( tourId: string, variant: OnboardingTourVariant, index: number, ): Promise => - ipcRenderer.invoke(IPC.onboardingUpdateTourStepVariant, { tourId, variant, index }), + callProjectRuntimeActionOr( + "onboarding", + "updateTourStep", + { argsList: [tourId, index, variant] }, + () => + ipcRenderer.invoke(IPC.onboardingUpdateTourStepVariant, { + tourId, + variant, + index, + }), + ), tutorial: { start: async (): Promise => - ipcRenderer.invoke(IPC.onboardingTutorialStart), + callProjectRuntimeActionOr( + "onboarding", + "markTutorialStarted", + {}, + () => ipcRenderer.invoke(IPC.onboardingTutorialStart), + ), dismiss: async (permanent: boolean): Promise => - ipcRenderer.invoke(IPC.onboardingTutorialDismiss, { permanent }), + callProjectRuntimeActionOr( + "onboarding", + "markTutorialDismissed", + { arg: permanent }, + () => + ipcRenderer.invoke(IPC.onboardingTutorialDismiss, { permanent }), + ), complete: async (): Promise => - ipcRenderer.invoke(IPC.onboardingTutorialComplete), + callProjectRuntimeActionOr( + "onboarding", + "markTutorialCompleted", + {}, + () => ipcRenderer.invoke(IPC.onboardingTutorialComplete), + ), updateAct: async ( actIndex: number, ctxSnapshot?: Record, ): Promise => - ipcRenderer.invoke(IPC.onboardingTutorialUpdateAct, { actIndex, ctxSnapshot }), + callProjectRuntimeActionOr( + "onboarding", + "updateTutorialAct", + { argsList: [actIndex, ctxSnapshot] }, + () => + ipcRenderer.invoke(IPC.onboardingTutorialUpdateAct, { + actIndex, + ctxSnapshot, + }), + ), setSilenced: async (silenced: boolean): Promise => - ipcRenderer.invoke(IPC.onboardingTutorialSetSilenced, { silenced }), + callProjectRuntimeActionOr( + "onboarding", + "setTutorialSilenced", + { arg: silenced }, + () => + ipcRenderer.invoke(IPC.onboardingTutorialSetSilenced, { silenced }), + ), clearSessionDismissal: async (): Promise => - ipcRenderer.invoke(IPC.onboardingTutorialClearSessionDismissal), + callProjectRuntimeActionOr( + "onboarding", + "clearTutorialSessionDismissal", + {}, + () => ipcRenderer.invoke(IPC.onboardingTutorialClearSessionDismissal), + ), shouldPrompt: async (): Promise => - ipcRenderer.invoke(IPC.onboardingTutorialShouldPrompt), + callProjectRuntimeActionOr( + "onboarding", + "shouldPromptTutorial", + {}, + () => ipcRenderer.invoke(IPC.onboardingTutorialShouldPrompt), + ), }, }, automations: { list: async (): Promise => - ipcRenderer.invoke(IPC.automationsList), + callProjectRuntimeActionOr("automations", "list", {}, () => + ipcRenderer.invoke(IPC.automationsList), + ), toggle: async (args: { id: string; enabled: boolean; }): Promise => - ipcRenderer.invoke(IPC.automationsToggle, args), + callProjectRuntimeActionOr("automations", "toggleRule", { args }, () => + ipcRenderer.invoke(IPC.automationsToggle, args), + ), deleteRule: async ( args: AutomationDeleteRuleRequest, ): Promise => - ipcRenderer.invoke(IPC.automationsDeleteRule, args), + callProjectRuntimeActionOr("automations", "deleteRule", { args }, () => + ipcRenderer.invoke(IPC.automationsDeleteRule, args), + ), triggerManually: async ( args: AutomationManualTriggerRequest, ): Promise => - ipcRenderer.invoke(IPC.automationsTriggerManually, args), + callProjectRuntimeActionOr( + "automations", + "triggerManually", + { args }, + () => ipcRenderer.invoke(IPC.automationsTriggerManually, args), + ), getHistory: async (args: { id: string; limit?: number; }): Promise => - ipcRenderer.invoke(IPC.automationsGetHistory, args), + callProjectRuntimeActionOr("automations", "getHistory", { args }, () => + ipcRenderer.invoke(IPC.automationsGetHistory, args), + ), listRuns: async (args?: AutomationRunListArgs): Promise => - ipcRenderer.invoke(IPC.automationsListRuns, args ?? {}), + callProjectRuntimeActionOr( + "automations", + "listRuns", + { args: args ?? {} }, + () => ipcRenderer.invoke(IPC.automationsListRuns, args ?? {}), + ), getRunDetail: async (runId: string): Promise => - ipcRenderer.invoke(IPC.automationsGetRunDetail, { runId }), + callProjectRuntimeActionOr( + "automations", + "getRunDetail", + { args: { runId } }, + () => ipcRenderer.invoke(IPC.automationsGetRunDetail, { runId }), + ), getIngressStatus: async (): Promise => - ipcRenderer.invoke(IPC.automationsGetIngressStatus), + callProjectRuntimeActionOr("automations", "getIngressStatus", {}, () => + ipcRenderer.invoke(IPC.automationsGetIngressStatus), + ), listIngressEvents: async (args?: { limit?: number; }): Promise => - ipcRenderer.invoke(IPC.automationsListIngressEvents, args ?? {}), + callProjectRuntimeActionOr( + "automations", + "listIngressEvents", + { args: args ?? {} }, + () => ipcRenderer.invoke(IPC.automationsListIngressEvents, args ?? {}), + ), parseNaturalLanguage: async ( req: AutomationParseNaturalLanguageRequest, ): Promise => - ipcRenderer.invoke(IPC.automationsParseNaturalLanguage, req), + callProjectRuntimeActionOr( + "automation_planner", + "parseNaturalLanguage", + { args: req }, + () => ipcRenderer.invoke(IPC.automationsParseNaturalLanguage, req), + ), validateDraft: async ( req: AutomationValidateDraftRequest, ): Promise => - ipcRenderer.invoke(IPC.automationsValidateDraft, req), + callProjectRuntimeActionOr( + "automation_planner", + "validateDraft", + { args: req }, + () => ipcRenderer.invoke(IPC.automationsValidateDraft, req), + ), saveDraft: async ( req: AutomationSaveDraftRequest, ): Promise => - ipcRenderer.invoke(IPC.automationsSaveDraft, req), + callProjectRuntimeActionOr( + "automation_planner", + "saveDraft", + { args: req }, + () => ipcRenderer.invoke(IPC.automationsSaveDraft, req), + ), simulate: async ( req: AutomationSimulateRequest, ): Promise => - ipcRenderer.invoke(IPC.automationsSimulate, req), + callProjectRuntimeActionOr( + "automation_planner", + "simulate", + { args: req }, + () => ipcRenderer.invoke(IPC.automationsSimulate, req), + ), onEvent: (cb: (ev: AutomationsEventPayload) => void) => { const listener = ( _event: Electron.IpcRendererEvent, @@ -1479,33 +3233,70 @@ contextBridge.exposeInMainWorld("ade", { }, review: { listLaunchContext: async (): Promise => - ipcRenderer.invoke(IPC.reviewListLaunchContext), + callProjectRuntimeActionOr("review", "listLaunchContext", {}, () => + ipcRenderer.invoke(IPC.reviewListLaunchContext), + ), listRuns: async (args: ReviewListRunsArgs = {}): Promise => - ipcRenderer.invoke(IPC.reviewListRuns, args), + callProjectRuntimeActionOr("review", "listRuns", { args }, () => + ipcRenderer.invoke(IPC.reviewListRuns, args), + ), getRunDetail: async (runId: string): Promise => - ipcRenderer.invoke(IPC.reviewGetRunDetail, { runId }), + callProjectRuntimeActionOr( + "review", + "getRunDetail", + { args: { runId } }, + () => ipcRenderer.invoke(IPC.reviewGetRunDetail, { runId }), + ), startRun: async (args: ReviewStartRunArgs): Promise => - ipcRenderer.invoke(IPC.reviewStartRun, args), + callProjectRuntimeActionOr("review", "startRun", { args }, () => + ipcRenderer.invoke(IPC.reviewStartRun, args), + ), rerun: async (runId: string): Promise => - ipcRenderer.invoke(IPC.reviewRerun, { runId }), + callProjectRuntimeActionOr("review", "rerun", { arg: runId }, () => + ipcRenderer.invoke(IPC.reviewRerun, { runId }), + ), cancelRun: async (runId: string): Promise => - ipcRenderer.invoke(IPC.reviewCancelRun, { runId }), + callProjectRuntimeActionOr( + "review", + "cancelRun", + { args: { runId } }, + () => ipcRenderer.invoke(IPC.reviewCancelRun, { runId }), + ), recordFeedback: async ( - args: import("../shared/types").ReviewRecordFeedbackArgs, - ): Promise => - ipcRenderer.invoke(IPC.reviewRecordFeedback, args), + args: ReviewRecordFeedbackArgs, + ): Promise => + callProjectRuntimeActionOr("review", "recordFeedback", { args }, () => + ipcRenderer.invoke(IPC.reviewRecordFeedback, args), + ), listSuppressions: async ( - args: import("../shared/types").ReviewListSuppressionsArgs = {}, - ): Promise => - ipcRenderer.invoke(IPC.reviewListSuppressions, args), + args: ReviewListSuppressionsArgs = {}, + ): Promise => + callProjectRuntimeActionOr("review", "listSuppressions", { args }, () => + ipcRenderer.invoke(IPC.reviewListSuppressions, args), + ), deleteSuppression: async (suppressionId: string): Promise => - ipcRenderer.invoke(IPC.reviewDeleteSuppression, { suppressionId }), - qualityReport: async (): Promise => - ipcRenderer.invoke(IPC.reviewQualityReport), + callProjectRuntimeActionOr( + "review", + "deleteSuppression", + { args: { suppressionId } }, + () => + ipcRenderer.invoke(IPC.reviewDeleteSuppression, { suppressionId }), + ), + qualityReport: async (): Promise => + callProjectRuntimeActionOr("review", "qualityReport", {}, () => + ipcRenderer.invoke(IPC.reviewQualityReport), + ), onEvent: (cb: (ev: ReviewEventPayload) => void) => { - const listener = (_event: Electron.IpcRendererEvent, payload: ReviewEventPayload) => cb(payload); + const listener = ( + _event: Electron.IpcRendererEvent, + payload: ReviewEventPayload, + ) => cb(payload); ipcRenderer.on(IPC.reviewEvent, listener); - return () => ipcRenderer.removeListener(IPC.reviewEvent, listener); + const removeRemote = subscribeRemoteReviewEvents(cb); + return () => { + removeRemote(); + ipcRenderer.removeListener(IPC.reviewEvent, listener); + }; }, }, actions: { @@ -1514,15 +3305,21 @@ contextBridge.exposeInMainWorld("ade", { }, usage: { getSnapshot: async (): Promise => - ipcRenderer.invoke(IPC.usageGetSnapshot), + callProjectRuntimeActionOr("usage", "getUsageSnapshot", {}, () => + ipcRenderer.invoke(IPC.usageGetSnapshot), + ), refresh: async (): Promise => - ipcRenderer.invoke(IPC.usageRefresh), + callProjectRuntimeActionOr("usage", "forceRefresh", {}, () => + ipcRenderer.invoke(IPC.usageRefresh), + ), checkBudget: async (args: { scope: BudgetCapScope; scopeId?: string; provider: BudgetCapProvider; }): Promise => - ipcRenderer.invoke(IPC.usageCheckBudget, args), + callProjectRuntimeActionOr("budget", "checkBudget", { args }, () => + ipcRenderer.invoke(IPC.usageCheckBudget, args), + ), getCumulativeUsage: async (args: { scope: BudgetCapScope; scopeId?: string; @@ -1531,13 +3328,23 @@ contextBridge.exposeInMainWorld("ade", { totalTokens: number; totalCostUsd: number; weekKey: string; - }> => ipcRenderer.invoke(IPC.usageGetCumulativeUsage, args), + }> => + callProjectRuntimeActionOr("budget", "getCumulativeUsage", { args }, () => + ipcRenderer.invoke(IPC.usageGetCumulativeUsage, args), + ), getBudgetConfig: async (): Promise => - ipcRenderer.invoke(IPC.usageGetBudgetConfig), + callProjectRuntimeActionOr("budget", "getConfig", {}, () => + ipcRenderer.invoke(IPC.usageGetBudgetConfig), + ), saveBudgetConfig: async ( config: BudgetCapConfig, ): Promise => - ipcRenderer.invoke(IPC.usageSaveBudgetConfig, config), + callProjectRuntimeActionOr( + "budget", + "updateConfig", + { args: config }, + () => ipcRenderer.invoke(IPC.usageSaveBudgetConfig, config), + ), onUpdate: (cb: (snapshot: UsageSnapshot) => void) => { const listener = ( _event: Electron.IpcRendererEvent, @@ -1549,87 +3356,158 @@ contextBridge.exposeInMainWorld("ade", { }, missions: { list: async (args: ListMissionsArgs = {}): Promise => - ipcRenderer.invoke(IPC.missionsList, args), + callProjectRuntimeActionOr("mission", "list", { args }, () => + ipcRenderer.invoke(IPC.missionsList, args), + ), get: async (missionId: string): Promise => - ipcRenderer.invoke(IPC.missionsGet, { missionId }), + callProjectRuntimeActionOr("mission", "get", { arg: missionId }, () => + ipcRenderer.invoke(IPC.missionsGet, { missionId }), + ), create: async (args: CreateMissionArgs): Promise => - ipcRenderer.invoke(IPC.missionsCreate, args), + callProjectRuntimeActionOr("mission", "create", { args }, () => + ipcRenderer.invoke(IPC.missionsCreate, args), + ), update: async (args: UpdateMissionArgs): Promise => - ipcRenderer.invoke(IPC.missionsUpdate, args), + callProjectRuntimeActionOr("mission", "update", { args }, () => + ipcRenderer.invoke(IPC.missionsUpdate, args), + ), archive: async (args: ArchiveMissionArgs): Promise => - ipcRenderer.invoke(IPC.missionsArchive, args), + callProjectRuntimeActionOr("mission", "archive", { args }, () => + ipcRenderer.invoke(IPC.missionsArchive, args), + ), delete: async (args: DeleteMissionArgs): Promise => - ipcRenderer.invoke(IPC.missionsDelete, args), + callProjectRuntimeActionOr("mission", "delete", { args }, () => + ipcRenderer.invoke(IPC.missionsDelete, args), + ), updateStep: async (args: UpdateMissionStepArgs): Promise => - ipcRenderer.invoke(IPC.missionsUpdateStep, args), + callProjectRuntimeActionOr("mission", "updateStep", { args }, () => + ipcRenderer.invoke(IPC.missionsUpdateStep, args), + ), addArtifact: async ( args: AddMissionArtifactArgs, ): Promise => - ipcRenderer.invoke(IPC.missionsAddArtifact, args), + callProjectRuntimeActionOr("mission", "addArtifact", { args }, () => + ipcRenderer.invoke(IPC.missionsAddArtifact, args), + ), addIntervention: async ( args: AddMissionInterventionArgs, ): Promise => - ipcRenderer.invoke(IPC.missionsAddIntervention, args), + callProjectRuntimeActionOr("mission", "addIntervention", { args }, () => + ipcRenderer.invoke(IPC.missionsAddIntervention, args), + ), resolveIntervention: async ( args: ResolveMissionInterventionArgs, ): Promise => - ipcRenderer.invoke(IPC.missionsResolveIntervention, args), + callProjectRuntimeActionOr( + "mission", + "resolveIntervention", + { args }, + () => ipcRenderer.invoke(IPC.missionsResolveIntervention, args), + ), listPhaseItems: async ( args: ListPhaseItemsArgs = {}, ): Promise => - ipcRenderer.invoke(IPC.missionsListPhaseItems, args), + callProjectRuntimeActionOr("mission", "listPhaseItems", { args }, () => + ipcRenderer.invoke(IPC.missionsListPhaseItems, args), + ), savePhaseItem: async (args: SavePhaseItemArgs): Promise => - ipcRenderer.invoke(IPC.missionsSavePhaseItem, args), + callProjectRuntimeActionOr("mission", "savePhaseItem", { args }, () => + ipcRenderer.invoke(IPC.missionsSavePhaseItem, args), + ), deletePhaseItem: async (args: DeletePhaseItemArgs): Promise => - ipcRenderer.invoke(IPC.missionsDeletePhaseItem, args), + callProjectRuntimeActionOr("mission", "deletePhaseItem", { args }, () => + ipcRenderer.invoke(IPC.missionsDeletePhaseItem, args), + ).then(() => undefined), importPhaseItems: async ( args: ImportPhaseItemsArgs, ): Promise => - ipcRenderer.invoke(IPC.missionsImportPhaseItems, args), + callProjectRuntimeActionOr("mission", "importPhaseItems", { args }, () => + ipcRenderer.invoke(IPC.missionsImportPhaseItems, args), + ), exportPhaseItems: async ( args: ExportPhaseItemsArgs = {}, ): Promise => - ipcRenderer.invoke(IPC.missionsExportPhaseItems, args), + callProjectRuntimeActionOr("mission", "exportPhaseItems", { args }, () => + ipcRenderer.invoke(IPC.missionsExportPhaseItems, args), + ), listPhaseProfiles: async ( args: ListPhaseProfilesArgs = {}, ): Promise => - ipcRenderer.invoke(IPC.missionsListPhaseProfiles, args), + callProjectRuntimeActionOr("mission", "listPhaseProfiles", { args }, () => + ipcRenderer.invoke(IPC.missionsListPhaseProfiles, args), + ), savePhaseProfile: async ( args: SavePhaseProfileArgs, ): Promise => - ipcRenderer.invoke(IPC.missionsSavePhaseProfile, args), + callProjectRuntimeActionOr("mission", "savePhaseProfile", { args }, () => + ipcRenderer.invoke(IPC.missionsSavePhaseProfile, args), + ), deletePhaseProfile: async (args: DeletePhaseProfileArgs): Promise => - ipcRenderer.invoke(IPC.missionsDeletePhaseProfile, args), + callProjectRuntimeActionOr( + "mission", + "deletePhaseProfile", + { args }, + () => ipcRenderer.invoke(IPC.missionsDeletePhaseProfile, args), + ).then(() => undefined), clonePhaseProfile: async ( args: ClonePhaseProfileArgs, ): Promise => - ipcRenderer.invoke(IPC.missionsClonePhaseProfile, args), + callProjectRuntimeActionOr("mission", "clonePhaseProfile", { args }, () => + ipcRenderer.invoke(IPC.missionsClonePhaseProfile, args), + ), exportPhaseProfile: async ( args: ExportPhaseProfileArgs, ): Promise => - ipcRenderer.invoke(IPC.missionsExportPhaseProfile, args), + callProjectRuntimeActionOr( + "mission", + "exportPhaseProfile", + { args }, + () => ipcRenderer.invoke(IPC.missionsExportPhaseProfile, args), + ), importPhaseProfile: async ( args: ImportPhaseProfileArgs, ): Promise => - ipcRenderer.invoke(IPC.missionsImportPhaseProfile, args), + callProjectRuntimeActionOr( + "mission", + "importPhaseProfile", + { args }, + () => ipcRenderer.invoke(IPC.missionsImportPhaseProfile, args), + ), getPhaseConfiguration: async ( missionId: string, ): Promise => - ipcRenderer.invoke(IPC.missionsGetPhaseConfiguration, { missionId }), + callProjectRuntimeActionOr( + "mission", + "getPhaseConfiguration", + { arg: missionId }, + () => + ipcRenderer.invoke(IPC.missionsGetPhaseConfiguration, { missionId }), + ), getDashboard: async (): Promise => - ipcRenderer.invoke(IPC.missionsGetDashboard), + callProjectRuntimeActionOr("mission", "getDashboard", {}, () => + ipcRenderer.invoke(IPC.missionsGetDashboard), + ), getFullMissionView: async ( args: GetFullMissionViewArgs, ): Promise => - ipcRenderer.invoke(IPC.missionsGetFullMissionView, args), + callProjectRuntimeActionOr( + "mission", + "getFullMissionView", + { args }, + () => ipcRenderer.invoke(IPC.missionsGetFullMissionView, args), + ), preflight: async ( args: MissionPreflightRequest, ): Promise => - ipcRenderer.invoke(IPC.missionsPreflight, args), + callProjectRuntimeActionOr("mission", "preflight", { args }, () => + ipcRenderer.invoke(IPC.missionsPreflight, args), + ), getRunView: async ( args: GetMissionRunViewArgs, ): Promise => - ipcRenderer.invoke(IPC.missionsGetRunView, args), + callProjectRuntimeActionOr("mission", "getRunView", { args }, () => + ipcRenderer.invoke(IPC.missionsGetRunView, args), + ), subscribeRunView: ( args: GetMissionRunViewArgs, cb: (view: MissionRunView | null) => void, @@ -1645,17 +3523,21 @@ contextBridge.exposeInMainWorld("ade", { return; } inFlight = true; - void ipcRenderer.invoke(IPC.missionsGetRunView, args).then( - (view: MissionRunView | null) => { - if (!disposed) cb(view); - }, - () => {}, - ).finally(() => { - inFlight = false; - if (disposed || !pending) return; - pending = false; - scheduleRefresh(350); - }); + void callProjectRuntimeActionOr("mission", "getRunView", { args }, () => + ipcRenderer.invoke(IPC.missionsGetRunView, args), + ) + .then( + (view: MissionRunView | null) => { + if (!disposed) cb(view); + }, + () => {}, + ) + .finally(() => { + inFlight = false; + if (disposed || !pending) return; + pending = false; + scheduleRefresh(350); + }); }; const scheduleRefresh = (delayMs = 650) => { if (disposed) return; @@ -1698,10 +3580,35 @@ contextBridge.exposeInMainWorld("ade", { ipcRenderer.on(IPC.orchestratorEvent, runtimeListener); ipcRenderer.on(IPC.orchestratorThreadEvent, threadListener); ipcRenderer.on(IPC.orchestratorDagMutation, dagListener); + const removeRemoteMission = subscribeRemoteMissionEvents((payload) => { + if (payload.missionId !== args.missionId) return; + scheduleRefresh(); + }); + const removeRemoteOrchestrator = subscribeRemoteOrchestratorEvents( + (payload) => { + if (args.runId && payload.runId !== args.runId) return; + scheduleRefresh(); + }, + ); + const removeRemoteThread = subscribeRemoteOrchestratorThreadEvents( + (payload) => { + if (payload.missionId !== args.missionId) return; + if (args.runId && payload.runId !== args.runId) return; + scheduleRefresh(750); + }, + ); + const removeRemoteDag = subscribeRemoteDagMutationEvents((payload) => { + if (args.runId && payload.runId !== args.runId) return; + scheduleRefresh(750); + }); refresh(); return () => { disposed = true; if (refreshTimer) clearTimeout(refreshTimer); + removeRemoteMission(); + removeRemoteOrchestrator(); + removeRemoteThread(); + removeRemoteDag(); ipcRenderer.removeListener(IPC.missionsEvent, missionListener); ipcRenderer.removeListener(IPC.orchestratorEvent, runtimeListener); ipcRenderer.removeListener(IPC.orchestratorThreadEvent, threadListener); @@ -1714,202 +3621,461 @@ contextBridge.exposeInMainWorld("ade", { payload: MissionsEventPayload, ) => cb(payload); ipcRenderer.on(IPC.missionsEvent, listener); - return () => ipcRenderer.removeListener(IPC.missionsEvent, listener); + const removeRemote = subscribeRemoteMissionEvents(cb); + return () => { + removeRemote(); + ipcRenderer.removeListener(IPC.missionsEvent, listener); + }; }, }, orchestrator: { listRuns: async ( args: ListOrchestratorRunsArgs = {}, ): Promise => - ipcRenderer.invoke(IPC.orchestratorListRuns, args), + callProjectRuntimeActionOr( + "orchestrator_core", + "listRuns", + { args }, + () => ipcRenderer.invoke(IPC.orchestratorListRuns, args), + ), getRunGraph: async ( args: GetOrchestratorRunGraphArgs, ): Promise => - ipcRenderer.invoke(IPC.orchestratorGetRunGraph, args), + callProjectRuntimeActionOr( + "orchestrator_core", + "getRunGraph", + { args }, + () => ipcRenderer.invoke(IPC.orchestratorGetRunGraph, args), + ), startRun: async ( args: StartOrchestratorRunArgs, ): Promise<{ run: OrchestratorRun; steps: OrchestratorStep[] }> => - ipcRenderer.invoke(IPC.orchestratorStartRun, args), + callProjectRuntimeActionOr( + "orchestrator_core", + "startRun", + { args }, + () => ipcRenderer.invoke(IPC.orchestratorStartRun, args), + ), startRunFromMission: async ( args: StartOrchestratorRunFromMissionArgs, - ): Promise<{ run: OrchestratorRun; steps: OrchestratorStep[] }> => - ipcRenderer.invoke(IPC.orchestratorStartRunFromMission, args), + ): Promise<{ run: OrchestratorRun; steps: OrchestratorStep[] }> => { + const launch = + await callProjectRuntimeActionOr( + "orchestrator", + "startMissionRun", + { + args: { + missionId: args.missionId, + runMode: args.runMode, + autopilotOwnerId: args.autopilotOwnerId, + defaultExecutorKind: args.defaultExecutorKind, + defaultRetryLimit: args.defaultRetryLimit, + metadata: args.metadata ?? null, + plannerProvider: args.plannerProvider ?? undefined, + }, + }, + () => + ipcRenderer.invoke(IPC.orchestratorStartMissionRun, { + missionId: args.missionId, + runMode: args.runMode, + autopilotOwnerId: args.autopilotOwnerId, + defaultExecutorKind: args.defaultExecutorKind, + defaultRetryLimit: args.defaultRetryLimit, + metadata: args.metadata ?? null, + plannerProvider: args.plannerProvider ?? undefined, + }), + ); + if (!launch.started) { + throw new Error("Mission run did not produce a runnable execution."); + } + return launch.started; + }, startAttempt: async ( args: StartOrchestratorAttemptArgs, ): Promise => - ipcRenderer.invoke(IPC.orchestratorStartAttempt, args), + callProjectRuntimeActionOr( + "orchestrator_core", + "startAttempt", + { args }, + () => ipcRenderer.invoke(IPC.orchestratorStartAttempt, args), + ), completeAttempt: async ( args: CompleteOrchestratorAttemptArgs, ): Promise => - ipcRenderer.invoke(IPC.orchestratorCompleteAttempt, args), + callProjectRuntimeActionOr( + "orchestrator_core", + "completeAttempt", + { args }, + () => ipcRenderer.invoke(IPC.orchestratorCompleteAttempt, args), + ), tickRun: async (args: TickOrchestratorRunArgs): Promise => - ipcRenderer.invoke(IPC.orchestratorTickRun, args), + callProjectRuntimeActionOr("orchestrator_core", "tick", { args }, () => + ipcRenderer.invoke(IPC.orchestratorTickRun, args), + ), pauseRun: async ( args: PauseOrchestratorRunArgs, ): Promise => - ipcRenderer.invoke(IPC.orchestratorPauseRun, args), + callProjectRuntimeActionOr( + "orchestrator_core", + "pauseRun", + { + args: { + runId: args.runId, + reason: args.reason ?? "Paused from Missions UI.", + }, + }, + () => ipcRenderer.invoke(IPC.orchestratorPauseRun, args), + ), resumeRun: async ( args: ResumeOrchestratorRunArgs, ): Promise => - ipcRenderer.invoke(IPC.orchestratorResumeRun, args), + callProjectRuntimeActionOr("orchestrator", "resumeRun", { args }, () => + ipcRenderer.invoke(IPC.orchestratorResumeRun, args), + ), cancelRun: async ( args: CancelOrchestratorRunArgs, ): Promise => - ipcRenderer.invoke(IPC.orchestratorCancelRun, args), + callProjectRuntimeActionOr( + "orchestrator", + "cancelRunGracefully", + { args }, + () => ipcRenderer.invoke(IPC.orchestratorCancelRun, args), + ), cleanupTeamResources: async ( args: CleanupOrchestratorTeamResourcesArgs, ): Promise => - ipcRenderer.invoke(IPC.orchestratorCleanupTeamResources, args), + callProjectRuntimeActionOr( + "orchestrator", + "cleanupTeamResources", + { args }, + () => ipcRenderer.invoke(IPC.orchestratorCleanupTeamResources, args), + ), heartbeatClaims: async ( args: HeartbeatOrchestratorClaimsArgs, ): Promise => - ipcRenderer.invoke(IPC.orchestratorHeartbeatClaims, args), + callProjectRuntimeActionOr( + "orchestrator_core", + "heartbeatClaims", + { args }, + () => ipcRenderer.invoke(IPC.orchestratorHeartbeatClaims, args), + ), listTimeline: async ( args: ListOrchestratorTimelineArgs, ): Promise => - ipcRenderer.invoke(IPC.orchestratorListTimeline, args), + callProjectRuntimeActionOr( + "orchestrator_core", + "listTimeline", + { args }, + () => ipcRenderer.invoke(IPC.orchestratorListTimeline, args), + ), getMissionLogs: async ( args: GetMissionLogsArgs, ): Promise => - ipcRenderer.invoke(IPC.orchestratorGetMissionLogs, args), + callProjectRuntimeActionOr( + "orchestrator", + "getMissionLogs", + { args }, + () => ipcRenderer.invoke(IPC.orchestratorGetMissionLogs, args), + ), exportMissionLogs: async ( args: ExportMissionLogsArgs, ): Promise => - ipcRenderer.invoke(IPC.orchestratorExportMissionLogs, args), + callProjectRuntimeActionOr( + "orchestrator", + "exportMissionLogs", + { args }, + () => ipcRenderer.invoke(IPC.orchestratorExportMissionLogs, args), + ), getGateReport: async ( args: GetOrchestratorGateReportArgs = {}, ): Promise => - ipcRenderer.invoke(IPC.orchestratorGetGateReport, args), + callProjectRuntimeActionOr( + "orchestrator_core", + "getLatestGateReport", + { args }, + () => ipcRenderer.invoke(IPC.orchestratorGetGateReport, args), + ), getWorkerStates: async ( args: GetOrchestratorWorkerStatesArgs, ): Promise => - ipcRenderer.invoke(IPC.orchestratorGetWorkerStates, args), + callProjectRuntimeActionOr( + "orchestrator", + "getWorkerStates", + { args }, + () => ipcRenderer.invoke(IPC.orchestratorGetWorkerStates, args), + ), startMissionRun: async ( args: StartMissionRunWithAIArgs, ): Promise => - ipcRenderer.invoke(IPC.orchestratorStartMissionRun, args), + callProjectRuntimeActionOr( + "orchestrator", + "startMissionRun", + { args }, + () => ipcRenderer.invoke(IPC.orchestratorStartMissionRun, args), + ), steerMission: async (args: SteerMissionArgs): Promise => - ipcRenderer.invoke(IPC.orchestratorSteerMission, args), + callProjectRuntimeActionOr("orchestrator", "steerMission", { args }, () => + ipcRenderer.invoke(IPC.orchestratorSteerMission, args), + ), getModelCapabilities: async (): Promise => - ipcRenderer.invoke(IPC.orchestratorGetModelCapabilities), + callProjectRuntimeActionOr( + "orchestrator", + "getModelCapabilities", + {}, + () => ipcRenderer.invoke(IPC.orchestratorGetModelCapabilities), + ), getTeamMembers: async ( args: GetTeamMembersArgs, ): Promise => - ipcRenderer.invoke(IPC.orchestratorGetTeamMembers, args), + callProjectRuntimeActionOr( + "orchestrator", + "getTeamMembers", + { args }, + () => ipcRenderer.invoke(IPC.orchestratorGetTeamMembers, args), + ), getTeamRuntimeState: async ( args: GetTeamRuntimeStateArgs, ): Promise => - ipcRenderer.invoke(IPC.orchestratorGetTeamRuntimeState, args), + callProjectRuntimeActionOr( + "orchestrator_core", + "getRunState", + { arg: args.runId }, + () => ipcRenderer.invoke(IPC.orchestratorGetTeamRuntimeState, args), + ), finalizeRun: async (args: FinalizeRunArgs): Promise => - ipcRenderer.invoke(IPC.orchestratorFinalizeRun, args), + callProjectRuntimeActionOr("orchestrator", "finalizeRun", { args }, () => + ipcRenderer.invoke(IPC.orchestratorFinalizeRun, args), + ), sendChat: async ( args: SendOrchestratorChatArgs, ): Promise => - ipcRenderer.invoke(IPC.orchestratorSendChat, args), + callProjectRuntimeActionOr("orchestrator", "sendChat", { args }, () => + ipcRenderer.invoke(IPC.orchestratorSendChat, args), + ), getChat: async ( args: GetOrchestratorChatArgs, ): Promise => - ipcRenderer.invoke(IPC.orchestratorGetChat, args), + callProjectRuntimeActionOr("orchestrator", "getChat", { args }, () => + ipcRenderer.invoke(IPC.orchestratorGetChat, args), + ), listChatThreads: async ( args: ListOrchestratorChatThreadsArgs, ): Promise => - ipcRenderer.invoke(IPC.orchestratorListChatThreads, args), + callProjectRuntimeActionOr( + "orchestrator", + "listChatThreads", + { args }, + () => ipcRenderer.invoke(IPC.orchestratorListChatThreads, args), + ), getThreadMessages: async ( args: GetOrchestratorThreadMessagesArgs, ): Promise => - ipcRenderer.invoke(IPC.orchestratorGetThreadMessages, args), + callProjectRuntimeActionOr( + "orchestrator", + "getThreadMessages", + { args }, + () => ipcRenderer.invoke(IPC.orchestratorGetThreadMessages, args), + ), sendThreadMessage: async ( args: SendOrchestratorThreadMessageArgs, ): Promise => - ipcRenderer.invoke(IPC.orchestratorSendThreadMessage, args), + callProjectRuntimeActionOr( + "orchestrator", + "sendThreadMessage", + { args }, + () => ipcRenderer.invoke(IPC.orchestratorSendThreadMessage, args), + ), getWorkerDigest: async ( args: GetOrchestratorWorkerDigestArgs, ): Promise => - ipcRenderer.invoke(IPC.orchestratorGetWorkerDigest, args), + callProjectRuntimeActionOr( + "orchestrator", + "getWorkerDigest", + { args }, + () => ipcRenderer.invoke(IPC.orchestratorGetWorkerDigest, args), + ), listWorkerDigests: async ( args: ListOrchestratorWorkerDigestsArgs, ): Promise => - ipcRenderer.invoke(IPC.orchestratorListWorkerDigests, args), + callProjectRuntimeActionOr( + "orchestrator", + "listWorkerDigests", + { args }, + () => ipcRenderer.invoke(IPC.orchestratorListWorkerDigests, args), + ), getContextCheckpoint: async ( args: GetOrchestratorContextCheckpointArgs, ): Promise => - ipcRenderer.invoke(IPC.orchestratorGetContextCheckpoint, args), + callProjectRuntimeActionOr( + "orchestrator", + "getContextCheckpoint", + { args }, + () => ipcRenderer.invoke(IPC.orchestratorGetContextCheckpoint, args), + ), listLaneDecisions: async ( args: ListOrchestratorLaneDecisionsArgs, ): Promise => - ipcRenderer.invoke(IPC.orchestratorListLaneDecisions, args), + callProjectRuntimeActionOr( + "orchestrator", + "listLaneDecisions", + { args }, + () => ipcRenderer.invoke(IPC.orchestratorListLaneDecisions, args), + ), getMissionMetrics: async ( args: GetMissionMetricsArgs, ): Promise<{ config: MissionMetricsConfig | null; samples: MissionMetricSample[]; - }> => ipcRenderer.invoke(IPC.orchestratorGetMissionMetrics, args), + }> => + callProjectRuntimeActionOr( + "orchestrator", + "getMissionMetrics", + { args }, + () => ipcRenderer.invoke(IPC.orchestratorGetMissionMetrics, args), + ), setMissionMetricsConfig: async ( args: SetMissionMetricsConfigArgs, ): Promise => - ipcRenderer.invoke(IPC.orchestratorSetMissionMetricsConfig, args), + callProjectRuntimeActionOr( + "orchestrator", + "setMissionMetricsConfig", + { args }, + () => ipcRenderer.invoke(IPC.orchestratorSetMissionMetricsConfig, args), + ), getExecutionPlanPreview: async (args: { runId: string; }): Promise => - ipcRenderer.invoke(IPC.orchestratorGetExecutionPlanPreview, args), + callProjectRuntimeActionOr( + "orchestrator", + "getExecutionPlanPreview", + { args }, + () => ipcRenderer.invoke(IPC.orchestratorGetExecutionPlanPreview, args), + ), getMissionStateDocument: async ( args: GetMissionStateDocumentArgs, ): Promise => - ipcRenderer.invoke(IPC.orchestratorGetMissionStateDocument, args), + callProjectRuntimeActionOr( + "orchestrator", + "getMissionStateDocument", + { args }, + () => ipcRenderer.invoke(IPC.orchestratorGetMissionStateDocument, args), + ), listArtifacts: async ( args: ListOrchestratorArtifactsArgs, ): Promise => - ipcRenderer.invoke(IPC.orchestratorListArtifacts, args), + callProjectRuntimeActionOr( + "orchestrator", + "listArtifacts", + { args }, + () => ipcRenderer.invoke(IPC.orchestratorListArtifacts, args), + ), listWorkerCheckpoints: async ( args: ListOrchestratorWorkerCheckpointsArgs, ): Promise => - ipcRenderer.invoke(IPC.orchestratorListWorkerCheckpoints, args), + callProjectRuntimeActionOr( + "orchestrator", + "listWorkerCheckpoints", + { args }, + () => ipcRenderer.invoke(IPC.orchestratorListWorkerCheckpoints, args), + ), getPromptInspector: async ( args: GetOrchestratorPromptInspectorArgs, ): Promise => - ipcRenderer.invoke(IPC.orchestratorGetPromptInspector, args), + callProjectRuntimeActionOr( + "orchestrator", + "getPromptInspector", + { args }, + () => ipcRenderer.invoke(IPC.orchestratorGetPromptInspector, args), + ), getPlanningPromptPreview: async ( args: GetPlanningPromptPreviewArgs, ): Promise => - ipcRenderer.invoke(IPC.orchestratorGetPlanningPromptPreview, args), + callProjectRuntimeActionOr( + "orchestrator", + "getPlanningPromptPreview", + { args }, + () => + ipcRenderer.invoke(IPC.orchestratorGetPlanningPromptPreview, args), + ), getCheckpointStatus: async (args: { runId: string; }): Promise<{ savedAt: string; turnCount: number; compactionCount: number; - } | null> => ipcRenderer.invoke(IPC.orchestratorGetCheckpointStatus, args), + } | null> => + callProjectRuntimeActionOr( + "orchestrator", + "getCheckpointStatus", + { args }, + () => ipcRenderer.invoke(IPC.orchestratorGetCheckpointStatus, args), + ), getMissionBudgetStatus: async ( args: GetMissionBudgetStatusArgs, ): Promise => - ipcRenderer.invoke(IPC.orchestratorGetMissionBudgetStatus, args), + callProjectRuntimeActionOr( + "mission_budget", + "getMissionBudgetStatus", + { args }, + () => ipcRenderer.invoke(IPC.orchestratorGetMissionBudgetStatus, args), + ), getMissionBudgetTelemetry: async ( args: GetMissionBudgetTelemetryArgs, ): Promise => - ipcRenderer.invoke(IPC.orchestratorGetMissionBudgetTelemetry, args), + callProjectRuntimeActionOr( + "mission_budget", + "getMissionBudgetTelemetry", + { args }, + () => + ipcRenderer.invoke(IPC.orchestratorGetMissionBudgetTelemetry, args), + ), sendAgentMessage: async ( args: SendAgentMessageArgs, ): Promise => - ipcRenderer.invoke(IPC.orchestratorSendAgentMessage, args), + callProjectRuntimeActionOr( + "orchestrator", + "sendAgentMessage", + { args }, + () => ipcRenderer.invoke(IPC.orchestratorSendAgentMessage, args), + ), getGlobalChat: async ( args: GetGlobalChatArgs, ): Promise => - ipcRenderer.invoke(IPC.orchestratorGetGlobalChat, args), + callProjectRuntimeActionOr( + "orchestrator", + "getGlobalChat", + { args }, + () => ipcRenderer.invoke(IPC.orchestratorGetGlobalChat, args), + ), getActiveAgents: async ( args: GetActiveAgentsArgs, ): Promise => - ipcRenderer.invoke(IPC.orchestratorGetActiveAgents, args), + callProjectRuntimeActionOr( + "orchestrator", + "getActiveAgents", + { args }, + () => ipcRenderer.invoke(IPC.orchestratorGetActiveAgents, args), + ), getAggregatedUsage: async ( args: GetAggregatedUsageArgs, ): Promise => - ipcRenderer.invoke(IPC.getAggregatedUsage, args), + callProjectRuntimeActionOr( + "orchestrator", + "getAggregatedUsage", + { args }, + () => ipcRenderer.invoke(IPC.getAggregatedUsage, args), + ), onEvent: (cb: (ev: OrchestratorRuntimeEvent) => void) => { const listener = ( _event: Electron.IpcRendererEvent, payload: OrchestratorRuntimeEvent, ) => cb(payload); ipcRenderer.on(IPC.orchestratorEvent, listener); - return () => ipcRenderer.removeListener(IPC.orchestratorEvent, listener); + const removeRemote = subscribeRemoteOrchestratorEvents(cb); + return () => { + removeRemote(); + ipcRenderer.removeListener(IPC.orchestratorEvent, listener); + }; }, onThreadEvent: (cb: (ev: OrchestratorThreadEvent) => void) => { const listener = ( @@ -1917,8 +4083,11 @@ contextBridge.exposeInMainWorld("ade", { payload: OrchestratorThreadEvent, ) => cb(payload); ipcRenderer.on(IPC.orchestratorThreadEvent, listener); - return () => + const removeRemote = subscribeRemoteOrchestratorThreadEvents(cb); + return () => { + removeRemote(); ipcRenderer.removeListener(IPC.orchestratorThreadEvent, listener); + }; }, onDagMutation: (cb: (ev: DagMutationEvent) => void) => { const listener = ( @@ -1926,136 +4095,258 @@ contextBridge.exposeInMainWorld("ade", { payload: DagMutationEvent, ) => cb(payload); ipcRenderer.on(IPC.orchestratorDagMutation, listener); - return () => + const removeRemote = subscribeRemoteDagMutationEvents(cb); + return () => { + removeRemote(); ipcRenderer.removeListener(IPC.orchestratorDagMutation, listener); + }; }, }, lanes: { - list: async (args: ListLanesArgs = {}): Promise => - lanesListCache.get(serializeIpcCacheArgs(args)), + list: async (args: ListLanesArgs = {}): Promise => { + const runtime = await callProjectRuntimeActionIfBound( + "lane", + "list", + { args }, + ); + if (runtime.handled) return runtime.result; + return lanesListCache.get(serializeIpcCacheArgs(args)); + }, listSnapshots: async ( args: ListLanesArgs = {}, - ): Promise => - lanesListSnapshotsCache.get(serializeIpcCacheArgs(args)), + ): Promise => { + const runtime = await callProjectRuntimeActionIfBound( + "lane", + "listSnapshots", + { args }, + ); + if (runtime.handled) return runtime.result; + return lanesListSnapshotsCache.get(serializeIpcCacheArgs(args)); + }, create: async (args: CreateLaneArgs): Promise => { clearGitReadCaches(); - const lane = await ipcRenderer.invoke(IPC.lanesCreate, args); + const lane = await callProjectRuntimeActionOr( + "lane", + "create", + { args }, + () => ipcRenderer.invoke(IPC.lanesCreate, args), + ); clearGitReadCaches(); - return lane; + return lane as LaneSummary; }, createChild: async (args: CreateChildLaneArgs): Promise => { clearGitReadCaches(); - const lane = await ipcRenderer.invoke(IPC.lanesCreateChild, args); + const lane = await callProjectRuntimeActionOr( + "lane", + "createChild", + { args }, + () => ipcRenderer.invoke(IPC.lanesCreateChild, args), + ); clearGitReadCaches(); - return lane; + return lane as LaneSummary; }, createFromUnstaged: async ( args: CreateLaneFromUnstagedArgs, ): Promise => { clearGitReadCaches(); - const lane = await ipcRenderer.invoke(IPC.lanesCreateFromUnstaged, args); + const lane = await callProjectRuntimeActionOr( + "lane", + "createFromUnstaged", + { args }, + () => ipcRenderer.invoke(IPC.lanesCreateFromUnstaged, args), + ); clearGitReadCaches(); - return lane; + return lane as LaneSummary; }, importBranch: async (args: ImportBranchLaneArgs): Promise => { clearGitReadCaches(); - const lane = await ipcRenderer.invoke(IPC.lanesImportBranch, args); + const lane = await callProjectRuntimeActionOr( + "lane", + "importBranch", + { args }, + () => ipcRenderer.invoke(IPC.lanesImportBranch, args), + ); clearGitReadCaches(); - return lane; + return lane as LaneSummary; }, previewBranchSwitch: async ( args: LaneBranchSwitchArgs, ): Promise => - ipcRenderer.invoke(IPC.lanesPreviewBranchSwitch, args), + callProjectRuntimeActionOr("lane", "previewBranchSwitch", { args }, () => + ipcRenderer.invoke(IPC.lanesPreviewBranchSwitch, args), + ), switchBranch: async ( args: LaneBranchSwitchArgs, ): Promise => { clearGitReadCaches(); - const result = await ipcRenderer.invoke(IPC.lanesSwitchBranch, args); + const result = await callProjectRuntimeActionOr( + "lane", + "switchBranch", + { args }, + () => ipcRenderer.invoke(IPC.lanesSwitchBranch, args), + ); clearGitReadCaches(); - return result; + return result as LaneBranchSwitchResult; }, attach: async (args: AttachLaneArgs): Promise => { clearGitReadCaches(); - const lane = await ipcRenderer.invoke(IPC.lanesAttach, args); + const lane = await callProjectRuntimeActionOr( + "lane", + "attach", + { args }, + () => ipcRenderer.invoke(IPC.lanesAttach, args), + ); clearGitReadCaches(); - return lane; + return lane as LaneSummary; }, listUnregisteredWorktrees: async (): Promise => - ipcRenderer.invoke(IPC.lanesListUnregisteredWorktrees), - adoptAttached: async (args: AdoptAttachedLaneArgs): Promise => { + callProjectRuntimeActionOr("lane", "listUnregisteredWorktrees", {}, () => + ipcRenderer.invoke(IPC.lanesListUnregisteredWorktrees), + ), + adoptAttached: async ( + args: AdoptAttachedLaneArgs, + ): Promise => { clearGitReadCaches(); - const lane = await ipcRenderer.invoke(IPC.lanesAdoptAttached, args); + const lane = await callProjectRuntimeActionOr( + "lane", + "adoptAttached", + { args }, + () => ipcRenderer.invoke(IPC.lanesAdoptAttached, args), + ); clearGitReadCaches(); - return lane; + return lane as LaneSummary; }, rename: async (args: RenameLaneArgs): Promise => { clearGitReadCaches(); - await ipcRenderer.invoke(IPC.lanesRename, args); + await callProjectRuntimeActionOr("lane", "rename", { args }, () => + ipcRenderer.invoke(IPC.lanesRename, args), + ); clearGitReadCaches(); }, reparent: async (args: ReparentLaneArgs): Promise => { clearGitReadCaches(); - const result = await ipcRenderer.invoke(IPC.lanesReparent, args); + const result = await callProjectRuntimeActionOr( + "lane", + "reparent", + { args }, + () => ipcRenderer.invoke(IPC.lanesReparent, args), + ); clearGitReadCaches(); - return result; + return result as ReparentLaneResult; }, updateAppearance: async (args: UpdateLaneAppearanceArgs): Promise => { clearGitReadCaches(); - await ipcRenderer.invoke(IPC.lanesUpdateAppearance, args); + await callProjectRuntimeActionOr( + "lane", + "updateAppearance", + { args }, + () => ipcRenderer.invoke(IPC.lanesUpdateAppearance, args), + ); clearGitReadCaches(); }, archive: async (args: ArchiveLaneArgs): Promise => { clearGitReadCaches(); - await ipcRenderer.invoke(IPC.lanesArchive, args); + await callProjectRuntimeActionOr("lane", "archive", { args }, () => + ipcRenderer.invoke(IPC.lanesArchive, args), + ); clearGitReadCaches(); }, delete: async (args: DeleteLaneArgs): Promise => { clearGitReadCaches(); - await ipcRenderer.invoke(IPC.lanesDelete, args); + await callProjectRuntimeActionOr("lane", "delete", { args }, () => + ipcRenderer.invoke(IPC.lanesDelete, args), + ); clearGitReadCaches(); }, - cancelDelete: async (args: { laneId: string }): Promise<{ cancelled: boolean; reason?: string }> => - ipcRenderer.invoke(IPC.lanesDeleteCancel, args), + cancelDelete: async (args: { + laneId: string; + }): Promise<{ cancelled: boolean; reason?: string }> => + callProjectRuntimeActionOr( + "lane", + "cancelDelete", + { arg: args.laneId }, + () => ipcRenderer.invoke(IPC.lanesDeleteCancel, args), + ), getDeleteRisk: async (args: { laneId: string }): Promise => - ipcRenderer.invoke(IPC.lanesGetDeleteRisk, args), + callProjectRuntimeActionOr( + "lane", + "getDeleteRisk", + { arg: args.laneId }, + () => ipcRenderer.invoke(IPC.lanesGetDeleteRisk, args), + ), onDeleteEvent: (cb: (ev: LaneDeleteEvent) => void) => { const listener = ( _event: Electron.IpcRendererEvent, payload: LaneDeleteEvent, ) => cb(payload); ipcRenderer.on(IPC.lanesDeleteEvent, listener); - return () => ipcRenderer.removeListener(IPC.lanesDeleteEvent, listener); + const removeRemote = subscribeRemoteLaneDeleteEvents(cb); + return () => { + removeRemote(); + ipcRenderer.removeListener(IPC.lanesDeleteEvent, listener); + }; }, getStackChain: async (laneId: string): Promise => - ipcRenderer.invoke(IPC.lanesGetStackChain, { laneId }), + callProjectRuntimeActionOr("lane", "getStackChain", { arg: laneId }, () => + ipcRenderer.invoke(IPC.lanesGetStackChain, { laneId }), + ), getChildren: async (laneId: string): Promise => - ipcRenderer.invoke(IPC.lanesGetChildren, { laneId }), + callProjectRuntimeActionOr("lane", "getChildren", { arg: laneId }, () => + ipcRenderer.invoke(IPC.lanesGetChildren, { laneId }), + ), rebaseStart: async (args: RebaseStartArgs): Promise => - ipcRenderer.invoke(IPC.lanesRebaseStart, args), + callProjectRuntimeActionOr("lane", "rebaseStart", { args }, () => + ipcRenderer.invoke(IPC.lanesRebaseStart, args), + ), rebasePush: async (args: RebasePushArgs): Promise => - ipcRenderer.invoke(IPC.lanesRebasePush, args), + callProjectRuntimeActionOr("lane", "rebasePush", { args }, () => + ipcRenderer.invoke(IPC.lanesRebasePush, args), + ), rebaseRollback: async (args: RebaseRollbackArgs): Promise => - ipcRenderer.invoke(IPC.lanesRebaseRollback, args), + callProjectRuntimeActionOr("lane", "rebaseRollback", { args }, () => + ipcRenderer.invoke(IPC.lanesRebaseRollback, args), + ), rebaseAbort: async (args: RebaseAbortArgs): Promise => - ipcRenderer.invoke(IPC.lanesRebaseAbort, args), + callProjectRuntimeActionOr("lane", "rebaseAbort", { args }, () => + ipcRenderer.invoke(IPC.lanesRebaseAbort, args), + ), rebaseSubscribe: (cb: (ev: RebaseRunEventPayload) => void) => { const listener = ( _event: Electron.IpcRendererEvent, payload: RebaseRunEventPayload, ) => cb(payload); ipcRenderer.on(IPC.lanesRebaseEvent, listener); - return () => ipcRenderer.removeListener(IPC.lanesRebaseEvent, listener); + const removeRemote = subscribeRemoteLaneRebaseEvents(cb); + return () => { + removeRemote(); + ipcRenderer.removeListener(IPC.lanesRebaseEvent, listener); + }; }, listRebaseSuggestions: async (): Promise => - ipcRenderer.invoke(IPC.lanesListRebaseSuggestions), - dismissRebaseSuggestion: async (args: { laneId: string }): Promise => - ipcRenderer.invoke(IPC.lanesDismissRebaseSuggestion, args), + callProjectRuntimeActionOr("lane", "listRebaseSuggestions", {}, () => + ipcRenderer.invoke(IPC.lanesListRebaseSuggestions), + ), + dismissRebaseSuggestion: async (args: { + laneId: string; + }): Promise => { + await callProjectRuntimeActionOr( + "lane", + "dismissRebaseSuggestion", + { args }, + () => ipcRenderer.invoke(IPC.lanesDismissRebaseSuggestion, args), + ); + }, deferRebaseSuggestion: async (args: { laneId: string; minutes: number; - }): Promise => - ipcRenderer.invoke(IPC.lanesDeferRebaseSuggestion, args), + }): Promise => { + await callProjectRuntimeActionOr( + "lane", + "deferRebaseSuggestion", + { args }, + () => ipcRenderer.invoke(IPC.lanesDeferRebaseSuggestion, args), + ); + }, onRebaseSuggestionsEvent: ( cb: (ev: RebaseSuggestionsEventPayload) => void, ) => { @@ -2064,173 +4355,383 @@ contextBridge.exposeInMainWorld("ade", { payload: RebaseSuggestionsEventPayload, ) => cb(payload); ipcRenderer.on(IPC.lanesRebaseSuggestionsEvent, listener); - return () => + const removeRemote = subscribeRemoteLaneRebaseSuggestionsEvents(cb); + return () => { + removeRemote(); ipcRenderer.removeListener(IPC.lanesRebaseSuggestionsEvent, listener); + }; }, listAutoRebaseStatuses: async (): Promise => - ipcRenderer.invoke(IPC.lanesListAutoRebaseStatuses), - dismissAutoRebaseStatus: async (args: { laneId: string }): Promise => - ipcRenderer.invoke(IPC.lanesDismissAutoRebaseStatus, args), + callProjectRuntimeActionOr("lane", "listAutoRebaseStatuses", {}, () => + ipcRenderer.invoke(IPC.lanesListAutoRebaseStatuses), + ), + dismissAutoRebaseStatus: async (args: { + laneId: string; + }): Promise => { + await callProjectRuntimeActionOr( + "lane", + "dismissAutoRebaseStatus", + { args }, + () => ipcRenderer.invoke(IPC.lanesDismissAutoRebaseStatus, args), + ); + }, onAutoRebaseEvent: (cb: (ev: AutoRebaseEventPayload) => void) => { const listener = ( _event: Electron.IpcRendererEvent, payload: AutoRebaseEventPayload, ) => cb(payload); ipcRenderer.on(IPC.lanesAutoRebaseEvent, listener); - return () => + const removeRemote = subscribeRemoteLaneAutoRebaseEvents(cb); + return () => { + removeRemote(); ipcRenderer.removeListener(IPC.lanesAutoRebaseEvent, listener); + }; + }, + openFolder: async (args: { laneId: string }): Promise => { + const binding = await getRemoteProjectBinding(); + if (binding) { + throw new Error( + "Remote lane folders cannot be opened on this machine. Copy the remote path instead.", + ); + } + await ipcRenderer.invoke(IPC.lanesOpenFolder, args); }, - openFolder: async (args: { laneId: string }): Promise => - ipcRenderer.invoke(IPC.lanesOpenFolder, args), initEnv: async (args: InitLaneEnvArgs): Promise => - ipcRenderer.invoke(IPC.lanesInitEnv, args), + callProjectRuntimeActionOr("lane", "initEnv", { args }, () => + ipcRenderer.invoke(IPC.lanesInitEnv, args), + ), getEnvStatus: async ( args: GetLaneEnvStatusArgs, ): Promise => - ipcRenderer.invoke(IPC.lanesGetEnvStatus, args), + callProjectRuntimeActionOr("lane", "getEnvStatus", { args }, () => + ipcRenderer.invoke(IPC.lanesGetEnvStatus, args), + ), getOverlay: async ( args: GetLaneOverlayArgs, ): Promise => - ipcRenderer.invoke(IPC.lanesGetOverlay, args), + callProjectRuntimeActionOr("lane", "getOverlay", { args }, () => + ipcRenderer.invoke(IPC.lanesGetOverlay, args), + ), onEnvEvent: (cb: (ev: LaneEnvInitEvent) => void) => { const listener = ( _event: Electron.IpcRendererEvent, payload: LaneEnvInitEvent, ) => cb(payload); ipcRenderer.on(IPC.lanesEnvEvent, listener); - return () => ipcRenderer.removeListener(IPC.lanesEnvEvent, listener); + const removeRemote = subscribeRemoteLaneEnvEvents(cb); + return () => { + removeRemote(); + ipcRenderer.removeListener(IPC.lanesEnvEvent, listener); + }; }, listTemplates: async (): Promise => - ipcRenderer.invoke(IPC.lanesListTemplates), + callProjectRuntimeActionOr("lane", "listTemplates", {}, () => + ipcRenderer.invoke(IPC.lanesListTemplates), + ), getTemplate: async ( args: GetLaneTemplateArgs, ): Promise => - ipcRenderer.invoke(IPC.lanesGetTemplate, args), + callProjectRuntimeActionOr("lane", "getTemplate", { args }, () => + ipcRenderer.invoke(IPC.lanesGetTemplate, args), + ), getDefaultTemplate: async (): Promise => - ipcRenderer.invoke(IPC.lanesGetDefaultTemplate), + callProjectRuntimeActionOr("lane", "getDefaultTemplate", {}, () => + ipcRenderer.invoke(IPC.lanesGetDefaultTemplate), + ), setDefaultTemplate: async ( args: SetDefaultLaneTemplateArgs, - ): Promise => ipcRenderer.invoke(IPC.lanesSetDefaultTemplate, args), + ): Promise => { + await callProjectRuntimeActionOr( + "lane", + "setDefaultTemplate", + { args }, + () => ipcRenderer.invoke(IPC.lanesSetDefaultTemplate, args), + ); + }, applyTemplate: async ( args: ApplyLaneTemplateArgs, ): Promise => - ipcRenderer.invoke(IPC.lanesApplyTemplate, args), - saveTemplate: async (args: SaveLaneTemplateArgs): Promise => - ipcRenderer.invoke(IPC.lanesSaveTemplate, args), - deleteTemplate: async (args: DeleteLaneTemplateArgs): Promise => - ipcRenderer.invoke(IPC.lanesDeleteTemplate, args), + callProjectRuntimeActionOr("lane", "applyTemplate", { args }, () => + ipcRenderer.invoke(IPC.lanesApplyTemplate, args), + ), + saveTemplate: async (args: SaveLaneTemplateArgs): Promise => { + await callProjectRuntimeActionOr("lane", "saveTemplate", { args }, () => + ipcRenderer.invoke(IPC.lanesSaveTemplate, args), + ); + }, + deleteTemplate: async (args: DeleteLaneTemplateArgs): Promise => { + await callProjectRuntimeActionOr("lane", "deleteTemplate", { args }, () => + ipcRenderer.invoke(IPC.lanesDeleteTemplate, args), + ); + }, portGetLease: async (args: GetPortLeaseArgs): Promise => - ipcRenderer.invoke(IPC.lanesPortGetLease, args), + callProjectRuntimeActionOr("lane", "portGetLease", { args }, () => + ipcRenderer.invoke(IPC.lanesPortGetLease, args), + ), portListLeases: async (): Promise => - ipcRenderer.invoke(IPC.lanesPortListLeases), + callProjectRuntimeActionOr("lane", "portListLeases", {}, () => + ipcRenderer.invoke(IPC.lanesPortListLeases), + ), portAcquire: async (args: AcquirePortLeaseArgs): Promise => - ipcRenderer.invoke(IPC.lanesPortAcquire, args), - portRelease: async (args: ReleasePortLeaseArgs): Promise => - ipcRenderer.invoke(IPC.lanesPortRelease, args), + callProjectRuntimeActionOr("lane", "portAcquire", { args }, () => + ipcRenderer.invoke(IPC.lanesPortAcquire, args), + ), + portRelease: async (args: ReleasePortLeaseArgs): Promise => { + await callProjectRuntimeActionOr("lane", "portRelease", { args }, () => + ipcRenderer.invoke(IPC.lanesPortRelease, args), + ); + }, portListConflicts: async (): Promise => - ipcRenderer.invoke(IPC.lanesPortListConflicts), + callProjectRuntimeActionOr("lane", "portListConflicts", {}, () => + ipcRenderer.invoke(IPC.lanesPortListConflicts), + ), portRecoverOrphans: async (): Promise => - ipcRenderer.invoke(IPC.lanesPortRecoverOrphans), + callProjectRuntimeActionOr("lane", "portRecoverOrphans", {}, () => + ipcRenderer.invoke(IPC.lanesPortRecoverOrphans), + ), onPortEvent: (cb: (ev: PortAllocationEvent) => void) => { const listener = ( _event: Electron.IpcRendererEvent, payload: PortAllocationEvent, ) => cb(payload); ipcRenderer.on(IPC.lanesPortEvent, listener); - return () => ipcRenderer.removeListener(IPC.lanesPortEvent, listener); + const removeRemote = subscribeRemoteLanePortEvents(cb); + return () => { + removeRemote(); + ipcRenderer.removeListener(IPC.lanesPortEvent, listener); + }; }, proxyGetStatus: async (): Promise => - ipcRenderer.invoke(IPC.lanesProxyGetStatus), + callProjectRuntimeActionOr("lane", "proxyGetStatus", {}, () => + ipcRenderer.invoke(IPC.lanesProxyGetStatus), + ), proxyStart: async (args?: StartProxyArgs): Promise => - ipcRenderer.invoke(IPC.lanesProxyStart, args), - proxyStop: async (): Promise => - ipcRenderer.invoke(IPC.lanesProxyStop), + callProjectRuntimeActionOr("lane", "proxyStart", { args }, () => + ipcRenderer.invoke(IPC.lanesProxyStart, args), + ), + proxyStop: async (): Promise => { + await callProjectRuntimeActionOr("lane", "proxyStop", {}, () => + ipcRenderer.invoke(IPC.lanesProxyStop), + ); + }, proxyAddRoute: async (args: AddProxyRouteArgs): Promise => - ipcRenderer.invoke(IPC.lanesProxyAddRoute, args), - proxyRemoveRoute: async (args: RemoveProxyRouteArgs): Promise => - ipcRenderer.invoke(IPC.lanesProxyRemoveRoute, args), + callProjectRuntimeActionOr("lane", "proxyAddRoute", { args }, () => + ipcRenderer.invoke(IPC.lanesProxyAddRoute, args), + ), + proxyRemoveRoute: async (args: RemoveProxyRouteArgs): Promise => { + await callProjectRuntimeActionOr( + "lane", + "proxyRemoveRoute", + { args }, + () => ipcRenderer.invoke(IPC.lanesProxyRemoveRoute, args), + ); + }, proxyGetPreviewInfo: async ( args: GetPreviewInfoArgs, ): Promise => - ipcRenderer.invoke(IPC.lanesProxyGetPreviewInfo, args), - proxyOpenPreview: async (args: OpenPreviewArgs): Promise => - ipcRenderer.invoke(IPC.lanesProxyOpenPreview, args), + callProjectRuntimeActionOr("lane", "proxyGetPreviewInfo", { args }, () => + ipcRenderer.invoke(IPC.lanesProxyGetPreviewInfo, args), + ), + proxyOpenPreview: async (args: OpenPreviewArgs): Promise => { + const binding = await getProjectRuntimeBinding(); + if (binding) { + const runtime = + await callProjectRuntimeActionIfBound( + "lane", + "proxyGetPreviewInfo", + { args }, + ); + if (!runtime.handled) { + await ipcRenderer.invoke(IPC.lanesProxyOpenPreview, args); + return; + } + const info = runtime.result; + if (!info) throw new Error(`No preview route for lane: ${args.laneId}`); + await ipcRenderer.invoke(IPC.appOpenExternal, { url: info.previewUrl }); + return; + } + await ipcRenderer.invoke(IPC.lanesProxyOpenPreview, args); + }, onProxyEvent: (cb: (ev: LaneProxyEvent) => void) => { const listener = ( _event: Electron.IpcRendererEvent, payload: LaneProxyEvent, ) => cb(payload); ipcRenderer.on(IPC.lanesProxyEvent, listener); - return () => ipcRenderer.removeListener(IPC.lanesProxyEvent, listener); + const removeRemote = subscribeRemoteLaneProxyEvents(cb); + return () => { + removeRemote(); + ipcRenderer.removeListener(IPC.lanesProxyEvent, listener); + }; }, oauthGetStatus: async (): Promise => - ipcRenderer.invoke(IPC.lanesOAuthGetStatus), + callProjectRuntimeActionOr("lane", "oauthGetStatus", {}, () => + ipcRenderer.invoke(IPC.lanesOAuthGetStatus), + ), oauthUpdateConfig: async ( args: UpdateOAuthRedirectConfigArgs, - ): Promise => ipcRenderer.invoke(IPC.lanesOAuthUpdateConfig, args), + ): Promise => { + await callProjectRuntimeActionOr( + "lane", + "oauthUpdateConfig", + { args }, + () => ipcRenderer.invoke(IPC.lanesOAuthUpdateConfig, args), + ); + }, oauthGenerateRedirectUris: async ( args: GenerateRedirectUrisArgs, ): Promise => - ipcRenderer.invoke(IPC.lanesOAuthGenerateRedirectUris, args), + callProjectRuntimeActionOr( + "lane", + "oauthGenerateRedirectUris", + { args }, + () => ipcRenderer.invoke(IPC.lanesOAuthGenerateRedirectUris, args), + ), oauthEncodeState: async (args: EncodeOAuthStateArgs): Promise => - ipcRenderer.invoke(IPC.lanesOAuthEncodeState, args), + callProjectRuntimeActionOr("lane", "oauthEncodeState", { args }, () => + ipcRenderer.invoke(IPC.lanesOAuthEncodeState, args), + ), oauthDecodeState: async ( args: DecodeOAuthStateArgs, ): Promise => - ipcRenderer.invoke(IPC.lanesOAuthDecodeState, args), + callProjectRuntimeActionOr("lane", "oauthDecodeState", { args }, () => + ipcRenderer.invoke(IPC.lanesOAuthDecodeState, args), + ), oauthListSessions: async (): Promise => - ipcRenderer.invoke(IPC.lanesOAuthListSessions), + callProjectRuntimeActionOr("lane", "oauthListSessions", {}, () => + ipcRenderer.invoke(IPC.lanesOAuthListSessions), + ), onOAuthEvent: (cb: (ev: OAuthRedirectEvent) => void) => { const listener = ( _event: Electron.IpcRendererEvent, payload: OAuthRedirectEvent, ) => cb(payload); ipcRenderer.on(IPC.lanesOAuthEvent, listener); - return () => ipcRenderer.removeListener(IPC.lanesOAuthEvent, listener); + const removeRemote = subscribeRemoteLaneOAuthEvents(cb); + return () => { + removeRemote(); + ipcRenderer.removeListener(IPC.lanesOAuthEvent, listener); + }; }, diagnosticsGetStatus: async (): Promise => - ipcRenderer.invoke(IPC.lanesDiagnosticsGetStatus), + callProjectRuntimeActionOr("lane", "diagnosticsGetStatus", {}, () => + ipcRenderer.invoke(IPC.lanesDiagnosticsGetStatus), + ), diagnosticsGetLaneHealth: async ( args: GetLaneHealthArgs, ): Promise => - ipcRenderer.invoke(IPC.lanesDiagnosticsGetLaneHealth, args), + callProjectRuntimeActionOr( + "lane", + "diagnosticsGetLaneHealth", + { args }, + () => ipcRenderer.invoke(IPC.lanesDiagnosticsGetLaneHealth, args), + ), diagnosticsRunHealthCheck: async ( args: RunHealthCheckArgs, ): Promise => - ipcRenderer.invoke(IPC.lanesDiagnosticsRunHealthCheck, args), + callProjectRuntimeActionOr( + "lane", + "diagnosticsRunHealthCheck", + { args }, + () => ipcRenderer.invoke(IPC.lanesDiagnosticsRunHealthCheck, args), + ), diagnosticsRunFullCheck: async (): Promise => - ipcRenderer.invoke(IPC.lanesDiagnosticsRunFullCheck), + callProjectRuntimeActionOr("lane", "diagnosticsRunFullCheck", {}, () => + ipcRenderer.invoke(IPC.lanesDiagnosticsRunFullCheck), + ), diagnosticsActivateFallback: async ( args: ActivateFallbackArgs, - ): Promise => - ipcRenderer.invoke(IPC.lanesDiagnosticsActivateFallback, args), + ): Promise => { + await callProjectRuntimeActionOr( + "lane", + "diagnosticsActivateFallback", + { args }, + () => ipcRenderer.invoke(IPC.lanesDiagnosticsActivateFallback, args), + ); + }, diagnosticsDeactivateFallback: async ( args: DeactivateFallbackArgs, - ): Promise => - ipcRenderer.invoke(IPC.lanesDiagnosticsDeactivateFallback, args), + ): Promise => { + await callProjectRuntimeActionOr( + "lane", + "diagnosticsDeactivateFallback", + { args }, + () => ipcRenderer.invoke(IPC.lanesDiagnosticsDeactivateFallback, args), + ); + }, onDiagnosticsEvent: (cb: (ev: RuntimeDiagnosticsEvent) => void) => { const listener = ( _event: Electron.IpcRendererEvent, payload: RuntimeDiagnosticsEvent, ) => cb(payload); ipcRenderer.on(IPC.lanesDiagnosticsEvent, listener); - return () => + const removeRemote = subscribeRemoteLaneDiagnosticsEvents(cb); + return () => { + removeRemote(); ipcRenderer.removeListener(IPC.lanesDiagnosticsEvent, listener); + }; }, }, sessions: { list: async ( args: ListSessionsArgs = {}, - ): Promise => - ipcRenderer.invoke(IPC.sessionsList, args), - get: async (sessionId: string): Promise => - ipcRenderer.invoke(IPC.sessionsGet, { sessionId }), - delete: async (args: DeleteSessionArgs): Promise => - ipcRenderer.invoke(IPC.sessionsDelete, args), - updateMeta: async (args: UpdateSessionMetaArgs): Promise => - ipcRenderer.invoke(IPC.sessionsUpdateMeta, args), - readTranscriptTail: async (args: ReadTranscriptTailArgs): Promise => - ipcRenderer.invoke(IPC.sessionsReadTranscriptTail, args), + ): Promise => { + const runtime = await callProjectRuntimeActionIfBound< + TerminalSessionSummary[] + >("session", "list", { args }); + return runtime.handled + ? runtime.result + : ipcRenderer.invoke(IPC.sessionsList, args); + }, + get: async (sessionId: string): Promise => { + const runtime = + await callProjectRuntimeActionIfBound( + "session", + "get", + { arg: sessionId }, + ); + return runtime.handled + ? runtime.result + : ipcRenderer.invoke(IPC.sessionsGet, { sessionId }); + }, + delete: async (args: DeleteSessionArgs): Promise => { + sessionDeltaCache.clear(); + const runtime = await callProjectRuntimeActionIfBound( + "session", + "deleteSession", + { arg: args.sessionId }, + ); + if (!runtime.handled) await ipcRenderer.invoke(IPC.sessionsDelete, args); + sessionDeltaCache.clear(); + }, + updateMeta: async ( + args: UpdateSessionMetaArgs, + ): Promise => { + sessionDeltaCache.clear(); + const runtime = + await callProjectRuntimeActionIfBound( + "session", + "updateMeta", + { args }, + ); + const updated = runtime.handled + ? runtime.result + : await ipcRenderer.invoke(IPC.sessionsUpdateMeta, args); + sessionDeltaCache.clear(); + return updated as TerminalSessionSummary | null; + }, + readTranscriptTail: async ( + args: ReadTranscriptTailArgs, + ): Promise => { + const runtime = await callProjectRuntimeActionIfBound( + "session", + "readTranscriptTail", + { args }, + ); + return runtime.handled + ? runtime.result + : ipcRenderer.invoke(IPC.sessionsReadTranscriptTail, args); + }, getDelta: async (sessionId: string): Promise => sessionDeltaCache.get(sessionId), onChanged: (cb: (ev: TerminalSessionChangedEvent) => void) => { @@ -2239,161 +4740,346 @@ contextBridge.exposeInMainWorld("ade", { payload: TerminalSessionChangedEvent, ) => cb(payload); ipcRenderer.on(IPC.sessionsChanged, listener); - return () => ipcRenderer.removeListener(IPC.sessionsChanged, listener); + const removeRemote = subscribeRemoteSessionChangedEvents(cb); + return () => { + removeRemote(); + ipcRenderer.removeListener(IPC.sessionsChanged, listener); + }; }, }, agentChat: { list: async ( args: AgentChatListArgs = {}, - ): Promise => - ipcRenderer.invoke(IPC.agentChatList, args), + ): Promise => { + const runtime = await callProjectRuntimeActionIfBound< + AgentChatSessionSummary[] + >("chat", "listSessions", { + argsList: [ + args.laneId, + { includeAutomation: args.includeAutomation === true }, + ], + }); + return runtime.handled + ? runtime.result + : ipcRenderer.invoke(IPC.agentChatList, args); + }, getSummary: async ( args: AgentChatGetSummaryArgs, ): Promise => { - const sessionId = typeof args?.sessionId === "string" ? args.sessionId.trim() : ""; + const sessionId = + typeof args?.sessionId === "string" ? args.sessionId.trim() : ""; if (!sessionId) return ipcRenderer.invoke(IPC.agentChatGetSummary, args); - return agentChatSummaryCache.get(sessionId); + const runtime = + await callProjectRuntimeActionIfBound( + "chat", + "getSessionSummary", + { arg: sessionId }, + ); + return runtime.handled + ? runtime.result + : agentChatSummaryCache.get(sessionId); }, create: async (args: AgentChatCreateArgs): Promise => { agentChatSummaryCache.clear(); - return ipcRenderer.invoke(IPC.agentChatCreate, args); + const runtime = await callProjectRuntimeActionIfBound( + "chat", + "createSession", + { args }, + ); + const session = runtime.handled + ? runtime.result + : await ipcRenderer.invoke(IPC.agentChatCreate, args); + agentChatSummaryCache.clear(); + return session as AgentChatSession; }, - suggestLaneName: async (args: AgentChatSuggestLaneNameArgs): Promise => - ipcRenderer.invoke(IPC.agentChatSuggestLaneName, args), + suggestLaneName: async ( + args: AgentChatSuggestLaneNameArgs, + ): Promise => + callProjectRuntimeActionOr( + "chat", + "suggestLaneNameFromPrompt", + { args }, + () => ipcRenderer.invoke(IPC.agentChatSuggestLaneName, args), + ), parallelLaunchState: { - get: async (args: AgentChatParallelLaunchStateArgs): Promise => - ipcRenderer.invoke(IPC.agentChatParallelLaunchStateGet, args), + get: async ( + args: AgentChatParallelLaunchStateArgs, + ): Promise => + callProjectRuntimeActionOr( + "chat", + "getParallelLaunchState", + { args }, + () => ipcRenderer.invoke(IPC.agentChatParallelLaunchStateGet, args), + ), set: async (args: AgentChatSetParallelLaunchStateArgs): Promise => - ipcRenderer.invoke(IPC.agentChatParallelLaunchStateSet, args), + callProjectRuntimeActionOr( + "chat", + "setParallelLaunchState", + { args }, + () => ipcRenderer.invoke(IPC.agentChatParallelLaunchStateSet, args), + ), }, handoff: async ( args: AgentChatHandoffArgs, ): Promise => - ipcRenderer.invoke(IPC.agentChatHandoff, args), + callProjectRuntimeActionOr("chat", "handoffSession", { args }, () => + ipcRenderer.invoke(IPC.agentChatHandoff, args), + ), send: async (args: AgentChatSendArgs): Promise => { agentChatSummaryCache.clear(); - await ipcRenderer.invoke(IPC.agentChatSend, args); + const runtime = await callProjectRuntimeActionIfBound( + "chat", + "sendMessage", + { args }, + ); + if (!runtime.handled) await ipcRenderer.invoke(IPC.agentChatSend, args); agentChatSummaryCache.clear(); }, steer: async (args: AgentChatSteerArgs): Promise => { agentChatSummaryCache.clear(); - await ipcRenderer.invoke(IPC.agentChatSteer, args); + await callProjectRuntimeActionOr("chat", "steer", { args }, () => + ipcRenderer.invoke(IPC.agentChatSteer, args), + ); agentChatSummaryCache.clear(); }, cancelSteer: async (args: AgentChatCancelSteerArgs): Promise => { agentChatSummaryCache.clear(); - await ipcRenderer.invoke(IPC.agentChatCancelSteer, args); + await callProjectRuntimeActionOr("chat", "cancelSteer", { args }, () => + ipcRenderer.invoke(IPC.agentChatCancelSteer, args), + ); agentChatSummaryCache.clear(); }, editSteer: async (args: AgentChatEditSteerArgs): Promise => { agentChatSummaryCache.clear(); - await ipcRenderer.invoke(IPC.agentChatEditSteer, args); + await callProjectRuntimeActionOr("chat", "editSteer", { args }, () => + ipcRenderer.invoke(IPC.agentChatEditSteer, args), + ); agentChatSummaryCache.clear(); }, - dispatchSteer: async (args: AgentChatDispatchSteerArgs): Promise => { + dispatchSteer: async ( + args: AgentChatDispatchSteerArgs, + ): Promise => { agentChatSummaryCache.clear(); - const result = await ipcRenderer.invoke(IPC.agentChatDispatchSteer, args); + const result = await callProjectRuntimeActionOr( + "chat", + "dispatchSteer", + { args }, + () => ipcRenderer.invoke(IPC.agentChatDispatchSteer, args), + ); agentChatSummaryCache.clear(); return result; }, - cancelDispatchedSteer: async (args: AgentChatCancelDispatchedSteerArgs): Promise => { + cancelDispatchedSteer: async ( + args: AgentChatCancelDispatchedSteerArgs, + ): Promise => { agentChatSummaryCache.clear(); - const result = await ipcRenderer.invoke(IPC.agentChatCancelDispatchedSteer, args); + const result = await callProjectRuntimeActionOr( + "chat", + "cancelDispatchedSteer", + { args }, + () => ipcRenderer.invoke(IPC.agentChatCancelDispatchedSteer, args), + ); agentChatSummaryCache.clear(); return result; }, interrupt: async (args: AgentChatInterruptArgs): Promise => { agentChatSummaryCache.clear(); - await ipcRenderer.invoke(IPC.agentChatInterrupt, args); + const runtime = await callProjectRuntimeActionIfBound( + "chat", + "interrupt", + { args }, + ); + if (!runtime.handled) + await ipcRenderer.invoke(IPC.agentChatInterrupt, args); agentChatSummaryCache.clear(); }, resume: async (args: AgentChatResumeArgs): Promise => { agentChatSummaryCache.clear(); - const session = await ipcRenderer.invoke(IPC.agentChatResume, args); + const runtime = await callProjectRuntimeActionIfBound( + "chat", + "resumeSession", + { args }, + ); + const session = runtime.handled + ? runtime.result + : await ipcRenderer.invoke(IPC.agentChatResume, args); agentChatSummaryCache.clear(); - return session; + return session as AgentChatSession; }, approve: async (args: AgentChatApproveArgs): Promise => { agentChatSummaryCache.clear(); - await ipcRenderer.invoke(IPC.agentChatApprove, args); + const runtime = await callProjectRuntimeActionIfBound( + "chat", + "approveToolUse", + { args }, + ); + if (!runtime.handled) + await ipcRenderer.invoke(IPC.agentChatApprove, args); agentChatSummaryCache.clear(); }, - respondToInput: async (args: AgentChatRespondToInputArgs): Promise => { + respondToInput: async ( + args: AgentChatRespondToInputArgs, + ): Promise => { agentChatSummaryCache.clear(); - await ipcRenderer.invoke(IPC.agentChatRespondToInput, args); + const runtime = await callProjectRuntimeActionIfBound( + "chat", + "respondToInput", + { args }, + ); + if (!runtime.handled) + await ipcRenderer.invoke(IPC.agentChatRespondToInput, args); agentChatSummaryCache.clear(); }, - models: async (args: AgentChatModelsArgs): Promise => - ipcRenderer.invoke(IPC.agentChatModels, args), + models: async ( + args: AgentChatModelsArgs, + ): Promise => { + const runtime = await callProjectRuntimeActionIfBound< + AgentChatModelInfo[] + >("chat", "getAvailableModels", { args }); + return runtime.handled + ? runtime.result + : ipcRenderer.invoke(IPC.agentChatModels, args); + }, dispose: async (args: AgentChatDisposeArgs): Promise => { agentChatSummaryCache.clear(); - await ipcRenderer.invoke(IPC.agentChatDispose, args); + const runtime = await callProjectRuntimeActionIfBound( + "chat", + "dispose", + { args }, + ); + if (!runtime.handled) + await ipcRenderer.invoke(IPC.agentChatDispose, args); agentChatSummaryCache.clear(); }, archive: async (args: AgentChatArchiveArgs): Promise => { agentChatSummaryCache.clear(); - await ipcRenderer.invoke(IPC.agentChatArchive, args); + const runtime = await callProjectRuntimeActionIfBound( + "chat", + "archiveSession", + { args }, + ); + if (!runtime.handled) + await ipcRenderer.invoke(IPC.agentChatArchive, args); agentChatSummaryCache.clear(); }, unarchive: async (args: AgentChatArchiveArgs): Promise => { agentChatSummaryCache.clear(); - await ipcRenderer.invoke(IPC.agentChatUnarchive, args); + const runtime = await callProjectRuntimeActionIfBound( + "chat", + "unarchiveSession", + { args }, + ); + if (!runtime.handled) + await ipcRenderer.invoke(IPC.agentChatUnarchive, args); agentChatSummaryCache.clear(); }, delete: async (args: AgentChatDeleteArgs): Promise => { agentChatSummaryCache.clear(); - await ipcRenderer.invoke(IPC.agentChatDelete, args); + const runtime = await callProjectRuntimeActionIfBound( + "chat", + "deleteSession", + { args }, + ); + if (!runtime.handled) await ipcRenderer.invoke(IPC.agentChatDelete, args); agentChatSummaryCache.clear(); }, updateSession: async ( args: AgentChatUpdateSessionArgs, ): Promise => { agentChatSummaryCache.clear(); - const session = await ipcRenderer.invoke(IPC.agentChatUpdateSession, args); + const runtime = await callProjectRuntimeActionIfBound( + "chat", + "updateSession", + { args }, + ); + const session = runtime.handled + ? runtime.result + : await ipcRenderer.invoke(IPC.agentChatUpdateSession, args); agentChatSummaryCache.clear(); - return session; + return session as AgentChatSession; }, warmupModel: async (args: { sessionId: string; modelId: string; - }): Promise => ipcRenderer.invoke(IPC.agentChatWarmupModel, args), - onEvent: agentChatEventFanout, + }): Promise => + callProjectRuntimeActionOr("chat", "warmupModel", { args }, () => + ipcRenderer.invoke(IPC.agentChatWarmupModel, args), + ), + onEvent: subscribeAgentChatEvents, slashCommands: async ( args: AgentChatSlashCommandsArgs, - ): Promise => - ipcRenderer.invoke(IPC.agentChatSlashCommands, args), + ): Promise => { + const runtime = await callProjectRuntimeActionIfBound< + AgentChatSlashCommand[] + >("chat", "getSlashCommands", { args }); + return runtime.handled + ? runtime.result + : ipcRenderer.invoke(IPC.agentChatSlashCommands, args); + }, fileSearch: async ( args: AgentChatFileSearchArgs, ): Promise => - ipcRenderer.invoke(IPC.agentChatFileSearch, args), + callProjectRuntimeActionOr("chat", "fileSearch", { args }, () => + ipcRenderer.invoke(IPC.agentChatFileSearch, args), + ), getTurnFileDiff: async ( args: AgentChatGetTurnFileDiffArgs, ): Promise => - ipcRenderer.invoke(IPC.agentChatGetTurnFileDiff, args), + callProjectRuntimeActionOr("chat", "getTurnFileDiff", { args }, () => + ipcRenderer.invoke(IPC.agentChatGetTurnFileDiff, args), + ), listSubagents: async ( args: AgentChatSubagentListArgs, ): Promise => - ipcRenderer.invoke(IPC.agentChatListSubagents, args), + callProjectRuntimeActionOr("chat", "listSubagents", { args }, () => + ipcRenderer.invoke(IPC.agentChatListSubagents, args), + ), getSessionCapabilities: async ( args: AgentChatSessionCapabilitiesArgs, ): Promise => - ipcRenderer.invoke(IPC.agentChatGetSessionCapabilities, args), + callProjectRuntimeActionOr( + "chat", + "getSessionCapabilities", + { args }, + () => ipcRenderer.invoke(IPC.agentChatGetSessionCapabilities, args), + ), saveTempAttachment: async (args: { data: string; filename: string; }): Promise<{ path: string }> => - ipcRenderer.invoke(IPC.agentChatSaveTempAttachment, args), + callProjectRuntimeActionOr("chat", "saveTempAttachment", { args }, () => + ipcRenderer.invoke(IPC.agentChatSaveTempAttachment, args), + ), getEventHistory: async (args: { sessionId: string; maxEvents?: number; - }): Promise<{ sessionId: string; events: AgentChatEventEnvelope[]; truncated: boolean }> => - ipcRenderer.invoke(IPC.agentChatGetEventHistory, args), + }): Promise<{ + sessionId: string; + events: AgentChatEventEnvelope[]; + truncated: boolean; + }> => { + const runtime = await callProjectRuntimeActionIfBound<{ + sessionId: string; + events: AgentChatEventEnvelope[]; + truncated: boolean; + }>("chat", "getChatEventHistory", { + argsList: [args.sessionId, { maxEvents: args.maxEvents }], + }); + return runtime.handled + ? runtime.result + : ipcRenderer.invoke(IPC.agentChatGetEventHistory, args); + }, }, computerUse: { listArtifacts: async ( args: ComputerUseArtifactListArgs = {}, ): Promise => - ipcRenderer.invoke(IPC.computerUseListArtifacts, args), + callProjectRuntimeActionOr( + "computer_use_artifacts", + "listArtifacts", + { args }, + () => ipcRenderer.invoke(IPC.computerUseListArtifacts, args), + ), getOwnerSnapshot: async ( args: ComputerUseOwnerSnapshotArgs, ): Promise => @@ -2403,19 +5089,36 @@ contextBridge.exposeInMainWorld("ade", { ): Promise => clearAround( () => computerUseOwnerSnapshotCache.clear(), - () => ipcRenderer.invoke(IPC.computerUseRouteArtifact, args), + () => + callProjectRuntimeActionOr( + "computer_use_artifacts", + "routeArtifact", + { args }, + () => ipcRenderer.invoke(IPC.computerUseRouteArtifact, args), + ), ), updateArtifactReview: async ( args: ComputerUseArtifactReviewArgs, ): Promise => clearAround( () => computerUseOwnerSnapshotCache.clear(), - () => ipcRenderer.invoke(IPC.computerUseUpdateArtifactReview, args), + () => + callProjectRuntimeActionOr( + "computer_use_artifacts", + "updateArtifactReview", + { args }, + () => ipcRenderer.invoke(IPC.computerUseUpdateArtifactReview, args), + ), ), readArtifactPreview: async (args: { uri: string; }): Promise => - ipcRenderer.invoke(IPC.computerUseReadArtifactPreview, args), + callProjectRuntimeActionOr( + "computer_use_artifacts", + "readArtifactPreview", + { args }, + () => ipcRenderer.invoke(IPC.computerUseReadArtifactPreview, args), + ), onEvent: computerUseEventFanout, }, iosSimulator: { @@ -2423,52 +5126,141 @@ contextBridge.exposeInMainWorld("ade", { iosSimulatorStatusCache.get(), listDevices: async (): Promise => iosSimulatorDevicesCache.get(), - listLaunchTargets: async (args: IosSimulatorListLaunchTargetsArgs = {}): Promise => - ipcRenderer.invoke(IPC.iosSimulatorListLaunchTargets, args), - launch: async (args: IosSimulatorLaunchArgs = {}): Promise => { + listLaunchTargets: async ( + args: IosSimulatorListLaunchTargetsArgs = {}, + ): Promise => + callProjectRuntimeActionOr( + "ios_simulator", + "listLaunchTargets", + { args }, + () => ipcRenderer.invoke(IPC.iosSimulatorListLaunchTargets, args), + ), + launch: async ( + args: IosSimulatorLaunchArgs = {}, + ): Promise => { clearIosSimulatorStatusCaches(); try { - return await ipcRenderer.invoke(IPC.iosSimulatorLaunch, args); + return await callProjectRuntimeActionOr( + "ios_simulator", + "launch", + { args }, + () => ipcRenderer.invoke(IPC.iosSimulatorLaunch, args), + ); } finally { clearIosSimulatorStatusCaches(); } }, - attachToChatSession: async (args: { chatSessionId: string | null; callerChatSessionId?: string | null }): Promise => { + attachToChatSession: async (args: { + chatSessionId: string | null; + callerChatSessionId?: string | null; + }): Promise => { clearIosSimulatorStatusCaches(); try { - return await ipcRenderer.invoke(IPC.iosSimulatorAttachToChatSession, args); + return await callProjectRuntimeActionOr( + "ios_simulator", + "attachToChatSession", + { argsList: [args.chatSessionId, args.callerChatSessionId] }, + () => ipcRenderer.invoke(IPC.iosSimulatorAttachToChatSession, args), + ); } finally { clearIosSimulatorStatusCaches(); } }, - shutdown: async (args: IosSimulatorShutdownArgs = {}): Promise => { + shutdown: async ( + args: IosSimulatorShutdownArgs = {}, + ): Promise => { clearIosSimulatorStatusCaches(); try { - return await ipcRenderer.invoke(IPC.iosSimulatorShutdown, args); + return await callProjectRuntimeActionOr( + "ios_simulator", + "shutdown", + { args }, + () => ipcRenderer.invoke(IPC.iosSimulatorShutdown, args), + ); } finally { clearIosSimulatorStatusCaches(); } }, - screenshot: async (args: { deviceUdid?: string | null } = {}): Promise => - ipcRenderer.invoke(IPC.iosSimulatorScreenshot, args), - getScreenSnapshot: async (args: IosScreenSnapshotArgs = {}): Promise => - ipcRenderer.invoke(IPC.iosSimulatorGetScreenSnapshot, args), - getInspectorSnapshot: async (args: { deviceUdid?: string | null } = {}): Promise => - ipcRenderer.invoke(IPC.iosSimulatorGetInspectorSnapshot, args), - inspectPoint: async (args: IosSimulatorInspectPointArgs): Promise => - ipcRenderer.invoke(IPC.iosSimulatorInspectPoint, args), - getPreviewCapability: async (args: IosSimulatorListPreviewsArgs = {}): Promise => - ipcRenderer.invoke(IPC.iosSimulatorGetPreviewCapability, args), - listPreviewTargets: async (args: IosSimulatorListPreviewsArgs = {}): Promise => - ipcRenderer.invoke(IPC.iosSimulatorListPreviewTargets, args), - renderPreview: async (args: IosSimulatorRenderPreviewArgs): Promise => - ipcRenderer.invoke(IPC.iosSimulatorRenderPreview, args), - openPreviewWorkspace: async (args: IosSimulatorOpenPreviewWorkspaceArgs = {}): Promise<{ ok: true; path: string }> => - ipcRenderer.invoke(IPC.iosSimulatorOpenPreviewWorkspace, args), - startStream: async (args: IosSimulatorStartStreamArgs = {}): Promise => { + screenshot: async ( + args: { deviceUdid?: string | null } = {}, + ): Promise => + callProjectRuntimeActionOr("ios_simulator", "screenshot", { args }, () => + ipcRenderer.invoke(IPC.iosSimulatorScreenshot, args), + ), + getScreenSnapshot: async ( + args: IosScreenSnapshotArgs = {}, + ): Promise => + callProjectRuntimeActionOr( + "ios_simulator", + "getScreenSnapshot", + { args }, + () => ipcRenderer.invoke(IPC.iosSimulatorGetScreenSnapshot, args), + ), + getInspectorSnapshot: async ( + args: { deviceUdid?: string | null } = {}, + ): Promise => + callProjectRuntimeActionOr( + "ios_simulator", + "getInspectorSnapshot", + { args }, + () => ipcRenderer.invoke(IPC.iosSimulatorGetInspectorSnapshot, args), + ), + inspectPoint: async ( + args: IosSimulatorInspectPointArgs, + ): Promise => + callProjectRuntimeActionOr( + "ios_simulator", + "inspectPoint", + { args }, + () => ipcRenderer.invoke(IPC.iosSimulatorInspectPoint, args), + ), + getPreviewCapability: async ( + args: IosSimulatorListPreviewsArgs = {}, + ): Promise => + callProjectRuntimeActionOr( + "ios_simulator", + "getPreviewCapability", + { args }, + () => ipcRenderer.invoke(IPC.iosSimulatorGetPreviewCapability, args), + ), + listPreviewTargets: async ( + args: IosSimulatorListPreviewsArgs = {}, + ): Promise => + callProjectRuntimeActionOr( + "ios_simulator", + "listPreviewTargets", + { args }, + () => ipcRenderer.invoke(IPC.iosSimulatorListPreviewTargets, args), + ), + renderPreview: async ( + args: IosSimulatorRenderPreviewArgs, + ): Promise => + callProjectRuntimeActionOr( + "ios_simulator", + "renderPreview", + { args }, + () => ipcRenderer.invoke(IPC.iosSimulatorRenderPreview, args), + ), + openPreviewWorkspace: async ( + args: IosSimulatorOpenPreviewWorkspaceArgs = {}, + ): Promise<{ ok: true; path: string }> => + callProjectRuntimeActionOr( + "ios_simulator", + "openPreviewWorkspace", + { args }, + () => ipcRenderer.invoke(IPC.iosSimulatorOpenPreviewWorkspace, args), + ), + startStream: async ( + args: IosSimulatorStartStreamArgs = {}, + ): Promise => { clearIosSimulatorStatusCaches(); try { - return await ipcRenderer.invoke(IPC.iosSimulatorStartStream, args); + return await callProjectRuntimeActionOr( + "ios_simulator", + "startStream", + { args }, + () => ipcRenderer.invoke(IPC.iosSimulatorStartStream, args), + ); } finally { clearIosSimulatorStatusCaches(); } @@ -2476,441 +5268,1209 @@ contextBridge.exposeInMainWorld("ade", { stopStream: async (): Promise => { clearIosSimulatorStatusCaches(); try { - return await ipcRenderer.invoke(IPC.iosSimulatorStopStream); + return await callProjectRuntimeActionOr( + "ios_simulator", + "stopStream", + {}, + () => ipcRenderer.invoke(IPC.iosSimulatorStopStream), + ); } finally { clearIosSimulatorStatusCaches(); } }, getStreamStatus: async (): Promise => - ipcRenderer.invoke(IPC.iosSimulatorGetStreamStatus), + callProjectRuntimeActionOr("ios_simulator", "getStreamStatus", {}, () => + ipcRenderer.invoke(IPC.iosSimulatorGetStreamStatus), + ), getSimulatorWindowState: async (): Promise => ipcRenderer.invoke(IPC.iosSimulatorGetWindowState), - listSimulatorWindowSources: async (): Promise => { + listSimulatorWindowSources: async (): Promise< + IosSimulatorWindowSource[] + > => { return ipcRenderer.invoke(IPC.iosSimulatorListWindowSources); }, - tap: async (args: { deviceUdid?: string | null; projectRoot?: string | null; x: number; y: number }): Promise<{ ok: true }> => - ipcRenderer.invoke(IPC.iosSimulatorTap, args), - typeText: async (args: { deviceUdid?: string | null; projectRoot?: string | null; text: string }): Promise<{ ok: true }> => - ipcRenderer.invoke(IPC.iosSimulatorTypeText, args), + tap: async (args: { + deviceUdid?: string | null; + projectRoot?: string | null; + x: number; + y: number; + }): Promise<{ ok: true }> => + callProjectRuntimeActionOr("ios_simulator", "tap", { args }, () => + ipcRenderer.invoke(IPC.iosSimulatorTap, args), + ), + typeText: async (args: { + deviceUdid?: string | null; + projectRoot?: string | null; + text: string; + }): Promise<{ ok: true }> => + callProjectRuntimeActionOr("ios_simulator", "typeText", { args }, () => + ipcRenderer.invoke(IPC.iosSimulatorTypeText, args), + ), drag: async (args: IosSimulatorDragArgs): Promise<{ ok: true }> => - ipcRenderer.invoke(IPC.iosSimulatorDrag, args), + callProjectRuntimeActionOr("ios_simulator", "drag", { args }, () => + ipcRenderer.invoke(IPC.iosSimulatorDrag, args), + ), swipe: async (args: IosSimulatorDragArgs): Promise<{ ok: true }> => - ipcRenderer.invoke(IPC.iosSimulatorSwipe, args), - selectPoint: async (args: { deviceUdid?: string | null; projectRoot?: string | null; x: number; y: number }): Promise => - ipcRenderer.invoke(IPC.iosSimulatorSelectPoint, args), + callProjectRuntimeActionOr("ios_simulator", "swipe", { args }, () => + ipcRenderer.invoke(IPC.iosSimulatorSwipe, args), + ), + selectPoint: async (args: { + deviceUdid?: string | null; + projectRoot?: string | null; + x: number; + y: number; + }): Promise => + callProjectRuntimeActionOr("ios_simulator", "selectPoint", { args }, () => + ipcRenderer.invoke(IPC.iosSimulatorSelectPoint, args), + ), onEvent: iosSimulatorEventFanout, }, appControl: { getStatus: async (): Promise => appControlStatusCache.get(), - launch: async (args: AppControlLaunchArgs = {}): Promise => - clearAround(() => appControlStatusCache.clear(), () => ipcRenderer.invoke(IPC.appControlLaunch, args)), - launchInTerminal: async (args: AppControlLaunchArgs = {}): Promise => - clearAround(() => appControlStatusCache.clear(), () => ipcRenderer.invoke(IPC.appControlLaunchInTerminal, args)), + launch: async ( + args: AppControlLaunchArgs = {}, + ): Promise => + clearAround( + () => appControlStatusCache.clear(), + () => + callProjectRuntimeActionOr("app_control", "launch", { args }, () => + ipcRenderer.invoke(IPC.appControlLaunch, args), + ), + ), + launchInTerminal: async ( + args: AppControlLaunchArgs = {}, + ): Promise => + clearAround( + () => appControlStatusCache.clear(), + () => + callProjectRuntimeActionOr( + "app_control", + "launchInTerminal", + { args }, + () => ipcRenderer.invoke(IPC.appControlLaunchInTerminal, args), + ), + ), connect: async (args: AppControlConnectArgs): Promise => - clearAround(() => appControlStatusCache.clear(), () => ipcRenderer.invoke(IPC.appControlConnect, args)), - stop: async (args: AppControlStopArgs = {}): Promise<{ ok: true; previousSession: AppControlSession | null }> => - clearAround(() => appControlStatusCache.clear(), () => ipcRenderer.invoke(IPC.appControlStop, args)), + clearAround( + () => appControlStatusCache.clear(), + () => + callProjectRuntimeActionOr("app_control", "connect", { args }, () => + ipcRenderer.invoke(IPC.appControlConnect, args), + ), + ), + stop: async ( + args: AppControlStopArgs = {}, + ): Promise<{ ok: true; previousSession: AppControlSession | null }> => + clearAround( + () => appControlStatusCache.clear(), + () => + callProjectRuntimeActionOr("app_control", "stop", { args }, () => + ipcRenderer.invoke(IPC.appControlStop, args), + ), + ), screenshot: async (): Promise => - ipcRenderer.invoke(IPC.appControlScreenshot), - getSnapshot: async (args: AppControlSnapshotArgs = {}): Promise => - ipcRenderer.invoke(IPC.appControlGetSnapshot, args), - inspectPoint: async (args: AppControlInspectPointArgs): Promise => - ipcRenderer.invoke(IPC.appControlInspectPoint, args), - selectPoint: async (args: AppControlInspectPointArgs): Promise => - ipcRenderer.invoke(IPC.appControlSelectPoint, args), + callProjectRuntimeActionOr("app_control", "screenshot", {}, () => + ipcRenderer.invoke(IPC.appControlScreenshot), + ), + getSnapshot: async ( + args: AppControlSnapshotArgs = {}, + ): Promise => + callProjectRuntimeActionOr("app_control", "getSnapshot", { args }, () => + ipcRenderer.invoke(IPC.appControlGetSnapshot, args), + ), + inspectPoint: async ( + args: AppControlInspectPointArgs, + ): Promise => + callProjectRuntimeActionOr("app_control", "inspectPoint", { args }, () => + ipcRenderer.invoke(IPC.appControlInspectPoint, args), + ), + selectPoint: async ( + args: AppControlInspectPointArgs, + ): Promise => + callProjectRuntimeActionOr("app_control", "selectPoint", { args }, () => + ipcRenderer.invoke(IPC.appControlSelectPoint, args), + ), click: async (args: AppControlClickArgs): Promise<{ ok: true }> => - ipcRenderer.invoke(IPC.appControlClick, args), + callProjectRuntimeActionOr("app_control", "click", { args }, () => + ipcRenderer.invoke(IPC.appControlClick, args), + ), typeText: async (args: AppControlTypeTextArgs): Promise<{ ok: true }> => - ipcRenderer.invoke(IPC.appControlTypeText, args), - scroll: async (args: { x: number; y: number; deltaX: number; deltaY: number; scale?: number | null }): Promise<{ ok: true }> => - ipcRenderer.invoke(IPC.appControlScroll, args), + callProjectRuntimeActionOr("app_control", "typeText", { args }, () => + ipcRenderer.invoke(IPC.appControlTypeText, args), + ), + scroll: async (args: { + x: number; + y: number; + deltaX: number; + deltaY: number; + scale?: number | null; + }): Promise<{ ok: true }> => + callProjectRuntimeActionOr("app_control", "scroll", { args }, () => + ipcRenderer.invoke(IPC.appControlScroll, args), + ), dispatchKey: async (args: { type: "keyDown" | "keyUp" | "rawKeyDown" | "char"; key?: string | null; code?: string | null; text?: string | null; modifiers?: number | null; - }): Promise<{ ok: true }> => ipcRenderer.invoke(IPC.appControlDispatchKey, args), + }): Promise<{ ok: true }> => + callProjectRuntimeActionOr("app_control", "dispatchKey", { args }, () => + ipcRenderer.invoke(IPC.appControlDispatchKey, args), + ), listTargets: async (): Promise => - ipcRenderer.invoke(IPC.appControlListTargets), - attachToTarget: async (args: { targetId: string }): Promise => - clearAround(() => appControlStatusCache.clear(), () => ipcRenderer.invoke(IPC.appControlAttachToTarget, args)), + callProjectRuntimeActionOr("app_control", "listTargets", {}, () => + ipcRenderer.invoke(IPC.appControlListTargets), + ), + attachToTarget: async (args: { + targetId: string; + }): Promise => + clearAround( + () => appControlStatusCache.clear(), + () => + callProjectRuntimeActionOr( + "app_control", + "attachToTarget", + { args }, + () => ipcRenderer.invoke(IPC.appControlAttachToTarget, args), + ), + ), onEvent: appControlEventFanout, }, builtInBrowser: { getStatus: async (): Promise => builtInBrowserStatusCache.get(), - showPanel: async (args: BuiltInBrowserOpenPanelArgs = {}): Promise => - clearAround(() => builtInBrowserStatusCache.clear(), () => ipcRenderer.invoke(IPC.builtInBrowserShowPanel, args)), - setBounds: async (args: BuiltInBrowserBoundsArgs): Promise => - clearAround(() => builtInBrowserStatusCache.clear(), () => ipcRenderer.invoke(IPC.builtInBrowserSetBounds, args)), - attachWebview: async (args: BuiltInBrowserAttachWebviewArgs): Promise => - clearAround(() => builtInBrowserStatusCache.clear(), () => ipcRenderer.invoke(IPC.builtInBrowserAttachWebview, args)), - navigate: async (args: BuiltInBrowserNavigateArgs): Promise => - clearAround(() => builtInBrowserStatusCache.clear(), () => ipcRenderer.invoke(IPC.builtInBrowserNavigate, args)), - createTab: async (args: BuiltInBrowserCreateTabArgs = {}): Promise => - clearAround(() => builtInBrowserStatusCache.clear(), () => ipcRenderer.invoke(IPC.builtInBrowserCreateTab, args)), - switchTab: async (args: BuiltInBrowserTabArgs): Promise => - clearAround(() => builtInBrowserStatusCache.clear(), () => ipcRenderer.invoke(IPC.builtInBrowserSwitchTab, args)), - closeTab: async (args: BuiltInBrowserTabArgs): Promise => - clearAround(() => builtInBrowserStatusCache.clear(), () => ipcRenderer.invoke(IPC.builtInBrowserCloseTab, args)), + showPanel: async ( + args: BuiltInBrowserOpenPanelArgs = {}, + ): Promise => + clearAround( + () => builtInBrowserStatusCache.clear(), + () => ipcRenderer.invoke(IPC.builtInBrowserShowPanel, args), + ), + setBounds: async ( + args: BuiltInBrowserBoundsArgs, + ): Promise => + clearAround( + () => builtInBrowserStatusCache.clear(), + () => ipcRenderer.invoke(IPC.builtInBrowserSetBounds, args), + ), + attachWebview: async ( + args: BuiltInBrowserAttachWebviewArgs, + ): Promise => + clearAround( + () => builtInBrowserStatusCache.clear(), + () => ipcRenderer.invoke(IPC.builtInBrowserAttachWebview, args), + ), + navigate: async ( + args: BuiltInBrowserNavigateArgs, + ): Promise => + clearAround( + () => builtInBrowserStatusCache.clear(), + () => ipcRenderer.invoke(IPC.builtInBrowserNavigate, args), + ), + createTab: async ( + args: BuiltInBrowserCreateTabArgs = {}, + ): Promise => + clearAround( + () => builtInBrowserStatusCache.clear(), + () => ipcRenderer.invoke(IPC.builtInBrowserCreateTab, args), + ), + switchTab: async ( + args: BuiltInBrowserTabArgs, + ): Promise => + clearAround( + () => builtInBrowserStatusCache.clear(), + () => ipcRenderer.invoke(IPC.builtInBrowserSwitchTab, args), + ), + closeTab: async ( + args: BuiltInBrowserTabArgs, + ): Promise => + clearAround( + () => builtInBrowserStatusCache.clear(), + () => ipcRenderer.invoke(IPC.builtInBrowserCloseTab, args), + ), reload: async (): Promise => - clearAround(() => builtInBrowserStatusCache.clear(), () => ipcRenderer.invoke(IPC.builtInBrowserReload)), + clearAround( + () => builtInBrowserStatusCache.clear(), + () => ipcRenderer.invoke(IPC.builtInBrowserReload), + ), goBack: async (): Promise => - clearAround(() => builtInBrowserStatusCache.clear(), () => ipcRenderer.invoke(IPC.builtInBrowserGoBack)), + clearAround( + () => builtInBrowserStatusCache.clear(), + () => ipcRenderer.invoke(IPC.builtInBrowserGoBack), + ), goForward: async (): Promise => - clearAround(() => builtInBrowserStatusCache.clear(), () => ipcRenderer.invoke(IPC.builtInBrowserGoForward)), + clearAround( + () => builtInBrowserStatusCache.clear(), + () => ipcRenderer.invoke(IPC.builtInBrowserGoForward), + ), stop: async (): Promise => - clearAround(() => builtInBrowserStatusCache.clear(), () => ipcRenderer.invoke(IPC.builtInBrowserStop)), + clearAround( + () => builtInBrowserStatusCache.clear(), + () => ipcRenderer.invoke(IPC.builtInBrowserStop), + ), startInspect: async (): Promise => - clearAround(() => builtInBrowserStatusCache.clear(), () => ipcRenderer.invoke(IPC.builtInBrowserStartInspect)), + clearAround( + () => builtInBrowserStatusCache.clear(), + () => ipcRenderer.invoke(IPC.builtInBrowserStartInspect), + ), stopInspect: async (): Promise => - clearAround(() => builtInBrowserStatusCache.clear(), () => ipcRenderer.invoke(IPC.builtInBrowserStopInspect)), + clearAround( + () => builtInBrowserStatusCache.clear(), + () => ipcRenderer.invoke(IPC.builtInBrowserStopInspect), + ), captureScreenshot: async (): Promise => ipcRenderer.invoke(IPC.builtInBrowserCaptureScreenshot), - selectPoint: async (args: BuiltInBrowserSelectPointArgs): Promise => + selectPoint: async ( + args: BuiltInBrowserSelectPointArgs, + ): Promise => ipcRenderer.invoke(IPC.builtInBrowserSelectPoint, args), selectCurrent: async (): Promise => ipcRenderer.invoke(IPC.builtInBrowserSelectCurrent), clearSelection: async (): Promise<{ ok: true }> => - clearAround(() => builtInBrowserStatusCache.clear(), () => ipcRenderer.invoke(IPC.builtInBrowserClearSelection)), + clearAround( + () => builtInBrowserStatusCache.clear(), + () => ipcRenderer.invoke(IPC.builtInBrowserClearSelection), + ), onEvent: builtInBrowserEventFanout, }, macosVm: { getStatus: async (args: MacosVmStatusArgs = {}): Promise => macosVmStatusCache.get(serializeIpcCacheArgs(args)), provision: async (args: MacosVmProvisionArgs): Promise => - clearAround(() => macosVmStatusCache.clear(), () => ipcRenderer.invoke(IPC.macosVmProvision, args)), + clearAround( + () => macosVmStatusCache.clear(), + () => + callProjectRuntimeActionOr("macos_vm", "provision", { args }, () => + ipcRenderer.invoke(IPC.macosVmProvision, args), + ), + ), start: async (args: MacosVmStartArgs): Promise => - clearAround(() => macosVmStatusCache.clear(), () => ipcRenderer.invoke(IPC.macosVmStart, args)), + clearAround( + () => macosVmStatusCache.clear(), + () => + callProjectRuntimeActionOr("macos_vm", "start", { args }, () => + ipcRenderer.invoke(IPC.macosVmStart, args), + ), + ), stop: async (args: MacosVmStopArgs): Promise => - clearAround(() => macosVmStatusCache.clear(), () => ipcRenderer.invoke(IPC.macosVmStop, args)), - delete: async (args: MacosVmDeleteArgs): Promise<{ deleted: boolean; previous: MacosVmRecord | null }> => - clearAround(() => macosVmStatusCache.clear(), () => ipcRenderer.invoke(IPC.macosVmDelete, args)), - getAgentGuide: async (args: MacosVmAgentGuideArgs): Promise => - ipcRenderer.invoke(IPC.macosVmGetAgentGuide, args), - focusWindow: async (args: MacosVmFocusWindowArgs): Promise => - ipcRenderer.invoke(IPC.macosVmFocusWindow, args), - captureScreenshot: async (args: MacosVmCaptureScreenshotArgs): Promise => - ipcRenderer.invoke(IPC.macosVmCaptureScreenshot, args), - selectPoint: async (args: MacosVmSelectPointArgs): Promise => - ipcRenderer.invoke(IPC.macosVmSelectPoint, args), - click: async (args: MacosVmClickArgs): Promise<{ ok: true; window: MacosVmWindowTarget; x: number; y: number }> => - ipcRenderer.invoke(IPC.macosVmClick, args), - typeText: async (args: MacosVmTypeTextArgs): Promise<{ ok: true; window: MacosVmWindowTarget }> => - ipcRenderer.invoke(IPC.macosVmTypeText, args), + clearAround( + () => macosVmStatusCache.clear(), + () => + callProjectRuntimeActionOr("macos_vm", "stop", { args }, () => + ipcRenderer.invoke(IPC.macosVmStop, args), + ), + ), + delete: async ( + args: MacosVmDeleteArgs, + ): Promise<{ deleted: boolean; previous: MacosVmRecord | null }> => + clearAround( + () => macosVmStatusCache.clear(), + () => + callProjectRuntimeActionOr("macos_vm", "delete", { args }, () => + ipcRenderer.invoke(IPC.macosVmDelete, args), + ), + ), + getAgentGuide: async ( + args: MacosVmAgentGuideArgs, + ): Promise => + callProjectRuntimeActionOr("macos_vm", "getAgentGuide", { args }, () => + ipcRenderer.invoke(IPC.macosVmGetAgentGuide, args), + ), + focusWindow: async ( + args: MacosVmFocusWindowArgs, + ): Promise => + callProjectRuntimeActionOr("macos_vm", "focusWindow", { args }, () => + ipcRenderer.invoke(IPC.macosVmFocusWindow, args), + ), + captureScreenshot: async ( + args: MacosVmCaptureScreenshotArgs, + ): Promise => + callProjectRuntimeActionOr( + "macos_vm", + "captureScreenshot", + { args }, + () => ipcRenderer.invoke(IPC.macosVmCaptureScreenshot, args), + ), + selectPoint: async ( + args: MacosVmSelectPointArgs, + ): Promise => + callProjectRuntimeActionOr("macos_vm", "selectPoint", { args }, () => + ipcRenderer.invoke(IPC.macosVmSelectPoint, args), + ), + click: async ( + args: MacosVmClickArgs, + ): Promise<{ + ok: true; + window: MacosVmWindowTarget; + x: number; + y: number; + }> => + callProjectRuntimeActionOr("macos_vm", "click", { args }, () => + ipcRenderer.invoke(IPC.macosVmClick, args), + ), + typeText: async ( + args: MacosVmTypeTextArgs, + ): Promise<{ ok: true; window: MacosVmWindowTarget }> => + callProjectRuntimeActionOr("macos_vm", "typeText", { args }, () => + ipcRenderer.invoke(IPC.macosVmTypeText, args), + ), onEvent: macosVmEventFanout, }, terminal: { - list: async (args: ChatTerminalListArgs = {}): Promise => - ipcRenderer.invoke(IPC.terminalList, args), - read: async (args: ChatTerminalReadArgs = {}): Promise => - ipcRenderer.invoke(IPC.terminalRead, args), - write: async (args: ChatTerminalWriteArgs): Promise<{ ok: true }> => - ipcRenderer.invoke(IPC.terminalWrite, args), - signal: async (args: ChatTerminalSignalArgs): Promise<{ ok: true }> => - ipcRenderer.invoke(IPC.terminalSignal, args), - activeForChat: async (args: ChatTerminalActiveForChatArgs): Promise => - ipcRenderer.invoke(IPC.terminalActiveForChat, args), + list: async ( + args: ChatTerminalListArgs = {}, + ): Promise => { + const runtime = await callProjectRuntimeActionIfBound< + ChatTerminalSession[] + >("terminal", "list", { args }); + return runtime.handled + ? runtime.result + : ipcRenderer.invoke(IPC.terminalList, args); + }, + read: async ( + args: ChatTerminalReadArgs = {}, + ): Promise => { + const runtime = + await callProjectRuntimeActionIfBound( + "terminal", + "read", + { args }, + ); + return runtime.handled + ? runtime.result + : ipcRenderer.invoke(IPC.terminalRead, args); + }, + write: async (args: ChatTerminalWriteArgs): Promise<{ ok: true }> => { + const runtime = await callProjectRuntimeActionIfBound<{ ok: true }>( + "terminal", + "write", + { args }, + ); + return runtime.handled + ? runtime.result + : ipcRenderer.invoke(IPC.terminalWrite, args); + }, + signal: async (args: ChatTerminalSignalArgs): Promise<{ ok: true }> => { + const runtime = await callProjectRuntimeActionIfBound<{ ok: true }>( + "terminal", + "signal", + { args }, + ); + return runtime.handled + ? runtime.result + : ipcRenderer.invoke(IPC.terminalSignal, args); + }, + activeForChat: async ( + args: ChatTerminalActiveForChatArgs, + ): Promise => { + const runtime = + await callProjectRuntimeActionIfBound( + "terminal", + "activeForChat", + { args }, + ); + return runtime.handled + ? runtime.result + : ipcRenderer.invoke(IPC.terminalActiveForChat, args); + }, }, pty: { - create: async (args: PtyCreateArgs): Promise => - ipcRenderer.invoke(IPC.ptyCreate, args), - write: async (arg: { ptyId: string; data: string }): Promise => - ipcRenderer.invoke(IPC.ptyWrite, arg), + create: async (args: PtyCreateArgs): Promise => { + const runtime = await callProjectRuntimeActionIfBound( + "pty", + "create", + { args }, + ); + return runtime.handled + ? runtime.result + : ipcRenderer.invoke(IPC.ptyCreate, args); + }, + write: async (arg: { ptyId: string; data: string }): Promise => { + const runtime = await callProjectRuntimeActionIfBound( + "pty", + "write", + { args: arg }, + ); + if (!runtime.handled) await ipcRenderer.invoke(IPC.ptyWrite, arg); + }, resize: async (arg: { ptyId: string; cols: number; rows: number; - }): Promise => ipcRenderer.invoke(IPC.ptyResize, arg), + }): Promise => { + const runtime = await callProjectRuntimeActionIfBound( + "pty", + "resize", + { args: arg }, + ); + if (!runtime.handled) await ipcRenderer.invoke(IPC.ptyResize, arg); + }, dispose: async (arg: { ptyId: string; sessionId?: string; - }): Promise => ipcRenderer.invoke(IPC.ptyDispose, arg), - onData: ptyDataEventFanout, - onExit: ptyExitEventFanout, + }): Promise => { + const runtime = await callProjectRuntimeActionIfBound( + "pty", + "dispose", + { args: arg }, + ); + if (!runtime.handled) await ipcRenderer.invoke(IPC.ptyDispose, arg); + }, + onData: subscribePtyDataEvents, + onExit: subscribePtyExitEvents, }, diff: { - getChanges: async (args: GetDiffChangesArgs): Promise => - diffChangesCache.get(serializeIpcCacheArgs(args)), - getFile: async (args: GetFileDiffArgs): Promise => - ipcRenderer.invoke(IPC.diffGetFile, args), - getFilePatch: async (args: GetFilePatchArgs): Promise => - ipcRenderer.invoke(IPC.diffGetFilePatch, args), + getChanges: async (args: GetDiffChangesArgs): Promise => { + const runtime = await callProjectRuntimeActionIfBound( + "diff", + "getChanges", + { arg: args.laneId }, + ); + if (runtime.handled) return runtime.result; + return diffChangesCache.get(serializeIpcCacheArgs(args)); + }, + getFile: async (args: GetFileDiffArgs): Promise => { + const runtime = await callProjectRuntimeActionIfBound( + "diff", + "getFileDiff", + { + args: { + laneId: args.laneId, + filePath: args.path, + mode: args.mode, + compareRef: args.compareRef, + compareTo: args.compareTo, + }, + }, + ); + return runtime.handled + ? runtime.result + : ipcRenderer.invoke(IPC.diffGetFile, args); + }, + getFilePatch: async (args: GetFilePatchArgs): Promise => { + const runtime = await callProjectRuntimeActionIfBound( + "diff", + "getFilePatch", + { + args: { + laneId: args.laneId, + filePath: args.path, + mode: args.mode, + compareRef: args.compareRef, + compareTo: args.compareTo, + }, + }, + ); + return runtime.handled + ? runtime.result + : ipcRenderer.invoke(IPC.diffGetFilePatch, args); + }, }, files: { - writeTextAtomic: async (args: WriteTextAtomicArgs): Promise => - ipcRenderer.invoke(IPC.filesWriteTextAtomic, args), + writeTextAtomic: async (args: WriteTextAtomicArgs): Promise => { + const runtime = await callProjectRuntimeActionIfBound( + "file", + "writeTextAtomic", + { args }, + ); + if (!runtime.handled) + await ipcRenderer.invoke(IPC.filesWriteTextAtomic, args); + }, listWorkspaces: async ( args: FilesListWorkspacesArgs = {}, - ): Promise => - ipcRenderer.invoke(IPC.filesListWorkspaces, args), - listTree: async (args: FilesListTreeArgs): Promise => - ipcRenderer.invoke(IPC.filesListTree, args), - readFile: async (args: FilesReadFileArgs): Promise => - ipcRenderer.invoke(IPC.filesReadFile, args), - writeText: async (args: FilesWriteTextArgs): Promise => - ipcRenderer.invoke(IPC.filesWriteText, args), - createFile: async (args: FilesCreateFileArgs): Promise => - ipcRenderer.invoke(IPC.filesCreateFile, args), - createDirectory: async (args: FilesCreateDirectoryArgs): Promise => - ipcRenderer.invoke(IPC.filesCreateDirectory, args), - rename: async (args: FilesRenameArgs): Promise => - ipcRenderer.invoke(IPC.filesRename, args), - delete: async (args: FilesDeleteArgs): Promise => - ipcRenderer.invoke(IPC.filesDelete, args), - watchChanges: async (args: FilesWatchArgs): Promise => - ipcRenderer.invoke(IPC.filesWatchChanges, args), - stopWatching: async (args: FilesWatchArgs): Promise => - ipcRenderer.invoke(IPC.filesStopWatching, args), + ): Promise => { + const runtime = await callProjectRuntimeActionIfBound( + "file", + "listWorkspaces", + { args }, + ); + return runtime.handled + ? runtime.result + : ipcRenderer.invoke(IPC.filesListWorkspaces, args); + }, + listTree: async (args: FilesListTreeArgs): Promise => { + const runtime = await callProjectRuntimeActionIfBound( + "file", + "listTree", + { args }, + ); + return runtime.handled + ? runtime.result + : ipcRenderer.invoke(IPC.filesListTree, args); + }, + readFile: async (args: FilesReadFileArgs): Promise => { + const runtime = await callProjectRuntimeActionIfBound( + "file", + "readFile", + { args }, + ); + return runtime.handled + ? runtime.result + : ipcRenderer.invoke(IPC.filesReadFile, args); + }, + writeText: async (args: FilesWriteTextArgs): Promise => { + const runtime = await callProjectRuntimeActionIfBound( + "file", + "writeWorkspaceText", + { args }, + ); + if (!runtime.handled) await ipcRenderer.invoke(IPC.filesWriteText, args); + }, + createFile: async (args: FilesCreateFileArgs): Promise => { + const runtime = await callProjectRuntimeActionIfBound( + "file", + "createFile", + { args }, + ); + if (!runtime.handled) await ipcRenderer.invoke(IPC.filesCreateFile, args); + }, + createDirectory: async (args: FilesCreateDirectoryArgs): Promise => { + const runtime = await callProjectRuntimeActionIfBound( + "file", + "createDirectory", + { args }, + ); + if (!runtime.handled) + await ipcRenderer.invoke(IPC.filesCreateDirectory, args); + }, + rename: async (args: FilesRenameArgs): Promise => { + const runtime = await callProjectRuntimeActionIfBound( + "file", + "rename", + { args }, + ); + if (!runtime.handled) await ipcRenderer.invoke(IPC.filesRename, args); + }, + delete: async (args: FilesDeleteArgs): Promise => { + const runtime = await callProjectRuntimeActionIfBound( + "file", + "deletePath", + { args }, + ); + if (!runtime.handled) await ipcRenderer.invoke(IPC.filesDelete, args); + }, + watchChanges: async (args: FilesWatchArgs): Promise => { + const runtime = await callProjectRuntimeActionIfBound( + "file", + "watchWorkspace", + { args }, + ); + if (!runtime.handled) + await ipcRenderer.invoke(IPC.filesWatchChanges, args); + }, + stopWatching: async (args: FilesWatchArgs): Promise => { + const runtime = await callProjectRuntimeActionIfBound( + "file", + "stopWatching", + { args }, + ); + if (!runtime.handled) + await ipcRenderer.invoke(IPC.filesStopWatching, args); + }, quickOpen: async ( args: FilesQuickOpenArgs, - ): Promise => - ipcRenderer.invoke(IPC.filesQuickOpen, args), + ): Promise => { + const runtime = await callProjectRuntimeActionIfBound< + FilesQuickOpenItem[] + >("file", "quickOpen", { args }); + return runtime.handled + ? runtime.result + : ipcRenderer.invoke(IPC.filesQuickOpen, args); + }, searchText: async ( args: FilesSearchTextArgs, - ): Promise => - ipcRenderer.invoke(IPC.filesSearchText, args), + ): Promise => { + const runtime = await callProjectRuntimeActionIfBound< + FilesSearchTextMatch[] + >("file", "searchText", { args }); + return runtime.handled + ? runtime.result + : ipcRenderer.invoke(IPC.filesSearchText, args); + }, onChange: (cb: (ev: FileChangeEvent) => void) => { + const unsubscribeRuntime = subscribeRemoteFileChangeEvents(cb); const listener = ( _event: Electron.IpcRendererEvent, payload: FileChangeEvent, ) => cb(payload); ipcRenderer.on(IPC.filesChange, listener); - return () => ipcRenderer.removeListener(IPC.filesChange, listener); + return () => { + unsubscribeRuntime(); + ipcRenderer.removeListener(IPC.filesChange, listener); + }; }, }, git: { stageFile: async (args: GitFileActionArgs): Promise => { clearGitReadCaches(); - const result = await ipcRenderer.invoke(IPC.gitStageFile, args); + const runtime = await callProjectRuntimeActionIfBound( + "git", + "stageFile", + { args }, + ); + const result = runtime.handled + ? runtime.result + : await ipcRenderer.invoke(IPC.gitStageFile, args); clearGitReadCaches(); - return result; + return result as GitActionResult; }, - stageAll: async (args: GitBatchFileActionArgs): Promise => { + stageAll: async ( + args: GitBatchFileActionArgs, + ): Promise => { clearGitReadCaches(); - const result = await ipcRenderer.invoke(IPC.gitStageAll, args); + const runtime = await callProjectRuntimeActionIfBound( + "git", + "stageAll", + { args }, + ); + const result = runtime.handled + ? runtime.result + : await ipcRenderer.invoke(IPC.gitStageAll, args); clearGitReadCaches(); - return result; + return result as GitActionResult; }, unstageFile: async (args: GitFileActionArgs): Promise => { clearGitReadCaches(); - const result = await ipcRenderer.invoke(IPC.gitUnstageFile, args); + const runtime = await callProjectRuntimeActionIfBound( + "git", + "unstageFile", + { args }, + ); + const result = runtime.handled + ? runtime.result + : await ipcRenderer.invoke(IPC.gitUnstageFile, args); clearGitReadCaches(); - return result; + return result as GitActionResult; }, unstageAll: async ( args: GitBatchFileActionArgs, ): Promise => { clearGitReadCaches(); - const result = await ipcRenderer.invoke(IPC.gitUnstageAll, args); + const runtime = await callProjectRuntimeActionIfBound( + "git", + "unstageAll", + { args }, + ); + const result = runtime.handled + ? runtime.result + : await ipcRenderer.invoke(IPC.gitUnstageAll, args); clearGitReadCaches(); - return result; + return result as GitActionResult; }, discardFile: async (args: GitFileActionArgs): Promise => { clearGitReadCaches(); - const result = await ipcRenderer.invoke(IPC.gitDiscardFile, args); + const runtime = await callProjectRuntimeActionIfBound( + "git", + "discardFile", + { args }, + ); + const result = runtime.handled + ? runtime.result + : await ipcRenderer.invoke(IPC.gitDiscardFile, args); clearGitReadCaches(); - return result; + return result as GitActionResult; }, restoreStagedFile: async ( args: GitFileActionArgs, ): Promise => { clearGitReadCaches(); - const result = await ipcRenderer.invoke(IPC.gitRestoreStagedFile, args); + const runtime = await callProjectRuntimeActionIfBound( + "git", + "restoreStagedFile", + { args }, + ); + const result = runtime.handled + ? runtime.result + : await ipcRenderer.invoke(IPC.gitRestoreStagedFile, args); clearGitReadCaches(); - return result; + return result as GitActionResult; }, commit: async (args: GitCommitArgs): Promise => { clearGitReadCaches(); - const result = await ipcRenderer.invoke(IPC.gitCommit, args); + const runtime = await callProjectRuntimeActionIfBound( + "git", + "commit", + { args }, + ); + const result = runtime.handled + ? runtime.result + : await ipcRenderer.invoke(IPC.gitCommit, args); clearGitReadCaches(); - return result; + return result as GitActionResult; }, generateCommitMessage: async ( args: GitGenerateCommitMessageArgs, - ): Promise => - ipcRenderer.invoke(IPC.gitGenerateCommitMessage, args), + ): Promise => { + const runtime = + await callProjectRuntimeActionIfBound( + "git", + "generateCommitMessage", + { args }, + ); + return runtime.handled + ? runtime.result + : ipcRenderer.invoke(IPC.gitGenerateCommitMessage, args); + }, listRecentCommits: async (args: { laneId: string; limit?: number; - }): Promise => - ipcRenderer.invoke(IPC.gitListRecentCommits, args), - listCommitFiles: async (args: GitListCommitFilesArgs): Promise => - ipcRenderer.invoke(IPC.gitListCommitFiles, args), - getCommitMessage: async (args: GitGetCommitMessageArgs): Promise => - ipcRenderer.invoke(IPC.gitGetCommitMessage, args), + }): Promise => { + const runtime = await callProjectRuntimeActionIfBound( + "git", + "listRecentCommits", + { args }, + ); + return runtime.handled + ? runtime.result + : ipcRenderer.invoke(IPC.gitListRecentCommits, args); + }, + listCommitFiles: async ( + args: GitListCommitFilesArgs, + ): Promise => { + const runtime = await callProjectRuntimeActionIfBound( + "git", + "listCommitFiles", + { args }, + ); + return runtime.handled + ? runtime.result + : ipcRenderer.invoke(IPC.gitListCommitFiles, args); + }, + getCommitMessage: async ( + args: GitGetCommitMessageArgs, + ): Promise => { + const runtime = await callProjectRuntimeActionIfBound( + "git", + "getCommitMessage", + { args }, + ); + return runtime.handled + ? runtime.result + : ipcRenderer.invoke(IPC.gitGetCommitMessage, args); + }, revertCommit: async (args: GitRevertArgs): Promise => { clearGitReadCaches(); - const result = await ipcRenderer.invoke(IPC.gitRevertCommit, args); + const runtime = await callProjectRuntimeActionIfBound( + "git", + "revertCommit", + { args }, + ); + const result = runtime.handled + ? runtime.result + : await ipcRenderer.invoke(IPC.gitRevertCommit, args); clearGitReadCaches(); - return result; + return result as GitActionResult; }, cherryPickCommit: async ( args: GitCherryPickArgs, ): Promise => { clearGitReadCaches(); - const result = await ipcRenderer.invoke(IPC.gitCherryPickCommit, args); + const runtime = await callProjectRuntimeActionIfBound( + "git", + "cherryPickCommit", + { args }, + ); + const result = runtime.handled + ? runtime.result + : await ipcRenderer.invoke(IPC.gitCherryPickCommit, args); clearGitReadCaches(); - return result; + return result as GitActionResult; }, stashPush: async (args: GitStashPushArgs): Promise => { clearGitReadCaches(); - const result = await ipcRenderer.invoke(IPC.gitStashPush, args); + const runtime = await callProjectRuntimeActionIfBound( + "git", + "stashPush", + { args }, + ); + const result = runtime.handled + ? runtime.result + : await ipcRenderer.invoke(IPC.gitStashPush, args); clearGitReadCaches(); - return result; + return result as GitActionResult; + }, + stashList: async (args: { laneId: string }): Promise => { + const runtime = await callProjectRuntimeActionIfBound( + "git", + "listStashes", + { args }, + ); + return runtime.handled + ? runtime.result + : ipcRenderer.invoke(IPC.gitStashList, args); }, - stashList: async (args: { laneId: string }): Promise => - ipcRenderer.invoke(IPC.gitStashList, args), stashApply: async (args: GitStashRefArgs): Promise => { clearGitReadCaches(); - const result = await ipcRenderer.invoke(IPC.gitStashApply, args); + const runtime = await callProjectRuntimeActionIfBound( + "git", + "stashApply", + { args }, + ); + const result = runtime.handled + ? runtime.result + : await ipcRenderer.invoke(IPC.gitStashApply, args); clearGitReadCaches(); - return result; + return result as GitActionResult; }, stashPop: async (args: GitStashRefArgs): Promise => { clearGitReadCaches(); - const result = await ipcRenderer.invoke(IPC.gitStashPop, args); + const runtime = await callProjectRuntimeActionIfBound( + "git", + "stashPop", + { args }, + ); + const result = runtime.handled + ? runtime.result + : await ipcRenderer.invoke(IPC.gitStashPop, args); clearGitReadCaches(); - return result; + return result as GitActionResult; }, stashDrop: async (args: GitStashRefArgs): Promise => { clearGitReadCaches(); - const result = await ipcRenderer.invoke(IPC.gitStashDrop, args); + const runtime = await callProjectRuntimeActionIfBound( + "git", + "stashDrop", + { args }, + ); + const result = runtime.handled + ? runtime.result + : await ipcRenderer.invoke(IPC.gitStashDrop, args); clearGitReadCaches(); - return result; + return result as GitActionResult; }, stashClear: async (args: { laneId: string }): Promise => { clearGitReadCaches(); - const result = await ipcRenderer.invoke(IPC.gitStashClear, args); + const runtime = await callProjectRuntimeActionIfBound( + "git", + "stashClear", + { args }, + ); + const result = runtime.handled + ? runtime.result + : await ipcRenderer.invoke(IPC.gitStashClear, args); clearGitReadCaches(); - return result; + return result as GitActionResult; }, fetch: async (args: { laneId: string }): Promise => { clearGitReadCaches(); - const result = await ipcRenderer.invoke(IPC.gitFetch, args); + const runtime = await callProjectRuntimeActionIfBound( + "git", + "fetch", + { args }, + ); + const result = runtime.handled + ? runtime.result + : await ipcRenderer.invoke(IPC.gitFetch, args); clearGitReadCaches(); - return result; + return result as GitActionResult; }, pull: async (args: { laneId: string }): Promise => { clearGitReadCaches(); - const result = await ipcRenderer.invoke(IPC.gitPull, args); + const runtime = await callProjectRuntimeActionIfBound( + "git", + "pull", + { args }, + ); + const result = runtime.handled + ? runtime.result + : await ipcRenderer.invoke(IPC.gitPull, args); clearGitReadCaches(); - return result; + return result as GitActionResult; }, getSyncStatus: async (args: { laneId: string; - }): Promise => - ipcRenderer.invoke(IPC.gitGetSyncStatus, args), - getOriginRemote: async (args: { laneId: string }): Promise<{ remoteUrl: string | null; branch: string | null }> => - ipcRenderer.invoke(IPC.gitGetOriginRemote, args), - getOpenPrForBranch: async (args: { laneId: string; branch?: string }): Promise<{ prUrl: string | null; prNumber: number | null; title: string | null; headRefName: string | null }> => - ipcRenderer.invoke(IPC.gitGetOpenPrForBranch, args), + }): Promise => { + const runtime = + await callProjectRuntimeActionIfBound( + "git", + "getSyncStatus", + { args }, + ); + return runtime.handled + ? runtime.result + : ipcRenderer.invoke(IPC.gitGetSyncStatus, args); + }, + getOriginRemote: async (args: { + laneId: string; + }): Promise<{ remoteUrl: string | null; branch: string | null }> => { + const runtime = await callProjectRuntimeActionIfBound<{ + remoteUrl: string | null; + branch: string | null; + }>("git", "getOriginRemote", { args }); + return runtime.handled + ? runtime.result + : ipcRenderer.invoke(IPC.gitGetOriginRemote, args); + }, + getOpenPrForBranch: async (args: { + laneId: string; + branch?: string; + }): Promise<{ + prUrl: string | null; + prNumber: number | null; + title: string | null; + headRefName: string | null; + }> => { + const runtime = await callProjectRuntimeActionIfBound<{ + prUrl: string | null; + prNumber: number | null; + title: string | null; + headRefName: string | null; + }>("git", "getOpenPrForBranch", { args }); + return runtime.handled + ? runtime.result + : ipcRenderer.invoke(IPC.gitGetOpenPrForBranch, args); + }, sync: async (args: GitSyncArgs): Promise => { clearGitReadCaches(); - const result = await ipcRenderer.invoke(IPC.gitSync, args); + const runtime = await callProjectRuntimeActionIfBound( + "git", + "sync", + { args }, + ); + const result = runtime.handled + ? runtime.result + : await ipcRenderer.invoke(IPC.gitSync, args); clearGitReadCaches(); - return result; + return result as GitActionResult; }, push: async (args: GitPushArgs): Promise => { clearGitReadCaches(); - const result = await ipcRenderer.invoke(IPC.gitPush, args); + const runtime = await callProjectRuntimeActionIfBound( + "git", + "push", + { args }, + ); + const result = runtime.handled + ? runtime.result + : await ipcRenderer.invoke(IPC.gitPush, args); clearGitReadCaches(); - return result; + return result as GitActionResult; + }, + getConflictState: async (laneId: string): Promise => { + const runtime = await callProjectRuntimeActionIfBound( + "git", + "getConflictState", + { args: { laneId } }, + ); + return runtime.handled + ? runtime.result + : ipcRenderer.invoke(IPC.gitGetConflictState, { laneId }); + }, + rebaseContinue: async (laneId: string): Promise => { + const runtime = await callProjectRuntimeActionIfBound( + "git", + "rebaseContinue", + { args: { laneId } }, + ); + return runtime.handled + ? runtime.result + : ipcRenderer.invoke(IPC.gitRebaseContinue, { laneId }); + }, + rebaseAbort: async (laneId: string): Promise => { + const runtime = await callProjectRuntimeActionIfBound( + "git", + "rebaseAbort", + { args: { laneId } }, + ); + return runtime.handled + ? runtime.result + : ipcRenderer.invoke(IPC.gitRebaseAbort, { laneId }); + }, + mergeContinue: async (laneId: string): Promise => { + const runtime = await callProjectRuntimeActionIfBound( + "git", + "mergeContinue", + { args: { laneId } }, + ); + return runtime.handled + ? runtime.result + : ipcRenderer.invoke(IPC.gitMergeContinue, { laneId }); + }, + mergeAbort: async (laneId: string): Promise => { + const runtime = await callProjectRuntimeActionIfBound( + "git", + "mergeAbort", + { args: { laneId } }, + ); + return runtime.handled + ? runtime.result + : ipcRenderer.invoke(IPC.gitMergeAbort, { laneId }); }, - getConflictState: async (laneId: string): Promise => - ipcRenderer.invoke(IPC.gitGetConflictState, { laneId }), - rebaseContinue: async (laneId: string): Promise => - ipcRenderer.invoke(IPC.gitRebaseContinue, { laneId }), - rebaseAbort: async (laneId: string): Promise => - ipcRenderer.invoke(IPC.gitRebaseAbort, { laneId }), - mergeContinue: async (laneId: string): Promise => - ipcRenderer.invoke(IPC.gitMergeContinue, { laneId }), - mergeAbort: async (laneId: string): Promise => - ipcRenderer.invoke(IPC.gitMergeAbort, { laneId }), listBranches: async ( args: GitListBranchesArgs, - ): Promise => - gitBranchesCache.get(serializeIpcCacheArgs(args)), + ): Promise => { + const runtime = await callProjectRuntimeActionIfBound( + "git", + "listBranches", + { args }, + ); + if (runtime.handled) return runtime.result; + return gitBranchesCache.get(serializeIpcCacheArgs(args)); + }, getUserIdentity: async ( args: GitGetUserIdentityArgs, - ): Promise => - ipcRenderer.invoke(IPC.gitGetUserIdentity, args), + ): Promise => { + const runtime = await callProjectRuntimeActionIfBound( + "git", + "getUserIdentity", + { args }, + ); + return runtime.handled + ? runtime.result + : ipcRenderer.invoke(IPC.gitGetUserIdentity, args); + }, checkoutBranch: async ( args: GitCheckoutBranchArgs, - ): Promise => - ipcRenderer.invoke(IPC.gitCheckoutBranch, args), + ): Promise => { + clearGitReadCaches(); + const runtime = await callProjectRuntimeActionIfBound( + "git", + "checkoutBranch", + { args }, + ); + const result = runtime.handled + ? runtime.result + : await ipcRenderer.invoke(IPC.gitCheckoutBranch, args); + clearGitReadCaches(); + return result as GitActionResult; + }, }, conflicts: { getLaneStatus: async ( args: GetLaneConflictStatusArgs, ): Promise => - ipcRenderer.invoke(IPC.conflictsGetLaneStatus, args), + callProjectRuntimeActionOr("conflicts", "getLaneStatus", { args }, () => + ipcRenderer.invoke(IPC.conflictsGetLaneStatus, args), + ), listOverlaps: async (args: ListOverlapsArgs): Promise => - ipcRenderer.invoke(IPC.conflictsListOverlaps, args), + callProjectRuntimeActionOr("conflicts", "listOverlaps", { args }, () => + ipcRenderer.invoke(IPC.conflictsListOverlaps, args), + ), getRiskMatrix: async (): Promise => - ipcRenderer.invoke(IPC.conflictsGetRiskMatrix), + callProjectRuntimeActionOr("conflicts", "getRiskMatrix", {}, () => + ipcRenderer.invoke(IPC.conflictsGetRiskMatrix), + ), simulateMerge: async ( args: MergeSimulationArgs, ): Promise => - ipcRenderer.invoke(IPC.conflictsSimulateMerge, args), + callProjectRuntimeActionOr("conflicts", "simulateMerge", { args }, () => + ipcRenderer.invoke(IPC.conflictsSimulateMerge, args), + ), runPrediction: async ( args: RunConflictPredictionArgs = {}, ): Promise => - ipcRenderer.invoke(IPC.conflictsRunPrediction, args), + callProjectRuntimeActionOr("conflicts", "runPrediction", { args }, () => + ipcRenderer.invoke(IPC.conflictsRunPrediction, args), + ), getBatchAssessment: async (): Promise => - ipcRenderer.invoke(IPC.conflictsGetBatchAssessment), + callProjectRuntimeActionOr("conflicts", "getBatchAssessment", {}, () => + ipcRenderer.invoke(IPC.conflictsGetBatchAssessment), + ), listProposals: async (laneId: string): Promise => - ipcRenderer.invoke(IPC.conflictsListProposals, { laneId }), + callProjectRuntimeActionOr( + "conflicts", + "listProposals", + { args: { laneId } }, + () => ipcRenderer.invoke(IPC.conflictsListProposals, { laneId }), + ), prepareProposal: async ( args: PrepareConflictProposalArgs, ): Promise => - ipcRenderer.invoke(IPC.conflictsPrepareProposal, args), + callProjectRuntimeActionOr("conflicts", "prepareProposal", { args }, () => + ipcRenderer.invoke(IPC.conflictsPrepareProposal, args), + ), requestProposal: async ( args: RequestConflictProposalArgs, ): Promise => - ipcRenderer.invoke(IPC.conflictsRequestProposal, args), + callProjectRuntimeActionOr("conflicts", "requestProposal", { args }, () => + ipcRenderer.invoke(IPC.conflictsRequestProposal, args), + ), applyProposal: async ( args: ApplyConflictProposalArgs, ): Promise => - ipcRenderer.invoke(IPC.conflictsApplyProposal, args), + callProjectRuntimeActionOr("conflicts", "applyProposal", { args }, () => + ipcRenderer.invoke(IPC.conflictsApplyProposal, args), + ), undoProposal: async ( args: UndoConflictProposalArgs, ): Promise => - ipcRenderer.invoke(IPC.conflictsUndoProposal, args), + callProjectRuntimeActionOr("conflicts", "undoProposal", { args }, () => + ipcRenderer.invoke(IPC.conflictsUndoProposal, args), + ), runExternalResolver: async ( args: RunExternalConflictResolverArgs, ): Promise => - ipcRenderer.invoke(IPC.conflictsRunExternalResolver, args), + callProjectRuntimeActionOr( + "conflicts", + "runExternalResolver", + { args }, + () => ipcRenderer.invoke(IPC.conflictsRunExternalResolver, args), + ), listExternalResolverRuns: async ( args: ListExternalConflictResolverRunsArgs = {}, ): Promise => - ipcRenderer.invoke(IPC.conflictsListExternalResolverRuns, args), + callProjectRuntimeActionOr( + "conflicts", + "listExternalResolverRuns", + { args }, + () => ipcRenderer.invoke(IPC.conflictsListExternalResolverRuns, args), + ), commitExternalResolverRun: async ( args: CommitExternalConflictResolverRunArgs, ): Promise => - ipcRenderer.invoke(IPC.conflictsCommitExternalResolverRun, args), - prepareResolverSession: ( + callProjectRuntimeActionOr( + "conflicts", + "commitExternalResolverRun", + { args }, + () => ipcRenderer.invoke(IPC.conflictsCommitExternalResolverRun, args), + ), + prepareResolverSession: async ( args: PrepareResolverSessionArgs, ): Promise => - ipcRenderer.invoke(IPC.conflictsPrepareResolverSession, args), - attachResolverSession: ( + callProjectRuntimeActionOr( + "conflicts", + "prepareResolverSession", + { args }, + () => ipcRenderer.invoke(IPC.conflictsPrepareResolverSession, args), + ), + attachResolverSession: async ( args: AttachResolverSessionArgs, ): Promise => - ipcRenderer.invoke(IPC.conflictsAttachResolverSession, args), - finalizeResolverSession: ( + callProjectRuntimeActionOr( + "conflicts", + "attachResolverSession", + { args }, + () => ipcRenderer.invoke(IPC.conflictsAttachResolverSession, args), + ), + finalizeResolverSession: async ( args: FinalizeResolverSessionArgs, ): Promise => - ipcRenderer.invoke(IPC.conflictsFinalizeResolverSession, args), - cancelResolverSession: ( + callProjectRuntimeActionOr( + "conflicts", + "finalizeResolverSession", + { args }, + () => ipcRenderer.invoke(IPC.conflictsFinalizeResolverSession, args), + ), + cancelResolverSession: async ( args: CancelResolverSessionArgs, ): Promise => - ipcRenderer.invoke(IPC.conflictsCancelResolverSession, args), - suggestResolverTarget: ( + callProjectRuntimeActionOr( + "conflicts", + "cancelResolverSession", + { args }, + () => ipcRenderer.invoke(IPC.conflictsCancelResolverSession, args), + ), + suggestResolverTarget: async ( args: SuggestResolverTargetArgs, ): Promise => - ipcRenderer.invoke(IPC.conflictsSuggestResolverTarget, args), + callProjectRuntimeActionOr( + "conflicts", + "suggestResolverTarget", + { args }, + () => ipcRenderer.invoke(IPC.conflictsSuggestResolverTarget, args), + ), onEvent: (cb: (ev: ConflictEventPayload) => void) => { const listener = ( _event: Electron.IpcRendererEvent, @@ -2921,12 +6481,25 @@ contextBridge.exposeInMainWorld("ade", { }, }, feedback: { - prepareDraft: async (args: FeedbackPrepareDraftArgs): Promise => - ipcRenderer.invoke(IPC.feedbackPrepareDraft, args), - submitDraft: async (args: FeedbackSubmitDraftArgs): Promise => - ipcRenderer.invoke(IPC.feedbackSubmitDraft, args), + prepareDraft: async ( + args: FeedbackPrepareDraftArgs, + ): Promise => + callProjectRuntimeActionOr("feedback", "prepareDraft", { args }, () => + ipcRenderer.invoke(IPC.feedbackPrepareDraft, args), + ), + submitDraft: async ( + args: FeedbackSubmitDraftArgs, + ): Promise => + callProjectRuntimeActionOr( + "feedback", + "submitPreparedDraft", + { args }, + () => ipcRenderer.invoke(IPC.feedbackSubmitDraft, args), + ), list: async (): Promise => - ipcRenderer.invoke(IPC.feedbackList), + callProjectRuntimeActionOr("feedback", "list", {}, () => + ipcRenderer.invoke(IPC.feedbackList), + ), onUpdate: (cb: (event: FeedbackSubmissionEvent) => void): (() => void) => { const handler = ( _event: Electron.IpcRendererEvent, @@ -2937,190 +6510,412 @@ contextBridge.exposeInMainWorld("ade", { }, }, github: { - getStatus: async (opts?: { forceRefresh?: boolean }): Promise => - opts?.forceRefresh - ? clearAround(() => githubStatusCache.clear(), () => ipcRenderer.invoke(IPC.githubGetStatus, opts ?? {})) - : githubStatusCache.get(), + getStatus: async (opts?: { + forceRefresh?: boolean; + }): Promise => { + if (opts?.forceRefresh) githubStatusCache.clear(); + return callProjectRuntimeActionOr( + "github", + "getStatus", + { args: opts ?? {} }, + () => + opts?.forceRefresh + ? clearAround( + () => githubStatusCache.clear(), + () => ipcRenderer.invoke(IPC.githubGetStatus, opts ?? {}), + ) + : githubStatusCache.get(), + ); + }, setToken: async (token: string): Promise => - clearAround(() => githubStatusCache.clear(), () => ipcRenderer.invoke(IPC.githubSetToken, { token })), + clearAround( + () => githubStatusCache.clear(), + () => + callProjectRuntimeActionOr("github", "setToken", { arg: token }, () => + ipcRenderer.invoke(IPC.githubSetToken, { token }), + ), + ), clearToken: async (): Promise => - clearAround(() => githubStatusCache.clear(), () => ipcRenderer.invoke(IPC.githubClearToken)), + clearAround( + () => githubStatusCache.clear(), + () => + callProjectRuntimeActionOr("github", "clearToken", {}, () => + ipcRenderer.invoke(IPC.githubClearToken), + ), + ), detectRepo: async (): Promise<{ owner: string; name: string } | null> => { + const runtime = await callProjectRuntimeActionIfBound<{ + owner: string; + name: string; + } | null>("github", "detectRepo", {}); + if (runtime.handled) return runtime.result; const status = await githubStatusCache.get(); return status.repo; }, - listRepoLabels: async (args: { owner: string; name: string }): Promise> => - ipcRenderer.invoke(IPC.githubListRepoLabels, args), - listRepoCollaborators: async (args: { owner: string; name: string }): Promise> => - ipcRenderer.invoke(IPC.githubListRepoCollaborators, args), - listMyRepos: async (input: ListMyGitHubReposInput = {}): Promise => + listRepoLabels: async (args: { + owner: string; + name: string; + }): Promise> => + callProjectRuntimeActionOr("github", "listRepoLabels", { args }, () => + ipcRenderer.invoke(IPC.githubListRepoLabels, args), + ), + listRepoCollaborators: async (args: { + owner: string; + name: string; + }): Promise> => + callProjectRuntimeActionOr( + "github", + "listRepoCollaborators", + { args }, + () => ipcRenderer.invoke(IPC.githubListRepoCollaborators, args), + ), + listMyRepos: async ( + input: ListMyGitHubReposInput = {}, + ): Promise => ipcRenderer.invoke(IPC.githubListMyRepos, input), - publishCurrentProject: async (input: PublishProjectInput): Promise => - clearAround(() => githubStatusCache.clear(), () => ipcRenderer.invoke(IPC.githubPublishCurrentProject, input)), + publishCurrentProject: async ( + input: PublishProjectInput, + ): Promise => + clearAround( + () => githubStatusCache.clear(), + () => + callProjectRuntimeActionOr( + "github", + "publishCurrentProject", + { args: input }, + () => ipcRenderer.invoke(IPC.githubPublishCurrentProject, input), + ), + ), onStatusChanged: (cb: (status: GitHubStatus) => void): (() => void) => { - const listener = (_event: Electron.IpcRendererEvent, payload: GitHubStatus) => { + const listener = ( + _event: Electron.IpcRendererEvent, + payload: GitHubStatus, + ) => { githubStatusCache.clear(); cb(payload); }; ipcRenderer.on(IPC.githubStatusChanged, listener); - return () => ipcRenderer.removeListener(IPC.githubStatusChanged, listener); + return () => + ipcRenderer.removeListener(IPC.githubStatusChanged, listener); }, }, prs: { createFromLane: async (args: CreatePrFromLaneArgs): Promise => - ipcRenderer.invoke(IPC.prsCreateFromLane, args), + callProjectRuntimeActionOr("pr", "createFromLane", { args }, () => + ipcRenderer.invoke(IPC.prsCreateFromLane, args), + ), linkToLane: async (args: LinkPrToLaneArgs): Promise => - ipcRenderer.invoke(IPC.prsLinkToLane, args), + callProjectRuntimeActionOr("pr", "linkToLane", { args }, () => + ipcRenderer.invoke(IPC.prsLinkToLane, args), + ), getForLane: async (laneId: string): Promise => - ipcRenderer.invoke(IPC.prsGetForLane, { laneId }), + callProjectRuntimeActionOr("pr", "getForLane", { arg: laneId }, () => + ipcRenderer.invoke(IPC.prsGetForLane, { laneId }), + ), listAll: async (): Promise => - ipcRenderer.invoke(IPC.prsListAll), + callProjectRuntimeActionOr("pr", "listAll", { args: {} }, () => + ipcRenderer.invoke(IPC.prsListAll), + ), listOpenForRepo: async (): Promise => - ipcRenderer.invoke(IPC.prsListOpenForRepo), + callProjectRuntimeActionOr("pr", "listOpenPullRequests", {}, () => + ipcRenderer.invoke(IPC.prsListOpenForRepo), + ), refresh: async ( args: { prId?: string; prIds?: string[] } = {}, - ): Promise => ipcRenderer.invoke(IPC.prsRefresh, args), + ): Promise => + callProjectRuntimeActionOr("pr", "refresh", { args }, () => + ipcRenderer.invoke(IPC.prsRefresh, args), + ), getStatus: async (prId: string): Promise => - ipcRenderer.invoke(IPC.prsGetStatus, { prId }), + callProjectRuntimeActionOr("pr", "getStatus", { arg: prId }, () => + ipcRenderer.invoke(IPC.prsGetStatus, { prId }), + ), getChecks: async (prId: string): Promise => - ipcRenderer.invoke(IPC.prsGetChecks, { prId }), + callProjectRuntimeActionOr("pr", "getChecks", { arg: prId }, () => + ipcRenderer.invoke(IPC.prsGetChecks, { prId }), + ), getComments: async (prId: string): Promise => - ipcRenderer.invoke(IPC.prsGetComments, { prId }), + callProjectRuntimeActionOr("pr", "getComments", { arg: prId }, () => + ipcRenderer.invoke(IPC.prsGetComments, { prId }), + ), getReviews: async (prId: string): Promise => - ipcRenderer.invoke(IPC.prsGetReviews, { prId }), + callProjectRuntimeActionOr("pr", "getReviews", { arg: prId }, () => + ipcRenderer.invoke(IPC.prsGetReviews, { prId }), + ), getReviewThreads: async (prId: string): Promise => - ipcRenderer.invoke(IPC.prsGetReviewThreads, { prId }), + callProjectRuntimeActionOr("pr", "getReviewThreads", { arg: prId }, () => + ipcRenderer.invoke(IPC.prsGetReviewThreads, { prId }), + ), updateDescription: async (args: UpdatePrDescriptionArgs): Promise => - ipcRenderer.invoke(IPC.prsUpdateDescription, args), + callProjectRuntimeActionOr("pr", "updateDescription", { args }, () => + ipcRenderer.invoke(IPC.prsUpdateDescription, args), + ), delete: async (args: DeletePrArgs): Promise => - ipcRenderer.invoke(IPC.prsDelete, args), + callProjectRuntimeActionOr("pr", "delete", { args }, () => + ipcRenderer.invoke(IPC.prsDelete, args), + ), draftDescription: async ( args: DraftPrDescriptionArgs, ): Promise<{ title: string; body: string }> => - ipcRenderer.invoke(IPC.prsDraftDescription, args), + callProjectRuntimeActionOr("pr", "draftDescription", { args }, () => + ipcRenderer.invoke(IPC.prsDraftDescription, args), + ), land: async (args: LandPrArgs): Promise => - ipcRenderer.invoke(IPC.prsLand, args), + callProjectRuntimeActionOr("pr", "land", { args }, () => + ipcRenderer.invoke(IPC.prsLand, args), + ), landStack: async (args: LandStackArgs): Promise => - ipcRenderer.invoke(IPC.prsLandStack, args), - retargetBase: async (args: { prId: string; baseBranch: string }): Promise => - ipcRenderer.invoke(IPC.prsRetargetBase, args), - openInGitHub: async (prId: string): Promise => - ipcRenderer.invoke(IPC.prsOpenInGitHub, { prId }), + callProjectRuntimeActionOr("pr", "landStack", { args }, () => + ipcRenderer.invoke(IPC.prsLandStack, args), + ), + retargetBase: async (args: { + prId: string; + baseBranch: string; + }): Promise => + callProjectRuntimeActionOr( + "pr", + "retargetBase", + { argsList: [args.prId, args.baseBranch] }, + () => ipcRenderer.invoke(IPC.prsRetargetBase, args), + ), + openInGitHub: async (prId: string): Promise => { + const runtime = await callProjectRuntimeActionIfBound( + "pr", + "listAll", + { args: {} }, + ); + if (runtime.handled) { + const pr = runtime.result.find((entry) => entry.id === prId); + if (pr?.githubUrl) { + await ipcRenderer.invoke(IPC.appOpenExternal, { url: pr.githubUrl }); + return; + } + } + await ipcRenderer.invoke(IPC.prsOpenInGitHub, { prId }); + }, createQueue: (args: CreateQueuePrsArgs): Promise => - ipcRenderer.invoke(IPC.prsCreateQueue, args), + callProjectRuntimeActionOr("pr", "createQueuePrs", { args }, () => + ipcRenderer.invoke(IPC.prsCreateQueue, args), + ), createIntegration: ( args: CreateIntegrationPrArgs, ): Promise => - ipcRenderer.invoke(IPC.prsCreateIntegration, args), + callProjectRuntimeActionOr("pr", "createIntegrationPr", { args }, () => + ipcRenderer.invoke(IPC.prsCreateIntegration, args), + ), simulateIntegration: ( args: SimulateIntegrationArgs, ): Promise => - ipcRenderer.invoke(IPC.prsSimulateIntegration, args), + callProjectRuntimeActionOr("pr", "simulateIntegration", { args }, () => + ipcRenderer.invoke(IPC.prsSimulateIntegration, args), + ), commitIntegration: ( args: CommitIntegrationArgs, ): Promise => - ipcRenderer.invoke(IPC.prsCommitIntegration, args), + callProjectRuntimeActionOr("pr", "commitIntegration", { args }, () => + ipcRenderer.invoke(IPC.prsCommitIntegration, args), + ), listProposals: (): Promise => - ipcRenderer.invoke(IPC.prsListProposals), + callProjectRuntimeActionOr("pr", "listIntegrationProposals", {}, () => + ipcRenderer.invoke(IPC.prsListProposals), + ), updateProposal: (args: UpdateIntegrationProposalArgs): Promise => - ipcRenderer.invoke(IPC.prsUpdateProposal, args), + callProjectRuntimeActionOr( + "pr", + "updateIntegrationProposal", + { args }, + () => ipcRenderer.invoke(IPC.prsUpdateProposal, args), + ), deleteProposal: ( args: DeleteIntegrationProposalArgs, ): Promise => - ipcRenderer.invoke(IPC.prsDeleteProposal, args), + callProjectRuntimeActionOr( + "pr", + "deleteIntegrationProposal", + { args }, + () => ipcRenderer.invoke(IPC.prsDeleteProposal, args), + ), landStackEnhanced: (args: LandStackEnhancedArgs): Promise => - ipcRenderer.invoke(IPC.prsLandStackEnhanced, args), + callProjectRuntimeActionOr("pr", "landStackEnhanced", { args }, () => + ipcRenderer.invoke(IPC.prsLandStackEnhanced, args), + ), landQueueNext: (args: LandQueueNextArgs): Promise => - ipcRenderer.invoke(IPC.prsLandQueueNext, args), + callProjectRuntimeActionOr("pr", "landQueueNext", { args }, () => + ipcRenderer.invoke(IPC.prsLandQueueNext, args), + ), startQueueAutomation: ( args: StartQueueAutomationArgs, ): Promise => - ipcRenderer.invoke(IPC.prsStartQueueAutomation, args), + callProjectRuntimeActionOr("pr", "startQueueAutomation", { args }, () => + ipcRenderer.invoke(IPC.prsStartQueueAutomation, args), + ), pauseQueueAutomation: ( queueId: string, ): Promise => - ipcRenderer.invoke(IPC.prsPauseQueueAutomation, { queueId }), + callProjectRuntimeActionOr( + "pr", + "pauseQueueAutomation", + { arg: queueId }, + () => ipcRenderer.invoke(IPC.prsPauseQueueAutomation, { queueId }), + ), resumeQueueAutomation: ( args: ResumeQueueAutomationArgs, ): Promise => - ipcRenderer.invoke(IPC.prsResumeQueueAutomation, args), + callProjectRuntimeActionOr("pr", "resumeQueueAutomation", { args }, () => + ipcRenderer.invoke(IPC.prsResumeQueueAutomation, args), + ), cancelQueueAutomation: ( queueId: string, ): Promise => - ipcRenderer.invoke(IPC.prsCancelQueueAutomation, { queueId }), + callProjectRuntimeActionOr( + "pr", + "cancelQueueAutomation", + { arg: queueId }, + () => ipcRenderer.invoke(IPC.prsCancelQueueAutomation, { queueId }), + ), reorderQueuePrs: (args: ReorderQueuePrsArgs): Promise => - ipcRenderer.invoke(IPC.prsReorderQueue, args), + callProjectRuntimeActionOr("pr", "reorderQueuePrs", { args }, () => + ipcRenderer.invoke(IPC.prsReorderQueue, args), + ), getHealth: (prId: string): Promise => - ipcRenderer.invoke(IPC.prsGetHealth, { prId }), + callProjectRuntimeActionOr("pr", "getPrHealth", { arg: prId }, () => + ipcRenderer.invoke(IPC.prsGetHealth, { prId }), + ), getQueueState: (groupId: string): Promise => - ipcRenderer.invoke(IPC.prsGetQueueState, { groupId }), + callProjectRuntimeActionOr("pr", "getQueueState", { arg: groupId }, () => + ipcRenderer.invoke(IPC.prsGetQueueState, { groupId }), + ), listQueueStates: (args?: { includeCompleted?: boolean; limit?: number; }): Promise => - ipcRenderer.invoke(IPC.prsListQueueStates, args ?? {}), + callProjectRuntimeActionOr( + "pr", + "listQueueStates", + { args: args ?? {} }, + () => ipcRenderer.invoke(IPC.prsListQueueStates, args ?? {}), + ), getConflictAnalysis: (prId: string): Promise => - ipcRenderer.invoke(IPC.prsGetConflictAnalysis, { prId }), + callProjectRuntimeActionOr( + "pr", + "getConflictAnalysis", + { arg: prId }, + () => ipcRenderer.invoke(IPC.prsGetConflictAnalysis, { prId }), + ), getMergeContext: (prId: string): Promise => - ipcRenderer.invoke(IPC.prsGetMergeContext, { prId }), + callProjectRuntimeActionOr("pr", "getMergeContext", { arg: prId }, () => + ipcRenderer.invoke(IPC.prsGetMergeContext, { prId }), + ), listWithConflicts: (): Promise => - ipcRenderer.invoke(IPC.prsListWithConflicts), + callProjectRuntimeActionOr("pr", "listWithConflicts", {}, () => + ipcRenderer.invoke(IPC.prsListWithConflicts), + ), getGitHubSnapshot: (args?: { force?: boolean; }): Promise => - ipcRenderer.invoke(IPC.prsGetGitHubSnapshot, args ?? {}), + callProjectRuntimeActionOr( + "pr", + "getGithubSnapshot", + { args: args ?? {} }, + () => ipcRenderer.invoke(IPC.prsGetGitHubSnapshot, args ?? {}), + ), listIntegrationWorkflows: ( args: ListIntegrationWorkflowsArgs = {}, ): Promise => - ipcRenderer.invoke(IPC.prsListIntegrationWorkflows, args), + callProjectRuntimeActionOr( + "pr", + "listIntegrationWorkflows", + { args }, + () => ipcRenderer.invoke(IPC.prsListIntegrationWorkflows, args), + ), createIntegrationLaneForProposal: ( args: CreateIntegrationLaneForProposalArgs, ): Promise => - ipcRenderer.invoke(IPC.prsCreateIntegrationLaneForProposal, args), + callProjectRuntimeActionOr( + "pr", + "createIntegrationLaneForProposal", + { args }, + () => ipcRenderer.invoke(IPC.prsCreateIntegrationLaneForProposal, args), + ), startIntegrationResolution: ( args: StartIntegrationResolutionArgs, ): Promise => - ipcRenderer.invoke(IPC.prsStartIntegrationResolution, args), + callProjectRuntimeActionOr( + "pr", + "startIntegrationResolution", + { args }, + () => ipcRenderer.invoke(IPC.prsStartIntegrationResolution, args), + ), getIntegrationResolutionState: ( proposalId: string, ): Promise => - ipcRenderer.invoke(IPC.prsGetIntegrationResolutionState, { proposalId }), + callProjectRuntimeActionOr( + "pr", + "getIntegrationResolutionState", + { arg: proposalId }, + () => + ipcRenderer.invoke(IPC.prsGetIntegrationResolutionState, { + proposalId, + }), + ), recheckIntegrationStep: ( args: RecheckIntegrationStepArgs, ): Promise => - ipcRenderer.invoke(IPC.prsRecheckIntegrationStep, args), + callProjectRuntimeActionOr("pr", "recheckIntegrationStep", { args }, () => + ipcRenderer.invoke(IPC.prsRecheckIntegrationStep, args), + ), aiResolutionStart: ( args: PrAiResolutionStartArgs, ): Promise => - ipcRenderer.invoke(IPC.prsAiResolutionStart, args), + callProjectRuntimeActionOr("pr", "aiResolutionStart", { args }, () => + ipcRenderer.invoke(IPC.prsAiResolutionStart, args), + ), aiResolutionGetSession: ( args: PrAiResolutionGetSessionArgs, ): Promise => - ipcRenderer.invoke(IPC.prsAiResolutionGetSession, args), + callProjectRuntimeActionOr("pr", "aiResolutionGetSession", { args }, () => + ipcRenderer.invoke(IPC.prsAiResolutionGetSession, args), + ), aiResolutionInput: (args: PrAiResolutionInputArgs): Promise => - ipcRenderer.invoke(IPC.prsAiResolutionInput, args), + callProjectRuntimeActionOr("pr", "aiResolutionInput", { args }, () => + ipcRenderer.invoke(IPC.prsAiResolutionInput, args), + ), aiResolutionStop: (args: PrAiResolutionStopArgs): Promise => - ipcRenderer.invoke(IPC.prsAiResolutionStop, args), + callProjectRuntimeActionOr("pr", "aiResolutionStop", { args }, () => + ipcRenderer.invoke(IPC.prsAiResolutionStop, args), + ), issueResolutionStart: ( args: PrIssueResolutionStartArgs, ): Promise => - ipcRenderer.invoke(IPC.prsIssueResolutionStart, args), + callProjectRuntimeActionOr("pr", "issueResolutionStart", { args }, () => + ipcRenderer.invoke(IPC.prsIssueResolutionStart, args), + ), issueResolutionPreviewPrompt: ( args: PrIssueResolutionPromptPreviewArgs, ): Promise => - ipcRenderer.invoke(IPC.prsIssueResolutionPreviewPrompt, args), + callProjectRuntimeActionOr( + "pr", + "issueResolutionPreviewPrompt", + { args }, + () => ipcRenderer.invoke(IPC.prsIssueResolutionPreviewPrompt, args), + ), rebaseResolutionStart: ( args: RebaseResolutionStartArgs, ): Promise => - ipcRenderer.invoke(IPC.prsRebaseResolutionStart, args), + callProjectRuntimeActionOr("pr", "rebaseResolutionStart", { args }, () => + ipcRenderer.invoke(IPC.prsRebaseResolutionStart, args), + ), onAiResolutionEvent: (cb: (ev: PrAiResolutionEventPayload) => void) => { const listener = ( _event: Electron.IpcRendererEvent, payload: PrAiResolutionEventPayload, ) => cb(payload); ipcRenderer.on(IPC.prsAiResolutionEvent, listener); - return () => + const unsubscribeRemote = subscribeRemotePrAiResolutionEvents(cb); + return () => { + unsubscribeRemote(); ipcRenderer.removeListener(IPC.prsAiResolutionEvent, listener); + }; }, onEvent: (cb: (ev: PrEventPayload) => void) => { const listener = ( @@ -3128,76 +6923,224 @@ contextBridge.exposeInMainWorld("ade", { payload: PrEventPayload, ) => cb(payload); ipcRenderer.on(IPC.prsEvent, listener); - return () => ipcRenderer.removeListener(IPC.prsEvent, listener); + const unsubscribeRemote = subscribeRemotePrEvents(cb); + return () => { + unsubscribeRemote(); + ipcRenderer.removeListener(IPC.prsEvent, listener); + }; }, getDetail: async (prId: string): Promise => - ipcRenderer.invoke(IPC.prsGetDetail, { prId }), + callProjectRuntimeActionOr("pr", "getDetail", { arg: prId }, () => + ipcRenderer.invoke(IPC.prsGetDetail, { prId }), + ), getFiles: async (prId: string): Promise => - ipcRenderer.invoke(IPC.prsGetFiles, { prId }), + callProjectRuntimeActionOr("pr", "getFiles", { arg: prId }, () => + ipcRenderer.invoke(IPC.prsGetFiles, { prId }), + ), getCommits: async (prId: string): Promise => - ipcRenderer.invoke(IPC.prsGetCommits, { prId }), + callProjectRuntimeActionOr("pr", "getCommits", { arg: prId }, () => + ipcRenderer.invoke(IPC.prsGetCommits, { prId }), + ), getActionRuns: async (prId: string): Promise => - ipcRenderer.invoke(IPC.prsGetActionRuns, { prId }), + callProjectRuntimeActionOr("pr", "getActionRuns", { arg: prId }, () => + ipcRenderer.invoke(IPC.prsGetActionRuns, { prId }), + ), getActivity: async (prId: string): Promise => - ipcRenderer.invoke(IPC.prsGetActivity, { prId }), + callProjectRuntimeActionOr("pr", "getActivity", { arg: prId }, () => + ipcRenderer.invoke(IPC.prsGetActivity, { prId }), + ), addComment: async (args: AddPrCommentArgs): Promise => - ipcRenderer.invoke(IPC.prsAddComment, args), + callProjectRuntimeActionOr("pr", "addComment", { args }, () => + ipcRenderer.invoke(IPC.prsAddComment, args), + ), replyToReviewThread: async ( args: ReplyToPrReviewThreadArgs, ): Promise => - ipcRenderer.invoke(IPC.prsReplyToReviewThread, args), - resolveReviewThread: async (args: ResolvePrReviewThreadArgs): Promise => - ipcRenderer.invoke(IPC.prsResolveReviewThread, args), - updateTitle: async (args: UpdatePrTitleArgs): Promise => ipcRenderer.invoke(IPC.prsUpdateTitle, args), - updateBody: async (args: UpdatePrBodyArgs): Promise => ipcRenderer.invoke(IPC.prsUpdateBody, args), - setLabels: async (args: SetPrLabelsArgs): Promise => ipcRenderer.invoke(IPC.prsSetLabels, args), - requestReviewers: async (args: RequestPrReviewersArgs): Promise => ipcRenderer.invoke(IPC.prsRequestReviewers, args), - submitReview: async (args: SubmitPrReviewArgs): Promise => ipcRenderer.invoke(IPC.prsSubmitReview, args), - close: async (args: ClosePrArgs): Promise => ipcRenderer.invoke(IPC.prsClose, args), - reopen: async (args: ReopenPrArgs): Promise => ipcRenderer.invoke(IPC.prsReopen, args), - rerunChecks: async (args: RerunPrChecksArgs): Promise => ipcRenderer.invoke(IPC.prsRerunChecks, args), - aiReviewSummary: async (args: AiReviewSummaryArgs): Promise => ipcRenderer.invoke(IPC.prsAiReviewSummary, args), - issueInventorySync: async (prId: string): Promise => - ipcRenderer.invoke(IPC.prsIssueInventorySync, { prId }), + callProjectRuntimeActionOr("pr", "replyToReviewThread", { args }, () => + ipcRenderer.invoke(IPC.prsReplyToReviewThread, args), + ), + resolveReviewThread: async ( + args: ResolvePrReviewThreadArgs, + ): Promise => + callProjectRuntimeActionOr("pr", "resolveReviewThread", { args }, () => + ipcRenderer.invoke(IPC.prsResolveReviewThread, args), + ), + updateTitle: async (args: UpdatePrTitleArgs): Promise => + callProjectRuntimeActionOr("pr", "updateTitle", { args }, () => + ipcRenderer.invoke(IPC.prsUpdateTitle, args), + ), + updateBody: async (args: UpdatePrBodyArgs): Promise => + callProjectRuntimeActionOr("pr", "updateBody", { args }, () => + ipcRenderer.invoke(IPC.prsUpdateBody, args), + ), + setLabels: async (args: SetPrLabelsArgs): Promise => + callProjectRuntimeActionOr("pr", "setLabels", { args }, () => + ipcRenderer.invoke(IPC.prsSetLabels, args), + ), + requestReviewers: async (args: RequestPrReviewersArgs): Promise => + callProjectRuntimeActionOr("pr", "requestReviewers", { args }, () => + ipcRenderer.invoke(IPC.prsRequestReviewers, args), + ), + submitReview: async ( + args: SubmitPrReviewArgs, + ): Promise => + callProjectRuntimeActionOr("pr", "submitReview", { args }, () => + ipcRenderer.invoke(IPC.prsSubmitReview, args), + ), + close: async (args: ClosePrArgs): Promise => + callProjectRuntimeActionOr("pr", "closePr", { args }, () => + ipcRenderer.invoke(IPC.prsClose, args), + ), + reopen: async (args: ReopenPrArgs): Promise => + callProjectRuntimeActionOr("pr", "reopenPr", { args }, () => + ipcRenderer.invoke(IPC.prsReopen, args), + ), + rerunChecks: async (args: RerunPrChecksArgs): Promise => + callProjectRuntimeActionOr("pr", "rerunChecks", { args }, () => + ipcRenderer.invoke(IPC.prsRerunChecks, args), + ), + aiReviewSummary: async ( + args: AiReviewSummaryArgs, + ): Promise => + callProjectRuntimeActionOr("pr", "aiReviewSummary", { args }, () => + ipcRenderer.invoke(IPC.prsAiReviewSummary, args), + ), + issueInventorySync: async ( + prId: string, + ): Promise => { + const checks = await callProjectRuntimeActionIfBound( + "pr", + "getChecks", + { arg: prId }, + ); + const reviewThreads = checks.handled + ? await callProjectRuntimeActionIfBound( + "pr", + "getReviewThreads", + { arg: prId }, + ) + : ({ handled: false } as const); + const comments = + checks.handled && reviewThreads.handled + ? await callProjectRuntimeActionIfBound( + "pr", + "getComments", + { arg: prId }, + ) + : ({ handled: false } as const); + if (checks.handled && reviewThreads.handled && comments.handled) { + const runtime = + await callProjectRuntimeActionIfBound( + "issue_inventory", + "syncFromPrData", + { + argsList: [ + prId, + checks.result, + reviewThreads.result, + comments.result, + ], + }, + ); + if (runtime.handled) return runtime.result; + } + return ipcRenderer.invoke(IPC.prsIssueInventorySync, { prId }); + }, issueInventoryGet: async (prId: string): Promise => - ipcRenderer.invoke(IPC.prsIssueInventoryGet, { prId }), + callProjectRuntimeActionOr( + "issue_inventory", + "getInventory", + { arg: prId }, + () => ipcRenderer.invoke(IPC.prsIssueInventoryGet, { prId }), + ), issueInventoryGetNew: async (prId: string): Promise => - ipcRenderer.invoke(IPC.prsIssueInventoryGetNew, { prId }), + callProjectRuntimeActionOr( + "issue_inventory", + "getNewItems", + { arg: prId }, + () => ipcRenderer.invoke(IPC.prsIssueInventoryGetNew, { prId }), + ), issueInventoryMarkFixed: async ( prId: string, itemIds: string[], ): Promise => - ipcRenderer.invoke(IPC.prsIssueInventoryMarkFixed, { prId, itemIds }), + callProjectRuntimeActionOr( + "issue_inventory", + "markFixed", + { argsList: [prId, itemIds] }, + () => + ipcRenderer.invoke(IPC.prsIssueInventoryMarkFixed, { prId, itemIds }), + ), issueInventoryMarkDismissed: async ( prId: string, itemIds: string[], reason: string, ): Promise => - ipcRenderer.invoke(IPC.prsIssueInventoryMarkDismissed, { - prId, - itemIds, - reason, - }), + callProjectRuntimeActionOr( + "issue_inventory", + "markDismissed", + { argsList: [prId, itemIds, reason] }, + () => + ipcRenderer.invoke(IPC.prsIssueInventoryMarkDismissed, { + prId, + itemIds, + reason, + }), + ), issueInventoryMarkEscalated: async ( prId: string, itemIds: string[], ): Promise => - ipcRenderer.invoke(IPC.prsIssueInventoryMarkEscalated, { prId, itemIds }), + callProjectRuntimeActionOr( + "issue_inventory", + "markEscalated", + { argsList: [prId, itemIds] }, + () => + ipcRenderer.invoke(IPC.prsIssueInventoryMarkEscalated, { + prId, + itemIds, + }), + ), issueInventoryGetConvergence: async ( prId: string, ): Promise => - ipcRenderer.invoke(IPC.prsIssueInventoryGetConvergence, { prId }), + callProjectRuntimeActionOr( + "issue_inventory", + "getConvergenceStatus", + { arg: prId }, + () => ipcRenderer.invoke(IPC.prsIssueInventoryGetConvergence, { prId }), + ), issueInventoryReset: async (prId: string): Promise => - ipcRenderer.invoke(IPC.prsIssueInventoryReset, { prId }), + callProjectRuntimeActionOr( + "issue_inventory", + "resetInventory", + { arg: prId }, + () => ipcRenderer.invoke(IPC.prsIssueInventoryReset, { prId }), + ), convergenceStateGet: async (prId: string): Promise => - ipcRenderer.invoke(IPC.prsConvergenceStateGet, { prId }), + callProjectRuntimeActionOr( + "issue_inventory", + "getConvergenceRuntime", + { arg: prId }, + () => ipcRenderer.invoke(IPC.prsConvergenceStateGet, { prId }), + ), convergenceStateSave: async ( prId: string, state: PrConvergenceStatePatch, ): Promise => - ipcRenderer.invoke(IPC.prsConvergenceStateSave, { prId, state }), + callProjectRuntimeActionOr( + "issue_inventory", + "saveConvergenceRuntime", + { argsList: [prId, state] }, + () => ipcRenderer.invoke(IPC.prsConvergenceStateSave, { prId, state }), + ), convergenceStateDelete: async (prId: string): Promise => - ipcRenderer.invoke(IPC.prsConvergenceStateDelete, { prId }), + callProjectRuntimeActionOr( + "issue_inventory", + "resetConvergenceRuntime", + { arg: prId }, + () => ipcRenderer.invoke(IPC.prsConvergenceStateDelete, { prId }), + ), pathToMergeStart: async (args: { prId: string; modelId?: string | null; @@ -3206,65 +7149,145 @@ contextBridge.exposeInMainWorld("ade", { scope?: "checks" | "comments" | "both"; additionalInstructions?: string | null; }): Promise => - ipcRenderer.invoke(IPC.prsPathToMergeStart, args), + callProjectRuntimeActionOr( + "path_to_merge", + "startPathToMerge", + { args }, + () => ipcRenderer.invoke(IPC.prsPathToMergeStart, args), + ), pathToMergeStop: async (args: { prId: string; reason?: string | null; }): Promise => - ipcRenderer.invoke(IPC.prsPathToMergeStop, args), + callProjectRuntimeActionOr( + "path_to_merge", + "stopPathToMerge", + { args }, + () => ipcRenderer.invoke(IPC.prsPathToMergeStop, args), + ), pipelineSettingsGet: async (prId: string): Promise => - ipcRenderer.invoke(IPC.prsPipelineSettingsGet, { prId }), + callProjectRuntimeActionOr( + "issue_inventory", + "getPipelineSettings", + { arg: prId }, + () => ipcRenderer.invoke(IPC.prsPipelineSettingsGet, { prId }), + ), pipelineSettingsSave: async ( prId: string, settings: Partial, ): Promise => - ipcRenderer.invoke(IPC.prsPipelineSettingsSave, { prId, settings }), + callProjectRuntimeActionOr( + "issue_inventory", + "savePipelineSettings", + { argsList: [prId, settings] }, + () => + ipcRenderer.invoke(IPC.prsPipelineSettingsSave, { prId, settings }), + ), pipelineSettingsDelete: async (prId: string): Promise => - ipcRenderer.invoke(IPC.prsPipelineSettingsDelete, { prId }), + callProjectRuntimeActionOr( + "issue_inventory", + "deletePipelineSettings", + { arg: prId }, + () => ipcRenderer.invoke(IPC.prsPipelineSettingsDelete, { prId }), + ), dismissIntegrationCleanup: async ( args: DismissIntegrationCleanupArgs, ): Promise => - ipcRenderer.invoke(IPC.prsDismissIntegrationCleanup, args), + callProjectRuntimeActionOr( + "pr", + "dismissIntegrationCleanup", + { args }, + () => ipcRenderer.invoke(IPC.prsDismissIntegrationCleanup, args), + ), cleanupIntegrationWorkflow: async ( args: CleanupIntegrationWorkflowArgs, ): Promise => - ipcRenderer.invoke(IPC.prsCleanupIntegrationWorkflow, args), + callProjectRuntimeActionOr( + "pr", + "cleanupIntegrationWorkflow", + { args }, + () => ipcRenderer.invoke(IPC.prsCleanupIntegrationWorkflow, args), + ), getDeployments: async (prId: string): Promise => - ipcRenderer.invoke(IPC.prsGetDeployments, { prId }), + callProjectRuntimeActionOr("pr", "getDeployments", { arg: prId }, () => + ipcRenderer.invoke(IPC.prsGetDeployments, { prId }), + ), getAiSummary: async (prId: string): Promise => - ipcRenderer.invoke(IPC.prsGetAiSummary, { prId }), + callProjectRuntimeActionOr("pr", "getAiSummary", { arg: prId }, () => + ipcRenderer.invoke(IPC.prsGetAiSummary, { prId }), + ), regenerateAiSummary: async (prId: string): Promise => - ipcRenderer.invoke(IPC.prsRegenerateAiSummary, { prId }), + callProjectRuntimeActionOr( + "pr", + "regenerateAiSummary", + { arg: prId }, + () => ipcRenderer.invoke(IPC.prsRegenerateAiSummary, { prId }), + ), postReviewComment: async ( args: PostPrReviewCommentArgs, ): Promise => - ipcRenderer.invoke(IPC.prsPostReviewComment, args), + callProjectRuntimeActionOr("pr", "postReviewComment", { args }, () => + ipcRenderer.invoke(IPC.prsPostReviewComment, args), + ), setReviewThreadResolved: async ( args: SetPrReviewThreadResolvedArgs, ): Promise => - ipcRenderer.invoke(IPC.prsSetReviewThreadResolved, args), + callProjectRuntimeActionOr( + "pr", + "setReviewThreadResolved", + { args }, + () => ipcRenderer.invoke(IPC.prsSetReviewThreadResolved, args), + ), reactToComment: async (args: ReactToPrCommentArgs): Promise => - ipcRenderer.invoke(IPC.prsReactToComment, args), + callProjectRuntimeActionOr("pr", "reactToComment", { args }, () => + ipcRenderer.invoke(IPC.prsReactToComment, args), + ), launchIssueResolutionFromThread: async ( args: LaunchPrIssueResolutionFromThreadArgs, ): Promise => - ipcRenderer.invoke(IPC.prsLaunchIssueResolutionFromThread, args), + callProjectRuntimeActionOr( + "pr", + "launchIssueResolutionFromThread", + { args }, + () => ipcRenderer.invoke(IPC.prsLaunchIssueResolutionFromThread, args), + ), cleanupBranch: async ( args: CleanupPrBranchArgs, ): Promise => - ipcRenderer.invoke(IPC.prsCleanupBranch, args), + callProjectRuntimeActionOr("pr", "cleanupBranch", { args }, () => + ipcRenderer.invoke(IPC.prsCleanupBranch, args), + ), }, rebase: { scanNeeds: async (): Promise => - ipcRenderer.invoke(IPC.rebaseScanNeeds), + callProjectRuntimeActionOr("conflicts", "scanRebaseNeeds", {}, () => + ipcRenderer.invoke(IPC.rebaseScanNeeds), + ), getNeed: async (laneId: string): Promise => - ipcRenderer.invoke(IPC.rebaseGetNeed, { laneId }), + callProjectRuntimeActionOr( + "conflicts", + "getRebaseNeed", + { arg: laneId }, + () => ipcRenderer.invoke(IPC.rebaseGetNeed, { laneId }), + ), dismiss: async (laneId: string): Promise => - ipcRenderer.invoke(IPC.rebaseDismiss, { laneId }), + callProjectRuntimeActionOr( + "conflicts", + "dismissRebase", + { arg: laneId }, + () => ipcRenderer.invoke(IPC.rebaseDismiss, { laneId }), + ).then(() => undefined), defer: async (laneId: string, until: string): Promise => - ipcRenderer.invoke(IPC.rebaseDefer, { laneId, until }), + callProjectRuntimeActionOr( + "conflicts", + "deferRebase", + { argsList: [laneId, until] }, + () => ipcRenderer.invoke(IPC.rebaseDefer, { laneId, until }), + ).then(() => undefined), execute: async (args: RebaseLaneArgs): Promise => - ipcRenderer.invoke(IPC.rebaseExecute, args), + callProjectRuntimeActionOr("conflicts", "rebaseLane", { args }, () => + ipcRenderer.invoke(IPC.rebaseExecute, args), + ), onEvent: (cb: (ev: RebaseEventPayload) => void) => { const listener = ( _event: Electron.IpcRendererEvent, @@ -3278,103 +7301,346 @@ contextBridge.exposeInMainWorld("ade", { listOperations: async ( args: ListOperationsArgs = {}, ): Promise => - ipcRenderer.invoke(IPC.historyListOperations, args), + callProjectRuntimeActionOr("operation", "list", { args }, () => + ipcRenderer.invoke(IPC.historyListOperations, args), + ), exportOperations: async ( args: ExportHistoryArgs, - ): Promise => - ipcRenderer.invoke(IPC.historyExportOperations, args), + ): Promise => { + const listArgs: ListOperationsArgs = { + ...(typeof args?.laneId === "string" ? { laneId: args.laneId } : {}), + ...(typeof args?.kind === "string" ? { kind: args.kind } : {}), + limit: typeof args?.limit === "number" ? args.limit : 1000, + }; + const runtime = await callProjectRuntimeActionIfBound( + "operation", + "list", + { args: listArgs }, + ); + if (!runtime.handled) { + return ipcRenderer.invoke(IPC.historyExportOperations, args); + } + const binding = await getProjectRuntimeBinding(); + return ipcRenderer.invoke(IPC.historyExportOperations, { + ...args, + rows: runtime.result, + project: binding + ? { + rootPath: binding.rootPath, + displayName: binding.displayName, + } + : null, + }); + }, }, layout: { get: async (layoutId: string): Promise => - ipcRenderer.invoke(IPC.layoutGet, { layoutId }), + callProjectRuntimeActionOr("layout", "get", { args: { layoutId } }, () => + ipcRenderer.invoke(IPC.layoutGet, { layoutId }), + ), set: async (layoutId: string, layout: DockLayout): Promise => - ipcRenderer.invoke(IPC.layoutSet, { layoutId, layout }), + callProjectRuntimeActionOr( + "layout", + "set", + { args: { layoutId, layout } }, + () => ipcRenderer.invoke(IPC.layoutSet, { layoutId, layout }), + ).then(() => undefined), }, tilingTree: { get: async (layoutId: string): Promise => - ipcRenderer.invoke(IPC.tilingTreeGet, { layoutId }), + callProjectRuntimeActionOr( + "tiling_tree", + "get", + { args: { layoutId } }, + () => ipcRenderer.invoke(IPC.tilingTreeGet, { layoutId }), + ), set: async (layoutId: string, tree: unknown): Promise => - ipcRenderer.invoke(IPC.tilingTreeSet, { layoutId, tree }), + callProjectRuntimeActionOr( + "tiling_tree", + "set", + { args: { layoutId, tree } }, + () => ipcRenderer.invoke(IPC.tilingTreeSet, { layoutId, tree }), + ).then(() => undefined), }, graphState: { get: async (projectId: string): Promise => - ipcRenderer.invoke(IPC.graphStateGet, { projectId }), + callProjectRuntimeActionOr("graph_state", "get", {}, () => + ipcRenderer.invoke(IPC.graphStateGet, { projectId }), + ), set: async (projectId: string, state: GraphPersistedState): Promise => - ipcRenderer.invoke(IPC.graphStateSet, { projectId, state }), + callProjectRuntimeActionOr( + "graph_state", + "set", + { args: { state } }, + () => ipcRenderer.invoke(IPC.graphStateSet, { projectId, state }), + ).then(() => undefined), }, processes: { - listDefinitions: async (): Promise => - ipcRenderer.invoke(IPC.processesListDefinitions), - listRuntime: async (laneId: string): Promise => - ipcRenderer.invoke(IPC.processesListRuntime, { laneId }), - start: async (args: ProcessActionArgs): Promise => - ipcRenderer.invoke(IPC.processesStart, args), - stop: async (args: ProcessActionArgs): Promise => - ipcRenderer.invoke(IPC.processesStop, args), - restart: async (args: ProcessActionArgs): Promise => - ipcRenderer.invoke(IPC.processesRestart, args), - kill: async (args: ProcessActionArgs): Promise => - ipcRenderer.invoke(IPC.processesKill, args), - startStack: async (args: ProcessStackArgs): Promise => - ipcRenderer.invoke(IPC.processesStartStack, args), - stopStack: async (args: ProcessStackArgs): Promise => - ipcRenderer.invoke(IPC.processesStopStack, args), - restartStack: async (args: ProcessStackArgs): Promise => - ipcRenderer.invoke(IPC.processesRestartStack, args), - startGroup: async (args: ProcessGroupArgs): Promise => - ipcRenderer.invoke(IPC.processesStartGroup, args), - stopGroup: async (args: ProcessGroupArgs): Promise => - ipcRenderer.invoke(IPC.processesStopGroup, args), - restartGroup: async (args: ProcessGroupArgs): Promise => - ipcRenderer.invoke(IPC.processesRestartGroup, args), - startAll: async (args: { laneId: string }): Promise => - ipcRenderer.invoke(IPC.processesStartAll, args), - stopAll: async (args: { laneId: string }): Promise => - ipcRenderer.invoke(IPC.processesStopAll, args), - getLogTail: async (args: GetProcessLogTailArgs): Promise => - ipcRenderer.invoke(IPC.processesGetLogTail, args), + listDefinitions: async (): Promise => { + const runtime = await callProjectRuntimeActionIfBound< + ProcessDefinition[] + >("process", "listDefinitions"); + return runtime.handled + ? runtime.result + : ipcRenderer.invoke(IPC.processesListDefinitions); + }, + listRuntime: async (laneId: string): Promise => { + const runtime = await callProjectRuntimeActionIfBound( + "process", + "listRuntime", + { arg: laneId }, + ); + return runtime.handled + ? runtime.result + : ipcRenderer.invoke(IPC.processesListRuntime, { laneId }); + }, + start: async (args: ProcessActionArgs): Promise => { + const runtime = await callProjectRuntimeActionIfBound( + "process", + "start", + { args }, + ); + return runtime.handled + ? runtime.result + : ipcRenderer.invoke(IPC.processesStart, args); + }, + stop: async (args: ProcessActionArgs): Promise => { + const runtime = + await callProjectRuntimeActionIfBound( + "process", + "stop", + { args }, + ); + return runtime.handled + ? runtime.result + : ipcRenderer.invoke(IPC.processesStop, args); + }, + restart: async (args: ProcessActionArgs): Promise => { + const runtime = await callProjectRuntimeActionIfBound( + "process", + "restart", + { args }, + ); + return runtime.handled + ? runtime.result + : ipcRenderer.invoke(IPC.processesRestart, args); + }, + kill: async (args: ProcessActionArgs): Promise => { + const runtime = + await callProjectRuntimeActionIfBound( + "process", + "kill", + { args }, + ); + return runtime.handled + ? runtime.result + : ipcRenderer.invoke(IPC.processesKill, args); + }, + startStack: async (args: ProcessStackArgs): Promise => { + const runtime = await callProjectRuntimeActionIfBound( + "process", + "startStack", + { args }, + ); + return runtime.handled + ? runtime.result + : ipcRenderer.invoke(IPC.processesStartStack, args); + }, + stopStack: async (args: ProcessStackArgs): Promise => { + const runtime = await callProjectRuntimeActionIfBound( + "process", + "stopStack", + { args }, + ); + return runtime.handled + ? runtime.result + : ipcRenderer.invoke(IPC.processesStopStack, args); + }, + restartStack: async (args: ProcessStackArgs): Promise => { + const runtime = await callProjectRuntimeActionIfBound( + "process", + "restartStack", + { args }, + ); + return runtime.handled + ? runtime.result + : ipcRenderer.invoke(IPC.processesRestartStack, args); + }, + startGroup: async (args: ProcessGroupArgs): Promise => { + const runtime = await callProjectRuntimeActionIfBound( + "process", + "startGroup", + { args }, + ); + return runtime.handled + ? runtime.result + : ipcRenderer.invoke(IPC.processesStartGroup, args); + }, + stopGroup: async (args: ProcessGroupArgs): Promise => { + const runtime = await callProjectRuntimeActionIfBound( + "process", + "stopGroup", + { args }, + ); + return runtime.handled + ? runtime.result + : ipcRenderer.invoke(IPC.processesStopGroup, args); + }, + restartGroup: async (args: ProcessGroupArgs): Promise => { + const runtime = await callProjectRuntimeActionIfBound( + "process", + "restartGroup", + { args }, + ); + return runtime.handled + ? runtime.result + : ipcRenderer.invoke(IPC.processesRestartGroup, args); + }, + startAll: async (args: { laneId: string }): Promise => { + const runtime = await callProjectRuntimeActionIfBound( + "process", + "startAll", + { args }, + ); + return runtime.handled + ? runtime.result + : ipcRenderer.invoke(IPC.processesStartAll, args); + }, + stopAll: async (args: { laneId: string }): Promise => { + const runtime = await callProjectRuntimeActionIfBound( + "process", + "stopAll", + { args }, + ); + return runtime.handled + ? runtime.result + : ipcRenderer.invoke(IPC.processesStopAll, args); + }, + getLogTail: async (args: GetProcessLogTailArgs): Promise => { + const runtime = await callProjectRuntimeActionIfBound( + "process", + "getLogTail", + { args }, + ); + return runtime.handled + ? runtime.result + : ipcRenderer.invoke(IPC.processesGetLogTail, args); + }, onEvent: (cb: (ev: ProcessEvent) => void) => { const listener = ( _event: Electron.IpcRendererEvent, payload: ProcessEvent, ) => cb(payload); ipcRenderer.on(IPC.processesEvent, listener); - return () => ipcRenderer.removeListener(IPC.processesEvent, listener); + const unsubscribeRemote = subscribeRemoteProcessEvents(cb); + return () => { + unsubscribeRemote(); + ipcRenderer.removeListener(IPC.processesEvent, listener); + }; }, }, tests: { - listSuites: async (): Promise => - ipcRenderer.invoke(IPC.testsListSuites), - run: async (args: RunTestSuiteArgs): Promise => - ipcRenderer.invoke(IPC.testsRun, args), - stop: async (args: StopTestRunArgs): Promise => - ipcRenderer.invoke(IPC.testsStop, args), - listRuns: async (args: ListTestRunsArgs = {}): Promise => - ipcRenderer.invoke(IPC.testsListRuns, args), - getLogTail: async (args: GetTestLogTailArgs): Promise => - ipcRenderer.invoke(IPC.testsGetLogTail, args), + listSuites: async (): Promise => { + const runtime = await callProjectRuntimeActionIfBound< + TestSuiteDefinition[] + >("tests", "listSuites"); + return runtime.handled + ? runtime.result + : ipcRenderer.invoke(IPC.testsListSuites); + }, + run: async (args: RunTestSuiteArgs): Promise => { + const runtime = await callProjectRuntimeActionIfBound( + "tests", + "run", + { args }, + ); + return runtime.handled + ? runtime.result + : ipcRenderer.invoke(IPC.testsRun, args); + }, + stop: async (args: StopTestRunArgs): Promise => { + const runtime = await callProjectRuntimeActionIfBound( + "tests", + "stop", + { args }, + ); + return runtime.handled + ? runtime.result + : ipcRenderer.invoke(IPC.testsStop, args); + }, + listRuns: async ( + args: ListTestRunsArgs = {}, + ): Promise => { + const runtime = await callProjectRuntimeActionIfBound( + "tests", + "listRuns", + { args }, + ); + return runtime.handled + ? runtime.result + : ipcRenderer.invoke(IPC.testsListRuns, args); + }, + getLogTail: async (args: GetTestLogTailArgs): Promise => { + const runtime = await callProjectRuntimeActionIfBound( + "tests", + "getLogTail", + { args }, + ); + return runtime.handled + ? runtime.result + : ipcRenderer.invoke(IPC.testsGetLogTail, args); + }, onEvent: (cb: (ev: TestEvent) => void) => { const listener = ( _event: Electron.IpcRendererEvent, payload: TestEvent, ) => cb(payload); ipcRenderer.on(IPC.testsEvent, listener); - return () => ipcRenderer.removeListener(IPC.testsEvent, listener); + const unsubscribeRemote = subscribeRemoteTestEvents(cb); + return () => { + unsubscribeRemote(); + ipcRenderer.removeListener(IPC.testsEvent, listener); + }; }, }, projectConfig: { - get: async (): Promise => - projectConfigSnapshotCache.get(), + get: async (): Promise => { + const runtime = + await callProjectRuntimeActionIfBound( + "project_config", + "get", + ); + return runtime.handled + ? runtime.result + : projectConfigSnapshotCache.get(); + }, validate: async ( candidate: ProjectConfigCandidate, - ): Promise => - ipcRenderer.invoke(IPC.projectConfigValidate, { candidate }), + ): Promise => { + const runtime = + await callProjectRuntimeActionIfBound( + "project_config", + "validate", + { args: candidate }, + ); + return runtime.handled + ? runtime.result + : ipcRenderer.invoke(IPC.projectConfigValidate, { candidate }); + }, save: async ( candidate: ProjectConfigCandidate, ): Promise => { projectConfigSnapshotCache.clear(); try { - const snapshot = await ipcRenderer.invoke(IPC.projectConfigSave, { candidate }); + const runtime = + await callProjectRuntimeActionIfBound( + "project_config", + "save", + { args: candidate }, + ); + const snapshot = runtime.handled + ? runtime.result + : await ipcRenderer.invoke(IPC.projectConfigSave, { candidate }); projectConfigSnapshotCache.clear(); return snapshot; } catch (error) { @@ -3382,14 +7648,29 @@ contextBridge.exposeInMainWorld("ade", { throw error; } }, - diffAgainstDisk: async (): Promise => - ipcRenderer.invoke(IPC.projectConfigDiffAgainstDisk), + diffAgainstDisk: async (): Promise => { + const runtime = await callProjectRuntimeActionIfBound( + "project_config", + "diffAgainstDisk", + ); + return runtime.handled + ? runtime.result + : ipcRenderer.invoke(IPC.projectConfigDiffAgainstDisk); + }, confirmTrust: async ( arg: { sharedHash?: string } = {}, ): Promise => { projectConfigSnapshotCache.clear(); try { - return await ipcRenderer.invoke(IPC.projectConfigConfirmTrust, arg); + const runtime = + await callProjectRuntimeActionIfBound( + "project_config", + "confirmTrust", + { args: arg }, + ); + return runtime.handled + ? runtime.result + : ipcRenderer.invoke(IPC.projectConfigConfirmTrust, arg); } finally { projectConfigSnapshotCache.clear(); } @@ -3415,11 +7696,21 @@ contextBridge.exposeInMainWorld("ade", { content: string; importance?: "low" | "medium" | "high"; sourceRunId?: string; - }): Promise => ipcRenderer.invoke(IPC.memoryAdd, args), + }): Promise => + callProjectRuntimeActionOr("memory", "add", { args }, () => + ipcRenderer.invoke(IPC.memoryAdd, args), + ), pin: async (args: { id: string }): Promise => - ipcRenderer.invoke(IPC.memoryPin, args), + callProjectRuntimeActionOr("memory", "pin", { args }, () => + ipcRenderer.invoke(IPC.memoryPin, args), + ), updateCore: async (args: CtoUpdateCoreMemoryArgs): Promise => - ipcRenderer.invoke(IPC.memoryUpdateCore, args), + callProjectRuntimeActionOr( + "cto_state", + "updateCoreMemory", + { args: args.patch ?? {} }, + () => ipcRenderer.invoke(IPC.memoryUpdateCore, args), + ), getBudget: async ( args: { projectId?: string; @@ -3427,19 +7718,34 @@ contextBridge.exposeInMainWorld("ade", { scope?: "user" | "project" | "lane" | "mission" | "agent"; scopeOwnerId?: string; } = {}, - ): Promise => ipcRenderer.invoke(IPC.memoryGetBudget, args), + ): Promise => + callProjectRuntimeActionOr("memory", "getBudget", { args }, () => + ipcRenderer.invoke(IPC.memoryGetBudget, args), + ), getCandidates: async ( args: { projectId?: string; limit?: number } = {}, - ): Promise => ipcRenderer.invoke(IPC.memoryGetCandidates, args), + ): Promise => + callProjectRuntimeActionOr("memory", "getCandidates", { args }, () => + ipcRenderer.invoke(IPC.memoryGetCandidates, args), + ), promote: async (args: { id: string }): Promise => - ipcRenderer.invoke(IPC.memoryPromote, args), + callProjectRuntimeActionOr("memory", "promote", { args }, () => + ipcRenderer.invoke(IPC.memoryPromote, args), + ), promoteMissionEntry: async (args: { id: string; missionId: string; }): Promise => - ipcRenderer.invoke(IPC.memoryPromoteMissionEntry, args), + callProjectRuntimeActionOr( + "memory", + "promoteMissionEntry", + { args }, + () => ipcRenderer.invoke(IPC.memoryPromoteMissionEntry, args), + ), archive: async (args: { id: string }): Promise => - ipcRenderer.invoke(IPC.memoryArchive, args), + callProjectRuntimeActionOr("memory", "archive", { args }, () => + ipcRenderer.invoke(IPC.memoryArchive, args), + ), search: async (args: { query: string; projectId?: string; @@ -3448,7 +7754,10 @@ contextBridge.exposeInMainWorld("ade", { limit?: number; mode?: "lexical" | "hybrid"; status?: "promoted" | "candidate" | "archived" | "all"; - }): Promise => ipcRenderer.invoke(IPC.memorySearch, args), + }): Promise => + callProjectRuntimeActionOr("memory", "search", { args }, () => + ipcRenderer.invoke(IPC.memorySearch, args), + ), list: async ( args: { scope?: "project" | "agent" | "mission"; @@ -3456,13 +7765,18 @@ contextBridge.exposeInMainWorld("ade", { status?: "promoted" | "candidate" | "archived" | "all"; limit?: number; } = {}, - ): Promise => ipcRenderer.invoke(IPC.memoryList, args), + ): Promise => + callProjectRuntimeActionOr("memory", "list", { args }, () => + ipcRenderer.invoke(IPC.memoryList, args), + ), listMissionEntries: async (args: { missionId: string; runId?: string | null; status?: "promoted" | "candidate" | "archived" | "all"; }): Promise => - ipcRenderer.invoke(IPC.memoryListMissionEntries, args), + callProjectRuntimeActionOr("memory", "listMissionEntries", { args }, () => + ipcRenderer.invoke(IPC.memoryListMissionEntries, args), + ), listProcedures: async ( args: { status?: "promoted" | "candidate" | "archived" | "all"; @@ -3470,32 +7784,55 @@ contextBridge.exposeInMainWorld("ade", { query?: string; } = {}, ): Promise => - ipcRenderer.invoke(IPC.memoryListProcedures, args), + callProjectRuntimeActionOr("memory", "listProcedures", { args }, () => + ipcRenderer.invoke(IPC.memoryListProcedures, args), + ), getProcedureDetail: async (args: { id: string; }): Promise => - ipcRenderer.invoke(IPC.memoryGetProcedureDetail, args), + callProjectRuntimeActionOr("memory", "getProcedureDetail", { args }, () => + ipcRenderer.invoke(IPC.memoryGetProcedureDetail, args), + ), exportProcedureSkill: async (args: { id: string; name?: string; }): Promise<{ path: string; skill: SkillIndexEntry | null } | null> => - ipcRenderer.invoke(IPC.memoryExportProcedureSkill, args), + callProjectRuntimeActionOr( + "memory", + "exportProcedureSkill", + { args }, + () => ipcRenderer.invoke(IPC.memoryExportProcedureSkill, args), + ), listIndexedSkills: async (): Promise => - ipcRenderer.invoke(IPC.memoryListIndexedSkills), + callProjectRuntimeActionOr("memory", "listIndexedSkills", {}, () => + ipcRenderer.invoke(IPC.memoryListIndexedSkills), + ), reindexSkills: async ( args: { paths?: string[] } = {}, ): Promise => - ipcRenderer.invoke(IPC.memoryReindexSkills, args), + callProjectRuntimeActionOr("memory", "reindexSkills", { args }, () => + ipcRenderer.invoke(IPC.memoryReindexSkills, args), + ), syncKnowledge: async (): Promise => - ipcRenderer.invoke(IPC.memorySyncKnowledge), + callProjectRuntimeActionOr("memory", "syncKnowledge", {}, () => + ipcRenderer.invoke(IPC.memorySyncKnowledge), + ), getKnowledgeSyncStatus: async (): Promise => - ipcRenderer.invoke(IPC.memoryGetKnowledgeSyncStatus), + callProjectRuntimeActionOr("memory", "getKnowledgeSyncStatus", {}, () => + ipcRenderer.invoke(IPC.memoryGetKnowledgeSyncStatus), + ), getHealthStats: async (): Promise => - ipcRenderer.invoke(IPC.memoryHealthStats), + callProjectRuntimeActionOr("memory", "getHealthStats", {}, () => + ipcRenderer.invoke(IPC.memoryHealthStats), + ), downloadEmbeddingModel: async (): Promise => - ipcRenderer.invoke(IPC.memoryDownloadEmbeddingModel), + callProjectRuntimeActionOr("memory", "downloadEmbeddingModel", {}, () => + ipcRenderer.invoke(IPC.memoryDownloadEmbeddingModel), + ), runSweep: async (): Promise => - ipcRenderer.invoke(IPC.memoryRunSweep), + callProjectRuntimeActionOr("memory", "runSweep", {}, () => + ipcRenderer.invoke(IPC.memoryRunSweep), + ), onSweepStatus: (cb: (payload: MemorySweepStatusEventPayload) => void) => { const listener = ( _event: Electron.IpcRendererEvent, @@ -3505,7 +7842,9 @@ contextBridge.exposeInMainWorld("ade", { return () => ipcRenderer.removeListener(IPC.memorySweepStatus, listener); }, runConsolidation: async (): Promise => - ipcRenderer.invoke(IPC.memoryRunConsolidation), + callProjectRuntimeActionOr("memory", "runConsolidation", {}, () => + ipcRenderer.invoke(IPC.memoryRunConsolidation), + ), onConsolidationStatus: ( cb: (payload: MemoryConsolidationStatusEventPayload) => void, ) => { @@ -3520,145 +7859,292 @@ contextBridge.exposeInMainWorld("ade", { }, cto: { getState: async (args: CtoGetStateArgs = {}): Promise => - ipcRenderer.invoke(IPC.ctoGetState, args), + callProjectRuntimeActionOr( + "cto_state", + "getSnapshot", + { arg: args.recentLimit ?? 20 }, + () => ipcRenderer.invoke(IPC.ctoGetState, args), + ), ensureSession: async ( args: CtoEnsureSessionArgs = {}, ): Promise => - ipcRenderer.invoke(IPC.ctoEnsureSession, args), + callProjectRuntimeActionOr("chat", "ensureCtoSession", { args }, () => + ipcRenderer.invoke(IPC.ctoEnsureSession, args), + ), updateCoreMemory: async ( args: CtoUpdateCoreMemoryArgs, ): Promise => - ipcRenderer.invoke(IPC.ctoUpdateCoreMemory, args), + callProjectRuntimeActionOr( + "cto_state", + "updateCoreMemory", + { arg: args.patch ?? {} }, + () => ipcRenderer.invoke(IPC.ctoUpdateCoreMemory, args), + ), listSessionLogs: async ( args: CtoListSessionLogsArgs = {}, ): Promise => - ipcRenderer.invoke(IPC.ctoListSessionLogs, args), + callProjectRuntimeActionOr( + "cto_state", + "getSessionLogs", + { arg: args.limit ?? 40 }, + () => ipcRenderer.invoke(IPC.ctoListSessionLogs, args), + ), updateIdentity: async (args: CtoUpdateIdentityArgs): Promise => - ipcRenderer.invoke(IPC.ctoUpdateIdentity, args), - getOpenclawState: async (): Promise => - ipcRenderer.invoke(IPC.ctoGetOpenclawState), - updateOpenclawConfig: async ( - args: CtoUpdateOpenclawConfigArgs, - ): Promise => - ipcRenderer.invoke(IPC.ctoUpdateOpenclawConfig, args), - testOpenclawConnection: async ( - args: CtoTestOpenclawConnectionArgs = {}, - ): Promise => - ipcRenderer.invoke(IPC.ctoTestOpenclawConnection, args), - listOpenclawMessages: async ( - args: CtoListOpenclawMessagesArgs = {}, - ): Promise => - ipcRenderer.invoke(IPC.ctoListOpenclawMessages, args), - sendOpenclawMessage: async ( - args: CtoSendOpenclawMessageArgs, - ): Promise => - ipcRenderer.invoke(IPC.ctoSendOpenclawMessage, args), - onOpenclawConnectionStatus: ( - cb: (status: OpenclawBridgeStatus) => void, - ) => { - const listener = ( - _event: Electron.IpcRendererEvent, - payload: OpenclawBridgeStatus, - ) => cb(payload); - ipcRenderer.on(IPC.openclawConnectionStatus, listener); - return () => - ipcRenderer.removeListener(IPC.openclawConnectionStatus, listener); - }, + callProjectRuntimeActionOr( + "cto_state", + "updateIdentity", + { arg: args.patch ?? {} }, + () => ipcRenderer.invoke(IPC.ctoUpdateIdentity, args), + ), listAgents: async ( args: CtoListAgentsArgs = {}, - ): Promise => ipcRenderer.invoke(IPC.ctoListAgents, args), + ): Promise => + callProjectRuntimeActionOr("worker_agent", "listAgents", { args }, () => + ipcRenderer.invoke(IPC.ctoListAgents, args), + ), saveAgent: async (args: CtoSaveAgentArgs): Promise => - ipcRenderer.invoke(IPC.ctoSaveAgent, args), + callProjectRuntimeActionOr("worker_agent", "saveAgent", { args }, () => + ipcRenderer.invoke(IPC.ctoSaveAgent, args), + ), removeAgent: async (args: CtoRemoveAgentArgs): Promise => - ipcRenderer.invoke(IPC.ctoRemoveAgent, args), + callProjectRuntimeActionOr("worker_agent", "removeAgent", { args }, () => + ipcRenderer.invoke(IPC.ctoRemoveAgent, args), + ), setAgentStatus: async (args: CtoSetAgentStatusArgs): Promise => - ipcRenderer.invoke(IPC.ctoSetAgentStatus, args), + callProjectRuntimeActionOr( + "worker_agent", + "setAgentStatus", + { args }, + () => ipcRenderer.invoke(IPC.ctoSetAgentStatus, args), + ), listAgentRevisions: async ( args: CtoListAgentRevisionsArgs, ): Promise => - ipcRenderer.invoke(IPC.ctoListAgentRevisions, args), + callProjectRuntimeActionOr( + "worker_agent", + "listAgentRevisions", + { args }, + () => ipcRenderer.invoke(IPC.ctoListAgentRevisions, args), + ), rollbackAgentRevision: async ( args: CtoRollbackAgentRevisionArgs, ): Promise => - ipcRenderer.invoke(IPC.ctoRollbackAgentRevision, args), + callProjectRuntimeActionOr( + "worker_agent", + "rollbackAgentRevision", + { args }, + () => ipcRenderer.invoke(IPC.ctoRollbackAgentRevision, args), + ), ensureAgentSession: async ( args: CtoEnsureAgentSessionArgs, ): Promise => - ipcRenderer.invoke(IPC.ctoEnsureAgentSession, args), + callProjectRuntimeActionOr( + "chat", + "ensureAgentIdentitySession", + { args }, + () => ipcRenderer.invoke(IPC.ctoEnsureAgentSession, args), + ), getBudgetSnapshot: async ( args: CtoGetBudgetSnapshotArgs = {}, ): Promise => - ipcRenderer.invoke(IPC.ctoGetBudgetSnapshot, args), + callProjectRuntimeActionOr( + "worker_agent", + "getBudgetSnapshot", + { args }, + () => ipcRenderer.invoke(IPC.ctoGetBudgetSnapshot, args), + ), triggerAgentWakeup: async ( args: CtoTriggerAgentWakeupArgs, ): Promise => - ipcRenderer.invoke(IPC.ctoTriggerAgentWakeup, args), + callProjectRuntimeActionOr( + "worker_agent", + "triggerWakeup", + { args }, + () => ipcRenderer.invoke(IPC.ctoTriggerAgentWakeup, args), + ), listAgentRuns: async ( args: CtoListAgentRunsArgs = {}, ): Promise => - ipcRenderer.invoke(IPC.ctoListAgentRuns, args), + callProjectRuntimeActionOr( + "worker_agent", + "listAgentRuns", + { args }, + () => ipcRenderer.invoke(IPC.ctoListAgentRuns, args), + ), getAgentCoreMemory: async ( args: CtoGetAgentCoreMemoryArgs, ): Promise => - ipcRenderer.invoke(IPC.ctoGetAgentCoreMemory, args), + callProjectRuntimeActionOr( + "worker_agent", + "getCoreMemory", + { arg: args.agentId }, + () => ipcRenderer.invoke(IPC.ctoGetAgentCoreMemory, args), + ), updateAgentCoreMemory: async ( args: CtoUpdateAgentCoreMemoryArgs, ): Promise => - ipcRenderer.invoke(IPC.ctoUpdateAgentCoreMemory, args), + callProjectRuntimeActionOr( + "worker_agent", + "updateCoreMemory", + { argsList: [args.agentId, args.patch ?? {}] }, + () => ipcRenderer.invoke(IPC.ctoUpdateAgentCoreMemory, args), + ), listAgentSessionLogs: async ( args: CtoListAgentSessionLogsArgs, ): Promise => - ipcRenderer.invoke(IPC.ctoListAgentSessionLogs, args), + callProjectRuntimeActionOr( + "worker_agent", + "listSessionLogs", + { argsList: [args.agentId, args.limit ?? 40] }, + () => ipcRenderer.invoke(IPC.ctoListAgentSessionLogs, args), + ), getLinearConnectionStatus: async (): Promise => - ipcRenderer.invoke(IPC.ctoGetLinearConnectionStatus), + callProjectRuntimeActionOr( + "linear_issue_tracker", + "getConnectionStatus", + {}, + () => ipcRenderer.invoke(IPC.ctoGetLinearConnectionStatus), + ), setLinearToken: async ( args: CtoSetLinearTokenArgs, - ): Promise => - ipcRenderer.invoke(IPC.ctoSetLinearToken, args), - clearLinearToken: async (): Promise => - ipcRenderer.invoke(IPC.ctoClearLinearToken), + ): Promise => { + const runtime = await callProjectRuntimeActionIfBound( + "linear_credentials", + "setToken", + { arg: args.token }, + ); + if (runtime.handled) { + return callProjectRuntimeActionOr( + "linear_issue_tracker", + "getConnectionStatus", + {}, + () => ipcRenderer.invoke(IPC.ctoSetLinearToken, args), + ); + } + return ipcRenderer.invoke(IPC.ctoSetLinearToken, args); + }, + clearLinearToken: async (): Promise => { + const runtime = await callProjectRuntimeActionIfBound( + "linear_credentials", + "clearToken", + {}, + ); + if (runtime.handled) { + return callProjectRuntimeActionOr( + "linear_issue_tracker", + "getConnectionStatus", + {}, + () => ipcRenderer.invoke(IPC.ctoClearLinearToken), + ); + } + return ipcRenderer.invoke(IPC.ctoClearLinearToken); + }, getFlowPolicy: async (): Promise => - ipcRenderer.invoke(IPC.ctoGetFlowPolicy), + callProjectRuntimeActionOr("flow_policy", "getPolicy", {}, () => + ipcRenderer.invoke(IPC.ctoGetFlowPolicy), + ), saveFlowPolicy: async ( args: CtoSaveFlowPolicyArgs, ): Promise => - ipcRenderer.invoke(IPC.ctoSaveFlowPolicy, args), + callProjectRuntimeActionOr( + "flow_policy", + "savePolicy", + { argsList: [args.policy, args.actor ?? "user"] }, + () => ipcRenderer.invoke(IPC.ctoSaveFlowPolicy, args), + ), listFlowPolicyRevisions: async (): Promise => - ipcRenderer.invoke(IPC.ctoListFlowPolicyRevisions), + callProjectRuntimeActionOr( + "flow_policy", + "listRevisions", + { arg: 50 }, + () => ipcRenderer.invoke(IPC.ctoListFlowPolicyRevisions), + ), rollbackFlowPolicyRevision: async ( args: CtoRollbackFlowPolicyRevisionArgs, ): Promise => - ipcRenderer.invoke(IPC.ctoRollbackFlowPolicyRevision, args), + callProjectRuntimeActionOr( + "flow_policy", + "rollbackRevision", + { argsList: [args.revisionId, args.actor ?? "user"] }, + () => ipcRenderer.invoke(IPC.ctoRollbackFlowPolicyRevision, args), + ), simulateFlowRoute: async ( args: CtoSimulateFlowRouteArgs, ): Promise => - ipcRenderer.invoke(IPC.ctoSimulateFlowRoute, args), + callProjectRuntimeActionOr( + "linear_routing", + "simulateRoute", + { args }, + () => ipcRenderer.invoke(IPC.ctoSimulateFlowRoute, args), + ), getLinearWorkflowCatalog: async (): Promise => - ipcRenderer.invoke(IPC.ctoGetLinearWorkflowCatalog), + callProjectRuntimeActionOr( + "linear_issue_tracker", + "getWorkflowCatalog", + {}, + () => ipcRenderer.invoke(IPC.ctoGetLinearWorkflowCatalog), + ), getLinearSyncDashboard: async (): Promise => - ipcRenderer.invoke(IPC.ctoGetLinearSyncDashboard), + callProjectRuntimeActionOr("linear_sync", "getDashboard", {}, () => + ipcRenderer.invoke(IPC.ctoGetLinearSyncDashboard), + ), runLinearSyncNow: async (): Promise => - ipcRenderer.invoke(IPC.ctoRunLinearSyncNow), + callProjectRuntimeActionOr("linear_sync", "runSyncNow", {}, () => + ipcRenderer.invoke(IPC.ctoRunLinearSyncNow), + ), listLinearSyncQueue: async (): Promise => - ipcRenderer.invoke(IPC.ctoListLinearSyncQueue), + callProjectRuntimeActionOr( + "linear_sync", + "listQueue", + { args: { limit: 300 } }, + () => ipcRenderer.invoke(IPC.ctoListLinearSyncQueue), + ), getLinearWorkflowRunDetail: async ( args: CtoGetLinearWorkflowRunDetailArgs, ): Promise => - ipcRenderer.invoke(IPC.ctoGetLinearWorkflowRunDetail, args), + callProjectRuntimeActionOr("linear_sync", "getRunDetail", { args }, () => + ipcRenderer.invoke(IPC.ctoGetLinearWorkflowRunDetail, args), + ), resolveLinearSyncQueueItem: async ( args: CtoResolveLinearSyncQueueItemArgs, ): Promise => - ipcRenderer.invoke(IPC.ctoResolveLinearSyncQueueItem, args), + callProjectRuntimeActionOr( + "linear_sync", + "resolveQueueItem", + { args }, + () => ipcRenderer.invoke(IPC.ctoResolveLinearSyncQueueItem, args), + ), getLinearIngressStatus: async (): Promise => - ipcRenderer.invoke(IPC.ctoGetLinearIngressStatus), + callProjectRuntimeActionOr("linear_ingress", "getStatus", {}, () => + ipcRenderer.invoke(IPC.ctoGetLinearIngressStatus), + ), listLinearIngressEvents: async ( args: CtoListLinearIngressEventsArgs = {}, ): Promise => - ipcRenderer.invoke(IPC.ctoListLinearIngressEvents, args), + callProjectRuntimeActionOr( + "linear_ingress", + "listRecentEvents", + { arg: args.limit ?? 20 }, + () => ipcRenderer.invoke(IPC.ctoListLinearIngressEvents, args), + ), ensureLinearWebhook: async ( args: CtoEnsureLinearWebhookArgs = {}, - ): Promise => - ipcRenderer.invoke(IPC.ctoEnsureLinearWebhook, args), + ): Promise => { + const runtime = await callProjectRuntimeActionIfBound( + "linear_ingress", + "ensureRelayWebhook", + { arg: args.force === true }, + ); + if (runtime.handled) { + return callProjectRuntimeActionOr( + "linear_ingress", + "getStatus", + {}, + () => ipcRenderer.invoke(IPC.ctoEnsureLinearWebhook, args), + ); + } + return ipcRenderer.invoke(IPC.ctoEnsureLinearWebhook, args); + }, onLinearWorkflowEvent: ( cb: (event: LinearWorkflowEventPayload) => void, ) => { @@ -3673,53 +8159,139 @@ contextBridge.exposeInMainWorld("ade", { listAgentTaskSessions: async ( args: CtoListAgentTaskSessionsArgs, ): Promise => - ipcRenderer.invoke(IPC.ctoListAgentTaskSessions, args), + callProjectRuntimeActionOr( + "worker_agent", + "listAgentTaskSessions", + { args }, + () => ipcRenderer.invoke(IPC.ctoListAgentTaskSessions, args), + ), clearAgentTaskSession: async ( args: CtoClearAgentTaskSessionArgs, - ): Promise => ipcRenderer.invoke(IPC.ctoClearAgentTaskSession, args), + ): Promise => + callProjectRuntimeActionOr( + "worker_agent", + "clearAgentTaskSession", + { args }, + () => ipcRenderer.invoke(IPC.ctoClearAgentTaskSession, args), + ), getOnboardingState: async (): Promise => - ipcRenderer.invoke(IPC.ctoGetOnboardingState), + callProjectRuntimeActionOr("cto_state", "getOnboardingState", {}, () => + ipcRenderer.invoke(IPC.ctoGetOnboardingState), + ), completeOnboardingStep: async (args: { stepId: string; }): Promise => - ipcRenderer.invoke(IPC.ctoCompleteOnboardingStep, args), + callProjectRuntimeActionOr( + "cto_state", + "completeOnboardingStep", + { arg: args.stepId }, + () => ipcRenderer.invoke(IPC.ctoCompleteOnboardingStep, args), + ), dismissOnboarding: async (): Promise => - ipcRenderer.invoke(IPC.ctoDismissOnboarding), + callProjectRuntimeActionOr("cto_state", "dismissOnboarding", {}, () => + ipcRenderer.invoke(IPC.ctoDismissOnboarding), + ), resetOnboarding: async (): Promise => - ipcRenderer.invoke(IPC.ctoResetOnboarding), + callProjectRuntimeActionOr("cto_state", "resetOnboarding", {}, () => + ipcRenderer.invoke(IPC.ctoResetOnboarding), + ), previewSystemPrompt: async ( args: { identityOverride?: Record } = {}, ): Promise => - ipcRenderer.invoke(IPC.ctoPreviewSystemPrompt, args), + callProjectRuntimeActionOr( + "cto_state", + "previewSystemPrompt", + { arg: args.identityOverride }, + () => ipcRenderer.invoke(IPC.ctoPreviewSystemPrompt, args), + ), getLinearProjects: async (): Promise => - ipcRenderer.invoke(IPC.ctoGetLinearProjects), + callProjectRuntimeActionOr( + "linear_issue_tracker", + "listProjects", + {}, + () => ipcRenderer.invoke(IPC.ctoGetLinearProjects), + ), getLinearQuickView: async (): Promise => - ipcRenderer.invoke(IPC.ctoGetLinearQuickView), - getLinearIssuePickerData: async (): Promise => - ipcRenderer.invoke(IPC.ctoGetLinearIssuePickerData), + callProjectRuntimeActionOr( + "linear_issue_tracker", + "getQuickView", + {}, + () => ipcRenderer.invoke(IPC.ctoGetLinearQuickView), + ), + getLinearIssuePickerData: + async (): Promise => + callProjectRuntimeActionOr( + "linear_issue_tracker", + "getIssuePickerData", + {}, + () => ipcRenderer.invoke(IPC.ctoGetLinearIssuePickerData), + ), searchLinearIssues: async ( args: CtoSearchLinearIssuesArgs = {}, ): Promise => - ipcRenderer.invoke(IPC.ctoSearchLinearIssues, args), + callProjectRuntimeActionOr( + "linear_issue_tracker", + "searchIssues", + { args }, + () => ipcRenderer.invoke(IPC.ctoSearchLinearIssues, args), + ), setLinearOAuthClient: async ( args: CtoSetLinearOAuthClientArgs, - ): Promise => - ipcRenderer.invoke(IPC.ctoSetLinearOAuthClient, args), - clearLinearOAuthClient: async (): Promise => - ipcRenderer.invoke(IPC.ctoClearLinearOAuthClient), + ): Promise => { + const runtime = await callProjectRuntimeActionIfBound( + "linear_credentials", + "setOAuthClientCredentials", + { args }, + ); + if (runtime.handled) { + return callProjectRuntimeActionOr( + "linear_issue_tracker", + "getConnectionStatus", + {}, + () => ipcRenderer.invoke(IPC.ctoSetLinearOAuthClient, args), + ); + } + return ipcRenderer.invoke(IPC.ctoSetLinearOAuthClient, args); + }, + clearLinearOAuthClient: async (): Promise => { + const runtime = await callProjectRuntimeActionIfBound( + "linear_credentials", + "clearOAuthClientCredentials", + {}, + ); + if (runtime.handled) { + return callProjectRuntimeActionOr( + "linear_issue_tracker", + "getConnectionStatus", + {}, + () => ipcRenderer.invoke(IPC.ctoClearLinearOAuthClient), + ); + } + return ipcRenderer.invoke(IPC.ctoClearLinearOAuthClient); + }, startLinearOAuth: async (): Promise => - ipcRenderer.invoke(IPC.ctoStartLinearOAuth), + callProjectRuntimeActionOr("linear_oauth", "startSession", {}, () => + ipcRenderer.invoke(IPC.ctoStartLinearOAuth), + ), getLinearOAuthSession: async ( args: CtoGetLinearOAuthSessionArgs, ): Promise => - ipcRenderer.invoke(IPC.ctoGetLinearOAuthSession, args), + callProjectRuntimeActionOr( + "linear_oauth", + "getSession", + { arg: args.sessionId }, + () => ipcRenderer.invoke(IPC.ctoGetLinearOAuthSession, args), + ), runProjectScan: async (): Promise => - ipcRenderer.invoke(IPC.ctoRunProjectScan), + callProjectRuntimeActionOr("cto_state", "runProjectScan", {}, () => + ipcRenderer.invoke(IPC.ctoRunProjectScan), + ), }, updateCheckForUpdates: () => ipcRenderer.invoke(IPC.updateCheckForUpdates), updateGetState: (): Promise => ipcRenderer.invoke(IPC.updateGetState), - updateQuitAndInstall: (): Promise => ipcRenderer.invoke(IPC.updateQuitAndInstall), + updateQuitAndInstall: (): Promise => + ipcRenderer.invoke(IPC.updateQuitAndInstall), updateDismissInstalledNotice: () => ipcRenderer.invoke(IPC.updateDismissInstalledNotice), onUpdateEvent: (cb: (snapshot: AutoUpdateSnapshot) => void) => { diff --git a/apps/desktop/src/renderer/browserMock.ts b/apps/desktop/src/renderer/browserMock.ts index 6d9e8e5bb..2125915a3 100644 --- a/apps/desktop/src/renderer/browserMock.ts +++ b/apps/desktop/src/renderer/browserMock.ts @@ -58,24 +58,32 @@ const BUILTIN_MOCK_PROJECT = { createdAt: new Date().toISOString(), }; -const adeDbSnapshotByPath = import.meta.glob("./browser-mock-ade-snapshot.generated.json", { - eager: true, - import: "default", -}); +const adeDbSnapshotByPath = import.meta.glob( + "./browser-mock-ade-snapshot.generated.json", + { + eager: true, + import: "default", + }, +); -const ADE_DB_SNAPSHOT = adeDbSnapshotByPath["./browser-mock-ade-snapshot.generated.json"] ?? null; +const ADE_DB_SNAPSHOT = + adeDbSnapshotByPath["./browser-mock-ade-snapshot.generated.json"] ?? null; const USE_ADE_DB_SNAPSHOT = Boolean(ADE_DB_SNAPSHOT?.project); -const MOCK_PROJECT = USE_ADE_DB_SNAPSHOT && ADE_DB_SNAPSHOT?.project - ? { - ...BUILTIN_MOCK_PROJECT, - id: ADE_DB_SNAPSHOT.project.id, - name: ADE_DB_SNAPSHOT.project.name, - rootPath: ADE_DB_SNAPSHOT.project.rootPath, - gitDefaultBranch: ADE_DB_SNAPSHOT.project.gitDefaultBranch ?? BUILTIN_MOCK_PROJECT.gitDefaultBranch, - createdAt: ADE_DB_SNAPSHOT.project.createdAt ?? BUILTIN_MOCK_PROJECT.createdAt, - } - : BUILTIN_MOCK_PROJECT; +const MOCK_PROJECT = + USE_ADE_DB_SNAPSHOT && ADE_DB_SNAPSHOT?.project + ? { + ...BUILTIN_MOCK_PROJECT, + id: ADE_DB_SNAPSHOT.project.id, + name: ADE_DB_SNAPSHOT.project.name, + rootPath: ADE_DB_SNAPSHOT.project.rootPath, + gitDefaultBranch: + ADE_DB_SNAPSHOT.project.gitDefaultBranch ?? + BUILTIN_MOCK_PROJECT.gitDefaultBranch, + createdAt: + ADE_DB_SNAPSHOT.project.createdAt ?? BUILTIN_MOCK_PROJECT.createdAt, + } + : BUILTIN_MOCK_PROJECT; // ── Timestamps ──────────────────────────────────────────────── const now = new Date().toISOString(); @@ -92,10 +100,20 @@ function mockBrowserLaneHealth(laneId: string) { fallbackMode: false, lastCheckedAt: now, issues: [] as Array<{ - type: "process-dead" | "port-unresponsive" | "proxy-route-missing" | "port-conflict" | "env-init-failed"; + type: + | "process-dead" + | "port-unresponsive" + | "proxy-route-missing" + | "port-conflict" + | "env-init-failed"; message: string; actionLabel?: string; - actionType?: "reassign-port" | "restart-proxy" | "reinit-env" | "enable-fallback" | "refresh-preview"; + actionType?: + | "reassign-port" + | "restart-proxy" + | "reinit-env" + | "enable-fallback" + | "refresh-preview"; }>, }; } @@ -433,7 +451,10 @@ const BUILTIN_RUN_PROCESS_DEFINITIONS: any[] = [ restart: "never", gracefulShutdownMs: 10000, dependsOn: ["mock-dev"], - readiness: { type: "logRegex", pattern: "Local:\\s+http://localhost:[0-9]+" }, + readiness: { + type: "logRegex", + pattern: "Local:\\s+http://localhost:[0-9]+", + }, }, ]; @@ -521,7 +542,9 @@ function buildMockLanesFromAdeSnapshot(laneRows: any[]): any[] { name: String(raw.name ?? "lane"), description: raw.description ?? null, laneType: - raw.laneType === "primary" || raw.laneType === "worktree" || raw.laneType === "attached" + raw.laneType === "primary" || + raw.laneType === "worktree" || + raw.laneType === "attached" ? raw.laneType : "worktree", baseRef: String(raw.baseRef ?? "main"), @@ -553,52 +576,79 @@ function buildMockLanesFromAdeSnapshot(laneRows: any[]): any[] { } const MOCK_LANES: any[] = USE_ADE_DB_SNAPSHOT - ? buildMockLanesFromAdeSnapshot(Array.isArray(ADE_DB_SNAPSHOT?.lanes) ? ADE_DB_SNAPSHOT.lanes : []) + ? buildMockLanesFromAdeSnapshot( + Array.isArray(ADE_DB_SNAPSHOT?.lanes) ? ADE_DB_SNAPSHOT.lanes : [], + ) : BUILTIN_MOCK_LANES; -const ADE_DB_PR_SNAPSHOTS: any[] = USE_ADE_DB_SNAPSHOT && Array.isArray(ADE_DB_SNAPSHOT?.prSnapshots) - ? ADE_DB_SNAPSHOT.prSnapshots - : []; +const ADE_DB_PR_SNAPSHOTS: any[] = + USE_ADE_DB_SNAPSHOT && Array.isArray(ADE_DB_SNAPSHOT?.prSnapshots) + ? ADE_DB_SNAPSHOT.prSnapshots + : []; const ADE_DB_PR_SNAPSHOT_BY_ID = new Map( ADE_DB_PR_SNAPSHOTS.map((snapshot) => [String(snapshot.prId), snapshot]), ); -const ADE_DB_OPERATIONS: any[] = USE_ADE_DB_SNAPSHOT && Array.isArray(ADE_DB_SNAPSHOT?.operations) - ? ADE_DB_SNAPSHOT.operations - : []; -const ADE_DB_SESSIONS: any[] = USE_ADE_DB_SNAPSHOT && Array.isArray(ADE_DB_SNAPSHOT?.sessions) - ? ADE_DB_SNAPSHOT.sessions - : []; +const ADE_DB_OPERATIONS: any[] = + USE_ADE_DB_SNAPSHOT && Array.isArray(ADE_DB_SNAPSHOT?.operations) + ? ADE_DB_SNAPSHOT.operations + : []; +const ADE_DB_SESSIONS: any[] = + USE_ADE_DB_SNAPSHOT && Array.isArray(ADE_DB_SNAPSHOT?.sessions) + ? ADE_DB_SNAPSHOT.sessions + : []; /** Prefer exported DB rows when present; otherwise built-ins so Work is usable without a snapshot file. */ -const MOCK_SESSIONS: any[] = ADE_DB_SESSIONS.length > 0 ? ADE_DB_SESSIONS : BUILTIN_MOCK_SESSIONS; -const ADE_DB_CHAT_TRANSCRIPTS: Record = - USE_ADE_DB_SNAPSHOT && ADE_DB_SNAPSHOT?.chatTranscripts && typeof ADE_DB_SNAPSHOT.chatTranscripts === "object" +const MOCK_SESSIONS: any[] = + ADE_DB_SESSIONS.length > 0 ? ADE_DB_SESSIONS : BUILTIN_MOCK_SESSIONS; +const ADE_DB_CHAT_TRANSCRIPTS: Record< + string, + { events?: any[]; path?: string | null } +> = + USE_ADE_DB_SNAPSHOT && + ADE_DB_SNAPSHOT?.chatTranscripts && + typeof ADE_DB_SNAPSHOT.chatTranscripts === "object" ? ADE_DB_SNAPSHOT.chatTranscripts : {}; -const ADE_DB_PROCESS_DEFINITIONS: any[] = USE_ADE_DB_SNAPSHOT && Array.isArray(ADE_DB_SNAPSHOT?.processDefinitions) - ? ADE_DB_SNAPSHOT.processDefinitions - : []; -const ADE_DB_PROCESS_RUNTIME: any[] = USE_ADE_DB_SNAPSHOT && Array.isArray(ADE_DB_SNAPSHOT?.processRuntime) - ? ADE_DB_SNAPSHOT.processRuntime - : []; -const ADE_DB_STACK_BUTTONS: any[] = USE_ADE_DB_SNAPSHOT && Array.isArray(ADE_DB_SNAPSHOT?.stackButtons) - ? ADE_DB_SNAPSHOT.stackButtons - : []; -const ADE_DB_PROCESS_GROUPS: any[] = USE_ADE_DB_SNAPSHOT && Array.isArray(ADE_DB_SNAPSHOT?.processGroups) - ? ADE_DB_SNAPSHOT.processGroups - : []; +const ADE_DB_PROCESS_DEFINITIONS: any[] = + USE_ADE_DB_SNAPSHOT && Array.isArray(ADE_DB_SNAPSHOT?.processDefinitions) + ? ADE_DB_SNAPSHOT.processDefinitions + : []; +const ADE_DB_PROCESS_RUNTIME: any[] = + USE_ADE_DB_SNAPSHOT && Array.isArray(ADE_DB_SNAPSHOT?.processRuntime) + ? ADE_DB_SNAPSHOT.processRuntime + : []; +const ADE_DB_STACK_BUTTONS: any[] = + USE_ADE_DB_SNAPSHOT && Array.isArray(ADE_DB_SNAPSHOT?.stackButtons) + ? ADE_DB_SNAPSHOT.stackButtons + : []; +const ADE_DB_PROCESS_GROUPS: any[] = + USE_ADE_DB_SNAPSHOT && Array.isArray(ADE_DB_SNAPSHOT?.processGroups) + ? ADE_DB_SNAPSHOT.processGroups + : []; -const usingBuiltinRunDemo = !USE_ADE_DB_SNAPSHOT || ADE_DB_PROCESS_DEFINITIONS.length === 0; -const MOCK_PROCESS_DEFINITIONS: any[] = usingBuiltinRunDemo ? BUILTIN_RUN_PROCESS_DEFINITIONS : ADE_DB_PROCESS_DEFINITIONS; -const MOCK_PROCESS_RUNTIME: any[] = usingBuiltinRunDemo ? BUILTIN_RUN_PROCESS_RUNTIME : ADE_DB_PROCESS_RUNTIME; -const MOCK_STACK_BUTTONS: any[] = usingBuiltinRunDemo ? [] : ADE_DB_STACK_BUTTONS; -const MOCK_PROCESS_GROUPS: any[] = usingBuiltinRunDemo ? BUILTIN_RUN_PROCESS_GROUPS : ADE_DB_PROCESS_GROUPS; +const usingBuiltinRunDemo = + !USE_ADE_DB_SNAPSHOT || ADE_DB_PROCESS_DEFINITIONS.length === 0; +const MOCK_PROCESS_DEFINITIONS: any[] = usingBuiltinRunDemo + ? BUILTIN_RUN_PROCESS_DEFINITIONS + : ADE_DB_PROCESS_DEFINITIONS; +const MOCK_PROCESS_RUNTIME: any[] = usingBuiltinRunDemo + ? BUILTIN_RUN_PROCESS_RUNTIME + : ADE_DB_PROCESS_RUNTIME; +const MOCK_STACK_BUTTONS: any[] = usingBuiltinRunDemo + ? [] + : ADE_DB_STACK_BUTTONS; +const MOCK_PROCESS_GROUPS: any[] = usingBuiltinRunDemo + ? BUILTIN_RUN_PROCESS_GROUPS + : ADE_DB_PROCESS_GROUPS; -const ADE_DB_AUTOMATIONS = USE_ADE_DB_SNAPSHOT && ADE_DB_SNAPSHOT?.automations - ? ADE_DB_SNAPSHOT.automations - : null; +const ADE_DB_AUTOMATIONS = + USE_ADE_DB_SNAPSHOT && ADE_DB_SNAPSHOT?.automations + ? ADE_DB_SNAPSHOT.automations + : null; function normalizeBrowserMockRelPath(rel: unknown): string { - let s = String(rel ?? "").trim().replace(/\\/g, "/"); + let s = String(rel ?? "") + .trim() + .replace(/\\/g, "/"); while (s.startsWith("./")) s = s.slice(2); if (s === "." || s === "/") return ""; return s.replace(/\/+$/, ""); @@ -609,7 +659,8 @@ function languageIdForBrowserMockPath(relPath: string): string { const dot = lower.lastIndexOf("."); const ext = dot >= 0 ? lower.slice(dot) : ""; if (ext === ".ts" || ext === ".tsx") return "typescript"; - if (ext === ".js" || ext === ".jsx" || ext === ".mjs" || ext === ".cjs") return "javascript"; + if (ext === ".js" || ext === ".jsx" || ext === ".mjs" || ext === ".cjs") + return "javascript"; if (ext === ".json") return "json"; if (ext === ".yml" || ext === ".yaml") return "yaml"; if (ext === ".md") return "markdown"; @@ -621,19 +672,23 @@ function languageIdForBrowserMockPath(relPath: string): string { } /** Depth-1 listTree rows keyed by parent path ("" = workspace root), from `export-browser-mock-ade-snapshot.mjs`. */ -const ADE_DB_FILES_TREE_BY_WORKSPACE: Record> = - USE_ADE_DB_SNAPSHOT - && ADE_DB_SNAPSHOT?.filesTreeByWorkspace - && typeof ADE_DB_SNAPSHOT.filesTreeByWorkspace === "object" - ? ADE_DB_SNAPSHOT.filesTreeByWorkspace - : {}; +const ADE_DB_FILES_TREE_BY_WORKSPACE: Record< + string, + Record +> = USE_ADE_DB_SNAPSHOT && +ADE_DB_SNAPSHOT?.filesTreeByWorkspace && +typeof ADE_DB_SNAPSHOT.filesTreeByWorkspace === "object" + ? ADE_DB_SNAPSHOT.filesTreeByWorkspace + : {}; -const ADE_DB_FILES_CONTENTS_BY_WORKSPACE: Record> = - USE_ADE_DB_SNAPSHOT - && ADE_DB_SNAPSHOT?.filesContentsByWorkspace - && typeof ADE_DB_SNAPSHOT.filesContentsByWorkspace === "object" - ? ADE_DB_SNAPSHOT.filesContentsByWorkspace - : {}; +const ADE_DB_FILES_CONTENTS_BY_WORKSPACE: Record< + string, + Record +> = USE_ADE_DB_SNAPSHOT && +ADE_DB_SNAPSHOT?.filesContentsByWorkspace && +typeof ADE_DB_SNAPSHOT.filesContentsByWorkspace === "object" + ? ADE_DB_SNAPSHOT.filesContentsByWorkspace + : {}; function makeBuiltinSyntheticFilesTreeIndex(): Record { return { @@ -654,8 +709,18 @@ function makeBuiltinSyntheticFilesTreeIndex(): Record { }, ], apps: [ - { name: "desktop", path: "apps/desktop", type: "directory", changeStatus: null }, - { name: "ade-cli", path: "apps/ade-cli", type: "directory", changeStatus: null }, + { + name: "desktop", + path: "apps/desktop", + type: "directory", + changeStatus: null, + }, + { + name: "ade-cli", + path: "apps/ade-cli", + type: "directory", + changeStatus: null, + }, ], "apps/desktop": [ { @@ -664,7 +729,12 @@ function makeBuiltinSyntheticFilesTreeIndex(): Record { type: "file", changeStatus: null, }, - { name: "src", path: "apps/desktop/src", type: "directory", changeStatus: null }, + { + name: "src", + path: "apps/desktop/src", + type: "directory", + changeStatus: null, + }, ], "apps/desktop/src": [ { @@ -693,22 +763,32 @@ function makeBuiltinSyntheticFilesTreeIndex(): Record { }; } -const BUILTIN_FILES_TREE_BY_WORKSPACE: Record> = Object.fromEntries( - MOCK_LANES.map((lane) => [String(lane.id), makeBuiltinSyntheticFilesTreeIndex()]), +const BUILTIN_FILES_TREE_BY_WORKSPACE: Record< + string, + Record +> = Object.fromEntries( + MOCK_LANES.map((lane) => [ + String(lane.id), + makeBuiltinSyntheticFilesTreeIndex(), + ]), ); function getBrowserMockFilesWorkspaces(): any[] { return [...MOCK_LANES] .map((lane) => { - const laneType = lane.laneType === "primary" || lane.laneType === "attached" || lane.laneType === "worktree" - ? lane.laneType - : "worktree"; + const laneType = + lane.laneType === "primary" || + lane.laneType === "attached" || + lane.laneType === "worktree" + ? lane.laneType + : "worktree"; return { id: String(lane.id), kind: laneType, laneId: String(lane.id), name: String(lane.name ?? lane.id), - branchRef: typeof lane.branchRef === "string" ? lane.branchRef : undefined, + branchRef: + typeof lane.branchRef === "string" ? lane.branchRef : undefined, rootPath: String(lane.worktreePath ?? MOCK_PROJECT.rootPath), isReadOnlyByDefault: Boolean(lane.isEditProtected), mobileReadOnly: true, @@ -722,7 +802,10 @@ function getBrowserMockFilesWorkspaces(): any[] { }); } -function getBrowserMockListTreeNodes(workspaceId: string, parentPath: string): any[] { +function getBrowserMockListTreeNodes( + workspaceId: string, + parentPath: string, +): any[] { const parentKey = normalizeBrowserMockRelPath(parentPath); const snapTree = ADE_DB_FILES_TREE_BY_WORKSPACE[workspaceId]; if (snapTree && Object.prototype.hasOwnProperty.call(snapTree, parentKey)) { @@ -737,15 +820,20 @@ function getBrowserMockListTreeNodes(workspaceId: string, parentPath: string): a return []; } -function getBrowserMockReadFilePayload(workspaceId: string, relPath: string): any { +function getBrowserMockReadFilePayload( + workspaceId: string, + relPath: string, +): any { const normalized = normalizeBrowserMockRelPath(relPath); - const fromSnapshot = ADE_DB_FILES_CONTENTS_BY_WORKSPACE[workspaceId]?.[normalized]; + const fromSnapshot = + ADE_DB_FILES_CONTENTS_BY_WORKSPACE[workspaceId]?.[normalized]; if (fromSnapshot && typeof fromSnapshot.content === "string") { return { content: fromSnapshot.content, encoding: fromSnapshot.encoding ?? "utf-8", size: Number(fromSnapshot.size ?? fromSnapshot.content.length), - languageId: fromSnapshot.languageId ?? languageIdForBrowserMockPath(normalized), + languageId: + fromSnapshot.languageId ?? languageIdForBrowserMockPath(normalized), isBinary: Boolean(fromSnapshot.isBinary), }; } @@ -760,27 +848,39 @@ function getBrowserMockReadFilePayload(workspaceId: string, relPath: string): an } function isMockChatToolType(toolType: unknown): boolean { - const normalized = String(toolType ?? "").trim().toLowerCase(); + const normalized = String(toolType ?? "") + .trim() + .toLowerCase(); return Boolean( - normalized - && ( - normalized === "codex-chat" - || normalized === "claude-chat" - || normalized === "opencode-chat" - || normalized === "cursor" - || normalized === "droid" - || normalized === "droid-chat" - || normalized.endsWith("-chat") - ), + normalized && + (normalized === "codex-chat" || + normalized === "claude-chat" || + normalized === "opencode-chat" || + normalized === "cursor" || + normalized === "droid" || + normalized === "droid-chat" || + normalized.endsWith("-chat")), ); } -function inferMockChatProvider(session: any): "claude" | "codex" | "cursor" | "droid" | "opencode" { - const metadataProvider = String(session?.resumeMetadata?.provider ?? "").trim().toLowerCase(); - if (metadataProvider === "claude" || metadataProvider === "codex" || metadataProvider === "cursor" || metadataProvider === "droid" || metadataProvider === "opencode") { +function inferMockChatProvider( + session: any, +): "claude" | "codex" | "cursor" | "droid" | "opencode" { + const metadataProvider = String(session?.resumeMetadata?.provider ?? "") + .trim() + .toLowerCase(); + if ( + metadataProvider === "claude" || + metadataProvider === "codex" || + metadataProvider === "cursor" || + metadataProvider === "droid" || + metadataProvider === "opencode" + ) { return metadataProvider; } - const toolType = String(session?.toolType ?? "").trim().toLowerCase(); + const toolType = String(session?.toolType ?? "") + .trim() + .toLowerCase(); if (toolType.startsWith("claude")) return "claude"; if (toolType.startsWith("codex")) return "codex"; if (toolType === "cursor" || toolType.startsWith("cursor")) return "cursor"; @@ -790,7 +890,9 @@ function inferMockChatProvider(session: any): "claude" | "codex" | "cursor" | "d function getMockChatTranscriptEvents(sessionId: string): any[] { const events = ADE_DB_CHAT_TRANSCRIPTS[sessionId]?.events; - return Array.isArray(events) ? events.filter((entry) => entry?.sessionId === sessionId && entry?.event) : []; + return Array.isArray(events) + ? events.filter((entry) => entry?.sessionId === sessionId && entry?.event) + : []; } function latestMockDoneEvent(events: any[]): any | null { @@ -801,7 +903,9 @@ function latestMockDoneEvent(events: any[]): any | null { return null; } -function fallbackMockModelForProvider(provider: "claude" | "codex" | "cursor" | "droid" | "opencode"): string { +function fallbackMockModelForProvider( + provider: "claude" | "codex" | "cursor" | "droid" | "opencode", +): string { if (provider === "claude") return "sonnet"; if (provider === "codex") return DEFAULT_BROWSER_MOCK_CODEX_MODEL; if (provider === "cursor") return "auto"; @@ -809,7 +913,9 @@ function fallbackMockModelForProvider(provider: "claude" | "codex" | "cursor" | return "opencode/mock"; } -function fallbackMockModelIdForProvider(provider: "claude" | "codex" | "cursor" | "droid" | "opencode"): string { +function fallbackMockModelIdForProvider( + provider: "claude" | "codex" | "cursor" | "droid" | "opencode", +): string { if (provider === "claude") return DEFAULT_BROWSER_MOCK_CLAUDE_MODEL; if (provider === "codex") return DEFAULT_BROWSER_MOCK_CODEX_MODEL; if (provider === "cursor") return "cursor/auto"; @@ -823,19 +929,20 @@ function mockAgentChatSummaryFromSession(session: any): any | null { const events = getMockChatTranscriptEvents(String(session.id)); const done = latestMockDoneEvent(events); const modelId = String( - session.resumeMetadata?.modelId - ?? session.resumeMetadata?.launch?.modelId - ?? done?.modelId - ?? fallbackMockModelIdForProvider(provider), + session.resumeMetadata?.modelId ?? + session.resumeMetadata?.launch?.modelId ?? + done?.modelId ?? + fallbackMockModelIdForProvider(provider), ); const model = String( - session.resumeMetadata?.model - ?? session.resumeMetadata?.launch?.model - ?? done?.model - ?? fallbackMockModelForProvider(provider), + session.resumeMetadata?.model ?? + session.resumeMetadata?.launch?.model ?? + done?.model ?? + fallbackMockModelForProvider(provider), ); const endedAt = session.endedAt ?? null; - const lastActivityAt = session.lastActivityAt ?? session.endedAt ?? session.startedAt ?? now; + const lastActivityAt = + session.lastActivityAt ?? session.endedAt ?? session.startedAt ?? now; const status = session.status === "running" ? "idle" : "ended"; return { sessionId: String(session.id), @@ -851,12 +958,16 @@ function mockAgentChatSummaryFromSession(session: any): any | null { executionMode: session.resumeMetadata?.executionMode ?? null, permissionMode: session.resumeMetadata?.permissionMode ?? null, interactionMode: session.resumeMetadata?.interactionMode ?? null, - claudePermissionMode: session.resumeMetadata?.claudePermissionMode ?? undefined, - codexApprovalPolicy: session.resumeMetadata?.codexApprovalPolicy ?? undefined, + claudePermissionMode: + session.resumeMetadata?.claudePermissionMode ?? undefined, + codexApprovalPolicy: + session.resumeMetadata?.codexApprovalPolicy ?? undefined, codexSandbox: session.resumeMetadata?.codexSandbox ?? undefined, codexConfigSource: session.resumeMetadata?.codexConfigSource ?? undefined, - opencodePermissionMode: session.resumeMetadata?.opencodePermissionMode ?? undefined, - droidPermissionMode: session.resumeMetadata?.droidPermissionMode ?? undefined, + opencodePermissionMode: + session.resumeMetadata?.opencodePermissionMode ?? undefined, + droidPermissionMode: + session.resumeMetadata?.droidPermissionMode ?? undefined, cursorModeSnapshot: session.resumeMetadata?.cursorModeSnapshot ?? undefined, cursorModeId: session.resumeMetadata?.cursorModeId ?? null, cursorConfigValues: session.resumeMetadata?.cursorConfigValues ?? null, @@ -880,9 +991,9 @@ function mockAgentChatSummaryFromSession(session: any): any | null { } function listMockAgentChatSummaries(args: any = {}): any[] { - let rows = MOCK_SESSIONS - .map(mockAgentChatSummaryFromSession) - .filter((session): session is any => Boolean(session)); + let rows = MOCK_SESSIONS.map(mockAgentChatSummaryFromSession).filter( + (session): session is any => Boolean(session), + ); if (typeof args?.laneId === "string" && args.laneId.trim()) { rows = rows.filter((session) => session.laneId === args.laneId.trim()); } @@ -1215,7 +1326,9 @@ const INTEGRATION_PRS: any[] = [ // ── All PRs combined ────────────────────────────────────────── const ALL_PRS = USE_ADE_DB_SNAPSHOT - ? (Array.isArray(ADE_DB_SNAPSHOT?.prs) ? ADE_DB_SNAPSHOT.prs : []) + ? Array.isArray(ADE_DB_SNAPSHOT?.prs) + ? ADE_DB_SNAPSHOT.prs + : [] : [...NORMAL_PRS, ...QUEUE_PRS, ...INTEGRATION_PRS]; // ── Merge Contexts ──────────────────────────────────────────── @@ -1931,7 +2044,9 @@ const BUILTIN_MOCK_REBASE_NEEDS: any[] = [ ]; const MOCK_REBASE_NEEDS: any[] = USE_ADE_DB_SNAPSHOT - ? (Array.isArray(ADE_DB_SNAPSHOT?.rebaseNeeds) ? ADE_DB_SNAPSHOT.rebaseNeeds : []) + ? Array.isArray(ADE_DB_SNAPSHOT?.rebaseNeeds) + ? ADE_DB_SNAPSHOT.rebaseNeeds + : [] : BUILTIN_MOCK_REBASE_NEEDS; // ── Queue Landing State ─────────────────────────────────────── @@ -2044,12 +2159,15 @@ const BUILTIN_MOCK_QUEUE_STATE: Record = { const MOCK_QUEUE_STATE: Record = USE_ADE_DB_SNAPSHOT ? Object.fromEntries( - (Array.isArray(ADE_DB_SNAPSHOT?.queueStates) ? ADE_DB_SNAPSHOT.queueStates : []).flatMap( - (state: any) => { - const keys = [state?.groupId, state?.queueId].filter(Boolean).map(String); - return keys.map((key) => [key, state]); - }, - ), + (Array.isArray(ADE_DB_SNAPSHOT?.queueStates) + ? ADE_DB_SNAPSHOT.queueStates + : [] + ).flatMap((state: any) => { + const keys = [state?.groupId, state?.queueId] + .filter(Boolean) + .map(String); + return keys.map((key) => [key, state]); + }), ) : BUILTIN_MOCK_QUEUE_STATE; @@ -2221,7 +2339,9 @@ const BUILTIN_MOCK_INTEGRATION_WORKFLOWS: any[] = [ ]; const MOCK_INTEGRATION_WORKFLOWS: any[] = USE_ADE_DB_SNAPSHOT - ? (Array.isArray(ADE_DB_SNAPSHOT?.integrationWorkflows) ? ADE_DB_SNAPSHOT.integrationWorkflows : []) + ? Array.isArray(ADE_DB_SNAPSHOT?.integrationWorkflows) + ? ADE_DB_SNAPSHOT.integrationWorkflows + : [] : BUILTIN_MOCK_INTEGRATION_WORKFLOWS; const BUILTIN_MOCK_GITHUB_SNAPSHOT: any = { @@ -2311,9 +2431,10 @@ const BUILTIN_MOCK_GITHUB_SNAPSHOT: any = { ], }; -const MOCK_GITHUB_SNAPSHOT: any = USE_ADE_DB_SNAPSHOT && ADE_DB_SNAPSHOT?.githubSnapshot - ? ADE_DB_SNAPSHOT.githubSnapshot - : BUILTIN_MOCK_GITHUB_SNAPSHOT; +const MOCK_GITHUB_SNAPSHOT: any = + USE_ADE_DB_SNAPSHOT && ADE_DB_SNAPSHOT?.githubSnapshot + ? ADE_DB_SNAPSHOT.githubSnapshot + : BUILTIN_MOCK_GITHUB_SNAPSHOT; // ═══════════════════════════════════════════════════════════════ // Wire it up @@ -2329,13 +2450,19 @@ const MOCK_GITHUB_SNAPSHOT: any = USE_ADE_DB_SNAPSHOT && ADE_DB_SNAPSHOT?.github */ function shouldInstallBrowserMock(target: Window): boolean { const w = target as any; - return !(w.ade && !w.__adeBrowserMock && typeof w.ade.sync?.getStatus === "function"); + return !( + w.ade && + !w.__adeBrowserMock && + typeof w.ade.sync?.getStatus === "function" + ); } if (typeof window !== "undefined" && shouldInstallBrowserMock(window)) { const w = window as any; if (w.ade) { - console.warn("[ADE] Re-applying full window.ade browser mock (e.g. Vite HMR)."); + console.warn( + "[ADE] Re-applying full window.ade browser mock (e.g. Vite HMR).", + ); } else { console.warn( "[ADE] Running outside Electron — injecting browser mock for window.ade", @@ -2437,7 +2564,12 @@ if (typeof window !== "undefined" && shouldInstallBrowserMock(window)) { const BROWSER_MOCK_AI_STATUS: any = { mode: "guest", - availableProviders: { claude: false, codex: false, cursor: false, droid: false }, + availableProviders: { + claude: false, + codex: false, + cursor: false, + droid: false, + }, models: { claude: [], codex: [], cursor: [], droid: [] }, features: [], providerConnections: { @@ -2481,9 +2613,10 @@ if (typeof window !== "undefined" && shouldInstallBrowserMock(window)) { lastPolledAt: now, errors: [], }; - const BROWSER_USAGE_SNAPSHOT: any = USE_ADE_DB_SNAPSHOT && ADE_DB_SNAPSHOT?.usageSnapshot - ? ADE_DB_SNAPSHOT.usageSnapshot - : BROWSER_MOCK_USAGE_SNAPSHOT; + const BROWSER_USAGE_SNAPSHOT: any = + USE_ADE_DB_SNAPSHOT && ADE_DB_SNAPSHOT?.usageSnapshot + ? ADE_DB_SNAPSHOT.usageSnapshot + : BROWSER_MOCK_USAGE_SNAPSHOT; const BROWSER_MOCK_BUDGET_CONFIG: any = { refreshIntervalMin: 15, @@ -2578,9 +2711,10 @@ if (typeof window !== "undefined" && shouldInstallBrowserMock(window)) { totalCostUsd: 0, }, }; - const BROWSER_MISSION_DASHBOARD: any = USE_ADE_DB_SNAPSHOT && ADE_DB_SNAPSHOT?.missionDashboard - ? ADE_DB_SNAPSHOT.missionDashboard - : BROWSER_MOCK_MISSION_DASHBOARD; + const BROWSER_MISSION_DASHBOARD: any = + USE_ADE_DB_SNAPSHOT && ADE_DB_SNAPSHOT?.missionDashboard + ? ADE_DB_SNAPSHOT.missionDashboard + : BROWSER_MOCK_MISSION_DASHBOARD; const BROWSER_MOCK_EMPTY_FULL_MISSION_VIEW: any = { mission: null, @@ -2641,13 +2775,47 @@ if (typeof window !== "undefined" && shouldInstallBrowserMock(window)) { v8: "0.0.0-browser", }, env: {}, + localRuntime: { + connectionState: "idle", + serviceInstall: { + state: "skipped", + attempted: false, + path: null, + message: + "Background service installation is not available in the browser mock.", + exitCode: null, + updatedAt: null, + }, + serviceHealth: { + state: "unsupported", + installed: null, + running: null, + path: null, + message: + "Background service status is not available in the browser mock.", + checkedAt: null, + }, + }, }), getProject: resolved(MOCK_PROJECT), - getWindowSession: resolved({ windowId: 1, project: MOCK_PROJECT }), + getWindowSession: resolved({ + windowId: 1, + project: MOCK_PROJECT, + binding: { + kind: "local", + key: `local:${MOCK_PROJECT.rootPath}`, + rootPath: MOCK_PROJECT.rootPath, + displayName: MOCK_PROJECT.name, + }, + }), newWindow: resolved({ windowId: 2 }), - openProjectInNewWindow: resolvedArg({ windowId: 2, project: MOCK_PROJECT }), + openProjectInNewWindow: resolvedArg({ + windowId: 2, + project: MOCK_PROJECT, + }), closeWindow: resolvedArg({ closed: false }), onProjectChanged: () => () => {}, + onProjectBindingChanged: () => () => {}, onNavigate: () => () => {}, openExternal: resolvedArg(undefined), revealPath: resolvedArg(undefined), @@ -2665,7 +2833,8 @@ if (typeof window !== "undefined" && shouldInstallBrowserMock(window)) { chooseDirectory: resolvedArg(null), browseDirectories: async (args?: { inputPath?: string }) => { const inputPath = - typeof args?.inputPath === "string" && args.inputPath.trim().length > 0 + typeof args?.inputPath === "string" && + args.inputPath.trim().length > 0 ? args.inputPath : "~/"; return { @@ -2699,9 +2868,17 @@ if (typeof window !== "undefined" && shouldInstallBrowserMock(window)) { }), listRecent: resolved([]), closeCurrent: resolved(undefined), - resolveIcon: resolvedArg({ dataUrl: null, sourcePath: null, mimeType: null }), + resolveIcon: resolvedArg({ + dataUrl: null, + sourcePath: null, + mimeType: null, + }), chooseIcon: resolvedArg(null), - removeIcon: resolvedArg({ dataUrl: null, sourcePath: null, mimeType: null }), + removeIcon: resolvedArg({ + dataUrl: null, + sourcePath: null, + mimeType: null, + }), switchToPath: resolvedArg(MOCK_PROJECT), forgetRecent: resolvedArg([]), reorderRecent: resolvedArg([]), @@ -2729,6 +2906,160 @@ if (typeof window !== "undefined" && shouldInstallBrowserMock(window)) { onMissing: noop, onStateEvent: noop, }, + remoteRuntime: { + listTargets: resolved([]), + getConnectionSnapshot: resolved({ + connections: [], + connectedCount: 0, + updatedAt: Date.now(), + }), + onConnectionSnapshotChanged: noop, + listDiscoveredMachines: resolved([]), + saveTarget: resolvedArg({ + id: "mock-remote", + name: "Mock remote", + hostname: "mock.local", + sshUser: "ade", + port: 22, + sshKeyPath: null, + lastSeenArch: null, + runtimeBinaryVersion: null, + lastConnectedAt: null, + }), + removeTarget: resolvedArg({ removed: true }), + connect: resolvedArg({ + target: { + id: "mock-remote", + name: "Mock remote", + hostname: "mock.local", + sshUser: "ade", + port: 22, + sshKeyPath: null, + lastSeenArch: "darwin-arm64", + runtimeBinaryVersion: "0.0.0-browser", + lastConnectedAt: Date.now(), + }, + arch: "darwin-arm64", + version: "0.0.0-browser", + projects: [], + }), + listProjects: resolvedArg([]), + addProject: async (_id: string, rootPath: string) => ({ + projectId: `mock-${ + rootPath + .replace(/[^a-z0-9]+/gi, "-") + .replace(/^-|-$/g, "") + .toLowerCase() || "project" + }`, + rootPath, + displayName: + rootPath.split(/[\\/]/).filter(Boolean).at(-1) || "Mock project", + addedAt: Date.now(), + lastOpenedAt: Date.now(), + gitOriginUrl: null, + }), + browseDirectories: resolvedArg2({ + inputPath: "", + resolvedPath: "/Users/ade", + directoryPath: "/Users/ade", + parentPath: "/Users", + exactDirectoryPath: "/Users/ade", + openableProjectRoot: null, + entries: [], + }), + getProjectDetail: async (_id: string, rootPath: string) => ({ + rootPath, + isGitRepo: true, + branchName: "main", + dirtyCount: 0, + aheadBehind: { ahead: 0, behind: 0 }, + lastCommit: null, + readmeExcerpt: null, + languages: [], + laneCount: 0, + lastOpenedAt: null, + subdirectoryCount: 0, + }), + getDefaultParentDir: resolved("/Users/ade/Projects"), + createProject: async ( + _id: string, + input: { name: string; parentDir: string }, + ) => { + const rootPath = `${input.parentDir.replace(/\/+$/g, "")}/${input.name}`; + return { + projectId: `mock-${input.name}`, + rootPath, + displayName: input.name, + addedAt: Date.now(), + lastOpenedAt: Date.now(), + gitOriginUrl: null, + }; + }, + cloneProject: async ( + _id: string, + input: { url: string; parentDir: string; name?: string }, + ) => { + const name = + input.name || + input.url + .split(/[/:]/) + .pop() + ?.replace(/\.git$/i, "") || + "repo"; + const rootPath = `${input.parentDir.replace(/\/+$/g, "")}/${name}`; + return { + projectId: `mock-${name}`, + rootPath, + displayName: name, + addedAt: Date.now(), + lastOpenedAt: Date.now(), + gitOriginUrl: input.url, + }; + }, + listMyGitHubRepos: resolvedArg2({ repos: [] }), + openProject: async (id: string, projectId: string) => ({ + kind: "remote" as const, + key: `remote:${id}:${projectId}`, + targetId: id, + runtimeName: "Mock remote", + projectId, + rootPath: "/Users/ade/mock-project", + displayName: "mock-project", + }), + callAction: async ( + _id: string, + _projectId: string, + request: { domain: string; action: string }, + ) => ({ + domain: request.domain, + action: request.action, + result: + request.domain === "lane" && request.action === "list" + ? [ + { + id: "lane-main", + name: "Main", + branchName: "main", + laneType: "primary", + }, + ] + : null, + statusHints: {}, + }), + streamEvents: resolvedArg({ events: [], nextCursor: 0, hasMore: false }), + checkLocalWork: async (_id: string, project: { + projectId: string; + displayName: string; + gitOriginUrl: string | null; + }) => ({ + remoteProjectId: project.projectId, + remoteDisplayName: project.displayName, + remoteGitOriginUrl: project.gitOriginUrl, + matches: [], + hasDirtyWork: false, + }), + disconnect: resolvedArg({ disconnected: true }), + }, keybindings: { get: resolved({ definitions: [], overrides: [] }), set: resolvedArg({ definitions: [], overrides: [] }), @@ -2749,6 +3080,7 @@ if (typeof window !== "undefined" && shouldInstallBrowserMock(window)) { transferBrainToLocal: resolved(BROWSER_MOCK_SYNC_SNAPSHOT), getPin: resolved({ pin: null }), setPin: resolvedArg(BROWSER_MOCK_SYNC_SNAPSHOT), + generatePin: resolved(BROWSER_MOCK_SYNC_SNAPSHOT), clearPin: resolved(BROWSER_MOCK_SYNC_SNAPSHOT), setActiveLanePresence: resolvedArg(undefined), onEvent: () => () => {}, @@ -2826,14 +3158,17 @@ if (typeof window !== "undefined" && shouldInstallBrowserMock(window)) { available: false, version: null, detail: "Browser preview does not run local VM providers.", - docsUrl: "https://cua.ai/docs/lume/guide/getting-started/installation", + docsUrl: + "https://cua.ai/docs/lume/guide/getting-started/installation", }, tools: [], laneVm: null, vms: [], docs: { - appleVirtualization: "https://developer.apple.com/documentation/virtualization", - appleSharedDirectories: "https://developer.apple.com/documentation/virtualization/vzvirtiofilesystemdeviceconfiguration", + appleVirtualization: + "https://developer.apple.com/documentation/virtualization", + appleSharedDirectories: + "https://developer.apple.com/documentation/virtualization/vzvirtiofilesystemdeviceconfiguration", lume: "https://cua.ai/docs/lume/guide/fundamentals/vm-management", }, }), @@ -2968,7 +3303,8 @@ if (typeof window !== "undefined" && shouldInstallBrowserMock(window)) { resetTourProgress: resolvedArg(BROWSER_MOCK_TOUR_PROGRESS), markTourCompletedVariant: resolvedArg2(BROWSER_MOCK_TOUR_PROGRESS), markTourDismissedVariant: resolvedArg2(BROWSER_MOCK_TOUR_PROGRESS), - updateTourStepVariant: async (_a: any, _b: any, _c: any) => BROWSER_MOCK_TOUR_PROGRESS, + updateTourStepVariant: async (_a: any, _b: any, _c: any) => + BROWSER_MOCK_TOUR_PROGRESS, tutorial: { start: resolved(BROWSER_MOCK_TOUR_PROGRESS), dismiss: resolvedArg(BROWSER_MOCK_TOUR_PROGRESS), @@ -2980,60 +3316,67 @@ if (typeof window !== "undefined" && shouldInstallBrowserMock(window)) { }, }, automations: { - list: resolved(USE_ADE_DB_SNAPSHOT && Array.isArray(ADE_DB_AUTOMATIONS?.rules) ? ADE_DB_AUTOMATIONS.rules : [ - { - id: "auto-session-review", - name: "PR follow-up thread", - description: - "When a pull request changes, send a focused follow-up prompt to an automation-owned chat thread.", - enabled: true, - mode: "review", - triggers: [{ type: "git.pr_updated", branch: "main" }], - trigger: { type: "git.pr_updated", branch: "main" }, - execution: { - kind: "agent-session", - session: { title: "PR follow-up thread" }, - }, - executor: { mode: "automation-bot" }, - modelConfig: { - orchestratorModel: { - modelId: "anthropic/claude-sonnet-4-6", - thinkingLevel: "medium", - }, - }, - permissionConfig: { - providers: { - opencode: "edit", - claude: "plan", - codexSandbox: "workspace-write", - allowedTools: ["git", "github"], - }, - }, - prompt: - "Review the latest PR update and leave a concise follow-up summary with any high-signal next steps.", - reviewProfile: "incremental", - toolPalette: ["repo", "git", "github", "memory", "mission"], - contextSources: [ - { type: "project-memory" }, - { type: "automation-memory" }, - ], - memory: { mode: "automation-plus-project" }, - guardrails: {}, - outputs: { disposition: "comment-only", createArtifact: true }, - verification: { verifyBeforePublish: false, mode: "intervention" }, - billingCode: "auto:session-review", - actions: [], - running: false, - lastRunAt: now, - lastRunStatus: "succeeded", - confidence: { - value: 0.84, - label: "high", - reason: - "Recent runs consistently produced concise PR follow-up notes.", - }, - }, - ]), + list: resolved( + USE_ADE_DB_SNAPSHOT && Array.isArray(ADE_DB_AUTOMATIONS?.rules) + ? ADE_DB_AUTOMATIONS.rules + : [ + { + id: "auto-session-review", + name: "PR follow-up thread", + description: + "When a pull request changes, send a focused follow-up prompt to an automation-owned chat thread.", + enabled: true, + mode: "review", + triggers: [{ type: "git.pr_updated", branch: "main" }], + trigger: { type: "git.pr_updated", branch: "main" }, + execution: { + kind: "agent-session", + session: { title: "PR follow-up thread" }, + }, + executor: { mode: "automation-bot" }, + modelConfig: { + orchestratorModel: { + modelId: "anthropic/claude-sonnet-4-6", + thinkingLevel: "medium", + }, + }, + permissionConfig: { + providers: { + opencode: "edit", + claude: "plan", + codexSandbox: "workspace-write", + allowedTools: ["git", "github"], + }, + }, + prompt: + "Review the latest PR update and leave a concise follow-up summary with any high-signal next steps.", + reviewProfile: "incremental", + toolPalette: ["repo", "git", "github", "memory", "mission"], + contextSources: [ + { type: "project-memory" }, + { type: "automation-memory" }, + ], + memory: { mode: "automation-plus-project" }, + guardrails: {}, + outputs: { disposition: "comment-only", createArtifact: true }, + verification: { + verifyBeforePublish: false, + mode: "intervention", + }, + billingCode: "auto:session-review", + actions: [], + running: false, + lastRunAt: now, + lastRunStatus: "succeeded", + confidence: { + value: 0.84, + label: "high", + reason: + "Recent runs consistently produced concise PR follow-up notes.", + }, + }, + ], + ), toggle: resolvedArg([]), triggerManually: resolvedArg({ id: "run-1", @@ -3054,32 +3397,36 @@ if (typeof window !== "undefined" && shouldInstallBrowserMock(window)) { summary: "Manual run completed.", billingCode: "auto:session-review", }), - getHistory: resolvedArg(USE_ADE_DB_SNAPSHOT && Array.isArray(ADE_DB_AUTOMATIONS?.runs) ? ADE_DB_AUTOMATIONS.runs : [ - { - id: "run-1", - automationId: "auto-session-review", - chatSessionId: "chat-auto-1", - missionId: null, - triggerType: "git.pr_updated", - startedAt: now, - endedAt: now, - status: "succeeded", - executionKind: "agent-session", - actionsCompleted: 1, - actionsTotal: 1, - errorMessage: null, - spendUsd: 1.32, - confidence: { - value: 0.81, - label: "high", - reason: "Automation summarized the latest PR update clearly.", - }, - triggerMetadata: { repository: "ADE", branch: "main" }, - summary: - "Summarized the latest PR update and suggested next review points.", - billingCode: "auto:session-review", - }, - ]), + getHistory: resolvedArg( + USE_ADE_DB_SNAPSHOT && Array.isArray(ADE_DB_AUTOMATIONS?.runs) + ? ADE_DB_AUTOMATIONS.runs + : [ + { + id: "run-1", + automationId: "auto-session-review", + chatSessionId: "chat-auto-1", + missionId: null, + triggerType: "git.pr_updated", + startedAt: now, + endedAt: now, + status: "succeeded", + executionKind: "agent-session", + actionsCompleted: 1, + actionsTotal: 1, + errorMessage: null, + spendUsd: 1.32, + confidence: { + value: 0.81, + label: "high", + reason: "Automation summarized the latest PR update clearly.", + }, + triggerMetadata: { repository: "ADE", branch: "main" }, + summary: + "Summarized the latest PR update and suggested next review points.", + billingCode: "auto:session-review", + }, + ], + ), getRunDetail: resolvedArg({ run: { id: "run-1", @@ -3150,32 +3497,36 @@ if (typeof window !== "undefined" && shouldInstallBrowserMock(window)) { receivedAt: now, }, }), - listRuns: resolvedArg(USE_ADE_DB_SNAPSHOT && Array.isArray(ADE_DB_AUTOMATIONS?.runs) ? ADE_DB_AUTOMATIONS.runs : [ - { - id: "run-1", - automationId: "auto-session-review", - chatSessionId: "chat-auto-1", - missionId: null, - triggerType: "git.pr_updated", - startedAt: now, - endedAt: now, - status: "succeeded", - executionKind: "agent-session", - actionsCompleted: 1, - actionsTotal: 1, - errorMessage: null, - spendUsd: 1.32, - confidence: { - value: 0.81, - label: "high", - reason: "Automation summarized the latest PR update clearly.", - }, - triggerMetadata: { repository: "ADE", branch: "main" }, - summary: - "Summarized the latest PR update and suggested next review points.", - billingCode: "auto:session-review", - }, - ]), + listRuns: resolvedArg( + USE_ADE_DB_SNAPSHOT && Array.isArray(ADE_DB_AUTOMATIONS?.runs) + ? ADE_DB_AUTOMATIONS.runs + : [ + { + id: "run-1", + automationId: "auto-session-review", + chatSessionId: "chat-auto-1", + missionId: null, + triggerType: "git.pr_updated", + startedAt: now, + endedAt: now, + status: "succeeded", + executionKind: "agent-session", + actionsCompleted: 1, + actionsTotal: 1, + errorMessage: null, + spendUsd: 1.32, + confidence: { + value: 0.81, + label: "high", + reason: "Automation summarized the latest PR update clearly.", + }, + triggerMetadata: { repository: "ADE", branch: "main" }, + summary: + "Summarized the latest PR update and suggested next review points.", + billingCode: "auto:session-review", + }, + ], + ), getIngressStatus: resolved({ githubRelay: { configured: true, @@ -3198,21 +3549,25 @@ if (typeof window !== "undefined" && shouldInstallBrowserMock(window)) { lastError: null, }, }), - listIngressEvents: resolvedArg(USE_ADE_DB_SNAPSHOT && Array.isArray(ADE_DB_AUTOMATIONS?.ingressEvents) ? ADE_DB_AUTOMATIONS.ingressEvents : [ - { - id: "ingress-1", - source: "github-relay", - eventKey: "delivery-1", - automationIds: ["auto-session-review"], - triggerType: "git.pr_updated", - eventName: "pull_request", - status: "dispatched", - summary: "PR synchronize event dispatched to matching rules.", - errorMessage: null, - cursor: "cursor-1", - receivedAt: now, - }, - ]), + listIngressEvents: resolvedArg( + USE_ADE_DB_SNAPSHOT && Array.isArray(ADE_DB_AUTOMATIONS?.ingressEvents) + ? ADE_DB_AUTOMATIONS.ingressEvents + : [ + { + id: "ingress-1", + source: "github-relay", + eventKey: "delivery-1", + automationIds: ["auto-session-review"], + triggerType: "git.pr_updated", + eventName: "pull_request", + status: "dispatched", + summary: "PR synchronize event dispatched to matching rules.", + errorMessage: null, + cursor: "cursor-1", + receivedAt: now, + }, + ], + ), parseNaturalLanguage: resolvedArg({ draft: { name: "Mock automation", @@ -3270,22 +3625,25 @@ if (typeof window !== "undefined" && shouldInstallBrowserMock(window)) { color: lane.color ?? null, })), recentCommitsByLane: Object.fromEntries( - MOCK_LANES.map((lane) => [lane.id, [ - { - sha: "abc1234567890", - shortSha: "abc1234", - subject: `Recent work on ${lane.name}`, - authoredAt: now, - pushed: false, - }, - { - sha: "def4567890123", - shortSha: "def4567", - subject: `Follow-up fix on ${lane.name}`, - authoredAt: yesterday, - pushed: true, - }, - ]]), + MOCK_LANES.map((lane) => [ + lane.id, + [ + { + sha: "abc1234567890", + shortSha: "abc1234", + subject: `Recent work on ${lane.name}`, + authoredAt: now, + pushed: false, + }, + { + sha: "def4567890123", + shortSha: "def4567", + subject: `Follow-up fix on ${lane.name}`, + authoredAt: yesterday, + pushed: true, + }, + ], + ]), ), recommendedModelId: DEFAULT_BROWSER_MOCK_CODEX_MODEL, }), @@ -3294,18 +3652,32 @@ if (typeof window !== "undefined" && shouldInstallBrowserMock(window)) { id: "review-run-1", projectId: MOCK_PROJECT.id, laneId: MOCK_LANES[1]?.id ?? "lane-auth", - target: { mode: "lane_diff", laneId: MOCK_LANES[1]?.id ?? "lane-auth" }, + target: { + mode: "lane_diff", + laneId: MOCK_LANES[1]?.id ?? "lane-auth", + }, config: { compareAgainst: { kind: "default_branch" }, selectionMode: "full_diff", dirtyOnly: false, modelId: DEFAULT_BROWSER_MOCK_CODEX_MODEL, reasoningEffort: "medium", - budgets: { maxFiles: 60, maxDiffChars: 180000, maxPromptChars: 220000, maxFindings: 12 }, + budgets: { + maxFiles: 60, + maxDiffChars: 180000, + maxPromptChars: 220000, + maxFindings: 12, + }, publishBehavior: "local_only", }, targetLabel: "feature/auth-flow vs main", - compareTarget: { kind: "default_branch", label: "main", ref: "main", laneId: null, branchRef: "main" }, + compareTarget: { + kind: "default_branch", + label: "main", + ref: "main", + laneId: null, + branchRef: "main", + }, status: "completed", summary: "Found two actionable risks in the auth flow changes.", errorMessage: null, @@ -3329,11 +3701,22 @@ if (typeof window !== "undefined" && shouldInstallBrowserMock(window)) { dirtyOnly: false, modelId: DEFAULT_BROWSER_MOCK_CODEX_MODEL, reasoningEffort: "medium", - budgets: { maxFiles: 60, maxDiffChars: 180000, maxPromptChars: 220000, maxFindings: 12 }, + budgets: { + maxFiles: 60, + maxDiffChars: 180000, + maxPromptChars: 220000, + maxFindings: 12, + }, publishBehavior: "local_only", }, targetLabel: "feature/auth-flow vs main", - compareTarget: { kind: "default_branch", label: "main", ref: "main", laneId: null, branchRef: "main" }, + compareTarget: { + kind: "default_branch", + label: "main", + ref: "main", + laneId: null, + branchRef: "main", + }, status: "completed", summary: "Found two actionable risks in the auth flow changes.", errorMessage: null, @@ -3355,7 +3738,8 @@ if (typeof window !== "undefined" && shouldInstallBrowserMock(window)) { evidence: [ { kind: "diff_hunk", - summary: "Session write happens before token exchange success is confirmed.", + summary: + "Session write happens before token exchange success is confirmed.", filePath: "src/auth/oauth.ts", line: 128, quote: "saveSession(session);", @@ -3390,7 +3774,8 @@ if (typeof window !== "undefined" && shouldInstallBrowserMock(window)) { artifactType: "diff_bundle", title: "Diff bundle", mimeType: "text/plain", - contentText: "diff --git a/src/auth/oauth.ts b/src/auth/oauth.ts\n@@ ...", + contentText: + "diff --git a/src/auth/oauth.ts b/src/auth/oauth.ts\n@@ ...", metadata: null, createdAt: now, }, @@ -3410,7 +3795,8 @@ if (typeof window !== "undefined" && shouldInstallBrowserMock(window)) { startedAt: yesterday, endedAt: now, lastActivityAt: now, - lastOutputPreview: "Found two actionable risks in the auth flow changes.", + lastOutputPreview: + "Found two actionable risks in the auth flow changes.", summary: "Saved review transcript for local diff review.", }, }), @@ -3425,7 +3811,12 @@ if (typeof window !== "undefined" && shouldInstallBrowserMock(window)) { dirtyOnly: false, modelId: DEFAULT_BROWSER_MOCK_CODEX_MODEL, reasoningEffort: "medium", - budgets: { maxFiles: 60, maxDiffChars: 180000, maxPromptChars: 220000, maxFindings: 12 }, + budgets: { + maxFiles: 60, + maxDiffChars: 180000, + maxPromptChars: 220000, + maxFindings: 12, + }, publishBehavior: "local_only", }, targetLabel: "feature/auth-flow review", @@ -3452,7 +3843,12 @@ if (typeof window !== "undefined" && shouldInstallBrowserMock(window)) { dirtyOnly: false, modelId: DEFAULT_BROWSER_MOCK_CODEX_MODEL, reasoningEffort: "medium", - budgets: { maxFiles: 60, maxDiffChars: 180000, maxPromptChars: 220000, maxFindings: 12 }, + budgets: { + maxFiles: 60, + maxDiffChars: 180000, + maxPromptChars: 220000, + maxFindings: 12, + }, publishBehavior: "local_only", }, targetLabel: "feature/auth-flow review", @@ -3494,8 +3890,16 @@ if (typeof window !== "undefined" && shouldInstallBrowserMock(window)) { recentFeedback: [], byClass: [ { findingClass: "intent_drift" as const, total: 4, addressed: 2 }, - { findingClass: "incomplete_rollout" as const, total: 5, addressed: 3 }, - { findingClass: "late_stage_regression" as const, total: 2, addressed: 1 }, + { + findingClass: "incomplete_rollout" as const, + total: 5, + addressed: 3, + }, + { + findingClass: "late_stage_regression" as const, + total: 2, + addressed: 1, + }, ], }), onEvent: noop, @@ -3505,30 +3909,52 @@ if (typeof window !== "undefined" && shouldInstallBrowserMock(window)) { }, missions: { list: async (args: any = {}) => { - const rows = USE_ADE_DB_SNAPSHOT && Array.isArray(ADE_DB_SNAPSHOT?.missions) - ? ADE_DB_SNAPSHOT.missions - : []; + const rows = + USE_ADE_DB_SNAPSHOT && Array.isArray(ADE_DB_SNAPSHOT?.missions) + ? ADE_DB_SNAPSHOT.missions + : []; const status = typeof args?.status === "string" ? args.status : null; const laneId = typeof args?.laneId === "string" ? args.laneId : null; const includeArchived = args?.includeArchived === true; - const activeStatuses = new Set(["queued", "planning", "plan_review", "in_progress", "intervention_required"]); + const activeStatuses = new Set([ + "queued", + "planning", + "plan_review", + "in_progress", + "intervention_required", + ]); let filtered = rows; - if (!includeArchived) filtered = filtered.filter((mission: any) => !mission.archivedAt); - if (laneId) filtered = filtered.filter((mission: any) => mission.laneId === laneId); + if (!includeArchived) + filtered = filtered.filter((mission: any) => !mission.archivedAt); + if (laneId) + filtered = filtered.filter( + (mission: any) => mission.laneId === laneId, + ); if (status === "active") { - filtered = filtered.filter((mission: any) => activeStatuses.has(mission.status)); + filtered = filtered.filter((mission: any) => + activeStatuses.has(mission.status), + ); } else if (status === "in_progress") { - filtered = filtered.filter((mission: any) => mission.status === "in_progress" || mission.status === "plan_review"); + filtered = filtered.filter( + (mission: any) => + mission.status === "in_progress" || + mission.status === "plan_review", + ); } else if (status) { - filtered = filtered.filter((mission: any) => mission.status === status); + filtered = filtered.filter( + (mission: any) => mission.status === status, + ); } - const limit = Number.isFinite(args?.limit) ? Math.max(1, Math.floor(args.limit)) : filtered.length; + const limit = Number.isFinite(args?.limit) + ? Math.max(1, Math.floor(args.limit)) + : filtered.length; return filtered.slice(0, limit); }, get: async (missionId: string) => { - const rows = USE_ADE_DB_SNAPSHOT && Array.isArray(ADE_DB_SNAPSHOT?.missions) - ? ADE_DB_SNAPSHOT.missions - : []; + const rows = + USE_ADE_DB_SNAPSHOT && Array.isArray(ADE_DB_SNAPSHOT?.missions) + ? ADE_DB_SNAPSHOT.missions + : []; return rows.find((mission: any) => mission.id === missionId) ?? null; }, create: resolvedArg({ id: "mock" }), @@ -3572,7 +3998,7 @@ if (typeof window !== "undefined" && shouldInstallBrowserMock(window)) { getPhaseConfiguration: resolvedArg(null), getDashboard: resolved(BROWSER_MISSION_DASHBOARD), getFullMissionView: async (missionId: string) => - (USE_ADE_DB_SNAPSHOT && ADE_DB_SNAPSHOT?.missionFullViews?.[missionId]) + USE_ADE_DB_SNAPSHOT && ADE_DB_SNAPSHOT?.missionFullViews?.[missionId] ? ADE_DB_SNAPSHOT.missionFullViews[missionId] : BROWSER_MOCK_EMPTY_FULL_MISSION_VIEW, preflight: resolvedArg({ @@ -3774,7 +4200,10 @@ if (typeof window !== "undefined" && shouldInstallBrowserMock(window)) { updateAppearance: resolvedArg(undefined), archive: resolvedArg(undefined), delete: resolvedArg(undefined), - cancelDelete: resolvedArg({ cancelled: false, reason: "no active delete" }), + cancelDelete: resolvedArg({ + cancelled: false, + reason: "no active delete", + }), getDeleteRisk: resolvedArg({ laneId: "mock", branchRef: null, @@ -3940,7 +4369,9 @@ if (typeof window !== "undefined" && shouldInstallBrowserMock(window)) { callbackPaths: [], }), oauthUpdateConfig: resolvedArg(undefined), - oauthGenerateRedirectUris: resolvedArg([{ provider: "google", uris: [] as string[], instructions: "" }]), + oauthGenerateRedirectUris: resolvedArg([ + { provider: "google", uris: [] as string[], instructions: "" }, + ]), oauthEncodeState: resolvedArg("ade:mock"), oauthDecodeState: resolvedArg(null), oauthListSessions: resolved([]), @@ -3954,9 +4385,13 @@ if (typeof window !== "undefined" && shouldInstallBrowserMock(window)) { fallbackLanes: [] as string[], }), diagnosticsGetLaneHealth: async (args: { laneId: string }) => - typeof args?.laneId === "string" ? mockBrowserLaneHealth(args.laneId) : null, + typeof args?.laneId === "string" + ? mockBrowserLaneHealth(args.laneId) + : null, diagnosticsRunHealthCheck: async (args: { laneId: string }) => - mockBrowserLaneHealth(typeof args?.laneId === "string" ? args.laneId : "mock"), + mockBrowserLaneHealth( + typeof args?.laneId === "string" ? args.laneId : "mock", + ), diagnosticsRunFullCheck: resolved([]), diagnosticsActivateFallback: resolvedArg(undefined), diagnosticsDeactivateFallback: resolvedArg(undefined), @@ -3967,12 +4402,18 @@ if (typeof window !== "undefined" && shouldInstallBrowserMock(window)) { list: async (args: any = {}) => { let rows = MOCK_SESSIONS; if (typeof args?.laneId === "string" && args.laneId.trim()) { - rows = rows.filter((session) => session.laneId === args.laneId.trim()); + rows = rows.filter( + (session) => session.laneId === args.laneId.trim(), + ); } if (typeof args?.status === "string" && args.status.trim()) { - rows = rows.filter((session) => session.status === args.status.trim()); + rows = rows.filter( + (session) => session.status === args.status.trim(), + ); } - const limit = Number.isFinite(args?.limit) ? Math.max(1, Math.floor(args.limit)) : rows.length; + const limit = Number.isFinite(args?.limit) + ? Math.max(1, Math.floor(args.limit)) + : rows.length; return rows.slice(0, limit); }, get: async (sessionId: string) => @@ -3981,10 +4422,16 @@ if (typeof window !== "undefined" && shouldInstallBrowserMock(window)) { updateMeta: resolvedArg(null), readTranscriptTail: async (args: any = {}) => { const sessionId = String(args?.sessionId ?? "").trim(); - const lines = getMockChatTranscriptEvents(sessionId).map((entry) => JSON.stringify(entry)); + const lines = getMockChatTranscriptEvents(sessionId).map((entry) => + JSON.stringify(entry), + ); const raw = lines.join("\n"); - const maxBytes = Number.isFinite(args?.maxBytes) ? Math.max(0, Math.floor(args.maxBytes)) : raw.length; - return raw.length > maxBytes ? raw.slice(Math.max(0, raw.length - maxBytes)) : raw; + const maxBytes = Number.isFinite(args?.maxBytes) + ? Math.max(0, Math.floor(args.maxBytes)) + : raw.length; + return raw.length > maxBytes + ? raw.slice(Math.max(0, raw.length - maxBytes)) + : raw; }, getDelta: resolvedArg(null), onChanged: noop, @@ -4007,7 +4454,10 @@ if (typeof window !== "undefined" && shouldInstallBrowserMock(window)) { steer: resolvedArg(undefined), cancelSteer: resolvedArg(undefined), editSteer: resolvedArg(undefined), - dispatchSteer: resolvedArg({ delivered: false, reason: "Browser mock does not run chat sessions." }), + dispatchSteer: resolvedArg({ + delivered: false, + reason: "Browser mock does not run chat sessions.", + }), cancelDispatchedSteer: resolvedArg({ cancelled: false }), interrupt: resolvedArg(undefined), resume: resolvedArg({ id: "mock" }), @@ -4031,10 +4481,14 @@ if (typeof window !== "undefined" && shouldInstallBrowserMock(window)) { supportsInterrupt: false, }), saveTempAttachment: resolvedArg({ path: "/tmp/browser-mock-attachment" }), - getEventHistory: async (arg: { sessionId: string; maxEvents?: number }) => ({ + getEventHistory: async (arg: { + sessionId: string; + maxEvents?: number; + }) => ({ sessionId: typeof arg?.sessionId === "string" ? arg.sessionId : "", events: (() => { - const sessionId = typeof arg?.sessionId === "string" ? arg.sessionId : ""; + const sessionId = + typeof arg?.sessionId === "string" ? arg.sessionId : ""; const events = getMockChatTranscriptEvents(sessionId); const maxEvents = Number.isFinite(arg?.maxEvents) ? Math.max(1, Math.floor(arg.maxEvents!)) @@ -4042,7 +4496,8 @@ if (typeof window !== "undefined" && shouldInstallBrowserMock(window)) { return events.length > maxEvents ? events.slice(-maxEvents) : events; })(), truncated: (() => { - const sessionId = typeof arg?.sessionId === "string" ? arg.sessionId : ""; + const sessionId = + typeof arg?.sessionId === "string" ? arg.sessionId : ""; const events = getMockChatTranscriptEvents(sessionId); const maxEvents = Number.isFinite(arg?.maxEvents) ? Math.max(1, Math.floor(arg.maxEvents!)) @@ -4342,7 +4797,14 @@ if (typeof window !== "undefined" && shouldInstallBrowserMock(window)) { fetchedAt: now, sdk: { packageName: "@linear/sdk", - surfaces: ["viewer", "organization", "projects", "teams", "assignedIssues", "issues"], + surfaces: [ + "viewer", + "organization", + "projects", + "teams", + "assignedIssues", + "issues", + ], }, }), getLinearIssuePickerData: resolvedArg({ @@ -4408,7 +4870,11 @@ if (typeof window !== "undefined" && shouldInstallBrowserMock(window)) { }), }, pty: { - create: resolvedArg({ ptyId: "mock", sessionId: "mock-session", pid: 1234 }), + create: resolvedArg({ + ptyId: "mock", + sessionId: "mock-session", + pid: 1234, + }), write: resolvedArg(undefined), resize: resolvedArg(undefined), dispose: resolvedArg(undefined), @@ -4446,8 +4912,12 @@ if (typeof window !== "undefined" && shouldInstallBrowserMock(window)) { stopWatching: resolvedArg(undefined), quickOpen: async (args: any) => { const workspaceId = String(args?.workspaceId ?? ""); - const q = String(args?.query ?? "").trim().toLowerCase(); - const limit = Number.isFinite(args?.limit) ? Math.max(1, Math.floor(args.limit)) : 25; + const q = String(args?.query ?? "") + .trim() + .toLowerCase(); + const limit = Number.isFinite(args?.limit) + ? Math.max(1, Math.floor(args.limit)) + : 25; const rootNodes = getBrowserMockListTreeNodes(workspaceId, ""); const flat: { path: string; score: number }[] = []; const maxCollect = 400; @@ -4457,7 +4927,10 @@ if (typeof window !== "undefined" && shouldInstallBrowserMock(window)) { if (!node?.path) continue; const hay = String(node.path).toLowerCase(); if (!q || hay.includes(q)) { - flat.push({ path: node.path, score: prefixScore + (node.name?.length ?? 0) }); + flat.push({ + path: node.path, + score: prefixScore + (node.name?.length ?? 0), + }); } if (node.type === "directory") { const kids = getBrowserMockListTreeNodes(workspaceId, node.path); @@ -4559,7 +5032,8 @@ if (typeof window !== "undefined" && shouldInstallBrowserMock(window)) { body: "## Description\n\nMock feedback", labels: ["bug"], generationMode: "deterministic", - generationWarning: "ADE used a deterministic draft because no AI model was selected.", + generationWarning: + "ADE used a deterministic draft because no AI model was selected.", }), submitDraft: resolvedArg({ id: "mock-feedback-1", @@ -4628,17 +5102,18 @@ if (typeof window !== "undefined" && shouldInstallBrowserMock(window)) { }, prs: { createFromLane: resolvedArg( - USE_ADE_DB_SNAPSHOT ? null : NORMAL_PRS[0] ?? null, + USE_ADE_DB_SNAPSHOT ? null : (NORMAL_PRS[0] ?? null), ), linkToLane: resolvedArg( - USE_ADE_DB_SNAPSHOT ? null : NORMAL_PRS[0] ?? null, + USE_ADE_DB_SNAPSHOT ? null : (NORMAL_PRS[0] ?? null), ), getForLane: async (laneId: string) => ALL_PRS.find((pr: any) => pr.laneId === laneId) ?? null, listAll: resolved(ALL_PRS), refresh: resolved(ALL_PRS), getStatus: async (prId: string) => - ADE_DB_PR_SNAPSHOT_BY_ID.get(prId)?.status ?? MOCK_STATUS_BY_PR[prId] ?? { + ADE_DB_PR_SNAPSHOT_BY_ID.get(prId)?.status ?? + MOCK_STATUS_BY_PR[prId] ?? { prId, state: "open", checksStatus: "passing", @@ -4648,11 +5123,17 @@ if (typeof window !== "undefined" && shouldInstallBrowserMock(window)) { behindBaseBy: 0, }, getChecks: async (prId: string) => - ADE_DB_PR_SNAPSHOT_BY_ID.get(prId)?.checks ?? MOCK_CHECKS_BY_PR[prId] ?? [], + ADE_DB_PR_SNAPSHOT_BY_ID.get(prId)?.checks ?? + MOCK_CHECKS_BY_PR[prId] ?? + [], getComments: async (prId: string) => - ADE_DB_PR_SNAPSHOT_BY_ID.get(prId)?.comments ?? MOCK_COMMENTS_BY_PR[prId] ?? [], + ADE_DB_PR_SNAPSHOT_BY_ID.get(prId)?.comments ?? + MOCK_COMMENTS_BY_PR[prId] ?? + [], getReviews: async (prId: string) => - ADE_DB_PR_SNAPSHOT_BY_ID.get(prId)?.reviews ?? MOCK_REVIEWS_BY_PR[prId] ?? [], + ADE_DB_PR_SNAPSHOT_BY_ID.get(prId)?.reviews ?? + MOCK_REVIEWS_BY_PR[prId] ?? + [], getReviewThreads: resolvedArg([]), updateDescription: resolvedArg(undefined), delete: resolvedArg({ deleted: true }), @@ -4670,7 +5151,7 @@ if (typeof window !== "undefined" && shouldInstallBrowserMock(window)) { commitIntegration: resolvedArg({ groupId: "group-int-mock", integrationLaneId: "lane-search", - pr: USE_ADE_DB_SNAPSHOT ? null : INTEGRATION_PRS[0] ?? null, + pr: USE_ADE_DB_SNAPSHOT ? null : (INTEGRATION_PRS[0] ?? null), mergeResults: [], }), landStackEnhanced: resolvedArg([]), @@ -4811,7 +5292,10 @@ if (typeof window !== "undefined" && shouldInstallBrowserMock(window)) { convergenceStateDelete: async (prId: string) => { delete MOCK_CONVERGENCE_RUNTIME[prId]; }, - pathToMergeStart: async (args: { prId: string; permissionMode?: string | null }) => { + pathToMergeStart: async (args: { + prId: string; + permissionMode?: string | null; + }) => { const runtime = MOCK_CONVERGENCE_RUNTIME[args.prId] ?? createDefaultConvergenceRuntime(args.prId); @@ -4825,7 +5309,10 @@ if (typeof window !== "undefined" && shouldInstallBrowserMock(window)) { MOCK_CONVERGENCE_RUNTIME[args.prId] = runtime; return { prId: args.prId, scheduled: true, runtime: { ...runtime } }; }, - pathToMergeStop: async (args: { prId: string; reason?: string | null }) => { + pathToMergeStop: async (args: { + prId: string; + reason?: string | null; + }) => { const runtime = MOCK_CONVERGENCE_RUNTIME[args.prId] ?? null; if (runtime) { runtime.autoConvergeEnabled = false; @@ -4925,7 +5412,7 @@ if (typeof window !== "undefined" && shouldInstallBrowserMock(window)) { dismissIntegrationCleanup: resolvedArg( USE_ADE_DB_SNAPSHOT ? undefined - : BUILTIN_MOCK_INTEGRATION_WORKFLOWS[1] ?? undefined, + : (BUILTIN_MOCK_INTEGRATION_WORKFLOWS[1] ?? undefined), ), cleanupIntegrationWorkflow: resolvedArg({ proposalId: "workflow-int-active", @@ -4957,15 +5444,21 @@ if (typeof window !== "undefined" && shouldInstallBrowserMock(window)) { listOperations: async (args: any = {}) => { let rows = ADE_DB_OPERATIONS; if (typeof args?.laneId === "string" && args.laneId.trim()) { - rows = rows.filter((operation) => operation.laneId === args.laneId.trim()); + rows = rows.filter( + (operation) => operation.laneId === args.laneId.trim(), + ); } if (typeof args?.kind === "string" && args.kind.trim()) { - rows = rows.filter((operation) => operation.kind === args.kind.trim()); + rows = rows.filter( + (operation) => operation.kind === args.kind.trim(), + ); } if (typeof args?.status === "string" && args.status !== "all") { rows = rows.filter((operation) => operation.status === args.status); } - const limit = Number.isFinite(args?.limit) ? Math.max(1, Math.floor(args.limit)) : rows.length; + const limit = Number.isFinite(args?.limit) + ? Math.max(1, Math.floor(args.limit)) + : rows.length; return rows.slice(0, limit); }, exportOperations: async (args: any = {}) => ({ @@ -5038,7 +5531,8 @@ if (typeof window !== "undefined" && shouldInstallBrowserMock(window)) { installAvailable: false, installTargetPath: "~/.local/bin/ade", installTargetDirOnPath: false, - message: "ADE-launched agents can use ade. Terminal access is not installed yet.", + message: + "ADE-launched agents can use ade. Terminal access is not installed yet.", nextAction: "Run npm link in apps/ade-cli for local development.", }), installForUser: resolved({ @@ -5058,7 +5552,8 @@ if (typeof window !== "undefined" && shouldInstallBrowserMock(window)) { installAvailable: false, installTargetPath: "~/.local/bin/ade", installTargetDirOnPath: false, - message: "ADE-launched agents can use ade. Terminal access is not installed yet.", + message: + "ADE-launched agents can use ade. Terminal access is not installed yet.", nextAction: "Run npm link in apps/ade-cli for local development.", }, }), diff --git a/apps/desktop/src/renderer/components/app/AppShell.tsx b/apps/desktop/src/renderer/components/app/AppShell.tsx index 7825b89c1..09b781a69 100644 --- a/apps/desktop/src/renderer/components/app/AppShell.tsx +++ b/apps/desktop/src/renderer/components/app/AppShell.tsx @@ -31,6 +31,7 @@ import type { OnboardingStatus, PrEventPayload, ProjectInfo, + OpenProjectBinding, TerminalSessionSummary, } from "../../../shared/types"; import { @@ -238,6 +239,7 @@ export function AppShell({ children }: { children: React.ReactNode }) { const navigate = useNavigate(); const setProject = useAppStore((s) => s.setProject); const setProjectHydrated = useAppStore((s) => s.setProjectHydrated); + const setProjectBinding = useAppStore((s) => s.setProjectBinding); const refreshLanes = useAppStore((s) => s.refreshLanes); const refreshProviderMode = useAppStore((s) => s.refreshProviderMode); const refreshKeybindings = useAppStore((s) => s.refreshKeybindings); @@ -365,19 +367,36 @@ export function AppShell({ children }: { children: React.ReactNode }) { } }; - const applyProjectState = (nextProject: ProjectInfo | null) => { - const nextProjectRoot = nextProject?.rootPath ?? null; + const applyProjectState = (nextProject: ProjectInfo | null, nextBinding?: OpenProjectBinding | null) => { + const remoteBinding = nextBinding?.kind === "remote" ? nextBinding : null; + const nextProjectRoot = remoteBinding?.rootPath ?? nextProject?.rootPath ?? null; const currentProjectRoot = useAppStore.getState().project?.rootPath ?? null; const currentShowWelcome = useAppStore.getState().showWelcome; const currentIsNewTabOpen = useAppStore.getState().isNewTabOpen; - const hasStoredProject = Boolean(nextProject); + const hasStoredProject = Boolean(nextProject || remoteBinding); const projectChanged = nextProjectRoot !== currentProjectRoot; const welcomeChanged = currentShowWelcome === hasStoredProject; + if (remoteBinding) { + setProject({ + rootPath: remoteBinding.rootPath, + displayName: remoteBinding.displayName, + baseRef: "main", + }); + setProjectBinding(remoteBinding); + setShowWelcome(false); + clearScheduledRefreshes(); + void refreshLanes({ includeStatus: false }); + return; + } + if (currentIsNewTabOpen && nextProject && !projectChanged) { setProject(nextProject); - if (currentShowWelcome) setShowWelcome(false); + setProjectBinding(nextBinding ?? null); + // Leave showWelcome alone — the user explicitly opened the new-tab + // UI; a stale project-changed event for the same root must not kick + // them back to the project content. return; } @@ -386,6 +405,7 @@ export function AppShell({ children }: { children: React.ReactNode }) { setShowWelcome(false); } else { setProject(null); + setProjectBinding(null); setShowWelcome(true); } @@ -422,9 +442,9 @@ export function AppShell({ children }: { children: React.ReactNode }) { const initializeProjectState = async () => { setProjectHydrated(false); try { - const nextProject = await window.ade.app.getProject(); + const session = await window.ade.app.getWindowSession(); if (cancelled) return; - applyProjectState(nextProject); + applyProjectState(session.project, session.binding); } catch { if (cancelled) return; setProject(null); @@ -458,15 +478,24 @@ export function AppShell({ children }: { children: React.ReactNode }) { applyProjectState(nextProject); setProjectHydrated(true); }); + const disposeProjectBindingChanged = window.ade.app.onProjectBindingChanged((binding) => { + const state = useAppStore.getState(); + if (state.projectTransition) return; + setProjectHydrated(false); + applyProjectState(binding?.kind === "local" ? state.project : null, binding); + setProjectHydrated(true); + }); void initializeProjectState(); return () => { cancelled = true; clearScheduledRefreshes(); disposeProjectChanged(); + disposeProjectBindingChanged(); }; }, [ setProject, + setProjectBinding, setProjectHydrated, refreshLanes, refreshProviderMode, diff --git a/apps/desktop/src/renderer/components/app/CommandPalette.test.tsx b/apps/desktop/src/renderer/components/app/CommandPalette.test.tsx index 7bf29ce5a..787710614 100644 --- a/apps/desktop/src/renderer/components/app/CommandPalette.test.tsx +++ b/apps/desktop/src/renderer/components/app/CommandPalette.test.tsx @@ -91,12 +91,8 @@ describe("CommandPalette", () => { render( - - + + , ); await waitFor(() => { @@ -107,7 +103,9 @@ describe("CommandPalette", () => { }); }); - expect(await screen.findByRole("button", { name: /open directory/i })).toBeTruthy(); + expect( + await screen.findByRole("button", { name: /open directory/i }), + ).toBeTruthy(); expect(screen.getByText("Versic")).toBeTruthy(); }); @@ -127,12 +125,8 @@ describe("CommandPalette", () => { render( - - + + , ); await waitFor(() => { @@ -142,7 +136,9 @@ describe("CommandPalette", () => { limit: 200, }); }); - const button = await screen.findByRole("button", { name: /open directory/i }); + const button = await screen.findByRole("button", { + name: /open directory/i, + }); fireEvent.click(button); await waitFor(() => { @@ -151,7 +147,7 @@ describe("CommandPalette", () => { defaultPath: "/Users/admin/Projects", }); expect(switchProjectToPath).toHaveBeenCalledWith( - "/Users/admin/Projects/Versic" + "/Users/admin/Projects/Versic", ); }); }); @@ -175,11 +171,13 @@ describe("CommandPalette", () => { intent="project-browse" onOpenChange={onOpenChange} /> - + , ); await waitFor(() => { - expect(document.querySelector('[data-tour="project.browser"]')).toBeTruthy(); + expect( + document.querySelector('[data-tour="project.browser"]'), + ).toBeTruthy(); }); window.dispatchEvent(new CustomEvent(PROJECT_BROWSER_CLOSE_EVENT)); @@ -222,12 +220,8 @@ describe("CommandPalette", () => { render( - - + + , ); await waitFor(() => { @@ -237,7 +231,9 @@ describe("CommandPalette", () => { limit: 200, }); }); - const inputs = await screen.findAllByPlaceholderText(/paste a path, type to filter, or drop a folder anywhere/i); + const inputs = await screen.findAllByPlaceholderText( + /paste a path, type to filter, or drop a folder anywhere/i, + ); const input = inputs.at(-1) as HTMLInputElement; fireEvent.drop(input, { dataTransfer: { files: [new File(["stale"], "stale")] }, @@ -266,9 +262,135 @@ describe("CommandPalette", () => { }); await waitFor(() => { - expect(switchProjectToPath).toHaveBeenCalledWith("/Users/admin/Projects/FreshFolder"); + expect(switchProjectToPath).toHaveBeenCalledWith( + "/Users/admin/Projects/FreshFolder", + ); expect(switchProjectToPath).toHaveBeenCalledTimes(1); expect(browseDirectories).toHaveBeenCalledTimes(3); }); }); + + it("warns before opening a remote project when matching local work is dirty", async () => { + const switchRemoteProject = vi.fn(async () => {}); + seedStore({ + projectBinding: null, + switchRemoteProject, + }); + const remoteProject = { + projectId: "project-remote-ade", + rootPath: "/remote/ADE", + displayName: "ADE", + addedAt: 1, + lastOpenedAt: 2, + gitOriginUrl: "git@github.com:example/ade.git", + }; + const remoteRuntime = { + getConnectionSnapshot: vi.fn(async () => ({ + connectedCount: 1, + updatedAt: Date.now(), + connections: [ + { + target: { + id: "target-1", + name: "Mac Studio", + hostname: "studio.tailnet.ts.net", + sshUser: "admin", + port: 22, + sshKeyPath: null, + lastSeenArch: "darwin-arm64", + runtimeBinaryVersion: "1.0.0", + lastConnectedAt: Date.now(), + }, + state: "connected", + arch: "darwin-arm64", + version: "1.0.0", + projects: [], + lastError: null, + lastAttemptedAt: Date.now(), + connectedAt: Date.now(), + }, + ], + })), + onConnectionSnapshotChanged: vi.fn(() => () => {}), + browseDirectories: vi.fn(async () => ({ + inputPath: "~/", + resolvedPath: "/remote/ADE", + directoryPath: "/remote/ADE", + parentPath: "/remote", + exactDirectoryPath: "/remote/ADE", + openableProjectRoot: "/remote/ADE", + entries: [], + })), + getProjectDetail: vi.fn(async () => ({ + rootPath: "/remote/ADE", + isGitRepo: true, + branchName: "main", + dirtyCount: 0, + aheadBehind: null, + lastCommit: null, + readmeExcerpt: null, + languages: [], + laneCount: null, + lastOpenedAt: null, + subdirectoryCount: null, + })), + addProject: vi.fn(async () => remoteProject), + checkLocalWork: vi.fn(async () => ({ + remoteProjectId: remoteProject.projectId, + remoteDisplayName: remoteProject.displayName, + remoteGitOriginUrl: remoteProject.gitOriginUrl, + hasDirtyWork: true, + matches: [ + { + rootPath: "/Users/admin/Projects/ADE", + displayName: "ADE", + gitOriginUrl: "git@github.com:example/ade.git", + dirtyCount: 3, + }, + ], + })), + }; + globalThis.window.ade = { + ...globalThis.window.ade, + remoteRuntime, + } as any; + + render( + + + , + ); + + const machineButton = await screen.findByRole("button", { + name: /Mac Studio/i, + }); + fireEvent.click(machineButton); + fireEvent.click(await screen.findByRole("button", { name: /OPEN/i })); + + await waitFor(() => + expect(remoteRuntime.browseDirectories).toHaveBeenCalledWith("target-1", { + partialPath: "~/", + cwd: null, + limit: 200, + }), + ); + fireEvent.click(await screen.findByRole("button", { name: /Open ADE/i })); + + await waitFor(() => + expect( + screen.getByRole("dialog", { name: "Open remote tab?" }), + ).toBeTruthy(), + ); + expect(screen.getByText("3 changed files")).toBeTruthy(); + expect(screen.getAllByText("/Users/admin/Projects/ADE").length).toBeGreaterThan(0); + expect(switchRemoteProject).not.toHaveBeenCalled(); + + fireEvent.click(screen.getByRole("button", { name: "Open remote tab" })); + await waitFor(() => + expect(switchRemoteProject).toHaveBeenCalledWith( + "target-1", + "project-remote-ade", + ), + ); + }); }); diff --git a/apps/desktop/src/renderer/components/app/CommandPalette.tsx b/apps/desktop/src/renderer/components/app/CommandPalette.tsx index ffb9744b1..ec4634e16 100644 --- a/apps/desktop/src/renderer/components/app/CommandPalette.tsx +++ b/apps/desktop/src/renderer/components/app/CommandPalette.tsx @@ -1,4 +1,10 @@ -import React, { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import React, { + useCallback, + useEffect, + useMemo, + useRef, + useState, +} from "react"; import * as Dialog from "@radix-ui/react-dialog"; import ReactMarkdown, { type Components } from "react-markdown"; import remarkGfm from "remark-gfm"; @@ -7,6 +13,7 @@ import { ArrowRight, CircleNotch, Clock, + DesktopTower, Folder, FolderOpen, GitBranch, @@ -17,7 +24,15 @@ import { } from "@phosphor-icons/react"; import { motion, AnimatePresence } from "motion/react"; import { useNavigate } from "react-router-dom"; -import type { ProjectBrowseResult, ProjectDetail } from "../../../shared/types"; +import type { + ProjectBrowseInput, + ProjectBrowseResult, + ProjectDetail, + RemoteRuntimeConnectionSnapshot, + RemoteRuntimeConnectionStatus, + RemoteRuntimeLocalWorkCheckResult, + RemoteRuntimeProjectRecord, +} from "../../../shared/types"; import { extractError } from "../../lib/format"; import { fadeScale } from "../../lib/motion"; import { PROJECT_BROWSER_CLOSE_EVENT } from "../../lib/projectBrowserEvents"; @@ -28,13 +43,16 @@ import { AddProjectChooser } from "../projects/AddProjectChooser"; import { CloneProjectForm } from "../projects/CloneProjectForm"; import { CreateProjectForm } from "../projects/CreateProjectForm"; import { ProjectActionSuccess } from "../projects/ProjectActionSuccess"; +import { RemoteProjectOpenDialog } from "../projects/RemoteProjectOpenDialog"; +import { RemoteTargetList } from "../remoteTargets/RemoteTargetList"; export type CommandPaletteIntent = | "default" | "project-browse" | "project-add" | "project-create" - | "project-clone"; + | "project-clone" + | "project-remote"; type CommandPaletteMode = CommandPaletteIntent | "project-success"; @@ -42,6 +60,15 @@ type ProjectActionOutcome = { verb: "Created" | "Cloned"; displayName: string; rootPath: string; + location: ProjectLocation; + projectId?: string; +}; + +type PendingRemoteProjectOpen = { + targetId: string; + runtimeName: string; + project: RemoteRuntimeProjectRecord; + localWork: RemoteRuntimeLocalWorkCheckResult; }; type Command = { @@ -63,11 +90,23 @@ type BrowseRow = { isGitRepo: boolean; }; +type ProjectLocation = + | { kind: "local"; id: "local"; name: string } + | { kind: "remote"; targetId: string; name: string }; + +const LOCAL_PROJECT_LOCATION: ProjectLocation = { + kind: "local", + id: "local", + name: "This Mac", +}; + function stripTrailingSeparator(input: string): string { if (input.length <= 1) return input; if (/^[a-z]:[\\/]$/i.test(input)) return input; if (/^[/\\]{2}[^/\\]+[/\\][^/\\]+[/\\]?$/i.test(input)) return input; - return input.endsWith("/") || input.endsWith("\\") ? input.slice(0, -1) : input; + return input.endsWith("/") || input.endsWith("\\") + ? input.slice(0, -1) + : input; } function relativeFromNow(iso: string | null | undefined): string | null { @@ -158,16 +197,23 @@ export function CommandPalette({ const lanes = useAppStore((s) => s.lanes); const selectedLaneId = useAppStore((s) => s.selectedLaneId); const project = useAppStore((s) => s.project); + const projectBinding = useAppStore((s) => s.projectBinding); const selectLane = useAppStore((s) => s.selectLane); const switchProjectToPath = useAppStore((s) => s.switchProjectToPath); + const switchRemoteProject = useAppStore((s) => s.switchRemoteProject); const hasActiveProject = Boolean(project?.rootPath); const [mode, setMode] = useState("default"); - const [actionOutcome, setActionOutcome] = useState(null); + const [actionOutcome, setActionOutcome] = + useState(null); const [q, setQ] = useState(""); const [selectedIdx, setSelectedIdx] = useState(0); - const [browseInput, setBrowseInput] = useState(defaultBrowseInput(project?.rootPath)); - const [browseResult, setBrowseResult] = useState(null); + const [browseInput, setBrowseInput] = useState( + defaultBrowseInput(project?.rootPath), + ); + const [browseResult, setBrowseResult] = useState( + null, + ); const [browseSelectedIdx, setBrowseSelectedIdx] = useState(0); const [browseLoading, setBrowseLoading] = useState(false); const [browseError, setBrowseError] = useState(null); @@ -177,26 +223,114 @@ export function CommandPalette({ const [detailLoading, setDetailLoading] = useState(false); const [detailPath, setDetailPath] = useState(null); const [isDragging, setIsDragging] = useState(false); + const [selectedProjectLocation, setSelectedProjectLocation] = + useState(null); + const [remoteSnapshot, setRemoteSnapshot] = + useState(null); + const [pendingRemoteOpen, setPendingRemoteOpen] = + useState(null); + const [openingPendingRemote, setOpeningPendingRemote] = useState(false); const listRef = useRef(null); const browseRequestRef = useRef(0); const detailRequestRef = useRef(0); const dragCounterRef = useRef(0); + const openIntentRef = useRef<{ + open: boolean; + intent: CommandPaletteIntent; + } | null>(null); + + const remoteLocations = useMemo( + () => + (remoteSnapshot?.connections ?? []) + .filter((connection) => connection.state === "connected") + .map( + ( + connection, + ): ProjectLocation & { status: RemoteRuntimeConnectionStatus } => ({ + kind: "remote", + targetId: connection.target.id, + name: connection.target.name, + status: connection, + }), + ), + [remoteSnapshot], + ); + + const activeProjectLocation = + selectedProjectLocation ?? LOCAL_PROJECT_LOCATION; + const activeRemoteTargetId = + activeProjectLocation.kind === "remote" + ? activeProjectLocation.targetId + : null; + const activeBrowseRoot = activeRemoteTargetId + ? projectBinding?.kind === "remote" && + projectBinding.targetId === activeRemoteTargetId + ? projectBinding.rootPath + : null + : (project?.rootPath ?? null); + const browseMachineName = activeProjectLocation.name; + + const browseDirectoriesForActiveLocation = useCallback( + (input: ProjectBrowseInput) => + activeRemoteTargetId + ? window.ade.remoteRuntime.browseDirectories( + activeRemoteTargetId, + input, + ) + : window.ade.project.browseDirectories(input), + [activeRemoteTargetId], + ); + + const getProjectDetailForActiveLocation = useCallback( + (rootPath: string) => + activeRemoteTargetId + ? window.ade.remoteRuntime.getProjectDetail( + activeRemoteTargetId, + rootPath, + ) + : window.ade.project.getDetail(rootPath), + [activeRemoteTargetId], + ); + + useEffect(() => { + if (!open) return; + const remoteRuntime = window.ade.remoteRuntime; + if (!remoteRuntime?.getConnectionSnapshot) return; + let cancelled = false; + void remoteRuntime + .getConnectionSnapshot() + .then((snapshot) => { + if (!cancelled) setRemoteSnapshot(snapshot); + }) + .catch(() => { + if (!cancelled) setRemoteSnapshot(null); + }); + const unsubscribe = + remoteRuntime.onConnectionSnapshotChanged?.((snapshot) => { + if (!cancelled) setRemoteSnapshot(snapshot); + }) ?? (() => {}); + return () => { + cancelled = true; + unsubscribe(); + }; + }, [open]); const startProjectBrowse = useCallback(() => { setMode("project-browse"); setQ(""); setSelectedIdx(0); - setBrowseInput(defaultBrowseInput(project?.rootPath)); + setBrowseInput(defaultBrowseInput(activeBrowseRoot)); setBrowseResult(null); setBrowseError(null); setBrowseSelectedIdx(0); - }, [project?.rootPath]); + }, [activeBrowseRoot]); const startProjectAdd = useCallback(() => { setMode("project-add"); setQ(""); setActionOutcome(null); + setSelectedProjectLocation(null); }, []); const startProjectCreate = useCallback(() => { @@ -209,7 +343,18 @@ export function CommandPalette({ setActionOutcome(null); }, []); + const startProjectRemote = useCallback(() => { + setMode("project-remote"); + setActionOutcome(null); + }, []); + useEffect(() => { + const previous = openIntentRef.current; + const changed = + previous == null || previous.open !== open || previous.intent !== intent; + openIntentRef.current = { open, intent }; + if (!changed) return; + if (!open) { setMode("default"); setQ(""); @@ -219,6 +364,9 @@ export function CommandPalette({ setOpenProjectPending(false); setSystemPickerPending(false); setActionOutcome(null); + setSelectedProjectLocation(null); + setPendingRemoteOpen(null); + setOpeningPendingRemote(false); return; } @@ -242,11 +390,24 @@ export function CommandPalette({ return; } + if (intent === "project-remote") { + startProjectRemote(); + return; + } + setMode("default"); setQ(""); setSelectedIdx(0); setBrowseError(null); - }, [intent, open, startProjectAdd, startProjectBrowse, startProjectClone, startProjectCreate]); + }, [ + intent, + open, + startProjectAdd, + startProjectBrowse, + startProjectClone, + startProjectCreate, + startProjectRemote, + ]); useEffect(() => { if (!open || mode !== "project-browse") return; @@ -254,7 +415,8 @@ export function CommandPalette({ onOpenChange(false); }; window.addEventListener(PROJECT_BROWSER_CLOSE_EVENT, closeBrowser); - return () => window.removeEventListener(PROJECT_BROWSER_CLOSE_EVENT, closeBrowser); + return () => + window.removeEventListener(PROJECT_BROWSER_CLOSE_EVENT, closeBrowser); }, [mode, onOpenChange, open]); const commands: Command[] = useMemo(() => { @@ -283,22 +445,126 @@ export function CommandPalette({ closeOnRun: false, run: startProjectClone, }, - { id: "go-project", title: "Go to Run", shortcut: "G 1", group: "Navigation", run: () => navigate("/project") }, - { id: "go-lanes", title: "Go to Lanes", shortcut: "G L", group: "Navigation", run: () => navigate("/lanes") }, - { id: "go-files", title: "Go to Files", shortcut: "G F", group: "Navigation", run: () => navigate("/files") }, - { id: "go-work", title: "Go to Work", shortcut: "G T", group: "Navigation", run: () => navigate("/work") }, - { id: "go-graph", title: "Go to Graph", shortcut: "G G", group: "Navigation", run: () => navigate("/graph") }, - { id: "go-prs", title: "Go to PRs", shortcut: "G R", group: "Navigation", run: () => navigate(readStoredPrsRoute(project?.rootPath) ?? "/prs") }, - { id: "go-history", title: "Go to History", shortcut: "G H", group: "Navigation", run: () => navigate("/history") }, - { id: "go-missions", title: "Go to Missions", shortcut: "G M", group: "Navigation", run: () => navigate("/missions") }, - { id: "go-automations", title: "Go to Automations", hint: "Automation rules and agent workflows", group: "Navigation", run: () => navigate("/automations") }, - { id: "go-settings", title: "Go to Settings", shortcut: "G S", group: "Navigation", run: () => navigate("/settings") }, - { id: "go-settings-general", title: "Go to General Settings", hint: "Setup reminder, app info", group: "Settings", run: () => navigate("/settings?tab=general") }, - { id: "go-settings-appearance", title: "Go to Appearance", hint: "Theme, chat font size, chat notifications", group: "Settings", run: () => navigate("/settings?tab=appearance") }, - { id: "go-settings-ai", title: "Go to AI Settings", hint: "Providers, models, AI defaults", group: "Settings", run: () => navigate("/settings?tab=ai") }, - { id: "go-settings-integrations", title: "Go to Integrations", hint: "GitHub, Linear, computer use", group: "Settings", run: () => navigate("/settings?tab=integrations") }, - { id: "go-settings-workspace", title: "Go to Workspace Settings", hint: "Project health and docs generation", group: "Settings", run: () => navigate("/settings?tab=workspace") }, - { id: "go-settings-usage", title: "Go to Usage", hint: "Token usage, cost breakdown", group: "Settings", run: () => navigate("/settings?tab=usage") }, + { + id: "project-remote", + title: "Connect to remote machine", + hint: "Register an SSH target and list its ADE projects", + group: "Projects", + closeOnRun: false, + run: startProjectRemote, + }, + { + id: "go-project", + title: "Go to Run", + shortcut: "G 1", + group: "Navigation", + run: () => navigate("/project"), + }, + { + id: "go-lanes", + title: "Go to Lanes", + shortcut: "G L", + group: "Navigation", + run: () => navigate("/lanes"), + }, + { + id: "go-files", + title: "Go to Files", + shortcut: "G F", + group: "Navigation", + run: () => navigate("/files"), + }, + { + id: "go-work", + title: "Go to Work", + shortcut: "G T", + group: "Navigation", + run: () => navigate("/work"), + }, + { + id: "go-graph", + title: "Go to Graph", + shortcut: "G G", + group: "Navigation", + run: () => navigate("/graph"), + }, + { + id: "go-prs", + title: "Go to PRs", + shortcut: "G R", + group: "Navigation", + run: () => navigate(readStoredPrsRoute(project?.rootPath) ?? "/prs"), + }, + { + id: "go-history", + title: "Go to History", + shortcut: "G H", + group: "Navigation", + run: () => navigate("/history"), + }, + { + id: "go-missions", + title: "Go to Missions", + shortcut: "G M", + group: "Navigation", + run: () => navigate("/missions"), + }, + { + id: "go-automations", + title: "Go to Automations", + hint: "Automation rules and agent workflows", + group: "Navigation", + run: () => navigate("/automations"), + }, + { + id: "go-settings", + title: "Go to Settings", + shortcut: "G S", + group: "Navigation", + run: () => navigate("/settings"), + }, + { + id: "go-settings-general", + title: "Go to General Settings", + hint: "Setup reminder, app info", + group: "Settings", + run: () => navigate("/settings?tab=general"), + }, + { + id: "go-settings-appearance", + title: "Go to Appearance", + hint: "Theme, chat font size, chat notifications", + group: "Settings", + run: () => navigate("/settings?tab=appearance"), + }, + { + id: "go-settings-ai", + title: "Go to AI Settings", + hint: "Providers, models, AI defaults", + group: "Settings", + run: () => navigate("/settings?tab=ai"), + }, + { + id: "go-settings-integrations", + title: "Go to Integrations", + hint: "GitHub, Linear, computer use", + group: "Settings", + run: () => navigate("/settings?tab=integrations"), + }, + { + id: "go-settings-workspace", + title: "Go to Workspace Settings", + hint: "Project health and docs generation", + group: "Settings", + run: () => navigate("/settings?tab=workspace"), + }, + { + id: "go-settings-usage", + title: "Go to Usage", + hint: "Token usage, cost breakdown", + group: "Settings", + run: () => navigate("/settings?tab=usage"), + }, { id: "action-create-lane", title: "Create Lane", @@ -334,8 +600,11 @@ export function CommandPalette({ group: "Lanes", run: () => { if (!lanes.length) return; - const currentIdx = lanes.findIndex((lane) => lane.id === selectedLaneId); - const nextLane = lanes[(currentIdx + 1 + lanes.length) % lanes.length]; + const currentIdx = lanes.findIndex( + (lane) => lane.id === selectedLaneId, + ); + const nextLane = + lanes[(currentIdx + 1 + lanes.length) % lanes.length]; if (!nextLane) return; selectLane(nextLane.id); navigate(`/lanes?laneId=${encodeURIComponent(nextLane.id)}`); @@ -348,8 +617,11 @@ export function CommandPalette({ group: "Lanes", run: () => { if (!lanes.length) return; - const currentIdx = lanes.findIndex((lane) => lane.id === selectedLaneId); - const nextLane = lanes[(currentIdx - 1 + lanes.length) % lanes.length]; + const currentIdx = lanes.findIndex( + (lane) => lane.id === selectedLaneId, + ); + const nextLane = + lanes[(currentIdx - 1 + lanes.length) % lanes.length]; if (!nextLane) return; selectLane(nextLane.id); navigate(`/lanes?laneId=${encodeURIComponent(nextLane.id)}`); @@ -374,7 +646,7 @@ export function CommandPalette({ { id: "ping", title: "Ping preload bridge", - hint: "Expect \"pong\"", + hint: 'Expect "pong"', group: "Debug", run: async () => { await window.ade.app.ping(); @@ -388,6 +660,7 @@ export function CommandPalette({ command.id === "project-browse" || command.id === "project-create" || command.id === "project-clone" || + command.id === "project-remote" || command.id === "go-project" || command.id === "ping", ); @@ -404,13 +677,16 @@ export function CommandPalette({ startProjectBrowse, startProjectClone, startProjectCreate, + startProjectRemote, ]); const filtered = useMemo(() => { const needle = q.trim().toLowerCase(); if (!needle) return commands; - return commands.filter((command) => - command.title.toLowerCase().includes(needle) || (command.hint ?? "").toLowerCase().includes(needle) + return commands.filter( + (command) => + command.title.toLowerCase().includes(needle) || + (command.hint ?? "").toLowerCase().includes(needle), ); }, [commands, q]); @@ -456,31 +732,44 @@ export function CommandPalette({ }, [browseResult]); const openableProjectRoot = browseResult?.openableProjectRoot ?? null; - const isCurrentProjectTarget = Boolean(openableProjectRoot && project?.rootPath === openableProjectRoot); - const canOpenProject = Boolean(openableProjectRoot) && !isCurrentProjectTarget; + const isCurrentProjectTarget = Boolean( + openableProjectRoot && activeBrowseRoot === openableProjectRoot, + ); + const canOpenProject = + Boolean(openableProjectRoot) && !isCurrentProjectTarget; const openProjectLabel = isCurrentProjectTarget ? "Already open" : "Open"; - const highlightedRow = browseSelectedIdx >= 0 ? (browseRows[browseSelectedIdx] ?? null) : null; + const highlightedRow = + browseSelectedIdx >= 0 ? (browseRows[browseSelectedIdx] ?? null) : null; const highlightedPath = useMemo(() => { if (highlightedRow && highlightedRow.kind === "directory") { return stripTrailingSeparator(highlightedRow.path); } if (openableProjectRoot) return openableProjectRoot; - if (browseResult?.exactDirectoryPath) return browseResult.exactDirectoryPath; + if (browseResult?.exactDirectoryPath) + return browseResult.exactDirectoryPath; return null; }, [browseResult?.exactDirectoryPath, highlightedRow, openableProjectRoot]); - const highlightedIsRepo = highlightedRow?.kind === "directory" - ? highlightedRow.isGitRepo - : Boolean(openableProjectRoot && highlightedPath && highlightedPath === openableProjectRoot); + const highlightedIsRepo = + highlightedRow?.kind === "directory" + ? highlightedRow.isGitRepo + : Boolean( + openableProjectRoot && + highlightedPath && + highlightedPath === openableProjectRoot, + ); const detailTarget = highlightedPath; - const openTarget = highlightedIsRepo && highlightedRow?.kind === "directory" && highlightedPath - ? highlightedPath - : openableProjectRoot; + const openTarget = + highlightedIsRepo && highlightedRow?.kind === "directory" && highlightedPath + ? highlightedPath + : openableProjectRoot; const openTargetLabel = openTarget ? pathLabel(openTarget) : null; - const canOpenHighlighted = Boolean(openTarget) && openTarget !== project?.rootPath; - const isMac = typeof navigator !== "undefined" && /mac/i.test(navigator.platform); + const canOpenHighlighted = + Boolean(openTarget) && openTarget !== activeBrowseRoot; + const isMac = + typeof navigator !== "undefined" && /mac/i.test(navigator.platform); const openShortcutLabel = `${isMac ? "⌘" : "Ctrl"}↵`; useEffect(() => { @@ -489,16 +778,26 @@ export function CommandPalette({ setBrowseLoading(true); setBrowseError(null); const timeout = globalThis.setTimeout(() => { - void window.ade.project - .browseDirectories({ - partialPath: browseInput, - cwd: project?.rootPath ?? null, - limit: 200, - }) + void Promise.resolve() + .then(() => + browseDirectoriesForActiveLocation({ + partialPath: browseInput, + cwd: activeBrowseRoot, + limit: 200, + }), + ) .then((result) => { if (browseRequestRef.current !== requestId) return; + if (!result) + throw new Error("Project browser did not return a result."); setBrowseResult(result); - setBrowseSelectedIdx(result.openableProjectRoot ? -1 : (result.parentPath || result.entries.length > 0 ? 0 : -1)); + setBrowseSelectedIdx( + result.openableProjectRoot + ? -1 + : result.parentPath || result.entries.length > 0 + ? 0 + : -1, + ); }) .catch((error) => { if (browseRequestRef.current !== requestId) return; @@ -514,7 +813,13 @@ export function CommandPalette({ return () => { globalThis.clearTimeout(timeout); }; - }, [browseInput, mode, open, project?.rootPath]); + }, [ + activeBrowseRoot, + browseDirectoriesForActiveLocation, + browseInput, + mode, + open, + ]); useEffect(() => { if (mode !== "default") return; @@ -545,7 +850,7 @@ export function CommandPalette({ setDetailPath(detailTarget); const timeout = globalThis.setTimeout(() => { void Promise.resolve() - .then(() => window.ade.project.getDetail(detailTarget)) + .then(() => getProjectDetailForActiveLocation(detailTarget)) .then((result) => { if (detailRequestRef.current !== requestId) return; setDetail(result); @@ -562,7 +867,14 @@ export function CommandPalette({ return () => { globalThis.clearTimeout(timeout); }; - }, [detail, detailTarget, highlightedIsRepo, mode, open]); + }, [ + detail, + detailTarget, + getProjectDetailForActiveLocation, + highlightedIsRepo, + mode, + open, + ]); useEffect(() => { if (mode !== "project-browse") return; @@ -574,12 +886,18 @@ export function CommandPalette({ setBrowseSelectedIdx(-1); return; } - if (!openableProjectRoot && browseSelectedIdx < 0 && browseRows.length > 0) { + if ( + !openableProjectRoot && + browseSelectedIdx < 0 && + browseRows.length > 0 + ) { setBrowseSelectedIdx(0); return; } if (browseSelectedIdx >= browseRows.length) { - setBrowseSelectedIdx(openableProjectRoot ? -1 : Math.max(0, browseRows.length - 1)); + setBrowseSelectedIdx( + openableProjectRoot ? -1 : Math.max(0, browseRows.length - 1), + ); } }, [browseRows.length, browseSelectedIdx, mode, openableProjectRoot]); @@ -587,7 +905,10 @@ export function CommandPalette({ if (!listRef.current || idx < 0) return; const items = listRef.current.querySelectorAll("[data-cmd-item]"); const target = items[idx]; - if (target instanceof HTMLElement && typeof target.scrollIntoView === "function") { + if ( + target instanceof HTMLElement && + typeof target.scrollIntoView === "function" + ) { target.scrollIntoView({ block: "nearest" }); } }, []); @@ -611,7 +932,7 @@ export function CommandPalette({ console.error("Command palette command failed", error); }); }, - [onOpenChange] + [onOpenChange], ); const activateBrowseRow = useCallback((row: BrowseRow) => { @@ -621,12 +942,38 @@ export function CommandPalette({ const handleOpenProject = useCallback( async (targetPath: string | null | undefined) => { - const nextTarget = typeof targetPath === "string" ? targetPath.trim() : ""; + const nextTarget = + typeof targetPath === "string" ? targetPath.trim() : ""; if (!nextTarget) return; setBrowseError(null); setOpenProjectPending(true); try { - await switchProjectToPath(nextTarget); + if (activeRemoteTargetId) { + const remoteProject = await window.ade.remoteRuntime.addProject( + activeRemoteTargetId, + nextTarget, + ); + const localWork = + await window.ade.remoteRuntime.checkLocalWork( + activeRemoteTargetId, + remoteProject, + ); + if (localWork.hasDirtyWork) { + setPendingRemoteOpen({ + targetId: activeRemoteTargetId, + runtimeName: browseMachineName, + project: remoteProject, + localWork, + }); + return; + } + await switchRemoteProject( + activeRemoteTargetId, + remoteProject.projectId, + ); + } else { + await switchProjectToPath(nextTarget); + } onOpenChange(false); } catch (error) { setBrowseError(extractError(error)); @@ -634,16 +981,43 @@ export function CommandPalette({ setOpenProjectPending(false); } }, - [onOpenChange, switchProjectToPath] + [ + activeRemoteTargetId, + browseMachineName, + onOpenChange, + switchProjectToPath, + switchRemoteProject, + ], ); + const confirmPendingRemoteOpen = useCallback(async () => { + if (!pendingRemoteOpen) return; + setOpeningPendingRemote(true); + setBrowseError(null); + try { + await switchRemoteProject( + pendingRemoteOpen.targetId, + pendingRemoteOpen.project.projectId, + ); + setPendingRemoteOpen(null); + onOpenChange(false); + } catch (error) { + setBrowseError(extractError(error)); + } finally { + setOpeningPendingRemote(false); + } + }, [onOpenChange, pendingRemoteOpen, switchRemoteProject]); + const handleChooseInSystemPicker = useCallback(async () => { setBrowseError(null); setSystemPickerPending(true); try { const selected = await window.ade.project.chooseDirectory({ title: "Open project", - defaultPath: browseResult?.exactDirectoryPath ?? browseResult?.directoryPath ?? undefined, + defaultPath: + browseResult?.exactDirectoryPath ?? + browseResult?.directoryPath ?? + undefined, }); if (!selected) return; await handleOpenProject(selected); @@ -652,7 +1026,11 @@ export function CommandPalette({ } finally { setSystemPickerPending(false); } - }, [browseResult?.directoryPath, browseResult?.exactDirectoryPath, handleOpenProject]); + }, [ + browseResult?.directoryPath, + browseResult?.exactDirectoryPath, + handleOpenProject, + ]); const handleDefaultKeyDown = useCallback( (event: React.KeyboardEvent) => { @@ -665,7 +1043,9 @@ export function CommandPalette({ if (event.key === "ArrowUp") { if (filtered.length === 0) return; event.preventDefault(); - setSelectedIdx((prev) => (prev - 1 + filtered.length) % filtered.length); + setSelectedIdx( + (prev) => (prev - 1 + filtered.length) % filtered.length, + ); return; } if (event.key === "Enter") { @@ -675,7 +1055,7 @@ export function CommandPalette({ runCommand(command); } }, - [filtered, runCommand, selectedIdx] + [filtered, runCommand, selectedIdx], ); const handleBrowseKeyDown = useCallback( @@ -715,7 +1095,15 @@ export function CommandPalette({ } } }, - [activateBrowseRow, browseRows, browseSelectedIdx, canOpenProject, handleOpenProject, openTarget, openableProjectRoot] + [ + activateBrowseRow, + browseRows, + browseSelectedIdx, + canOpenProject, + handleOpenProject, + openTarget, + openableProjectRoot, + ], ); const handleDragEnter = useCallback((event: React.DragEvent) => { @@ -753,19 +1141,23 @@ export function CommandPalette({ const requestId = ++browseRequestRef.current; setBrowseLoading(true); setBrowseError(null); - void window.ade.project - .browseDirectories({ - partialPath: nextBrowseInput, - cwd: project?.rootPath ?? null, - limit: 200, - }) + void Promise.resolve() + .then(() => + browseDirectoriesForActiveLocation({ + partialPath: nextBrowseInput, + cwd: activeBrowseRoot, + limit: 200, + }), + ) .then((result) => { if (browseRequestRef.current !== requestId) return; + if (!result) + throw new Error("Project browser did not return a result."); const nextTarget = - result.openableProjectRoot - ?? result.exactDirectoryPath - ?? result.directoryPath - ?? droppedPath; + result.openableProjectRoot ?? + result.exactDirectoryPath ?? + result.directoryPath ?? + droppedPath; if (nextTarget) { void handleOpenProject(nextTarget); return; @@ -781,7 +1173,7 @@ export function CommandPalette({ setBrowseLoading(false); }); }, - [handleOpenProject, project?.rootPath] + [activeBrowseRoot, browseDirectoriesForActiveLocation, handleOpenProject], ); const isBrowsing = mode === "project-browse"; @@ -789,35 +1181,47 @@ export function CommandPalette({ mode === "project-add" || mode === "project-create" || mode === "project-clone" || + mode === "project-remote" || mode === "project-success"; - const isWideAddFlow = mode === "project-clone"; + const isWideAddFlow = mode === "project-clone" || mode === "project-remote"; const resultHeightClass = isBrowsing ? "h-[620px] max-h-[86vh]" : isAddFlow - ? "max-h-[86vh]" - : "max-h-[400px]"; + ? "max-h-[86vh]" + : "max-h-[400px]"; const widthClass = isBrowsing ? "w-[1080px]" : isWideAddFlow - ? "w-[820px]" - : isAddFlow - ? "w-[640px]" - : "w-[680px]"; + ? "w-[820px]" + : isAddFlow + ? "w-[640px]" + : "w-[680px]"; const positionClass = isBrowsing ? "fixed inset-0 z-[130] m-auto" : isAddFlow - ? "fixed inset-0 z-[130] m-auto h-fit" - : "fixed left-1/2 top-[12%] z-[130] -translate-x-1/2"; + ? "fixed inset-0 z-[130] m-auto h-fit" + : "fixed left-1/2 top-[12%] z-[130] -translate-x-1/2"; const inputPlaceholder = isBrowsing - ? "Paste a path, type to filter, or drop a folder anywhere…" + ? activeRemoteTargetId + ? `Browse ${browseMachineName} by path…` + : "Paste a path, type to filter, or drop a folder anywhere…" : "Search commands..."; const handleProjectActionSuccess = useCallback( - (verb: "Created" | "Cloned", result: { rootPath: string; displayName: string }) => { - setActionOutcome({ verb, displayName: result.displayName, rootPath: result.rootPath }); + ( + verb: "Created" | "Cloned", + result: { rootPath: string; displayName: string; projectId?: string }, + ) => { + setActionOutcome({ + verb, + displayName: result.displayName, + rootPath: result.rootPath, + projectId: result.projectId, + location: activeProjectLocation, + }); setMode("project-success"); }, - [], + [activeProjectLocation], ); const handleSuccessOpen = useCallback(async () => { @@ -826,30 +1230,50 @@ export function CommandPalette({ return; } try { - await switchProjectToPath(actionOutcome.rootPath); + if (actionOutcome.location.kind === "remote" && actionOutcome.projectId) { + await switchRemoteProject( + actionOutcome.location.targetId, + actionOutcome.projectId, + ); + } else { + await switchProjectToPath(actionOutcome.rootPath); + } } catch (error) { console.error("Failed to open new project", error); } onOpenChange(false); - }, [actionOutcome, onOpenChange, switchProjectToPath]); + }, [actionOutcome, onOpenChange, switchProjectToPath, switchRemoteProject]); const handleSuccessStay = useCallback(() => { onOpenChange(false); }, [onOpenChange]); - const addFlowTitle = - mode === "project-add" - ? "Add a project" - : mode === "project-create" - ? "Create a new project" - : mode === "project-clone" - ? "Clone from GitHub" - : actionOutcome - ? `${actionOutcome.verb}!` - : ""; + let addFlowTitle = ""; + switch (mode) { + case "project-add": + addFlowTitle = selectedProjectLocation + ? `Add a project on ${browseMachineName}` + : "Add a project"; + break; + case "project-create": + addFlowTitle = `Create a new project${activeRemoteTargetId ? ` on ${browseMachineName}` : ""}`; + break; + case "project-clone": + addFlowTitle = `Clone from GitHub${activeRemoteTargetId ? ` on ${browseMachineName}` : ""}`; + break; + case "project-remote": + addFlowTitle = "Connect to a machine"; + break; + default: + if (actionOutcome) addFlowTitle = `${actionOutcome.verb}!`; + } const showAddFlowBack = - mode === "project-create" || mode === "project-clone" || mode === "project-success"; + (mode === "project-add" && selectedProjectLocation !== null) || + mode === "project-create" || + mode === "project-clone" || + mode === "project-remote" || + mode === "project-success"; return ( @@ -887,7 +1311,7 @@ export function CommandPalette({ "max-w-[96vw]", resultHeightClass, "overflow-hidden rounded-2xl", - "flex flex-col focus:outline-none" + "flex flex-col focus:outline-none", )} style={{ background: @@ -905,10 +1329,24 @@ export function CommandPalette({ initial="initial" animate="animate" exit="exit" - onDragEnter={isBrowsing ? handleDragEnter : undefined} - onDragOver={isBrowsing ? handleDragOver : undefined} - onDragLeave={isBrowsing ? handleDragLeave : undefined} - onDrop={isBrowsing ? handleDrop : undefined} + onDragEnter={ + isBrowsing && !activeRemoteTargetId + ? handleDragEnter + : undefined + } + onDragOver={ + isBrowsing && !activeRemoteTargetId + ? handleDragOver + : undefined + } + onDragLeave={ + isBrowsing && !activeRemoteTargetId + ? handleDragLeave + : undefined + } + onDrop={ + isBrowsing && !activeRemoteTargetId ? handleDrop : undefined + } > {isBrowsing && (
{mode === "project-browse" ? "Browse folders in ADE and open a Git repository without leaving the app." : isAddFlow - ? "Open, create, or clone a project." - : "Search ADE commands and jump to actions quickly."} + ? "Open, create, clone, or connect to a project." + : "Search ADE commands and jump to actions quickly."} {isAddFlow ? ( @@ -955,7 +1394,11 @@ export function CommandPalette({ type="button" onClick={() => { setActionOutcome(null); - setMode("project-add"); + if (mode === "project-add") { + setSelectedProjectLocation(null); + } else { + setMode("project-add"); + } }} className="inline-flex h-8 items-center justify-center gap-1.5 rounded-md border border-transparent px-2 text-xs font-medium text-[var(--color-muted-fg)] transition-colors hover:border-[var(--color-border)] hover:bg-[var(--color-muted)] hover:text-[var(--color-fg)]" aria-label="Back to chooser" @@ -983,13 +1426,21 @@ export function CommandPalette({
- + { if (isBrowsing) { @@ -1000,11 +1451,13 @@ export function CommandPalette({ setQ(event.target.value); setSelectedIdx(0); }} - onKeyDown={isBrowsing ? handleBrowseKeyDown : handleDefaultKeyDown} + onKeyDown={ + isBrowsing ? handleBrowseKeyDown : handleDefaultKeyDown + } placeholder={inputPlaceholder} className={cn( "h-[56px] w-full bg-transparent text-[15px] text-[var(--color-fg)] outline-none placeholder:text-[var(--color-muted-fg)]", - !isBrowsing && "font-mono" + !isBrowsing && "font-mono", )} autoFocus /> @@ -1017,27 +1470,108 @@ export function CommandPalette({ {isAddFlow ? (
{mode === "project-add" ? ( - { - if (choice === "open") { - startProjectBrowse(); - } else if (choice === "create") { - startProjectCreate(); - } else { - startProjectClone(); - } - }} - /> + selectedProjectLocation === null && + remoteLocations.length > 0 ? ( + { + setSelectedProjectLocation(location); + }} + /> + ) : ( + { + if (choice === "open") { + startProjectBrowse(); + } else if (choice === "create") { + startProjectCreate(); + } else { + startProjectClone(); + } + }} + /> + ) ) : mode === "project-create" ? ( + window.ade.remoteRuntime.getDefaultParentDir( + activeRemoteTargetId, + ) + : undefined + } + browseDirectories={ + activeRemoteTargetId + ? (input) => + window.ade.remoteRuntime.browseDirectories( + activeRemoteTargetId, + input, + ) + : undefined + } + chooseDirectory={ + activeRemoteTargetId ? null : undefined + } + createProject={ + activeRemoteTargetId + ? (input) => + window.ade.remoteRuntime.createProject( + activeRemoteTargetId, + input, + ) + : undefined + } onCancel={() => setMode("project-add")} - onCreated={(result) => handleProjectActionSuccess("Created", result)} + onCreated={(result) => + handleProjectActionSuccess("Created", result) + } /> ) : mode === "project-clone" ? ( + window.ade.remoteRuntime.getDefaultParentDir( + activeRemoteTargetId, + ) + : undefined + } + browseDirectories={ + activeRemoteTargetId + ? (input) => + window.ade.remoteRuntime.browseDirectories( + activeRemoteTargetId, + input, + ) + : undefined + } + chooseDirectory={ + activeRemoteTargetId ? null : undefined + } + cloneProject={ + activeRemoteTargetId + ? (input) => + window.ade.remoteRuntime.cloneProject( + activeRemoteTargetId, + input, + ) + : undefined + } + allowTokenSetup={true} onCancel={() => setMode("project-add")} - onCloned={(result) => handleProjectActionSuccess("Cloned", result)} + onCloned={(result) => + handleProjectActionSuccess("Cloned", result) + } /> + ) : mode === "project-remote" ? ( + ) : mode === "project-success" && actionOutcome ? ( {browseLoading && !browseResult ? (
- + Scanning folders…
) : browseRows.length === 0 ? ( @@ -1079,7 +1617,7 @@ export function CommandPalette({ "mx-2 flex w-[calc(100%-1rem)] items-center justify-between gap-3 rounded-lg border px-3 py-2 text-left transition-all duration-150", isSelected ? "border-[var(--color-accent)] bg-[color-mix(in_srgb,var(--color-accent)_14%,transparent)] -translate-y-[0.5px]" - : "border-transparent hover:border-[color-mix(in_srgb,var(--color-accent)_20%,var(--color-border))] hover:bg-[color-mix(in_srgb,var(--color-accent)_5%,transparent)]" + : "border-transparent hover:border-[color-mix(in_srgb,var(--color-accent)_20%,var(--color-border))] hover:bg-[color-mix(in_srgb,var(--color-accent)_5%,transparent)]", )} style={ isSelected @@ -1089,7 +1627,9 @@ export function CommandPalette({ } : undefined } - onMouseEnter={() => setBrowseSelectedIdx(index)} + onMouseEnter={() => + setBrowseSelectedIdx(index) + } onClick={() => activateBrowseRow(row)} >
@@ -1105,18 +1645,29 @@ export function CommandPalette({ style={{ background: "linear-gradient(135deg, rgba(167,139,250,0.30), rgba(167,139,250,0.08))", - boxShadow: "0 0 0 1px rgba(167,139,250,0.30) inset", + boxShadow: + "0 0 0 1px rgba(167,139,250,0.30) inset", }} > - + ) : ( - + )}
-
{row.title}
+
+ {row.title} +
{row.hint}
@@ -1127,7 +1678,9 @@ export function CommandPalette({ weight="regular" className={cn( "shrink-0 transition-opacity", - isSelected ? "opacity-100 text-[var(--color-accent)]" : "opacity-40 text-[var(--color-muted-fg)]" + isSelected + ? "opacity-100 text-[var(--color-accent)]" + : "opacity-40 text-[var(--color-muted-fg)]", )} /> @@ -1145,7 +1698,7 @@ export function CommandPalette({ highlightedPath={highlightedPath} highlightedIsRepo={highlightedIsRepo} browseResult={browseResult} - activeProjectPath={project?.rootPath ?? null} + activeProjectPath={activeBrowseRoot} />
@@ -1158,7 +1711,11 @@ export function CommandPalette({ }} >
- + Drop to open
@@ -1169,7 +1726,8 @@ export function CommandPalette({ style={{ background: "linear-gradient(180deg, color-mix(in srgb, var(--color-surface-recessed) 92%, rgba(167,139,250,0.06)), var(--color-surface-recessed))", - borderColor: "color-mix(in srgb, var(--color-accent) 12%, var(--color-border))", + borderColor: + "color-mix(in srgb, var(--color-accent) 12%, var(--color-border))", }} >
@@ -1182,33 +1740,45 @@ export function CommandPalette({ Already open. ) : ( <> - ↑↓ + + ↑↓ + navigate - + + ↵ + step in - {openShortcutLabel} + + {openShortcutLabel} + open directory )}
- + {!activeRemoteTargetId ? ( + + ) : null}
@@ -1237,7 +1817,9 @@ export function CommandPalette({ ) : (
{filtered.length === 0 ? ( -
No matches.
+
+ No matches. +
) : (
    {(() => { @@ -1260,9 +1842,11 @@ export function CommandPalette({ "mx-2 flex w-[calc(100%-1rem)] items-center justify-between gap-3 rounded-lg border px-3 py-2.5 text-left transition-colors", isSelected ? "border-[var(--color-accent)] bg-[var(--color-accent-muted)]" - : "border-transparent hover:border-[var(--color-border)] hover:bg-[var(--color-muted)]" + : "border-transparent hover:border-[var(--color-border)] hover:bg-[var(--color-muted)]", )} - onMouseEnter={() => setSelectedIdx(index)} + onMouseEnter={() => + setSelectedIdx(index) + } onClick={() => runCommand(command)} >
    @@ -1270,7 +1854,9 @@ export function CommandPalette({ {command.title}
    {command.hint ? ( -
    {command.hint}
    +
    + {command.hint} +
    ) : null}
@@ -1279,7 +1865,11 @@ export function CommandPalette({ {command.shortcut} ) : null} - +
@@ -1293,6 +1883,18 @@ export function CommandPalette({ )}
)} + {pendingRemoteOpen ? ( + setPendingRemoteOpen(null)} + onContinue={() => { + void confirmPendingRemoteOpen(); + }} + /> + ) : null} @@ -1302,6 +1904,76 @@ export function CommandPalette({ ); } +function ProjectLocationChooser({ + remoteLocations, + onChoose, +}: { + remoteLocations: Array< + ProjectLocation & { status: RemoteRuntimeConnectionStatus } + >; + onChoose: (location: ProjectLocation) => void; +}) { + const locations: Array< + ProjectLocation & { status?: RemoteRuntimeConnectionStatus } + > = [LOCAL_PROJECT_LOCATION, ...remoteLocations]; + return ( +
+ {locations.map((location) => { + const isRemote = location.kind === "remote"; + const key = isRemote ? location.targetId : location.id; + const status = isRemote ? location.status : null; + return ( + + ); + })} +
+ ); +} + type BrowsePreviewProps = { detail: ProjectDetail | null; detailLoading: boolean; @@ -1322,14 +1994,19 @@ function BrowsePreview({ activeProjectPath, }: BrowsePreviewProps) { const showingDetailForPath = detailPath === highlightedPath ? detail : null; - const isLoading = detailLoading && detailPath === highlightedPath && !showingDetailForPath; + const isLoading = + detailLoading && detailPath === highlightedPath && !showingDetailForPath; if (!highlightedPath) { return (
- +

Pick a folder to see its repo details, or drop one here.

@@ -1363,21 +2040,33 @@ function BrowsePreview({ boxShadow: "0 0 0 1px rgba(167,139,250,0.35) inset", }} > - + ) : ( - + )} -

{displayName}

+

+ {displayName} +

{isActiveProject && ( Open now )}
-
{highlightedPath}
+
+ {highlightedPath} +
{isLoading ? ( @@ -1397,29 +2086,39 @@ function BrowsePreview({ } function RepoDetailBlocks({ detail }: { detail: ProjectDetail }) { - const lastCommitRelative = detail.lastCommit ? relativeFromNow(detail.lastCommit.isoDate) : null; + const lastCommitRelative = detail.lastCommit + ? relativeFromNow(detail.lastCommit.isoDate) + : null; const lastOpenedRelative = relativeFromNow(detail.lastOpenedAt); return ( <>
{detail.branchName && ( - } tone="accent"> + } + tone="accent" + > {detail.branchName} )} - {detail.aheadBehind && (detail.aheadBehind.ahead > 0 || detail.aheadBehind.behind > 0) && ( - - {detail.aheadBehind.ahead > 0 ? `↑${detail.aheadBehind.ahead} ` : ""} - {detail.aheadBehind.behind > 0 ? `↓${detail.aheadBehind.behind}` : ""} - - )} + {detail.aheadBehind && + (detail.aheadBehind.ahead > 0 || detail.aheadBehind.behind > 0) && ( + + {detail.aheadBehind.ahead > 0 + ? `↑${detail.aheadBehind.ahead} ` + : ""} + {detail.aheadBehind.behind > 0 + ? `↓${detail.aheadBehind.behind}` + : ""} + + )} {typeof detail.dirtyCount === "number" && detail.dirtyCount > 0 && ( {detail.dirtyCount} uncommitted )} - {typeof detail.dirtyCount === "number" && detail.dirtyCount === 0 && detail.branchName && ( - clean - )} + {typeof detail.dirtyCount === "number" && + detail.dirtyCount === 0 && + detail.branchName && clean} {typeof detail.laneCount === "number" && detail.laneCount > 0 && ( } tone="muted"> {detail.laneCount} lane{detail.laneCount === 1 ? "" : "s"} @@ -1437,7 +2136,9 @@ function RepoDetailBlocks({ detail }: { detail: ProjectDetail }) {
Last commit
-
{detail.lastCommit.subject}
+
+ {detail.lastCommit.subject} +
{detail.lastCommit.shortSha} {lastCommitRelative && · {lastCommitRelative}} @@ -1461,13 +2162,17 @@ function RepoDetailBlocks({ detail }: { detail: ProjectDetail }) {
{detail.languages.map((lang) => { - const color = LANGUAGE_SWATCHES[lang.name] ?? "var(--color-accent)"; + const color = + LANGUAGE_SWATCHES[lang.name] ?? "var(--color-accent)"; return ( - + {lang.name} {Math.round(lang.fraction * 100)}% @@ -1491,19 +2196,24 @@ function PlainDirectoryBlock({ highlightedPath: string; detail: ProjectDetail | null; }) { - const subCount = detail?.subdirectoryCount ?? (browseResult?.exactDirectoryPath === highlightedPath - ? browseResult.entries.length - : null); + const subCount = + detail?.subdirectoryCount ?? + (browseResult?.exactDirectoryPath === highlightedPath + ? browseResult.entries.length + : null); return (
Plain folder {typeof subCount === "number" && ( - {subCount} subfolder{subCount === 1 ? "" : "s"} + + {subCount} subfolder{subCount === 1 ? "" : "s"} + )}

- No git repository here. Step into a subfolder, paste a path, or drop a folder to force-open. + No git repository here. Step into a subfolder, paste a path, or drop a + folder to force-open.

); @@ -1511,20 +2221,36 @@ function PlainDirectoryBlock({ const README_COMPONENTS: Components = { h1: ({ children }) => ( -

{children}

+

+ {children} +

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

{children}

+

+ {children} +

), h3: ({ children }) => ( -
{children}
+
+ {children} +
), h4: ({ children }) => ( -
{children}
+
+ {children} +
), p: ({ children }) =>

{children}

, - ul: ({ children }) =>
    {children}
, - ol: ({ children }) =>
    {children}
, + ul: ({ children }) => ( +
    + {children} +
+ ), + ol: ({ children }) => ( +
    + {children} +
+ ), li: ({ children }) =>
  • {children}
  • , a: ({ children, href }) => ( ), th: ({ children }) => ( - {children} + + {children} + + ), + td: ({ children }) => ( + + {children} + ), - td: ({ children }) => {children}, img: () => null, }; @@ -1611,8 +2343,10 @@ function StatusChip({ const toneStyle = tone === "accent" ? { - background: "color-mix(in srgb, var(--color-accent) 14%, transparent)", - borderColor: "color-mix(in srgb, var(--color-accent) 40%, var(--color-border))", + background: + "color-mix(in srgb, var(--color-accent) 14%, transparent)", + borderColor: + "color-mix(in srgb, var(--color-accent) 40%, var(--color-border))", color: "var(--color-accent)", } : tone === "warn" diff --git a/apps/desktop/src/renderer/components/app/SettingsPage.tsx b/apps/desktop/src/renderer/components/app/SettingsPage.tsx index bf9dbe510..e3505be42 100644 --- a/apps/desktop/src/renderer/components/app/SettingsPage.tsx +++ b/apps/desktop/src/renderer/components/app/SettingsPage.tsx @@ -15,6 +15,7 @@ import { COLORS, MONO_FONT, SANS_FONT, LABEL_STYLE, cardStyle, outlineButton, pr import { ConfirmDialog, PromptDialog, useConfirmDialog, usePromptDialog } from "../shared/InlineDialogs"; import type { PhaseProfile, PhaseCard } from "../../../shared/types"; import { PhaseCardEditor } from "../missions/PhaseCardEditor"; +import { useAppStore } from "../../state/appStore"; const SECTIONS = [ { id: "general", label: "General", icon: GearSix }, @@ -448,10 +449,16 @@ function PhaseProfilesSection() { export function SettingsPage() { const location = useLocation(); const [searchParams, setSearchParams] = useSearchParams(); + const projectBinding = useAppStore((s) => s.projectBinding); + const memoryAvailable = projectBinding?.kind !== "remote"; + const visibleSections = memoryAvailable + ? SECTIONS + : SECTIONS.filter((entry) => entry.id !== "memory"); const tabParam = searchParams.get("tab"); - const canonicalTab = tabParam && SECTIONS.some((s) => s.id === tabParam) + const canonicalTab = tabParam && visibleSections.some((s) => s.id === tabParam) ? (tabParam as SectionId) : tabParam && TAB_ALIASES[tabParam] + && (TAB_ALIASES[tabParam] !== "memory" || memoryAvailable) ? TAB_ALIASES[tabParam] : null; const validTab = canonicalTab; @@ -463,7 +470,10 @@ export function SettingsPage() { if (validTab && validTab !== section) { setSection(validTab); } - }, [validTab, section]); + if (!memoryAvailable && section === "memory") { + setSection("general"); + } + }, [memoryAvailable, validTab, section]); useEffect(() => { if (!tabParam || !canonicalTab || tabParam === canonicalTab) return; @@ -508,7 +518,7 @@ export function SettingsPage() { SETTINGS
    - {SECTIONS.map((s, i) => { + {visibleSections.map((s, i) => { const isActive = section === s.id; const isHovered = hoveredId === s.id; diff --git a/apps/desktop/src/renderer/components/app/TopBar.test.tsx b/apps/desktop/src/renderer/components/app/TopBar.test.tsx index dcd0527a4..25621a33d 100644 --- a/apps/desktop/src/renderer/components/app/TopBar.test.tsx +++ b/apps/desktop/src/renderer/components/app/TopBar.test.tsx @@ -53,6 +53,8 @@ function makeSyncSnapshot(overrides: Record = {}) { ipAddresses: [], metadata: {}, }, + projectHydrated: true, + showWelcome: false, currentBrain: null, clusterState: null, bootstrapToken: "bootstrap-token", @@ -82,6 +84,12 @@ function makeSyncSnapshot(overrides: Record = {}) { function resetStore() { useAppStore.setState({ project: { rootPath: "/Users/arul/ADE", name: "ADE" } as any, + projectBinding: { + kind: "local", + key: "local:/Users/arul/ADE", + rootPath: "/Users/arul/ADE", + displayName: "ADE", + }, terminalAttention: { runningCount: 0, activeCount: 0, @@ -98,6 +106,15 @@ function resetStore() { projectTransitionError: null, clearProjectTransitionError: vi.fn(), switchProjectToPath: vi.fn(async () => undefined), + switchRemoteProject: vi.fn(async (targetId: string, projectId: string) => ({ + kind: "remote", + key: `remote:${targetId}:${projectId}`, + targetId, + runtimeName: "Mac Studio", + projectId, + rootPath: "/srv/ade/remote-app", + displayName: "Remote App", + })), } as any); } @@ -158,6 +175,22 @@ describe("TopBar", () => { getStatus: vi.fn(async () => makeSyncSnapshot()), onEvent: vi.fn(() => () => {}), }, + github: { + getStatus: vi.fn(async () => ({ + tokenStored: false, + tokenDecryptionFailed: false, + storageScope: "app", + repo: { owner: "acme", name: "ade", url: "https://github.com/acme/ade" }, + hasOrigin: true, + userLogin: null, + scopes: [], + checkedAt: "2026-04-22T00:00:00.000Z", + repoAccessOk: true, + repoAccessError: null, + connected: false, + })), + onStatusChanged: vi.fn(() => () => {}), + }, zoom: { setLevel: vi.fn(), }, @@ -187,7 +220,7 @@ describe("TopBar", () => { await waitFor(() => { expect(globalThis.window.ade.project.listRecent).toHaveBeenCalled(); }); - expect(screen.queryByText("1 phone connected")).toBeNull(); + expect(screen.queryByText("1 phone connected to ADE Desktop")).toBeNull(); expect(globalThis.window.ade.sync.getStatus).not.toHaveBeenCalled(); }); @@ -205,6 +238,61 @@ describe("TopBar", () => { expect(globalThis.window.ade.project.resolveIcon).not.toHaveBeenCalled(); }); + it("renders a remote project tab without local sync polling", async () => { + useAppStore.setState({ + project: { rootPath: "/srv/ade/remote-app", displayName: "Remote App", baseRef: "main" }, + projectBinding: { + kind: "remote", + key: "remote:studio:project-1", + targetId: "studio", + runtimeName: "Mac Studio", + projectId: "project-1", + rootPath: "/srv/ade/remote-app", + displayName: "Remote App", + }, + projectHydrated: true, + showWelcome: false, + } as any); + + render(); + + expect(await screen.findByTitle("Mac Studio: /srv/ade/remote-app")).toBeTruthy(); + expect(screen.getByText("Remote App")).toBeTruthy(); + expect(screen.getByText("Mac Studio")).toBeTruthy(); + expect(globalThis.window.ade.sync.getStatus).not.toHaveBeenCalled(); + expect(screen.queryByTitle("Connect a phone to this machine")).toBeNull(); + }); + + it("keeps local tabs visible when a remote project is active", async () => { + render(); + + const localTab = await screen.findByTitle("/Users/arul/ADE"); + + await act(async () => { + useAppStore.setState({ + project: { rootPath: "/srv/ade/remote-app", displayName: "Remote App", baseRef: "main" }, + projectBinding: { + kind: "remote", + key: "remote:studio:project-1", + targetId: "studio", + runtimeName: "Mac Studio", + projectId: "project-1", + rootPath: "/srv/ade/remote-app", + displayName: "Remote App", + }, + projectHydrated: true, + showWelcome: false, + } as any); + }); + + expect(await screen.findByTitle("Mac Studio: /srv/ade/remote-app")).toBeTruthy(); + expect(screen.getByTitle("/Users/arul/ADE")).toBeTruthy(); + + fireEvent.click(localTab); + + expect(useAppStore.getState().switchProjectToPath).toHaveBeenCalledWith("/Users/arul/ADE"); + }); + it("opens a blank ADE window from the top bar", async () => { render(); @@ -255,13 +343,13 @@ describe("TopBar", () => { it("opens the phone sync drawer from the host status control", async () => { render(); - expect(await screen.findByText("1 phone connected")).toBeTruthy(); + expect(await screen.findByText("1 phone connected to ADE Desktop")).toBeTruthy(); - fireEvent.click(screen.getByTitle("Connect a phone to this computer")); + fireEvent.click(screen.getByTitle("Connect a phone to this machine")); expect(screen.getByText("Connect to the ADE mobile app")).toBeTruthy(); expect(screen.getByTestId("sync-devices-section")).toBeTruthy(); - expect(screen.getByTitle("Connect a phone to this computer").getAttribute("aria-expanded")).toBe("true"); + expect(screen.getByTitle("Connect a phone to this machine").getAttribute("aria-expanded")).toBe("true"); fireEvent.click(screen.getByTitle("Close phone sync")); @@ -297,7 +385,7 @@ describe("TopBar", () => { }); }); - expect(await screen.findByText("1 phone connected")).toBeTruthy(); + expect(await screen.findByText("1 phone connected to ADE Desktop")).toBeTruthy(); }); it("does not refresh phone sync status on an idle interval", async () => { @@ -339,7 +427,7 @@ describe("TopBar", () => { window.dispatchEvent(new Event("focus")); }); - expect(await screen.findByText("1 phone connected")).toBeTruthy(); + expect(await screen.findByText("1 phone connected to ADE Desktop")).toBeTruthy(); expect(getStatus).toHaveBeenCalledTimes(2); }); diff --git a/apps/desktop/src/renderer/components/app/TopBar.tsx b/apps/desktop/src/renderer/components/app/TopBar.tsx index d6f496834..fac8f09fd 100644 --- a/apps/desktop/src/renderer/components/app/TopBar.tsx +++ b/apps/desktop/src/renderer/components/app/TopBar.tsx @@ -1,5 +1,24 @@ -import React, { useCallback, useEffect, useMemo, useRef, useState } from "react"; -import { ArrowSquareOut, ChatCircleDots, CircleNotch, DeviceMobile, Folder, FolderOpen, Plus, Minus, Trash, UploadSimple, X } from "@phosphor-icons/react"; +import React, { + useCallback, + useEffect, + useMemo, + useRef, + useState, +} from "react"; +import { + ArrowSquareOut, + ChatCircleDots, + CircleNotch, + DesktopTower, + DeviceMobile, + Folder, + FolderOpen, + Plus, + Minus, + Trash, + UploadSimple, + X, +} from "@phosphor-icons/react"; import * as Dialog from "@radix-ui/react-dialog"; import { useAppStore } from "../../state/appStore"; @@ -14,15 +33,27 @@ import { } from "../../lib/zoom"; import { cn } from "../ui/cn"; import { SmartTooltip } from "../ui/SmartTooltip"; -import type { ProcessRuntime, ProjectIcon, RecentProjectSummary, SyncRoleSnapshot } from "../../../shared/types"; +import type { + ProcessRuntime, + ProjectIcon, + OpenProjectBinding, + RecentProjectSummary, + RemoteRuntimeConnectionSnapshot, + SyncRoleSnapshot, +} from "../../../shared/types"; import { AutoUpdateControl } from "./AutoUpdateControl"; import { FeedbackReporterModal } from "./FeedbackReporterModal"; import { HelpMenu } from "../onboarding/HelpMenu"; import { LinearQuickViewButton } from "./LinearQuickViewButton"; import { PublishToGitHubDialog } from "../projects/PublishToGitHubDialog"; +import { RemoteTargetList } from "../remoteTargets/RemoteTargetList"; import { SyncDevicesSection } from "../settings/SyncDevicesSection"; -const RUNNING_LANE_PROCESS_STATES: ProcessRuntime["status"][] = ["starting", "running", "degraded"]; +const RUNNING_LANE_PROCESS_STATES: ProcessRuntime["status"][] = [ + "starting", + "running", + "degraded", +]; const ADE_PROJECT_TAB_ROOT_MIME = "application/x-ade-project-root"; const ADE_PROJECT_TAB_WINDOW_MIME = "application/x-ade-window-id"; @@ -33,6 +64,7 @@ const PROJECT_ICON_CACHE_MAX = 24; const projectIconCache = new Map(); const PROJECT_ICON_ACCENT_CACHE_MAX = 48; const projectIconAccentCache = new Map(); +type RemoteProjectTab = Extract; function getProjectIconFromCache(rootPath: string): ProjectIcon | undefined { const cached = projectIconCache.get(rootPath); if (cached === undefined) return undefined; @@ -53,7 +85,10 @@ function setProjectIconCache(rootPath: string, icon: ProjectIcon): void { } projectIconCache.set(rootPath, icon); } -function setProjectIconAccentCache(cacheKey: string, color: string | null): void { +function setProjectIconAccentCache( + cacheKey: string, + color: string | null, +): void { if (projectIconAccentCache.has(cacheKey)) { projectIconAccentCache.delete(cacheKey); } else if (projectIconAccentCache.size >= PROJECT_ICON_ACCENT_CACHE_MAX) { @@ -64,7 +99,9 @@ function setProjectIconAccentCache(cacheKey: string, color: string | null): void } function toHexByte(value: number): string { - return Math.max(0, Math.min(255, Math.round(value))).toString(16).padStart(2, "0"); + return Math.max(0, Math.min(255, Math.round(value))) + .toString(16) + .padStart(2, "0"); } function balancedAccentColor(red: number, green: number, blue: number): string { @@ -83,8 +120,10 @@ function balancedAccentColor(red: number, green: number, blue: number): string { } async function deriveIconAccentColor(dataUrl: string): Promise { - if (projectIconAccentCache.has(dataUrl)) return projectIconAccentCache.get(dataUrl) ?? null; - if (typeof document === "undefined" || typeof Image === "undefined") return null; + if (projectIconAccentCache.has(dataUrl)) + return projectIconAccentCache.get(dataUrl) ?? null; + if (typeof document === "undefined" || typeof Image === "undefined") + return null; const color = await new Promise((resolve) => { const image = new Image(); @@ -92,8 +131,14 @@ async function deriveIconAccentColor(dataUrl: string): Promise { image.onload = () => { try { const canvas = document.createElement("canvas"); - const width = Math.max(1, Math.min(24, image.naturalWidth || image.width || 24)); - const height = Math.max(1, Math.min(24, image.naturalHeight || image.height || 24)); + const width = Math.max( + 1, + Math.min(24, image.naturalWidth || image.width || 24), + ); + const height = Math.max( + 1, + Math.min(24, image.naturalHeight || image.height || 24), + ); canvas.width = width; canvas.height = height; const ctx = canvas.getContext("2d", { willReadFrequently: true }); @@ -118,7 +163,8 @@ async function deriveIconAccentColor(dataUrl: string): Promise { const min = Math.min(red, green, blue); const saturation = max === 0 ? 0 : (max - min) / max; const luminance = 0.2126 * red + 0.7152 * green + 0.0722 * blue; - if (saturation < 0.08 && (luminance < 28 || luminance > 230)) continue; + if (saturation < 0.08 && (luminance < 28 || luminance > 230)) + continue; const weight = alpha * (0.18 + saturation * 1.65); redTotal += red * weight; greenTotal += green * weight; @@ -129,7 +175,13 @@ async function deriveIconAccentColor(dataUrl: string): Promise { resolve(null); return; } - resolve(balancedAccentColor(redTotal / weightTotal, greenTotal / weightTotal, blueTotal / weightTotal)); + resolve( + balancedAccentColor( + redTotal / weightTotal, + greenTotal / weightTotal, + blueTotal / weightTotal, + ), + ); } catch { resolve(null); } @@ -147,21 +199,24 @@ const PHONE_SYNC_FOCUSABLE_SELECTOR = [ "textarea:not([disabled])", "input:not([disabled])", "select:not([disabled])", - "[tabindex]:not([tabindex=\"-1\"])", + '[tabindex]:not([tabindex="-1"])', ].join(","); function getFocusableElements(root: HTMLElement): HTMLElement[] { - return Array.from(root.querySelectorAll(PHONE_SYNC_FOCUSABLE_SELECTOR)) - .filter((element) => - element.getAttribute("aria-hidden") !== "true" - && !element.hasAttribute("disabled") - && element.tabIndex >= 0 - ); + return Array.from( + root.querySelectorAll(PHONE_SYNC_FOCUSABLE_SELECTOR), + ).filter( + (element) => + element.getAttribute("aria-hidden") !== "true" && + !element.hasAttribute("disabled") && + element.tabIndex >= 0, + ); } function syncDotClass(snapshot: SyncRoleSnapshot): string { if (snapshot.client.state === "error") return "ade-status-dot-error"; - if (snapshot.client.state === "connected" || snapshot.role === "brain") return "ade-status-dot-active"; + if (snapshot.client.state === "connected" || snapshot.role === "brain") + return "ade-status-dot-active"; return "ade-status-dot-warning"; } @@ -178,12 +233,11 @@ function fallbackProjectName(rootPath: string): string { return rootPath.split(/[\\/]/).filter(Boolean).pop() ?? rootPath; } -function confirmProjectTabRemoval(projectName: string, isCurrent: boolean, isMissing: boolean): boolean { +function confirmProjectTabRemoval(projectName: string): boolean { const label = projectName.trim() || "this project"; - const action = isCurrent && !isMissing - ? `Close "${label}" project tab?` - : `Close "${label}" project tab?`; - return window.confirm(`${action}\n\nThis does not remove it from Recent Projects or delete any files on disk.`); + return window.confirm( + `Close "${label}" project tab?\n\nThis does not remove it from Recent Projects or delete any files on disk.`, + ); } function deriveSyncLabel(snapshot: SyncRoleSnapshot | null): string | null { @@ -192,7 +246,8 @@ function deriveSyncLabel(snapshot: SyncRoleSnapshot | null): string | null { if (snapshot.role === "brain") { const count = snapshot.connectedPeers.length; if (count > 0) { - return `${count} phone${count === 1 ? "" : "s"} connected`; + const machineName = snapshot.localDevice.name.trim() || "this machine"; + return `${count} phone${count === 1 ? "" : "s"} connected to ${machineName}`; } return "Phone sync ready"; } @@ -212,16 +267,18 @@ function ProjectTabIcon({ isCurrent, animate, disabled, + readOnly = false, onAccentColorChange, }: { rootPath: string; isCurrent: boolean; animate: boolean; disabled: boolean; + readOnly?: boolean; onAccentColorChange?: (rootPath: string, color: string | null) => void; }) { const [icon, setIcon] = useState(() => - disabled ? null : getProjectIconFromCache(rootPath) ?? null + disabled ? null : (getProjectIconFromCache(rootPath) ?? null), ); const [failed, setFailed] = useState(false); const [iconDialogOpen, setIconDialogOpen] = useState(false); @@ -250,13 +307,16 @@ function ProjectTabIcon({ let cancelled = false; const timer = window.setTimeout(() => { - window.ade.project.resolveIcon(rootPath).then((nextIcon) => { - if (cancelled) return; - setProjectIconCache(rootPath, nextIcon); - setIcon(nextIcon); - }).catch(() => { - if (!cancelled) setIcon(null); - }); + window.ade.project + .resolveIcon(rootPath) + .then((nextIcon) => { + if (cancelled) return; + setProjectIconCache(rootPath, nextIcon); + setIcon(nextIcon); + }) + .catch(() => { + if (!cancelled) setIcon(null); + }); }, 100); return () => { cancelled = true; @@ -273,11 +333,13 @@ function ProjectTabIcon({ cancelled = true; }; } - deriveIconAccentColor(dataUrl).then((color) => { - if (!cancelled) onAccentColorChange?.(rootPath, color); - }).catch(() => { - if (!cancelled) onAccentColorChange?.(rootPath, null); - }); + deriveIconAccentColor(dataUrl) + .then((color) => { + if (!cancelled) onAccentColorChange?.(rootPath, color); + }) + .catch(() => { + if (!cancelled) onAccentColorChange?.(rootPath, null); + }); return () => { cancelled = true; }; @@ -295,19 +357,22 @@ function ProjectTabIcon({ /> ); - const iconNode = !icon?.dataUrl || failed ? fallbackIcon : ( - setFailed(true)} - /> - ); + const iconNode = + !icon?.dataUrl || failed ? ( + fallbackIcon + ) : ( + setFailed(true)} + /> + ); const handleChooseIcon = useCallback(async () => { if (disabled || choosing) return; @@ -322,7 +387,9 @@ function ProjectTabIcon({ if (nextIcon.dataUrl) { setIconDialogOpen(false); } else { - setIconError("ADE saved the path, but the image could not be rendered as a project icon."); + setIconError( + "ADE saved the path, but the image could not be rendered as a project icon.", + ); } } } catch (error) { @@ -353,6 +420,23 @@ function ProjectTabIcon({ if (disabled) return iconNode; + if (readOnly) { + return ( + event.stopPropagation()} + onKeyDown={(event) => event.stopPropagation()} + onMouseDown={(event) => event.stopPropagation()} + > + {iconNode} + + ); + } + return ( event.stopPropagation()} onMouseDown={(event) => event.stopPropagation()} > - {choosing || removing ? : iconNode} + {choosing || removing ? ( + + ) : ( + iconNode + )} @@ -388,7 +480,9 @@ function ProjectTabIcon({ >
    - Project icon + + Project icon + Preview and manage this project's shared icon. @@ -433,7 +527,13 @@ function ProjectTabIcon({ disabled={choosing || removing} onClick={handleRemoveIcon} > - {removing ? : null} + {removing ? ( + + ) : null} Remove
    @@ -454,6 +560,7 @@ function ProjectTabIcon({ export function TopBar() { const project = useAppStore((s) => s.project); + const projectBinding = useAppStore((s) => s.projectBinding); const projectHydrated = useAppStore((s) => s.projectHydrated); const showWelcome = useAppStore((s) => s.showWelcome); const closeProject = useAppStore((s) => s.closeProject); @@ -464,32 +571,56 @@ export function TopBar() { const cancelNewTab = useAppStore((s) => s.cancelNewTab); const projectTransition = useAppStore((s) => s.projectTransition); const projectTransitionError = useAppStore((s) => s.projectTransitionError); - const clearProjectTransitionError = useAppStore((s) => s.clearProjectTransitionError); + const clearProjectTransitionError = useAppStore( + (s) => s.clearProjectTransitionError, + ); const switchProjectToPath = useAppStore((s) => s.switchProjectToPath); - const [recentProjects, setRecentProjects] = useState([]); - const [projectAccentColors, setProjectAccentColors] = useState>({}); + const switchRemoteProject = useAppStore((s) => s.switchRemoteProject); + const [recentProjects, setRecentProjects] = useState( + [], + ); + const [projectAccentColors, setProjectAccentColors] = useState< + Record + >({}); const [relocatingPath, setRelocatingPath] = useState(null); const [zoom, setZoom] = useState(getStoredZoomLevel); - const [syncSnapshot, setSyncSnapshot] = useState(null); + const [syncSnapshot, setSyncSnapshot] = useState( + null, + ); const [phoneSyncOpen, setPhoneSyncOpen] = useState(false); + const [remotePanelOpen, setRemotePanelOpen] = useState(false); + const [remoteSnapshot, setRemoteSnapshot] = + useState(null); const [feedbackOpen, setFeedbackOpen] = useState(false); const [publishOpen, setPublishOpen] = useState(false); const [openProjectTabRoots, setOpenProjectTabRoots] = useState([]); + const [openRemoteProjectTabs, setOpenRemoteProjectTabs] = useState< + RemoteProjectTab[] + >([]); const [dragIdx, setDragIdx] = useState(null); const [dropIdx, setDropIdx] = useState(null); const [windowId, setWindowId] = useState(null); const phoneSyncPanelRef = useRef(null); + const remotePanelRef = useRef(null); const dragCounterRef = useRef(0); const isProjectBusy = projectTransition != null || relocatingPath != null; + const remoteBinding = + projectBinding?.kind === "remote" ? projectBinding : null; const workspaceProjectOpen = projectHydrated === true && showWelcome !== true && isNewTabOpen !== true && - Boolean(project?.rootPath); - - const projectRootForRemote = workspaceProjectOpen ? project?.rootPath ?? null : null; - const { hasGitHubRemote, hasOrigin, refresh: refreshRemote } = - useGithubProjectRemote(projectRootForRemote); + Boolean(project?.rootPath) && + !remoteBinding; + + const projectRootForRemote = workspaceProjectOpen + ? (project?.rootPath ?? null) + : null; + const { + hasGitHubRemote, + hasOrigin, + refresh: refreshRemote, + } = useGithubProjectRemote(projectRootForRemote); const publishDefaultName = useMemo(() => { const root = project?.rootPath; if (!root) return ""; @@ -504,6 +635,9 @@ export function TopBar() { Boolean(project?.rootPath) && hasGitHubRemote === false && hasOrigin === false; + const connectedRemoteCount = remoteSnapshot?.connectedCount ?? 0; + const remoteButtonLabel = + connectedRemoteCount > 0 ? `Remote ${connectedRemoteCount}` : "Remote"; const applyZoom = useCallback((pct: number) => { const clamped = Math.max(MIN_ZOOM_LEVEL, Math.min(MAX_ZOOM_LEVEL, pct)); @@ -519,7 +653,7 @@ export function TopBar() { window.ade.project .listRecent() .then((rows) => setRecentProjects(rows)) - .catch(() => { }); + .catch(() => {}); }, []); useEffect(() => { @@ -529,37 +663,79 @@ export function TopBar() { useEffect(() => { const rootPath = project?.rootPath ?? null; if (!rootPath) { - setOpenProjectTabRoots([]); + // Only wipe local tabs when the user has explicitly closed the project + // (welcome screen visible, no remote binding, no transition in flight). + // Otherwise we'd nuke other tabs whenever `project` is briefly null mid + // open/switch/close. + if ( + !remoteBinding && + projectTransition == null && + showWelcome === true + ) { + setOpenProjectTabRoots([]); + } + return; + } + if (remoteBinding) { + return; + } + // Skip while a transition targeting a *different* root is in flight. + // During switch/close, `project` briefly points at the OLD root before + // the await resolves; re-adding it here would resurrect a tab the user + // just removed via handleRemoveTab. + if (projectTransition != null && projectTransition.rootPath !== rootPath) { return; } setOpenProjectTabRoots((prev) => - prev.includes(rootPath) ? prev : [...prev, rootPath] + prev.includes(rootPath) ? prev : [...prev, rootPath], ); - }, [project?.rootPath]); + }, [project?.rootPath, remoteBinding, projectTransition, showWelcome]); - const projectTabs = useMemo(() => - openProjectTabRoots.map((rootPath) => { - const recent = recentProjects.find((entry) => entry.rootPath === rootPath); - if (recent) return recent; - return { - rootPath, - displayName: - project?.rootPath === rootPath - ? project.displayName ?? fallbackProjectName(rootPath) - : fallbackProjectName(rootPath), - exists: true, - lastOpenedAt: "", - }; - }), - [openProjectTabRoots, project, recentProjects]); + useEffect(() => { + if (!remoteBinding) return; + setOpenRemoteProjectTabs((prev) => { + const existingIndex = prev.findIndex( + (entry) => entry.key === remoteBinding.key, + ); + if (existingIndex === -1) return [...prev, remoteBinding]; + const next = [...prev]; + next[existingIndex] = remoteBinding; + return next; + }); + }, [remoteBinding]); + + useEffect(() => { + if (project || remoteBinding) return; + // Same guard as above: only wipe remote tabs on a true close, not while a + // transition is in flight or before the welcome screen is shown. + if (projectTransition != null || showWelcome !== true) return; + setOpenRemoteProjectTabs([]); + }, [project, remoteBinding, projectTransition, showWelcome]); + + const projectTabs = useMemo( + () => + openProjectTabRoots.map((rootPath) => { + const recent = recentProjects.find( + (entry) => entry.rootPath === rootPath, + ); + if (recent) return recent; + return { + rootPath, + displayName: + project?.rootPath === rootPath + ? (project.displayName ?? fallbackProjectName(rootPath)) + : fallbackProjectName(rootPath), + exists: true, + lastOpenedAt: "", + }; + }), + [openProjectTabRoots, project, recentProjects], + ); useEffect(() => { let cancelled = false; - const getWindowSession = (window as unknown as { - ade?: { app?: { getWindowSession?: typeof window.ade.app.getWindowSession } }; - }).ade?.app?.getWindowSession; - if (typeof getWindowSession !== "function") return undefined; - getWindowSession() + window.ade.app + .getWindowSession() .then((session) => { if (!cancelled) setWindowId(session.windowId); }) @@ -579,6 +755,36 @@ export function TopBar() { return () => window.cancelAnimationFrame(frame); }, [phoneSyncOpen]); + useEffect(() => { + const remoteRuntime = window.ade.remoteRuntime; + if (!remoteRuntime?.getConnectionSnapshot) return; + let cancelled = false; + void remoteRuntime + .getConnectionSnapshot() + .then((snapshot) => { + if (!cancelled) setRemoteSnapshot(snapshot); + }) + .catch(() => { + if (!cancelled) setRemoteSnapshot(null); + }); + const unsubscribe = + remoteRuntime.onConnectionSnapshotChanged?.((snapshot) => { + if (!cancelled) setRemoteSnapshot(snapshot); + }) ?? (() => {}); + return () => { + cancelled = true; + unsubscribe(); + }; + }, []); + + useEffect(() => { + if (!remotePanelOpen) return; + const frame = window.requestAnimationFrame(() => { + remotePanelRef.current?.focus(); + }); + return () => window.cancelAnimationFrame(frame); + }, [remotePanelOpen]); + // Re-fetch when app regains focus (catches external deletions). useEffect(() => { const onFocus = () => fetchRecent(); @@ -595,7 +801,7 @@ export function TopBar() { useEffect(() => { let cancelled = false; let statusRequestVersion = 0; - if (!project?.rootPath) { + if (!project?.rootPath || remoteBinding) { setSyncSnapshot(null); setPhoneSyncOpen(false); return () => { @@ -604,11 +810,16 @@ export function TopBar() { } const refreshSyncStatus = () => { const requestVersion = ++statusRequestVersion; - void window.ade.sync.getStatus({ includeTransferReadiness: false }).then((snapshot) => { - if (!cancelled && requestVersion === statusRequestVersion) setSyncSnapshot(snapshot); - }).catch(() => { - if (!cancelled && requestVersion === statusRequestVersion) setSyncSnapshot(null); - }); + void window.ade.sync + .getStatus({ includeTransferReadiness: false }) + .then((snapshot) => { + if (!cancelled && requestVersion === statusRequestVersion) + setSyncSnapshot(snapshot); + }) + .catch(() => { + if (!cancelled && requestVersion === statusRequestVersion) + setSyncSnapshot(null); + }); }; setSyncSnapshot(null); refreshSyncStatus(); @@ -628,60 +839,81 @@ export function TopBar() { // them to the active project), so we re-run this effect on rootPath change // to force an immediate refetch. Focus refresh covers state changes that // happen while ADE is not active. - }, [project?.rootPath]); + }, [project?.rootPath, remoteBinding]); - const checkForActiveWorkloads = useCallback(async (projectRootPath: string): Promise => { - if (project?.rootPath !== projectRootPath) return true; + const checkForActiveWorkloads = useCallback( + async (projectRootPath: string): Promise => { + if (project?.rootPath !== projectRootPath) return true; - try { - const [lanes, runningSessions, agentChats, activeMissions] = await Promise.all([ - window.ade.lanes.list({ includeArchived: false }), - window.ade.sessions.list({ status: "running" }), - window.ade.agentChat.list(), - window.ade.missions.list({ status: "active" }) - ]); - - const laneRuntimes = await Promise.all( - lanes.map((lane) => window.ade.processes.listRuntime(lane.id).catch(() => [] as ProcessRuntime[])) - ); - - const activeProcesses = laneRuntimes - .flat() - .filter((runtime) => RUNNING_LANE_PROCESS_STATES.includes(runtime.status)); - const activeSessionCount = runningSessions.filter( - (session) => session.status === "running" && !isRunOwnedSession(session), - ).length; - const activeChatCount = agentChats.filter((chat) => chat.status === "active").length; - - const warnings: string[] = []; - if (activeProcesses.length > 0) { - warnings.push(`${activeProcesses.length} running lane process${activeProcesses.length === 1 ? "" : "es"}`); - } - if (activeSessionCount > 0) { - warnings.push(`${activeSessionCount} running terminal session${activeSessionCount === 1 ? "" : "s"}`); - } - if (activeChatCount > 0) { - warnings.push(`${activeChatCount} active chat${activeChatCount === 1 ? "" : "s"}`); - } - if (activeMissions.length > 0) { - warnings.push(`${activeMissions.length} active mission${activeMissions.length === 1 ? "" : "s"}`); - } + try { + const [lanes, runningSessions, agentChats, activeMissions] = + await Promise.all([ + window.ade.lanes.list({ includeArchived: false }), + window.ade.sessions.list({ status: "running" }), + window.ade.agentChat.list(), + window.ade.missions.list({ status: "active" }), + ]); + + const laneRuntimes = await Promise.all( + lanes.map((lane) => + window.ade.processes + .listRuntime(lane.id) + .catch(() => [] as ProcessRuntime[]), + ), + ); + + const activeProcesses = laneRuntimes + .flat() + .filter((runtime) => + RUNNING_LANE_PROCESS_STATES.includes(runtime.status), + ); + const activeSessionCount = runningSessions.filter( + (session) => + session.status === "running" && !isRunOwnedSession(session), + ).length; + const activeChatCount = agentChats.filter( + (chat) => chat.status === "active", + ).length; + + const warnings: string[] = []; + if (activeProcesses.length > 0) { + warnings.push( + `${activeProcesses.length} running lane process${activeProcesses.length === 1 ? "" : "es"}`, + ); + } + if (activeSessionCount > 0) { + warnings.push( + `${activeSessionCount} running terminal session${activeSessionCount === 1 ? "" : "s"}`, + ); + } + if (activeChatCount > 0) { + warnings.push( + `${activeChatCount} active chat${activeChatCount === 1 ? "" : "s"}`, + ); + } + if (activeMissions.length > 0) { + warnings.push( + `${activeMissions.length} active mission${activeMissions.length === 1 ? "" : "s"}`, + ); + } - if (warnings.length === 0) return true; + if (warnings.length === 0) return true; - const message = [ - "You are about to close this project.", - "The following active work items will be terminated:", - ...warnings.map((line) => `- ${line}`), - "", - "Do you want to continue?" - ].join("\n"); + const message = [ + "You are about to close this project.", + "The following active work items will be terminated:", + ...warnings.map((line) => `- ${line}`), + "", + "Do you want to continue?", + ].join("\n"); - return window.confirm(message); - } catch { - return true; - } - }, [project?.rootPath]); + return window.confirm(message); + } catch { + return true; + } + }, + [project?.rootPath], + ); const handleOpenNew = useCallback(() => { if (isProjectBusy) return; @@ -693,66 +925,157 @@ export function TopBar() { window.ade.app.newWindow().catch(() => {}); }, [isProjectBusy]); - const handleSwitchProject = useCallback((rootPath: string) => { + const handleSwitchProject = useCallback( + (rootPath: string) => { + if (isProjectBusy) return; + if (!remoteBinding && project?.rootPath === rootPath) { + cancelNewTab(); + return; + } + switchProjectToPath(rootPath).catch(() => {}); + }, + [ + cancelNewTab, + isProjectBusy, + project?.rootPath, + remoteBinding, + switchProjectToPath, + ], + ); + + const handleSwitchRemoteProject = useCallback( + (binding: RemoteProjectTab) => { + if (isProjectBusy) return; + if (remoteBinding?.key === binding.key) { + cancelNewTab(); + return; + } + switchRemoteProject(binding.targetId, binding.projectId).catch(() => {}); + }, + [ + cancelNewTab, + isProjectBusy, + remoteBinding?.key, + switchRemoteProject, + ], + ); + + const handleRemoveTab = useCallback( + (rootPath: string) => { + void (async () => { + const target = projectTabs.find((entry) => entry.rootPath === rootPath); + const fallbackName = fallbackProjectName(rootPath); + const confirmed = confirmProjectTabRemoval( + target?.displayName ?? fallbackName, + ); + if (!confirmed) return; + + const shouldClose = await checkForActiveWorkloads(rootPath); + if (!shouldClose) return; + + const currentIndex = openProjectTabRoots.indexOf(rootPath); + const nextTabRoots = openProjectTabRoots.filter( + (entry) => entry !== rootPath, + ); + setOpenProjectTabRoots(nextTabRoots); + if (!remoteBinding && project?.rootPath === rootPath) { + const nextRoot = + nextTabRoots[currentIndex] ?? + nextTabRoots[currentIndex - 1] ?? + null; + if (nextRoot) { + switchProjectToPath(nextRoot).catch(() => {}); + } else if (openRemoteProjectTabs[0]) { + switchRemoteProject( + openRemoteProjectTabs[0].targetId, + openRemoteProjectTabs[0].projectId, + ).catch(() => {}); + } else { + closeProject().catch(() => {}); + } + } + })().catch(() => {}); + }, + [ + checkForActiveWorkloads, + closeProject, + openProjectTabRoots, + openRemoteProjectTabs, + project?.rootPath, + projectTabs, + remoteBinding, + switchProjectToPath, + switchRemoteProject, + ], + ); + + const handleCloseRemoteTab = useCallback((binding: RemoteProjectTab) => { if (isProjectBusy) return; - if (project?.rootPath === rootPath) { - cancelNewTab(); + const closedIndex = openRemoteProjectTabs.findIndex( + (entry) => entry.key === binding.key, + ); + const nextRemoteTabs = openRemoteProjectTabs.filter( + (entry) => entry.key !== binding.key, + ); + setOpenRemoteProjectTabs(nextRemoteTabs); + if (remoteBinding?.key !== binding.key) return; + + const nextRemoteTab = + nextRemoteTabs[closedIndex] ?? nextRemoteTabs[closedIndex - 1] ?? null; + if (nextRemoteTab) { + switchRemoteProject(nextRemoteTab.targetId, nextRemoteTab.projectId).catch( + () => {}, + ); return; } - switchProjectToPath(rootPath).catch(() => { }); - }, [cancelNewTab, isProjectBusy, project?.rootPath, switchProjectToPath]); - - const handleRemoveTab = useCallback((rootPath: string) => { - void (async () => { - const target = projectTabs.find((entry) => entry.rootPath === rootPath); - const fallbackName = fallbackProjectName(rootPath); - const confirmed = confirmProjectTabRemoval( - target?.displayName ?? fallbackName, - project?.rootPath === rootPath, - target?.exists === false, - ); - if (!confirmed) return; - const shouldClose = await checkForActiveWorkloads(rootPath); - if (!shouldClose) return; + const nextLocalRoot = + openProjectTabRoots[openProjectTabRoots.length - 1] ?? null; + if (nextLocalRoot) { + switchProjectToPath(nextLocalRoot).catch(() => {}); + } else { + closeProject().catch(() => {}); + } + }, [ + closeProject, + isProjectBusy, + openProjectTabRoots, + openRemoteProjectTabs, + remoteBinding?.key, + switchProjectToPath, + switchRemoteProject, + ]); + + const handleRelocate = useCallback( + (oldPath: string) => { + setRelocatingPath(oldPath); + void (async () => { + const newProject = await openRepo().catch(() => null); + if (!newProject) return; + const nextRows = await window.ade.project + .forgetRecent(oldPath) + .catch(() => null); + if (nextRows) setRecentProjects(nextRows); + })() + .catch(() => {}) + .finally(() => setRelocatingPath(null)); + }, + [openRepo], + ); - const currentIndex = openProjectTabRoots.indexOf(rootPath); - const nextTabRoots = openProjectTabRoots.filter((entry) => entry !== rootPath); - setOpenProjectTabRoots(nextTabRoots); - if (project?.rootPath === rootPath) { - const nextRoot = - nextTabRoots[currentIndex] - ?? nextTabRoots[currentIndex - 1] - ?? null; - if (nextRoot) { - switchProjectToPath(nextRoot).catch(() => { }); - } else { - closeProject().catch(() => { }); - } + const handleDragStart = useCallback( + (e: React.DragEvent, idx: number, rootPath: string) => { + setDragIdx(idx); + dragCounterRef.current = 0; + e.dataTransfer.effectAllowed = "move"; + e.dataTransfer.setData("text/plain", String(idx)); + e.dataTransfer.setData(ADE_PROJECT_TAB_ROOT_MIME, rootPath); + if (windowId != null) { + e.dataTransfer.setData(ADE_PROJECT_TAB_WINDOW_MIME, String(windowId)); } - })().catch(() => { }); - }, [checkForActiveWorkloads, closeProject, openProjectTabRoots, project?.rootPath, projectTabs, switchProjectToPath]); - - const handleRelocate = useCallback((oldPath: string) => { - setRelocatingPath(oldPath); - void (async () => { - const newProject = await openRepo().catch(() => null); - if (!newProject) return; - const nextRows = await window.ade.project.forgetRecent(oldPath).catch(() => null); - if (nextRows) setRecentProjects(nextRows); - })().catch(() => { }).finally(() => setRelocatingPath(null)); - }, [openRepo]); - - const handleDragStart = useCallback((e: React.DragEvent, idx: number, rootPath: string) => { - setDragIdx(idx); - dragCounterRef.current = 0; - e.dataTransfer.effectAllowed = "move"; - e.dataTransfer.setData("text/plain", String(idx)); - e.dataTransfer.setData(ADE_PROJECT_TAB_ROOT_MIME, rootPath); - if (windowId != null) { - e.dataTransfer.setData(ADE_PROJECT_TAB_WINDOW_MIME, String(windowId)); - } - }, [windowId]); + }, + [windowId], + ); const handleDragOver = useCallback((e: React.DragEvent, idx: number) => { e.preventDefault(); @@ -764,122 +1087,224 @@ export function TopBar() { setDropIdx(null); }, []); - const handleDrop = useCallback((e: React.DragEvent, targetIdx: number) => { - if (dragIdx === null && Array.from(e.dataTransfer.types).includes(ADE_PROJECT_TAB_ROOT_MIME)) { - return; - } - e.preventDefault(); - e.stopPropagation(); - setDropIdx(null); - if (dragIdx === null || dragIdx === targetIdx) { + const handleDrop = useCallback( + (e: React.DragEvent, targetIdx: number) => { + if ( + dragIdx === null && + Array.from(e.dataTransfer.types).includes(ADE_PROJECT_TAB_ROOT_MIME) + ) { + return; + } + e.preventDefault(); + e.stopPropagation(); + setDropIdx(null); + if (dragIdx === null || dragIdx === targetIdx) { + setDragIdx(null); + return; + } + const items = [...openProjectTabRoots]; + const [moved] = items.splice(dragIdx, 1); + items.splice(targetIdx, 0, moved); + setOpenProjectTabRoots(items); setDragIdx(null); - return; - } - const items = [...openProjectTabRoots]; - const [moved] = items.splice(dragIdx, 1); - items.splice(targetIdx, 0, moved); - setOpenProjectTabRoots(items); - setDragIdx(null); - }, [dragIdx, openProjectTabRoots]); - - const handleProjectTabDrop = useCallback((e: React.DragEvent) => { - const rootPath = e.dataTransfer.getData(ADE_PROJECT_TAB_ROOT_MIME); - if (!rootPath) return; - e.preventDefault(); - setDropIdx(null); - setDragIdx(null); - - const sourceWindowIdRaw = e.dataTransfer.getData(ADE_PROJECT_TAB_WINDOW_MIME); - const parsedSourceWindowId = sourceWindowIdRaw ? Number(sourceWindowIdRaw) : null; - const sourceWindowId = parsedSourceWindowId != null && Number.isFinite(parsedSourceWindowId) - ? parsedSourceWindowId - : null; - if (sourceWindowId != null && sourceWindowId === windowId) return; - - if (project?.rootPath === rootPath) { - if (sourceWindowId != null) { - window.ade.app.closeWindow(sourceWindowId).catch(() => {}); + }, + [dragIdx, openProjectTabRoots], + ); + + const handleProjectTabDrop = useCallback( + (e: React.DragEvent) => { + const rootPath = e.dataTransfer.getData(ADE_PROJECT_TAB_ROOT_MIME); + if (!rootPath) return; + e.preventDefault(); + setDropIdx(null); + setDragIdx(null); + + const sourceWindowIdRaw = e.dataTransfer.getData( + ADE_PROJECT_TAB_WINDOW_MIME, + ); + const parsedSourceWindowId = sourceWindowIdRaw + ? Number(sourceWindowIdRaw) + : null; + const sourceWindowId = + parsedSourceWindowId != null && Number.isFinite(parsedSourceWindowId) + ? parsedSourceWindowId + : null; + if (sourceWindowId != null && sourceWindowId === windowId) return; + + if (project?.rootPath === rootPath) { + if (sourceWindowId != null) { + window.ade.app.closeWindow(sourceWindowId).catch(() => {}); + } + return; } - return; - } - switchProjectToPath(rootPath).catch(() => {}); - }, [project?.rootPath, switchProjectToPath, windowId]); + switchProjectToPath(rootPath).catch(() => {}); + }, + [project?.rootPath, switchProjectToPath, windowId], + ); const handleProjectTabDragOver = useCallback((e: React.DragEvent) => { - if (!Array.from(e.dataTransfer.types).includes(ADE_PROJECT_TAB_ROOT_MIME)) return; + if (!Array.from(e.dataTransfer.types).includes(ADE_PROJECT_TAB_ROOT_MIME)) + return; e.preventDefault(); e.dataTransfer.dropEffect = "move"; }, []); - const handleDragEnd = useCallback((e: React.DragEvent, rootPath?: string) => { - const draggedOutside = - rootPath && - (e.clientX < 0 || - e.clientY < 0 || - e.clientX > window.innerWidth || - e.clientY > window.innerHeight); - const droppedOnAdeTarget = e.dataTransfer.dropEffect && e.dataTransfer.dropEffect !== "none"; - setDragIdx(null); - setDropIdx(null); - if (draggedOutside && !droppedOnAdeTarget) { + const handleDragEnd = useCallback( + (e: React.DragEvent, rootPath?: string) => { + const draggedOutside = + rootPath && + (e.clientX < 0 || + e.clientY < 0 || + e.clientX > window.innerWidth || + e.clientY > window.innerHeight); + const droppedOnAdeTarget = + e.dataTransfer.dropEffect && e.dataTransfer.dropEffect !== "none"; + setDragIdx(null); + setDropIdx(null); + if (!draggedOutside || droppedOnAdeTarget || !rootPath) return; + + // Fire IPC immediately so the new window starts spawning while we + // optimistically clean up the source window's tab state. window.ade.app.openProjectInNewWindow(rootPath).catch(() => {}); - } - }, []); - const handleProjectAccentColorChange = useCallback((rootPath: string, color: string | null) => { - setProjectAccentColors((prev) => { - if ((prev[rootPath] ?? null) === color) return prev; - return { ...prev, [rootPath]: color }; - }); - }, []); + // Detach skips the confirmation + active workload checks intentionally: + // the user already committed to detaching by dragging the tab out, and + // the work is moving to a new window rather than terminating. + const currentIndex = openProjectTabRoots.indexOf(rootPath); + if (currentIndex === -1) return; + const nextTabRoots = openProjectTabRoots.filter( + (entry) => entry !== rootPath, + ); + setOpenProjectTabRoots(nextTabRoots); + if (!remoteBinding && project?.rootPath === rootPath) { + const nextRoot = + nextTabRoots[currentIndex] ?? nextTabRoots[currentIndex - 1] ?? null; + if (nextRoot) { + switchProjectToPath(nextRoot).catch(() => {}); + } else if (openRemoteProjectTabs[0]) { + switchRemoteProject( + openRemoteProjectTabs[0].targetId, + openRemoteProjectTabs[0].projectId, + ).catch(() => {}); + } else { + closeProject().catch(() => {}); + } + } + }, + [ + closeProject, + openProjectTabRoots, + openRemoteProjectTabs, + project?.rootPath, + remoteBinding, + switchProjectToPath, + switchRemoteProject, + ], + ); - const handlePhoneSyncDialogKeyDown = useCallback((event: React.KeyboardEvent) => { - if (event.key === "Escape") { - event.preventDefault(); - setPhoneSyncOpen(false); - return; - } - if (event.key !== "Tab") return; - - const panel = phoneSyncPanelRef.current; - if (!panel) return; - const focusable = getFocusableElements(panel); - if (focusable.length === 0) { - event.preventDefault(); - panel.focus(); - return; - } + const handleProjectAccentColorChange = useCallback( + (rootPath: string, color: string | null) => { + setProjectAccentColors((prev) => { + if ((prev[rootPath] ?? null) === color) return prev; + return { ...prev, [rootPath]: color }; + }); + }, + [], + ); - const first = focusable[0]; - const last = focusable[focusable.length - 1]; - if (document.activeElement === panel) { - event.preventDefault(); - (event.shiftKey ? last : first).focus(); - } else if (event.shiftKey && document.activeElement === first) { - event.preventDefault(); - last.focus(); - } else if (!event.shiftKey && document.activeElement === last) { - event.preventDefault(); - first.focus(); - } - }, []); + const handlePhoneSyncDialogKeyDown = useCallback( + (event: React.KeyboardEvent) => { + if (event.key === "Escape") { + event.preventDefault(); + setPhoneSyncOpen(false); + return; + } + if (event.key !== "Tab") return; + + const panel = phoneSyncPanelRef.current; + if (!panel) return; + const focusable = getFocusableElements(panel); + if (focusable.length === 0) { + event.preventDefault(); + panel.focus(); + return; + } + + const first = focusable[0]; + const last = focusable[focusable.length - 1]; + if (document.activeElement === panel) { + event.preventDefault(); + (event.shiftKey ? last : first).focus(); + } else if (event.shiftKey && document.activeElement === first) { + event.preventDefault(); + last.focus(); + } else if (!event.shiftKey && document.activeElement === last) { + event.preventDefault(); + first.focus(); + } + }, + [], + ); + + const handleRemotePanelKeyDown = useCallback( + (event: React.KeyboardEvent) => { + if (event.key === "Escape") { + event.preventDefault(); + setRemotePanelOpen(false); + return; + } + if (event.key !== "Tab") return; + + const panel = remotePanelRef.current; + if (!panel) return; + const focusable = getFocusableElements(panel); + if (focusable.length === 0) { + event.preventDefault(); + panel.focus(); + return; + } + + const first = focusable[0]; + const last = focusable[focusable.length - 1]; + if (document.activeElement === panel) { + event.preventDefault(); + (event.shiftKey ? last : first).focus(); + } else if (event.shiftKey && document.activeElement === first) { + event.preventDefault(); + last.focus(); + } else if (!event.shiftKey && document.activeElement === last) { + event.preventDefault(); + first.focus(); + } + }, + [], + ); const syncLabel = deriveSyncLabel(syncSnapshot); - const transitionTargetName = - projectTransition?.rootPath - ? (projectTabs.find((entry) => entry.rootPath === projectTransition.rootPath)?.displayName - ?? recentProjects.find((entry) => entry.rootPath === projectTransition.rootPath)?.displayName - ?? fallbackProjectName(projectTransition.rootPath) - ?? "project") - : "project"; - const projectTransitionLabel = - projectTransition == null - ? null - : projectTransition.kind === "opening" - ? "Opening project…" - : projectTransition.kind === "switching" - ? `Switching to ${transitionTargetName}…` - : "Closing project…"; + const transitionTargetName = projectTransition?.rootPath + ? (projectTabs.find( + (entry) => entry.rootPath === projectTransition.rootPath, + )?.displayName ?? + recentProjects.find( + (entry) => entry.rootPath === projectTransition.rootPath, + )?.displayName ?? + fallbackProjectName(projectTransition.rootPath) ?? + "project") + : "project"; + let projectTransitionLabel: string | null = null; + if (projectTransition != null) { + switch (projectTransition.kind) { + case "opening": + projectTransitionLabel = "Opening project…"; + break; + case "switching": + projectTransitionLabel = `Switching to ${transitionTargetName}…`; + break; + case "closing": + projectTransitionLabel = "Closing project…"; + break; + } + } return (
    - {projectTabs.length > 0 || isNewTabOpen ? ( + {openRemoteProjectTabs.length > 0 || + projectTabs.length > 0 || + isNewTabOpen ? ( <> + {openRemoteProjectTabs.map((remoteTab) => { + const isCurrentRemote = remoteBinding?.key === remoteTab.key; + return ( +
    handleSwitchRemoteProject(remoteTab)} + onKeyDown={(event) => { + if (event.key === "Enter" || event.key === " ") { + event.preventDefault(); + handleSwitchRemoteProject(remoteTab); + } + }} + > + + + {remoteTab.displayName} + + + +
    + ); + })} {projectTabs.map((rp, idx) => { - const isCurrent = project?.rootPath === rp.rootPath; + const isCurrent = + !remoteBinding && project?.rootPath === rp.rootPath; const isMissing = !rp.exists; const isRelocating = relocatingPath === rp.rootPath; const isSwitchTarget = - projectTransition?.kind === "switching" && projectTransition.rootPath === rp.rootPath; + projectTransition?.kind === "switching" && + projectTransition.rootPath === rp.rootPath; const isClosingTarget = projectTransition?.kind === "closing" && isCurrent; const isDragging = dragIdx === idx; const isDropTarget = dropIdx === idx && dragIdx !== idx; - const projectAccentColor = projectAccentColors[rp.rootPath] ?? null; + const projectAccentColor = + projectAccentColors[rp.rootPath] ?? null; const projectTabStyle = { WebkitAppRegion: "no-drag", - ...(projectAccentColor ? { "--project-tab-accent": projectAccentColor } : {}), + ...(projectAccentColor + ? { "--project-tab-accent": projectAccentColor } + : {}), } as React.CSSProperties; let projectTabState: string | undefined; if (isRelocating) projectTabState = "open"; @@ -932,9 +1425,15 @@ export function TopBar() { role={isMissing ? undefined : "button"} tabIndex={isMissing ? -1 : 0} data-state={projectTabState} - data-tour={isCurrent && workspaceProjectOpen ? "project.activeTab" : undefined} + data-tour={ + isCurrent && workspaceProjectOpen + ? "project.activeTab" + : undefined + } aria-current={isCurrent ? "true" : undefined} - aria-disabled={isRelocating || isProjectBusy ? true : undefined} + aria-disabled={ + isRelocating || isProjectBusy ? true : undefined + } draggable={!isMissing && !isRelocating && !isProjectBusy} onDragStart={(e) => handleDragStart(e, idx, rp.rootPath)} onDragOver={(e) => handleDragOver(e, idx)} @@ -947,9 +1446,10 @@ export function TopBar() { !isMissing && "cursor-pointer", isCurrent && "font-semibold", isRelocating && "pointer-events-none opacity-80", - (isSwitchTarget || isClosingTarget) && "pointer-events-none opacity-80", + (isSwitchTarget || isClosingTarget) && + "pointer-events-none opacity-80", isDragging && "opacity-40", - isDropTarget && "ring-1 ring-accent/50" + isDropTarget && "ring-1 ring-accent/50", )} style={projectTabStyle} onClick={() => { @@ -972,7 +1472,11 @@ export function TopBar() { onAccentColorChange={handleProjectAccentColorChange} /> {isSwitchTarget || isClosingTarget ? ( - + ) : null} {isCurrent && indicator != null && indicator !== "none" ? ( ) : null} {rp.displayName} @@ -1012,7 +1516,11 @@ export function TopBar() { }} title="Relocate project" > - + @@ -1111,7 +1646,7 @@ export function TopBar() { type="button" className={cn( "ade-shell-control inline-flex h-5.5 w-5.5 shrink-0 items-center justify-center", - "transition-[background-color,color,border-color,box-shadow] duration-150" + "transition-[background-color,color,border-color,box-shadow] duration-150", )} data-variant="ghost" onClick={handleOpenNewWindow} @@ -1145,8 +1680,10 @@ export function TopBar() { letterSpacing: "0.08em", textTransform: "uppercase", color: "var(--color-accent)", - background: "color-mix(in srgb, var(--color-accent) 18%, transparent)", - border: "1px solid color-mix(in srgb, var(--color-accent) 36%, transparent)", + background: + "color-mix(in srgb, var(--color-accent) 18%, transparent)", + border: + "1px solid color-mix(in srgb, var(--color-accent) 36%, transparent)", borderRadius: 6, cursor: isProjectBusy ? "not-allowed" : "pointer", opacity: isProjectBusy ? 0.55 : 1, @@ -1162,13 +1699,15 @@ export function TopBar() {
    - {projectTransitionLabel} + + {projectTransitionLabel} +
    ) : null} @@ -1176,12 +1715,14 @@ export function TopBar() {
    - {projectTransitionError} + + {projectTransitionError} + + {syncSnapshot && syncLabel ? ( +
    +
    + +
    +
    +
    + ) : null} + {phoneSyncOpen ? (
    - +
    -
    +
    Connect to the ADE mobile app
    -
    {syncLabel}
    +
    + {syncLabel} +
    - + {zoom}% @@ -1322,7 +1973,7 @@ export function TopBar() { type="button" className={cn( "ade-shell-control inline-flex h-[20px] w-[20px] items-center justify-center", - "transition-[background-color,color,border-color,box-shadow] duration-150" + "transition-[background-color,color,border-color,box-shadow] duration-150", )} onClick={zoomIn} title="Zoom in" diff --git a/apps/desktop/src/renderer/components/chat/AgentChatMessageList.tsx b/apps/desktop/src/renderer/components/chat/AgentChatMessageList.tsx index 70f373cea..6080829d5 100644 --- a/apps/desktop/src/renderer/components/chat/AgentChatMessageList.tsx +++ b/apps/desktop/src/renderer/components/chat/AgentChatMessageList.tsx @@ -78,6 +78,7 @@ import { type ChatTranscriptRenderEnvelope as TranscriptRenderEnvelope, } from "./chatTranscriptRows"; import { ChatUserMinimap } from "./ChatUserMinimap"; +import { AgentCliAuthCard, type AgentCliAuthCardInfo } from "./AgentCliAuthCard"; import { CHAT_TIMELINE_ROW_GAP_PX, buildMinimapDisplayEntries, @@ -1920,7 +1921,10 @@ function renderEvent( respondingApprovalIds?: Set; pendingApprovalIds?: Set; resolvedInputStates?: Map; + laneId?: string | null; sessionId?: string | null; + runtimeName?: string | null; + onRevealChatTerminal?: (terminal: { terminalId: string; ptyId: string; label: string }) => void; } ) { const event = envelope.event; @@ -2764,6 +2768,10 @@ function renderEvent( /* ── Error ── */ if (event.type === "error") { + const agentCliInfo: AgentCliAuthCardInfo | null = + typeof event.errorInfo === "object" && event.errorInfo?.agentCli + ? event.errorInfo.agentCli + : null; const errorCopyValue = event.detail?.trim().length ? `${event.message}\n\n${event.detail}` : event.message; @@ -2791,7 +2799,16 @@ function renderEvent( {event.detail}
    ) : null} - {event.errorInfo ? ( + {agentCliInfo ? ( + + ) : null} + {event.errorInfo && !agentCliInfo ? (
    {typeof event.errorInfo === "string" ? event.errorInfo : `${event.errorInfo.provider ? `${event.errorInfo.provider}` : ""}${event.errorInfo.model ? ` / ${event.errorInfo.model}` : ""}`}
    @@ -3314,7 +3331,9 @@ type EventRowProps = { respondingApprovalIds?: Set; pendingApprovalIds?: Set; resolvedInputStates?: Map; + laneId?: string | null; sessionId?: string | null; + runtimeName?: string | null; }; const EventRow = React.memo(function EventRow({ @@ -3337,7 +3356,9 @@ const EventRow = React.memo(function EventRow({ respondingApprovalIds, pendingApprovalIds, resolvedInputStates, + laneId, sessionId, + runtimeName, }: EventRowProps) { const workLogAnimate = Boolean(turnActive) && !sessionEnded @@ -3385,7 +3406,10 @@ const EventRow = React.memo(function EventRow({ respondingApprovalIds, pendingApprovalIds, resolvedInputStates, + laneId, sessionId, + runtimeName, + onRevealChatTerminal, })}
    ); @@ -3542,6 +3566,7 @@ export function AgentChatMessageList({ onOpenWorkspacePath, respondingApprovalIds, pendingApprovalIds, + laneId, sessionId, onInsertDraft, onRevealChatTerminal, @@ -3559,10 +3584,12 @@ export function AgentChatMessageList({ onRevealChatTerminal?: (terminal: { terminalId: string; ptyId: string; label: string }) => void; respondingApprovalIds?: Set; pendingApprovalIds?: Set; + laneId?: string | null; sessionId?: string | null; sessionEnded?: boolean; }) { const chatTranscriptDensity = useAppStore((s) => s.chatTranscriptDensity); + const runtimeName = useAppStore((s) => s.projectBinding?.kind === "remote" ? s.projectBinding.runtimeName : null); const timelineRowGapPx = useMemo(() => transcriptRowGapPx(chatTranscriptDensity), [chatTranscriptDensity]); const scrollRef = useRef(null); const contentWrapperRef = useRef(null); @@ -3971,7 +3998,9 @@ export function AgentChatMessageList({ respondingApprovalIds={respondingApprovalIds} pendingApprovalIds={pendingApprovalIds} resolvedInputStates={resolvedInputStates} + laneId={laneId} sessionId={sessionId} + runtimeName={runtimeName} /> ); } @@ -3998,10 +4027,12 @@ export function AgentChatMessageList({ respondingApprovalIds={respondingApprovalIds} pendingApprovalIds={pendingApprovalIds} resolvedInputStates={resolvedInputStates} + laneId={laneId} sessionId={sessionId} + runtimeName={runtimeName} /> ); - }, [activeTurnId, assistantLabel, surfaceMode, surfaceProfile, groupedRows, latestWorkLogIndex, turnModelState, handleApproval, handleMeasure, openWorkspacePath, handleNavigateSuggestion, handleReviewChanges, onInsertDraft, onRevealChatTerminal, respondingApprovalIds, pendingApprovalIds, resolvedInputStates, sessionId, sessionEnded]); + }, [activeTurnId, assistantLabel, surfaceMode, surfaceProfile, groupedRows, latestWorkLogIndex, turnModelState, handleApproval, handleMeasure, openWorkspacePath, handleNavigateSuggestion, handleReviewChanges, onInsertDraft, onRevealChatTerminal, respondingApprovalIds, pendingApprovalIds, resolvedInputStates, laneId, sessionId, sessionEnded, runtimeName]); // Compute the bottom spacer height for virtualized mode. const bottomSpacerHeight = useMemo(() => { diff --git a/apps/desktop/src/renderer/components/chat/AgentChatPane.tsx b/apps/desktop/src/renderer/components/chat/AgentChatPane.tsx index 69503a85b..be8ceda41 100644 --- a/apps/desktop/src/renderer/components/chat/AgentChatPane.tsx +++ b/apps/desktop/src/renderer/components/chat/AgentChatPane.tsx @@ -6150,6 +6150,7 @@ export function AgentChatPane({ assistantLabel={assistantLabel} respondingApprovalIds={respondingApprovalIds} pendingApprovalIds={pendingApprovalIds} + laneId={laneId} sessionId={selectedSessionId} onInsertDraft={insertComposerDraft} onRevealChatTerminal={(terminal) => { diff --git a/apps/desktop/src/renderer/components/chat/AgentCliAuthCard.test.tsx b/apps/desktop/src/renderer/components/chat/AgentCliAuthCard.test.tsx new file mode 100644 index 000000000..6b8d0b028 --- /dev/null +++ b/apps/desktop/src/renderer/components/chat/AgentCliAuthCard.test.tsx @@ -0,0 +1,142 @@ +/* @vitest-environment jsdom */ + +import React from "react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { AgentCliAuthCard, type AgentCliAuthCardInfo } from "./AgentCliAuthCard"; + +const originalAde = globalThis.window.ade; + +const missingCli: AgentCliAuthCardInfo = { + agent: "codex", + displayName: "Codex", + category: "missing", + installCommand: "npm install -g @openai/codex", + authCommand: "codex login", +}; + +const unauthenticatedCli: AgentCliAuthCardInfo = { + ...missingCli, + category: "unauthenticated", +}; + +function installAdeStub() { + globalThis.window.ade = { + pty: { + create: vi.fn().mockResolvedValue({ + sessionId: "terminal-auth-1", + ptyId: "pty-auth-1", + pid: 1234, + }), + }, + lanes: { + list: vi.fn().mockResolvedValue([{ id: "lane-default", name: "Main" }]), + }, + } as any; +} + +describe("AgentCliAuthCard", () => { + beforeEach(() => { + installAdeStub(); + }); + + afterEach(() => { + cleanup(); + if (originalAde === undefined) { + delete (globalThis.window as any).ade; + } else { + globalThis.window.ade = originalAde; + } + }); + + it("opens install commands in the chat PTY context", async () => { + const onRevealTerminal = vi.fn(); + + render( + , + ); + + fireEvent.click(screen.getByRole("button", { name: /run install/i })); + + await waitFor(() => { + expect(window.ade.pty.create).toHaveBeenCalledWith({ + laneId: "lane-1", + chatSessionId: "chat-1", + cols: 100, + rows: 28, + title: "install", + tracked: true, + toolType: "shell", + startupCommand: "npm install -g @openai/codex", + }); + }); + expect(onRevealTerminal).toHaveBeenCalledWith({ + terminalId: "terminal-auth-1", + ptyId: "pty-auth-1", + label: "install", + }); + }); + + it("opens auth commands in the chat PTY context", async () => { + const onRevealTerminal = vi.fn(); + + render( + , + ); + + expect(screen.queryByText("npm install -g @openai/codex")).toBeNull(); + + fireEvent.click(screen.getByRole("button", { name: /run auth/i })); + + await waitFor(() => { + expect(window.ade.pty.create).toHaveBeenCalledWith(expect.objectContaining({ + laneId: "lane-1", + chatSessionId: "chat-1", + title: "auth", + toolType: "shell", + startupCommand: "codex login", + })); + }); + expect(onRevealTerminal).toHaveBeenCalledWith({ + terminalId: "terminal-auth-1", + ptyId: "pty-auth-1", + label: "auth", + }); + }); + + it("names the remote runtime when the auth flow runs away from the local machine", () => { + render(); + + expect(screen.getByText(/Install the CLI on Mac Studio/i)).toBeTruthy(); + }); + + it("uses the project default lane when no chat context is available", async () => { + render(); + + const runInstall = screen.getByRole("button", { name: /run install/i }); + expect(runInstall).toHaveProperty("disabled", false); + + fireEvent.click(runInstall); + + await waitFor(() => { + expect(window.ade.lanes.list).toHaveBeenCalledWith({ + includeArchived: false, + includeStatus: false, + }); + expect(window.ade.pty.create).toHaveBeenCalledWith(expect.objectContaining({ + laneId: "lane-default", + startupCommand: "npm install -g @openai/codex", + })); + }); + }); +}); diff --git a/apps/desktop/src/renderer/components/chat/AgentCliAuthCard.tsx b/apps/desktop/src/renderer/components/chat/AgentCliAuthCard.tsx new file mode 100644 index 000000000..fd2b84ed8 --- /dev/null +++ b/apps/desktop/src/renderer/components/chat/AgentCliAuthCard.tsx @@ -0,0 +1,193 @@ +import { useCallback, useState } from "react"; +import { CopySimple, Play, Terminal, Warning } from "@phosphor-icons/react"; +import { cn } from "../ui/cn"; + +export type AgentCliAuthCardInfo = { + agent: string; + displayName: string; + category: "missing" | "unauthenticated"; + installCommand: string; + authCommand: string; +}; + +function CommandCopyButton({ command, label }: { command: string; label: string }) { + const [copied, setCopied] = useState(false); + + const handleCopy = useCallback(() => { + if (typeof navigator === "undefined" || !navigator.clipboard?.writeText) return; + void navigator.clipboard.writeText(command) + .then(() => { + setCopied(true); + window.setTimeout(() => setCopied(false), 1_500); + }) + .catch(() => setCopied(false)); + }, [command]); + + return ( + + ); +} + +function ShellRunButton({ + command, + label, + laneId, + chatSessionId, + onRevealTerminal, +}: { + command: string; + label: string; + laneId?: string | null; + chatSessionId?: string | null; + onRevealTerminal?: (terminal: { terminalId: string; ptyId: string; label: string }) => void; +}) { + const [running, setRunning] = useState(false); + const [error, setError] = useState(null); + const disabled = running || !window.ade?.pty?.create || (!laneId && !window.ade?.lanes?.list); + + const handleRun = useCallback(() => { + if (disabled) return; + setRunning(true); + setError(null); + const terminalLabel = label.replace(/^Run\s+/i, ""); + void (async () => { + const resolvedLaneId = laneId ?? (await window.ade.lanes.list({ + includeArchived: false, + includeStatus: false, + }))[0]?.id ?? null; + if (!resolvedLaneId) { + throw new Error("No active lane is available for this project."); + } + return window.ade.pty.create({ + laneId: resolvedLaneId, + ...(chatSessionId ? { chatSessionId } : {}), + cols: 100, + rows: 28, + title: terminalLabel, + tracked: true, + toolType: "shell", + startupCommand: command, + }); + })() + .then((created) => { + onRevealTerminal?.({ + terminalId: created.sessionId, + ptyId: created.ptyId, + label: terminalLabel, + }); + }) + .catch((err: unknown) => { + setError(err instanceof Error ? err.message : String(err)); + }) + .finally(() => setRunning(false)); + }, [chatSessionId, command, disabled, label, laneId, onRevealTerminal]); + + return ( +
    + + {error ? ( +
    + {error} +
    + ) : null} +
    + ); +} + +export function AgentCliAuthCard({ + agentCli, + laneId, + chatSessionId, + runtimeName, + onRevealTerminal, +}: { + agentCli: AgentCliAuthCardInfo; + laneId?: string | null; + chatSessionId?: string | null; + runtimeName?: string | null; + onRevealTerminal?: (terminal: { terminalId: string; ptyId: string; label: string }) => void; +}) { + const missing = agentCli.category === "missing"; + const installLocation = runtimeName?.trim() ? runtimeName.trim() : "this machine"; + const title = missing + ? `${agentCli.displayName} is not installed` + : `${agentCli.displayName} needs authentication`; + const body = missing + ? `Install the CLI on ${installLocation}, authenticate it, then retry the chat.` + : `Authenticate the CLI on ${installLocation}, then retry the chat.`; + + return ( +
    +
    +
    + {missing ? : } +
    +
    +
    + {title} +
    +
    + {body} +
    +
    + {missing ? ( +
    +
    + Install +
    +
    + + {agentCli.installCommand} + + + +
    +
    + ) : null} +
    +
    + Authenticate +
    +
    + + {agentCli.authCommand} + + + +
    +
    +
    +
    +
    +
    + ); +} diff --git a/apps/desktop/src/renderer/components/cto/CtoPage.tsx b/apps/desktop/src/renderer/components/cto/CtoPage.tsx index e9314b949..294320a75 100644 --- a/apps/desktop/src/renderer/components/cto/CtoPage.tsx +++ b/apps/desktop/src/renderer/components/cto/CtoPage.tsx @@ -20,7 +20,6 @@ import type { AgentChatSessionSummary, ChatSurfacePresentation, HeartbeatPolicy, - OpenclawBridgeStatus, WorkerAgentRun, } from "../../../shared/types"; import { AgentChatPane } from "../chat/AgentChatPane"; @@ -85,7 +84,6 @@ export function CtoPage() { const [ctoIdentity, setCtoIdentity] = useState(null); const [coreMemory, setCoreMemory] = useState(null); const [sessionLogs, setSessionLogs] = useState([]); - const [openclawStatus, setOpenclawStatus] = useState(null); useEffect(() => { const onTourTab = (event: Event) => { @@ -236,13 +234,6 @@ export function CtoPage() { void loadCtoHistory(); }, [activeTab, loadCtoHistory]); - useEffect(() => { - const unsubscribe = window.ade?.cto?.onOpenclawConnectionStatus?.((status) => { - setOpenclawStatus(status); - }); - return () => unsubscribe?.(); - }, []); - // Load revisions when worker selected useEffect(() => { if (!window.ade?.cto || !selectedAgentId) { setRevisions([]); return; } @@ -367,9 +358,7 @@ export function CtoPage() { try { const at = workerDraft.adapterType; const adapterConfig: Record = - at === "openclaw-webhook" - ? { url: workerDraft.webhookUrl, ...(workerDraft.authHeader.trim() ? { headers: { Authorization: workerDraft.authHeader.trim() } } : {}) } - : at === "process" + at === "process" ? { command: workerDraft.processCommand } : { ...(workerDraft.model.trim() ? { model: workerDraft.model.trim() } : {}) }; diff --git a/apps/desktop/src/renderer/components/cto/CtoSettingsPanel.tsx b/apps/desktop/src/renderer/components/cto/CtoSettingsPanel.tsx index 72081a67a..bd6119659 100644 --- a/apps/desktop/src/renderer/components/cto/CtoSettingsPanel.tsx +++ b/apps/desktop/src/renderer/components/cto/CtoSettingsPanel.tsx @@ -7,7 +7,6 @@ import { Button } from "../ui/Button"; import { cn } from "../ui/cn"; import { inputCls, labelCls, textareaCls } from "./shared/designTokens"; import { SmartTooltip } from "../ui/SmartTooltip"; -import { OpenclawConnectionPanel } from "./OpenclawConnectionPanel"; import { getCtoPersonalityPreset } from "./identityPresets"; import { CtoPromptPreview } from "./CtoPromptPreview"; @@ -82,12 +81,11 @@ export function CtoSettingsPanel({ finally { setMemorySaving(false); } }; - const [settingsTab, setSettingsTab] = useState<"identity" | "brief" | "integrations">("identity"); + const [settingsTab, setSettingsTab] = useState<"identity" | "brief">("identity"); const SUB_TABS = [ { id: "identity" as const, label: "Identity", tooltip: "CTO personality, model, and reasoning configuration." }, { id: "brief" as const, label: "Brief", tooltip: "Project summary, conventions, and focus areas that persist across sessions." }, - { id: "integrations" as const, label: "Integrations", tooltip: "OpenClaw bridge configuration." }, ]; return ( @@ -246,17 +244,6 @@ export function CtoSettingsPanel({
    )} - - {/* ── Integrations sub-tab ── */} - {settingsTab === "integrations" && ( -
    - {/* OpenClaw Bridge card */} -
    -
    OpenClaw Bridge
    - -
    -
    - )} ); diff --git a/apps/desktop/src/renderer/components/cto/OpenclawConnectionPanel.tsx b/apps/desktop/src/renderer/components/cto/OpenclawConnectionPanel.tsx deleted file mode 100644 index 38c7d0f1c..000000000 --- a/apps/desktop/src/renderer/components/cto/OpenclawConnectionPanel.tsx +++ /dev/null @@ -1,626 +0,0 @@ -import React, { useCallback, useEffect, useMemo, useRef, useState } from "react"; -import { - ArrowCounterClockwise, - CheckCircle, - CircleNotch, - WarningCircle, -} from "@phosphor-icons/react"; -import type { - CtoIdentity, - OpenclawMessageRecord, - OpenclawBridgeState, - OpenclawBridgeStatus, - OpenclawNotificationRoute, - OpenclawNotificationType, -} from "../../../shared/types"; -import { Button } from "../ui/Button"; -import { cn } from "../ui/cn"; -import { ConnectionStatusDot } from "./shared/ConnectionStatusDot"; -import { cardCls, inputCls, labelCls } from "./shared/designTokens"; - -const NOTIFICATION_TYPES: OpenclawNotificationType[] = [ - "mission_complete", - "ci_broken", - "blocked_run", -]; - -const CONNECTION_STATUS_LABEL: Record<"connected" | "degraded" | "disconnected", string> = { - connected: "Connected", - degraded: "Connecting", - disconnected: "Disconnected", -}; - -type DraftState = { - enabled: boolean; - bridgePort: string; - gatewayUrl: string; - gatewayToken: string; - hooksToken: string; - allowedAgentIds: string; - defaultTarget: string; - allowEmployeeTargets: boolean; - notificationRoutes: Record; -}; - -type ManualDraftState = { - agentId: string; - sessionKey: string; - message: string; -}; - -function routesToDraft(routes: OpenclawNotificationRoute[]): DraftState["notificationRoutes"] { - const base = Object.fromEntries( - NOTIFICATION_TYPES.map((type) => [ - type, - { - agentId: "", - sessionKey: "", - enabled: false, - }, - ]), - ) as DraftState["notificationRoutes"]; - - for (const route of routes) { - base[route.notificationType] = { - agentId: route.agentId ?? "", - sessionKey: route.sessionKey ?? "", - enabled: route.enabled !== false, - }; - } - return base; -} - -function stateToDraft(state: OpenclawBridgeState | null): DraftState { - return { - enabled: state?.config.enabled === true, - bridgePort: String(state?.config.bridgePort ?? 18791), - gatewayUrl: state?.config.gatewayUrl ?? "", - gatewayToken: state?.config.gatewayToken ?? "", - hooksToken: state?.config.hooksToken ?? "", - allowedAgentIds: (state?.config.allowedAgentIds ?? []).join(", "), - defaultTarget: state?.config.defaultTarget ?? "cto", - allowEmployeeTargets: state?.config.allowEmployeeTargets !== false, - notificationRoutes: routesToDraft(state?.config.notificationRoutes ?? []), - }; -} - -function normalizeRoutes( - routes: DraftState["notificationRoutes"], -): OpenclawNotificationRoute[] { - return NOTIFICATION_TYPES - .map((notificationType) => ({ - notificationType, - agentId: routes[notificationType].agentId.trim() || null, - sessionKey: routes[notificationType].sessionKey.trim() || null, - enabled: routes[notificationType].enabled, - })) - .filter((route) => route.enabled || route.agentId || route.sessionKey); -} - -export function OpenclawConnectionPanel({ - compact = false, - showConfig = true, - showRecentTraffic = !compact, - identity, - onSaveIdentity, - onStateChange, -}: { - compact?: boolean; - showConfig?: boolean; - showRecentTraffic?: boolean; - identity?: CtoIdentity | null; - onSaveIdentity?: (patch: Record) => Promise; - onStateChange?: (state: OpenclawBridgeState | null) => void; -}) { - const [state, setState] = useState(null); - const [draft, setDraft] = useState(stateToDraft(null)); - const [messages, setMessages] = useState([]); - const [saving, setSaving] = useState(false); - const [testing, setTesting] = useState(false); - const [error, setError] = useState(null); - const [contextSaving, setContextSaving] = useState(false); - const [contextError, setContextError] = useState(null); - const [manualDraft, setManualDraft] = useState({ - agentId: "", - sessionKey: "", - message: "", - }); - const [manualSending, setManualSending] = useState(false); - const [manualError, setManualError] = useState(null); - const [manualSuccess, setManualSuccess] = useState(null); - const [contextDraft, setContextDraft] = useState({ - shareMode: identity?.openclawContextPolicy?.shareMode ?? "filtered", - blockedCategories: (identity?.openclawContextPolicy?.blockedCategories ?? []).join(", "), - }); - const onStateChangeRef = useRef(onStateChange); - - useEffect(() => { - onStateChangeRef.current = onStateChange; - }, [onStateChange]); - - const connectionStatus: "connected" | "degraded" | "disconnected" = useMemo(() => { - if (state?.status.state === "connected") return "connected"; - if (state?.status.state === "reconnecting" || state?.status.state === "connecting") return "degraded"; - return "disconnected"; - }, [state?.status.state]); - - const load = useCallback(async () => { - if (!window.ade?.cto) return; - try { - const [nextState, nextMessages] = await Promise.all([ - window.ade.cto.getOpenclawState(), - window.ade.cto.listOpenclawMessages({ limit: compact ? 6 : 12 }), - ]); - setState(nextState); - setDraft(stateToDraft(nextState)); - setMessages(nextMessages); - setError(null); - onStateChangeRef.current?.(nextState); - } catch (err) { - setError(err instanceof Error ? err.message : "Failed to load OpenClaw state."); - setState(null); - setMessages([]); - onStateChangeRef.current?.(null); - } - }, [compact]); - - useEffect(() => { - void load(); - }, [load]); - - useEffect(() => { - const unsubscribe = window.ade?.cto?.onOpenclawConnectionStatus?.((nextStatus) => { - setState((current) => current ? { ...current, status: nextStatus } : current); - }); - return () => unsubscribe?.(); - }, []); - - useEffect(() => { - setContextDraft({ - shareMode: identity?.openclawContextPolicy?.shareMode ?? "filtered", - blockedCategories: (identity?.openclawContextPolicy?.blockedCategories ?? []).join(", "), - }); - }, [identity]); - - const saveConfig = useCallback(async () => { - if (!window.ade?.cto) return; - setSaving(true); - setError(null); - try { - const nextState = await window.ade.cto.updateOpenclawConfig({ - patch: { - enabled: draft.enabled, - bridgePort: Number(draft.bridgePort) || 18791, - gatewayUrl: draft.gatewayUrl.trim() || null, - gatewayToken: draft.gatewayToken.trim() || null, - hooksToken: draft.hooksToken.trim() || null, - allowedAgentIds: draft.allowedAgentIds - .split(",") - .map((entry) => entry.trim()) - .filter(Boolean), - defaultTarget: (draft.defaultTarget.trim() || "cto") as "cto" | `agent:${string}`, - allowEmployeeTargets: draft.allowEmployeeTargets, - notificationRoutes: normalizeRoutes(draft.notificationRoutes), - }, - }); - setState(nextState); - onStateChange?.(nextState); - await load(); - } catch (err) { - setError(err instanceof Error ? err.message : "Failed to save OpenClaw settings."); - } finally { - setSaving(false); - } - }, [draft, load, onStateChange]); - - const testConnection = useCallback(async () => { - if (!window.ade?.cto) return; - setTesting(true); - setError(null); - try { - await saveConfig(); - const nextStatus = await window.ade.cto.testOpenclawConnection({}); - setState((current) => current ? { ...current, status: nextStatus } : current); - await load(); - } catch (err) { - setError(err instanceof Error ? err.message : "OpenClaw connection test failed."); - } finally { - setTesting(false); - } - }, [load, saveConfig]); - - const saveContextPolicy = useCallback(async () => { - if (!onSaveIdentity) return; - setContextSaving(true); - setContextError(null); - try { - await onSaveIdentity({ - openclawContextPolicy: { - shareMode: contextDraft.shareMode, - blockedCategories: contextDraft.blockedCategories - .split(",") - .map((entry) => entry.trim()) - .filter(Boolean), - }, - }); - } catch (err) { - setContextError(err instanceof Error ? err.message : "Failed to save context policy."); - } finally { - setContextSaving(false); - } - }, [contextDraft, onSaveIdentity]); - - const sendManualMessage = useCallback(async () => { - if (!window.ade?.cto) return; - const message = manualDraft.message.trim(); - const sessionKey = manualDraft.sessionKey.trim(); - const agentId = manualDraft.agentId.trim(); - if (!message.length) { - setManualError("Enter a message before sending."); - return; - } - if (!sessionKey && !agentId) { - setManualError("Provide either a session key or an agent ID."); - return; - } - setManualSending(true); - setManualError(null); - setManualSuccess(null); - try { - await window.ade.cto.sendOpenclawMessage({ - sessionKey: sessionKey || null, - agentId: agentId || null, - message, - }); - setManualDraft((current) => ({ ...current, message: "" })); - setManualSuccess("Message queued for delivery."); - await load(); - } catch (err) { - setManualError(err instanceof Error ? err.message : "Failed to send OpenClaw message."); - } finally { - setManualSending(false); - } - }, [load, manualDraft]); - - return ( -
    -
    -
    - Connection Status - -
    -
    - - -
    -
    - - {showConfig && ( -
    -
    - - - - - - - - - - - - - -
    - - - - {!compact && ( -
    -
    Notification Routes
    - {NOTIFICATION_TYPES.map((notificationType) => ( -
    - - setDraft((current) => ({ - ...current, - notificationRoutes: { - ...current.notificationRoutes, - [notificationType]: { - ...current.notificationRoutes[notificationType], - agentId: event.target.value, - }, - }, - }))} - /> - setDraft((current) => ({ - ...current, - notificationRoutes: { - ...current.notificationRoutes, - [notificationType]: { - ...current.notificationRoutes[notificationType], - sessionKey: event.target.value, - }, - }, - }))} - /> -
    - ))} -
    - )} - -
    -
    - {state?.endpoints.healthUrl ? ( - <> - Health: {state.endpoints.healthUrl} -
    - Hook: {state.endpoints.hookUrl} -
    - Query: {state.endpoints.queryUrl} - - ) : ( - "Health, hook, and query endpoints appear once the local bridge listener starts." - )} -
    - -
    -
    - )} - - {state?.status.lastError && ( -
    - -
    {state.status.lastError}
    -
    - )} - - {error && ( -
    - -
    {error}
    -
    - )} - - {state?.status.state === "connected" && ( -
    -
    - - - Paired device {state.status.deviceId ?? "unknown"} - -
    -
    - Last connected: {state.status.lastConnectedAt ? new Date(state.status.lastConnectedAt).toLocaleString() : "n/a"} -
    -
    - )} - - {!compact && onSaveIdentity && ( -
    -
    -
    OpenClaw Context Policy
    -
    - Controls which metadata ADE includes when it sends notifications or bridge replies back into OpenClaw. -
    -
    - -
    - - - -
    - - {contextError &&
    {contextError}
    } - -
    - -
    -
    - )} - - {!compact && showConfig && ( -
    -
    -
    Manual Outbound Message
    -
    - Send a direct bridge message to a known OpenClaw session or agent to validate routing end to end. -
    -
    - -
    - - - - -