diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 27ae64927..192308eb9 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -206,12 +206,16 @@ jobs: with: node-version: 22 cache: npm - cache-dependency-path: | - apps/desktop/package-lock.json - apps/ade-cli/package-lock.json + cache-dependency-path: apps/ade-cli/package-lock.json - - name: Install desktop dependencies - run: cd apps/desktop && npm ci + - uses: actions/setup-python@v5 + if: runner.os == 'macOS' + with: + python-version: "3.11" + + - name: Configure node-gyp Python + if: runner.os == 'macOS' + run: echo "PYTHON=$(python3 -c 'import sys; print(sys.executable)')" >> "$GITHUB_ENV" - name: Install ADE CLI dependencies run: cd apps/ade-cli && npm ci @@ -258,54 +262,6 @@ jobs: key: nm-v2-${{ 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) ──────────── - # Runs the same dist:win pipeline that release-core.yml uses, so a PR - # that would break Windows release is caught here instead of at release - # 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 - - 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: 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: | - Remove-Item -Recurse -Force apps/desktop/release, apps/desktop/.cache -ErrorAction SilentlyContinue - New-Item -ItemType Directory -Path apps/desktop/.cache | Out-Null - - - name: Build and validate Windows release - env: - ELECTRON_CACHE: ${{ github.workspace }}\apps\desktop\.cache\electron - ELECTRON_BUILDER_CACHE: ${{ github.workspace }}\apps\desktop\.cache\electron-builder - run: cd apps/desktop && npm run dist:win - # ── Gate: all jobs must pass ────────────────────────────────────────── ci-pass: if: always() @@ -321,7 +277,6 @@ jobs: - build - build-runtime-binaries - validate-docs - - build-win runs-on: ubuntu-latest steps: - name: Check all jobs passed diff --git a/.github/workflows/release-core.yml b/.github/workflows/release-core.yml index 778816646..087e6c0d1 100644 --- a/.github/workflows/release-core.yml +++ b/.github/workflows/release-core.yml @@ -272,12 +272,16 @@ jobs: with: node-version: 22 cache: npm - cache-dependency-path: | - apps/desktop/package-lock.json - apps/ade-cli/package-lock.json + cache-dependency-path: apps/ade-cli/package-lock.json - - name: Install desktop dependencies - run: cd apps/desktop && npm ci + - uses: actions/setup-python@v5 + if: runner.os == 'macOS' + with: + python-version: "3.11" + + - name: Configure node-gyp Python + if: runner.os == 'macOS' + run: echo "PYTHON=$(python3 -c 'import sys; print(sys.executable)')" >> "$GITHUB_ENV" - name: Install ADE CLI dependencies run: cd apps/ade-cli && npm ci @@ -285,7 +289,7 @@ jobs: - name: Stamp runtime release version env: ADE_RELEASE_TAG: ${{ inputs.release_tag }} - run: cd apps/desktop && npm run version:release + run: node apps/desktop/scripts/set-release-version.mjs - name: Build ADE runtime binary run: cd apps/ade-cli && npm run build:static -- --target ${{ matrix.target }} diff --git a/README.md b/README.md index 0e10a8c6f..0b41d9d8e 100644 --- a/README.md +++ b/README.md @@ -123,9 +123,9 @@ Requirements: Windows x64, git on `PATH`, Node 22+ for headless CLI workflows. ```bash ade desktop -ade runtime status --text -ade runtime start -ade runtime stop +ade brain status --text +ade brain start +ade brain stop ade doctor --json ade code ade lanes create --name fix-checkout-flow @@ -138,11 +138,11 @@ ade actions list --text # discover every service action ## Architecture -Local-first, on purpose. The center of ADE is the **machine runtime** — a single per-machine `ade serve` service that owns projects, lanes, agent chats, work sessions, 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 local endpoint lives under `~/.ade/sock/ade.sock`. When desktop is running, its Electron main process also hosts a **desktop bridge endpoint** at `~/.ade/sock/desktop-bridge.sock` (override: `ADE_DESKTOP_BRIDGE_SOCKET_PATH`) so the runtime can proxy `ade browser …` calls into the Electron-only `WebContentsView` APIs it can't reach under `ELECTRON_RUN_AS_NODE=1`. +Local-first, on purpose. The center of ADE is the **brain** — the always-on, machine-owned ADE process for a channel. The brain owns the project catalog, sync websocket, and executor authority; desktop, `ade code`, the iOS app, and SSH-attached desktop windows attach to it as clients. Runtime state lives under `.ade/` inside each project (SQLite db, worktree checkouts, proof artifacts, encrypted secrets) and machine-wide state lives under `~/.ade` or `~/.ade-`. When desktop is running, its Electron main process also hosts a **desktop bridge endpoint** at `~/.ade/sock/desktop-bridge.sock` (override: `ADE_DESKTOP_BRIDGE_SOCKET_PATH`) so the brain can proxy `ade browser …` calls into the Electron-only `WebContentsView` APIs it can't reach under `ELECTRON_RUN_AS_NODE=1`. ```text -apps/ade-cli ADE runtime (`ade serve`) + `ade` CLI + `ade code` terminal client -apps/desktop Electron client — multi-window, attaches to a local or SSH-bound runtime +apps/ade-cli ADE brain + manual runtime entry points + `ade` CLI + `ade code` terminal client +apps/desktop Electron client — multi-window, attaches to a local brain or SSH-bound runtime apps/ios SwiftUI controller that pairs with an ADE machine over WebSocket apps/web Public website and download surface docs/ Product and engineering docs @@ -150,6 +150,51 @@ docs/ Product and engineering docs Deep reference: [ARCHITECTURE.md](docs/ARCHITECTURE.md). +## Glossary + +| Term | Meaning | +| --- | --- | +| Brain | The always-on, machine-owned ADE process for one channel. It carries the sync websocket, project catalog, local RPC endpoint, and executor authority. | +| Runtime | ADE execution machinery: processes/services that open DBs and run agents, PTYs, git, and orchestration. A runtime process can host the brain role, but "brain" is the authority/lifecycle term. | +| Manual runtime | A foreground runtime process started explicitly with `ade runtime run --socket `. Sync is always off; use it for dev/test work instead of the automated stable/beta/alpha brain service. | +| Machine | A physical computer with a per-channel ADE home and stable sync device identity. | +| Channel | A release lane such as stable, beta, alpha, or dev. Each channel has its own ADE home. | +| Client | A surface that attaches to the brain: desktop, `ade code`, ADE Mobile, or an SSH-bound desktop window. | +| Project | A registered repo with one ADE database at `/.ade/ade.db`. | +| Lane | A task worktree under `.ade/worktrees/` that shares the project database. | +| Catalog | The machine-level project list served by the brain to clients and ADE Mobile. | + +### Brain vs. manual runtime + +This table describes the current code behavior. + +| Capability | Brain | Manual runtime | +| --- | --- | --- | +| Lifecycle | Always-on login service for an ADE channel; Desktop can install/repair it in packaged builds. | Foreground process started explicitly with `ade runtime run --socket `. | +| Owner | Machine / ADE install. | User or developer who launched it. | +| Sync | Yes. | No; `ade runtime run` forces sync off. | +| Mobile websocket | Yes. | No. | +| Phone pairing / PIN | Yes. | No. | +| Mobile/machine catalog authority | Yes. | No; it may expose registry data to explicitly attached clients, but ADE Mobile ignores manual runtimes. | +| Runs agents, PTYs, git, lanes, PR work | Yes. | Yes. | +| Clients | Desktop, `ade code`, and ADE Mobile attach to it; SSH-bound desktop windows attach to the remote machine's ADE transport. | Only clients explicitly pointed at its endpoint attach to it. | +| Survives client close | Yes, when service-owned. Desktop/TUI fallback spawns still exist for recovery and dev paths. | Only while that foreground process is still running. | + +### How to test changes from a lane + +| Change you made | What to run/test | Why | +| --- | --- | --- | +| iOS UI/client-only change | Build the iOS app from the lane and connect it to an existing ADE brain. | The phone is a client; UI-only work does not require a new brain. | +| iOS sync protocol, project catalog, pairing, or remote-command change | Rebuild/restart the target brain from the lane, then build the iOS app from the same lane. | The phone and brain both need the new contract. | +| Desktop renderer UI change | Run/build Desktop from the lane and let it attach to the channel brain. | Renderer code is client-side unless it depends on new brain APIs. | +| Desktop main/preload/runtime-bridge change | Run/build Desktop from the lane; rebuild/restart the brain only if the runtime RPC contract or brain behavior changed. | Electron main is a client/bridge, but some handlers route through the brain. | +| `ade code` / TUI UI change | Build/run `ade code` from the lane and attach to the existing brain. | The TUI is a client of the brain. | +| TUI command that depends on new RPC or shared types | Rebuild/restart the brain from the lane, then run the lane's `ade code`. | Both sides of the RPC contract must match. | +| Brain, sync, project catalog, pairing, agents, PTYs, lanes, PR workflows, or CLI runtime service change | Rebuild the ADE CLI/brain from the lane and restart the target brain before testing clients. | These live in the always-on process; existing installed brains keep running old code. | +| Manual runtime behavior | Start `ade runtime run --socket ` from the lane and point a client at that endpoint. | Manual runtimes are standalone and sync is always off. | +| Remote runtime / SSH transport change | Test with a remote target using the lane-built desktop/runtime artifacts. | SSH-bound windows talk to the remote ADE transport, not the local mobile brain. | +| Docs or web-only change | Run the docs/web preview or static checks for that surface. | No ADE brain/client lifecycle is involved. | + ## Develop First-time setup: @@ -266,15 +311,75 @@ npm run dev:stop npm run dev:code # tests TUI wrapper creating the dev runtime ``` -Local packaged builds: +### Rebuild ADE Alpha or Beta locally + +Use these commands when you need a local packaged macOS channel build without +waiting for the GitHub release workflow. ```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`. Beta fetches `origin/main`, fast-forwards the local `main` checkout when possible, and builds that checkout as `ADE Beta`. It does not create a packaging worktree. These builds do not replace the production `ADE.app`, production `ade`, or `~/.ade` runtime/state. Alpha and Beta also use separate Electron profile directories (`ade-desktop-alpha` / `ade-desktop-beta`) so their browser storage and window state do not collide with dev or stable. -Local channel packages include this Mac's runtime binary. Release builds still require the full cross-platform runtime artifact set used by remote runtime bootstrap. +`package:alpha` builds exactly the checkout you are in. `package:beta` is +release-like: it fetches `origin/main`, fast-forwards the local `main` checkout +when possible, and builds that checkout as `ADE Beta`. It does not create a +packaging worktree. + +To smoke-test the Beta channel from a PR branch before it lands on `main`, pass +the branch checkout explicitly: + +```bash +node scripts/package-channel.mjs beta --repo "$PWD" --skip-install +``` + +Local channel outputs: + +```text +apps/desktop/release-alpha/mac-arm64/ADE Alpha.app +apps/desktop/release-alpha/ADE-Alpha-local.zip +apps/desktop/release-beta/mac-arm64/ADE Beta.app +apps/desktop/release-beta/ADE-Beta-local.zip +``` + +Install the build you want to test by replacing the matching app in +`/Applications`: + +```bash +rm -rf "/Applications/ADE Beta.app" +ditto "apps/desktop/release-beta/mac-arm64/ADE Beta.app" "/Applications/ADE Beta.app" +xattr -dr com.apple.quarantine "/Applications/ADE Beta.app" 2>/dev/null || true +``` + +Use `ADE Alpha.app` and `release-alpha` for Alpha. If the Dock already has an +ADE Alpha/Beta icon, remove and re-pin it after installing from `/Applications`; +Dock icons keep the exact bundle path they were pinned from, so an old icon can +launch a stale `apps/desktop/release-*` build even after `/Applications` was +updated. + +Launching a packaged channel build should install or repair that channel's +always-on brain service: + +```bash +launchctl print gui/$(id -u)/com.ade.runtime.beta +ls -l ~/Library/LaunchAgents/com.ade.runtime.beta.plist ~/.ade-beta/sock/ade.sock +``` + +Set or rotate the channel's mobile pairing PIN from the Desktop Mobile control, +or from the CLI against that channel home: + +```bash +ADE_PACKAGE_CHANNEL=beta ADE_HOME="$HOME/.ade-beta" ade brain pin generate +ADE_PACKAGE_CHANNEL=beta ADE_HOME="$HOME/.ade-beta" ade brain pin set 123456 +``` + +For Alpha, use `com.ade.runtime.alpha` and `~/.ade-alpha`. These builds do not +replace the production `ADE.app`, production `ade`, or `~/.ade` runtime/state. +Alpha and Beta also use separate Electron profile directories +(`ade-desktop-alpha` / `ade-desktop-beta`) so browser storage and window state +do not collide with dev or stable. Local channel packages include this Mac's +runtime binary. 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 `npm run test:desktop:sharded` for the full desktop suite. The desktop test suite is large, so run the smallest relevant subset first. diff --git a/RUNTIME-SYNC-RECONCILIATION.md b/RUNTIME-SYNC-RECONCILIATION.md new file mode 100644 index 000000000..02deadd67 --- /dev/null +++ b/RUNTIME-SYNC-RECONCILIATION.md @@ -0,0 +1,665 @@ +# ADE Brain / Runtime Architecture Overhaul — Ground Truth & Implementation Plan + +> **Status:** Locked plan, ready to execute. **Decision: Layer 1** — make the +> brain an always-on, machine-owned, singleton service that clients *attach* to, +> without changing what the brain does (no execution split, no changeset-engine +> rewrite). Every claim below is verified against code (file:line) by a read-only +> multi-agent sweep; nothing was run. +> +> **For the executing agent:** This is the single source of truth. Read it top to +> bottom, then implement Section 7 (phased plan) using the file:line anchors. +> Honor the guardrails in Section 0. When code disagrees with this doc, trust the +> code and note the drift here. + +--- + +## 0. Guardrails (read first) + +- **NEVER spawn a second `ade serve` / runtime on this machine while developing.** + The user runs a live **ADE Beta** whose brain is on this machine; a second + sync-enabled runtime on the same project DB triggers a brain-war that kills + Beta's sessions. Build and unit-test in isolation; do not launch runtimes. +- **Edit only in this worktree** (`.ade/worktrees/mobile-chat-ui-parity-0c4bcd0a/...`), + never the project-root checkout. +- **No git worktrees for sub-agents.** Work in the main working directory. +- **Two nouns only, going forward: `brain` and `runtime`.** Drop user-facing + "daemon" and "socket." + +--- + +## 1. Glossary (locked) + +| Term | Meaning | +|---|---| +| **Machine** | A physical computer. Shown to users by OS ComputerName (`scutil --get ComputerName`). In the DB it's a `devices` row (`device_type: desktop`), identified by a per-machine `deviceId` at `$ADE_HOME/secrets/sync-device-id`. | +| **Runtime** | An `ade serve` process. The thing that opens project DBs and executes work (agents, PTYs, git, orchestrator). | +| **Brain** | The **role**: the always-on, machine-owned runtime that carries the websocket + project catalog + executor-authority for a channel. **One per channel.** Normally the only runtime. (Legacy code/DB name: "host" / `brain_*` / `sync_cluster_state`.) | +| **Client** | Desktop renderer, ADE Code TUI, iOS app. They **attach** to the brain; they don't host. iOS is controller-only, always. | +| **Channel** | A release lane: stable (no suffix), **beta**, **alpha**, plus dev. Each isolates everything under its own `ADE_HOME` (`~/.ade`, `~/.ade-beta`, `~/.ade-alpha`). | +| **`ADE_HOME`** | The machine state root for a channel. Holds `projects.json`, `secrets/`, `sock/`, `runtime/`, `bin/`. Default `~/.ade`; channel builds set `~/.ade-`. | +| **Project** | A repo opened in ADE. Exactly **one DB** at `/.ade/ade.db`. One brain hosts many projects. | +| **DB** | `/.ade/ade.db` (SQLite WAL + cr-sqlite CRR). Per project. Local processes share the file; remote devices (phone) keep a **replica** synced via changesets. | +| **Lane** | A worktree under `.ade/worktrees/`. **Shares the project DB**; no DB or runtime of its own. "Lane runtime isolation" = ports/hostname/OAuth/env, run inside the active runtime. | +| **RPC socket** | Unix-domain socket (`$ADE_HOME/sock/ade.sock`), how local clients (desktop, TUI) attach. Per runtime process. Multiplexes all the runtime's projects. | +| **Sync websocket** | TCP 8787 (configurable), how the iOS app connects. Bound to one active project's host today (per-project port). | +| **Catalog** | The machine-level list of projects a brain hosts; sent to the phone in `hello_ok`; phone picks one. | +| **Replica (iOS)** | The phone's own SQLite copy (`Application Support/ADE/ade.db`) that converges with the host via cr-sqlite changesets. The phone never opens the host's DB file. | +| **Pairing** | Phone↔brain auth via a user-set 6-digit PIN → durable per-device secret (phone Keychain). PIN at `$ADE_HOME/secrets/sync-pin.json`, machine-global. | + +--- + +## 2. Context — what ADE is, and the surfaces involved + +ADE is a local-first, per-machine system. The engine is **`ade serve`** (in +`apps/ade-cli/`), which opens project DBs and runs all execution. Three client +surfaces attach to it: + +- **ADE Desktop** (`apps/desktop`, Electron) — spawns/attaches to `ade serve` via + `localRuntimeConnectionPool`; the renderer is a client over the RPC socket. +- **ADE Code / TUI** (`apps/ade-cli` `ade code`) — attaches to `ade serve` over + the RPC socket, or spawns one if absent. +- **ADE Mobile** (`apps/ios`) — connects over the **sync websocket**; controller-only. + +Everything is **channel-scoped** via `ADE_HOME`. Stable, beta, and alpha each get +their own `~/.ade[-channel]` tree (own socket, registry, secrets, DBs-of-record). + +--- + +## 3. Current architecture (verified) + +### 3.1 The brain is `ade serve`, fused runtime+brain, one per channel, already machine-scoped +- A single `ade serve` per channel owns the machine project registry + `$ADE_HOME/projects.json` (`machineLayout.ts:55`, `projectRegistry.ts:106-234`), + deterministic `projectId = project_` (`projectRegistry.ts:45-52`). +- It lazily opens **every** project's DB: `ProjectScopeRegistry.get` → + `createAdeRuntime({projectRoot})` → `openKvDb(/.ade/ade.db)` + (`projectScope.ts:63-81`, `bootstrap.ts:461`, `adeLayout.ts:110`). One DB handle + per project per process; the daemon holds many at once. +- It builds the mobile **catalog** from that registry + (`cli.ts:12865, 12913-12918`, `toMobileProjectSummary`). +- It runs the **websocket** host (`syncHostService.ts:1222`, `new WebSocketServer`). +- It **executes everything** — the host service borrows ~30 in-process services + (`syncHostService.ts:316-383`): `ptyService`, `agentChatService`, `laneService`, + `gitService`, CTO/worker stack, etc., constructed in `bootstrap.ts` against the + single `db`. **Brain and runtime are the same process.** + +### 3.2 How clients attach/spawn + lifecycle (who dies when) +- **Default machine socket** = `$ADE_HOME/sock/ade.sock` (`machineLayout.ts:47-49`), + channel-aware via `ADE_HOME`. +- **Desktop**: `tryConnect(socketPath)` first; on failure `spawnRuntime` → + `[cli, "serve", "--socket", socketPath]` (`localRuntimeConnectionPool.ts:112, + 1122-1133, 1284`). Desktop-spawned daemon gets `ADE_RUNTIME_PARENT_PID` + (`:1289`) → `monitorRuntimeParentProcess` kills it when the desktop dies + (`cli.ts:13172-13198`). **Dies with the desktop.** +- **TUI** (`ade code`): attaches first; else `spawnDaemon` **detached + unref'd, + NO parent-PID** (`connection.ts:451-475, 650-816`). **Survives the TUI forever** + (no idle-exit on the machine socket). +- **Idle-exit** (`monitorRuntimeIdleExit`, `cli.ts:13212-13237`) arms ONLY for + ephemeral `/tmp/ade-*` sockets, never the machine socket + (`isEphemeralRuntimeSocketPath`, `cli.ts:12255-12270`). +- **Background service** exists (launchd `KeepAlive=true` `installLaunchd.ts:68-71` + / systemd `Restart=always` `installSystemd.ts:46-52`) via `ade serve + --install-service`, channel-aware label `com.ade.runtime.` + (`common.ts:29-38`) — **but disabled for packaged beta/alpha** (see 3.6). +- **Attach-don't-own already works**: a fresh `tryConnect` returns `child: null` + (`localRuntimeConnectionPool.ts:1141-1146`); `dispose()` → + `disposeOwnedRuntimeChild(null)` early-returns (`:459, 1062`). An attached brain + is never killed by the desktop. ✅ + +### 3.3 The TWO brain surfaces + `forceHostRole` + cluster election +There are **two** places that claim the brain role on a project DB: +1. **`ade serve`** passes `forceHostRole: true` (`cli.ts:12908`). +2. **The desktop in-process syncService** also passes `forceHostRole: true` + (`main.ts:3540`) and calls `initialize()`→`refreshRoleState()` for **every** + project it opens (`main.ts:3589-3590`), seizing the cluster row even though its + host never starts (host startup is off by default there). +3. The default in `bootstrap.ts:1269` is `forceHostRole: … ?? true` — so even + without (1), every `createAdeRuntime` re-seizes. + +Election (`syncService.ts:733-793`): the brain is a single replicated row +`sync_cluster_state` (`deviceRegistryService.ts:46-52`) with `brain_device_id` + +`brain_epoch`. With `forceHostRole`, a process **unconditionally overwrites** +`brain_device_id` to itself and bumps the epoch (`syncService.ts:747-754`). Two +sync-enabled processes on one DB therefore flip the row back and forth → the war. +Without force, the non-brain path takes `stopHostIfRunning()` + viewer-peer +(`:767-786`) — cooperative, no seize. + +### 3.4 Projects / DBs / catalog ownership (machine-level) +- Registry + catalog are machine-level (`projects.json`), shared by all runtimes. + Confirmed: a machine-level brain can own the registry, catalog, and all + per-project DBs — the `ade serve` daemon already does. +- Multi-process access to one DB is designed-for: `PRAGMA busy_timeout=5000` + (`kvDb.ts:96-99`), `runtime_processes` heartbeat (`kvDb.ts:1531-1540`, + `processRegistryService.ts:62-309`), session `owner_pid`/`owner_process_started_at` + (`kvDb.ts:1516-1524`). Live PTYs/processes are owned by the spawning process and + reconciled to `detached` when it dies (`bootstrap.ts:567-584`). +- Sole hard machine-local exception: the Electron `built_in_browser` pane (needs + `WebContentsView`), proxied over `desktop-bridge.sock` (`machineLayout.ts:10-17`). + +### 3.5 Mobile connection + per-project-socket serving (VERIFIED — important) +- **The phone connects to a MACHINE conceptually, but project switching reconnects + it to a different per-project host PORT.** `prepareProjectConnection` returns a + **non-null** `connection` carrying that project scope's own host `port` + (`cli.ts:2985-2996`, sourced from `scope.runtime.syncService.getStatus() + .pairingConnectInfo`). The phone tears down and reconnects to that port + (`SyncService.swift:1605-1641`). The desktop path is identical on the wire + (`main.ts:4999-5028`). +- **The sync host is bound to ONE active project DB at a time.** It's constructed + with a single `db`/`projectId` (`syncService.ts:620-624`) and **rejects + changesets for any other project** (`resolveSyncHostInboundProjectScope`, + `syncHostService.ts:489-548`). `projectScope.switchSyncHost` deactivates the + previous host (`projectScope.ts:143-173`); steady state = exactly one host. +- **What already works for a machine-centric phone UX:** the **catalog is + machine-level** and is served over the existing connection without reconnect + (`hello_ok` inline + `project_catalog[_chunk]`, `syncHostService.ts:1972-2005, + 2915-2929`). Pairing secret is **machine-global** (`sync-pin.json` + + `paired_devices` under machine secrets). So: phone connects to the machine's + active sync host (8787), browses all projects, picks one, reconnects to that + project's port **using the same machine pairing token**. +- **Prior-session bug (must fix):** the iOS list + saved profiles + keychain tokens + were re-keyed on `siteId` (per-runtime/per-DB), declared "PRIMARY identity" + (`RemoteModels.swift:13-20, 147-153`; `SyncService.swift:6032-6037, 1989-2005`; + `SettingsPairingSection.swift:269-302`). This makes two project hosts on one + machine show as two rows — the **opposite** of machine-centric. Machine identity + (`deviceId`/`hostName`) is already plumbed through the model and the mDNS TXT, so + re-keying on it is contained (but touches the keychain token scheme → migration). + +### 3.6 Channels / `ADE_HOME` / service-install — why beta/alpha have no standing brain +- Channel resolved from `ADE_PACKAGE_CHANNEL` (Info.plist `LSEnvironment`) / + bundled `package.json` `adePackageChannel` / productName regex + (`main.ts:222-245`; `scripts/package-channel.mjs:13-30, 353-358`). +- `ADE_HOME=~/.ade-` set by Info.plist + desktop fallback (`main.ts:253`) + + CLI wrapper (`ade-cli-macos-wrapper.sh:25,31`). +- **`applyPackagedChannelDefaults()` forces `ADE_DISABLE_RUNTIME_SERVICE_INSTALL=1` + for packaged beta/alpha** (`main.ts:254`) → `shouldAttemptRuntimeServiceInstall` + false (`main.ts:1061-1065`) → **no launchd/systemd service** → the brain is only + the desktop-pool-owned daemon that **dies with the desktop**. THIS is why + beta/alpha have no always-on brain. +- Two channel-collision bugs in the installers: systemd unit filename is hardcoded + `ade-runtime.service` (`installSystemd.ts:23,73`) — channels clobber each other; + launchd log paths hardcoded `~/.ade/runtime/launchd.*.log` (`installLaunchd.ts:73-75`) + — all channels share them. (`ADE_HOME`/`ADE_PACKAGE_CHANNEL` are already in the + service env passthrough, `common.ts:54-59`.) + +### 3.7 Terminology sprawl +- User-facing "**socket**": 50+ occurrences (global `--socket` flag, `ade serve + --socket`, `ade code --socket`, doctor output, 40+ `ade --socket …` examples, + in-chat agent guidance in `ChatWorkLogBlock.tsx:207`, `ChatIosSimulatorPanel.tsx`). +- User-facing "**daemon**": ~17 (help banners `cli.ts:424-438, 989-1022`, status + messages `cli.ts:12492, 12626-12684`, iOS `SettingsPairingSection.swift:427-428`). +- "**runtime**": ~1000+ (command `ade runtime`, lane runtime, UI labels) — keep. +- "**brain**": 0 user-facing today (only icons/role enums). +- Confusing interplay: `ade runtime` vs `ade serve` vs `--headless` vs `--socket` + all blur "the process I attach to." + +--- + +## 4. Why it's wrong / broken + +1. **Brain-war (the crash).** `forceHostRole` makes any sync-enabled process on a + shared project DB seize `sync_cluster_state` (3.3). A second `ade serve` — or + even the desktop opening the same project — flips the brain row, churning host + start/stop and killing live sessions. Root cause of the repeated ADE Beta crashes. +2. **No always-on brain for channels.** The brain dies with whatever client spawned + it (3.2), and the standing-service path is force-disabled for beta/alpha (3.6). + You can't reliably reach your machine from the phone if the brain isn't running. +3. **Brain is hostage to a client.** Desktop-spawned brain is tied to the desktop's + lifetime; TUI-spawned brain leaks forever. Ownership is incoherent. +4. **Mobile is keyed per-runtime, not per-machine** (3.5) — contradicts "connect to + a machine." Two project hosts = two phone rows. +5. **Service installers collide across channels** (3.6). +6. **Terminology confuses users** (3.7) — three words for one process; "socket" is + a transport leak. + +--- + +## 5. Target architecture (Layer 1) — keep behavior, change ownership/lifecycle + +**The brain keeps doing exactly what `ade serve` does today** (owns registry + +all DBs + catalog + websocket + executes everything). We change only WHERE it +lives and WHO owns it: + +### 5.1 Brain = always-on machine service per channel +- One standing `ade serve` per channel, installed as a launchd/systemd service, + surviving client quits, explicitly started/stopped by the user. +- Clients (desktop, TUI) **attach** to it; if it's not up, they may start it, but + they never *own* or *claim* it. + +### 5.2 No claim, no war +- Remove `forceHostRole` so the standing brain bootstraps the cluster row once and + everyone else cooperatively reads it. A second/dev runtime becomes a viewer, not + a second brain. + +### 5.3 Mobile = machine-centric (keep per-project sockets behind a machine pairing) +- Phone lists **machines** (keyed on `deviceId`/ComputerName), pairs once per + machine (PIN, already machine-global), connects → machine catalog → pick project + → reconnect to that project's host port **under the same machine token** + (internal plumbing; the user only sees "machine → projects"). +- **Out of scope (future / Layer 2):** collapsing per-project sockets into one + multiplexed websocket. That requires per-project-multiplexed changeset streaming + over one connection (the host changeset engine + the phone CRDT store are both + single-active-project today; `syncHostService.ts:489-548`, the phone's + `resetOutboundCursorStateForActiveProject`). Documented, not done now. + +### 5.4 Terminology = brain + runtime +- Add `ade brain start|stop|status` and `ade brain pin set|generate|clear`. +- Replace user-facing "daemon" → "brain", "socket" → "brain"/"endpoint". + +### 5.5 Explicitly OUT of scope (do NOT do in this overhaul) +- **No execution split** (separate worker runtimes streaming PTY/chat to a thin + brain over IPC). Execution stays in the brain process. The `transferReadiness` + "stop terminals before transfer" gate (`syncService.ts:1148-1151`) proves + execution + brain must co-reside; moving it needs a PTY/chat IPC protocol — + a separate, much larger project. +- **No single multiplexed mobile websocket** (5.3). + +--- + +## 6. What this means per surface + +- **CLI / brain (`apps/ade-cli`):** `ade serve` becomes the brain; gains a friendly + `ade brain` front-door. Stops force-claiming. Standing-service install enabled + per channel. `ade code` keeps attaching (already does). +- **Desktop (`apps/desktop`):** stops seizing the cluster row (`main.ts:3540` + `forceHostRole:false`); keeps attaching to the standing brain (already safe); + isolated/dev runtimes spawn with `--no-sync`. Settings "Pair a phone" surfaces + the brain + its websocket details (already mostly there in `SyncDevicesSection.tsx`). +- **TUI (`ade code`):** benefits from the always-on brain (attaches, no spawn churn). + Dev loops (`ade code --socket` / dev build) use a separate `ADE_HOME` or + `--no-sync` to stay off the brain. +- **Mobile (`apps/ios`):** re-keyed to machines; one row per machine; per-machine + PIN; project switching is internal. No more per-runtime rows. + +--- + +## 7. Implementation plan (phased, file-level) + +Phases are independently shippable. Order: **P1 → P4 → P2 → P3** (lifecycle first +to kill the crash; installer fixes ride with it; then naming; then mobile). Each +edit cites current file:line. + +### Phase 1 — Brain lifecycle: always-on + drop the war + dev isolation +**Goal:** standing brain per channel; no client owns/claims it; dev runtimes can't disrupt it. + +1. **Enable the standing service for channels.** + `apps/desktop/src/main/main.ts:254` — remove (or dev-only-gate) the forced + `ADE_DISABLE_RUNTIME_SERVICE_INSTALL = "1"` for packaged beta/alpha. Gating at + `main.ts:1061-1065` then installs the launchd/systemd service correctly. +2. **Drop `forceHostRole` at ALL THREE source sites** (removing one is insufficient + — `bootstrap.ts` re-defaults to true): + - `apps/ade-cli/src/cli.ts:12908` — remove `forceHostRole: true`. + - `apps/ade-cli/src/bootstrap.ts:1269` — `forceHostRole: … ?? true` → `?? false`. + - `apps/desktop/src/main/main.ts:3540` — set `forceHostRole: false`. + - Read-sites need no edits (relax automatically): `syncService.ts:457, 488, 528, + 715, 747-758, 940`; `projectScope.ts:200`. + - **High blast radius:** the `bootstrap.ts:1269` default flip affects EVERY + `createAdeRuntime` caller. Sweep all callers with `syncRuntime.enabled` that + relied on the implicit `true` before landing. +3. **Isolate dev runtimes from the brain.** + `apps/desktop/src/main/services/localRuntime/localRuntimeConnectionPool.ts` — + in `startIsolatedRuntime`/`spawnRuntime` (`:1173-1207, 1284-1287`) force + `--no-sync` (via `buildLocalRuntimeServeArgs(..., {disableSync:true})`, + `:107-115`) for build-mismatch isolated daemons so they never touch + `sync_cluster_state` or bind the host port (`cli.ts:12864, 13107-13116`). + Document `ADE_HOME` as the manual escape for `ade code --socket` dev loops. +4. **Attach-don't-own:** verify only (already correct, 3.2). Keep the desktop + fallback spawn parent-PID-bound (`:1289`) — do NOT make it persistent; the + always-on guarantee comes from the service, and an orphan fallback would block + the service's socket bind. +5. **Tests:** rewrite fixtures asserting `forceHostRole:true`/seize semantics to + cooperative election — `projectScope.test.ts:55,122,168,220,264`, desktop + `syncService.test.ts:526,856-953`. Add a test: two runtimes on one DB → second + becomes viewer, no epoch flip. + +### Phase 2 — Service-installer channel fixes (ride with P1) +1. `apps/ade-cli/src/serviceManager/installSystemd.ts:23,73,87,101,102` — derive the + unit filename from the channel (mirror `ADE_RUNTIME_SERVICE_NAME`, + `common.ts:29-38`) instead of hardcoded `ade-runtime.service`. +2. `apps/ade-cli/src/serviceManager/installLaunchd.ts:73-75` — root + `launchd.out.log`/`launchd.err.log` under `ADE_HOME`, not hardcoded `~/.ade`. + +### Phase 3 — `ade brain` command + terminology pass +1. **Add `ade brain` subcommand** (`cli.ts` dispatch near `primary === "serve"`): + - `ade brain start|stop|status` (wraps the service-managed runtime; reuse + `runtime`/service-manager handlers). + - `ade brain pin set|generate|clear` (alias of `ade sync pin …`, + `cli.ts:11892-11926`). + - Optional `ade brain show` — print websocket connect details (port + address + candidates + ComputerName) for phone pairing. + - Keep `ade serve` as the foreground brain process (rename help to "brain"). +2. **Rename user-facing strings** (term-audit list; ~70-80 strings). Highest-value: + - `cli.ts` help banners + status: `:424-438, 973, 989, 1002-1040, 1056-1058, + 12492, 12626-12684, 14910-14911`. "daemon"→"brain"; "runtime daemon socket"→ + "brain connection". + - Flags: keep `--socket` working internally but hide from help / alias a + friendlier form; update `ade code --socket`/`--require-socket` help text. + - `apps/desktop/src/renderer/components/chat/ChatWorkLogBlock.tsx:207` and + `ChatIosSimulatorPanel.tsx:1986-2030` — drop "socket" from agent guidance. + - iOS `SettingsPairingSection.swift:427-428` — "Background ADE" label + (internal; optional). + - Docs: `docs/README.md`, `docs/PRD.md`, `docs/ARCHITECTURE.md` "daemon"→"brain". + - Leave internal identifiers (`socketPath`, `brain_device_id`, etc.) as-is. + +### Phase 4 — Mobile: machine-centric hosts + pairing/PIN (revert prior siteId work) +**Goal:** the phone connects to a **machine** (never a "runtime"/"socket"), pairs +once with a PIN, then reaches that machine over LAN/Tailscale. Much of the +pairing/PIN UI already EXISTS from prior work; the change is re-keying to machine +identity and keeping the copy machine-centric. + +**A. Code re-key + migration** +1. **Re-key discovery/saved/token storage on machine identity** (`deviceId`/ + `hostName`), reverting the prior-session `siteId` keying: + - `RemoteModels.swift:13-20, 147-153` — machine identity primary; `siteId` + demoted to an internal per-connection detail. + - `SyncService.swift:6032-6037` (`syncRuntimeIdentityKey`), `1989-2005` + (`profileStorageKey`), `6046-6091` (`applyDiscoveredHosts`) — key on `deviceId`, + collapsing per-project hosts into ONE machine row. + - `SettingsPairingSection.swift:269-302` — machine merge key; one row per machine. +2. **Keychain token migration:** tokens are saved under the per-`siteId` + `profileStorageKey`; add a one-time migration re-homing them to the machine + (`deviceId`) key so paired machines stay paired. Host secret is already + machine-global → one token works across that machine's project ports. +3. **Project switching stays internal:** phone keeps reconnecting to per-project + ports on `project_switch_result`, all under the one machine pairing — no UX + change beyond "machine → catalog → project." + +**B. Mobile connection + pairing UX (per-machine)** +4. **Machine list / discovery:** the phone shows a list of **machines** by + ComputerName (e.g. "Arul's Mac Studio"), discovered via mDNS/Bonjour + Tailscale, + plus manual host/port entry. One row per machine (subtitle = optional brain + name). No runtime/socket/IP jargon. (UI exists — `DiscoverHostsSheet`, "Nearby + machines" — just ensure rows are machine-keyed after step 1.) +5. **One-time PIN pairing → durable token:** first connect over wifi, user enters + the machine's 6-digit PIN (`SettingsPinSheet` keypad); the host mints a durable + per-device secret saved in the phone Keychain. Later connects use that token over + LAN or Tailscale — no PIN re-entry. (Flow exists; keep it.) +6. **Setting the PIN (machine-global) — two surfaces, both already wired:** + - **Desktop:** the "Pair a phone" panel shows the **machine's brain + its + websocket connect details** + a PIN editor (generate/set/clear), + `SyncDevicesSection.tsx:613-660`. Reframe copy to brain/machine. + - **CLI:** `ade brain pin set|generate|clear` (alias of `ade sync pin …`). +7. **Friendly "no PIN set" path:** when a machine has no PIN, the phone shows the + "Set a PIN" screen with the exact command (`ade brain pin generate`) + the + desktop Settings path, instead of a cryptic error. (Exists: `SettingsPinSetupSheet` + + reactive `pin_not_set` routing — keep, reword to brain.) +8. **Cancelable connect:** keep the Cancel-during-connecting affordance + terminal + "unreachable" state from prior work so the phone never gets stuck in a loop. + +**C. After connecting** +9. Phone gets the machine's **project catalog** over the connection, picks a + project, reads its replica (per-project reconnect is internal). No "desktop vs + CLI" notion on the phone — just **machine → projects**. + +### Phase 5 — Remote runtime: preserve hardening + confirm safe +**VERIFIED: the overhaul does NOT break remote runtimes.** The desktop SSHes in and +runs **`ade rpc --stdio`** (a thin stdio proxy), not `ade serve` — so the REMOTE +machine's own `ade serve` is the brain for its projects; its `sync_cluster_state` +lives in the remote DB; the local desktop is a pure RPC controller that never +writes the remote cluster row (`bindRemoteProject` touches no syncService; +in-process sync is gated off + local-only). Per-change: (a) forceHostRole removal +SAFE, (b) always-on service SAFE/redundant (remote daemon already detached/ +always-on), (c) `--no-sync` SAFE (remote bootstrap never uses the isolated path). +Evidence: `remoteBootstrap.ts` (`rpc --stdio` cmd ~1350-1352; env prefix 230-263), +`cli.ts` (rpc-stdio proxy 12757-12822; daemon spawn 12445-12484; forceHostRole 12908). +1. **PRESERVE the recent hardening — DO NOT REGRESS.** Commit `203afab3a "Harden ADE + remote runtime connections"` is ALREADY in this branch's base (merge-base = main + tip `97589d7e6`); it is NOT stale. The only local touches are TINY: + `runtimeBridge.ts` (+3 lines) and `remoteConnectionPool.ts` (+1). Reconcile those + 4 lines against the hardened baseline; keep all hardening behavior intact + (multi-route fallback, bounded timeouts, host-key trust, compatibility warnings, + open-generation guards). +2. **Edge test — stale remote cluster row.** Cooperative election only bootstraps + the brain when `!cluster` (`syncService.ts:755-756`); `forceHostRole` masks a + STALE `sync_cluster_state` row (pointing at a departed brain device) by always + re-seizing. Add handling + a test so a remote DB carrying a stale brain row still + elects the live remote daemon as host. +3. **Keep `--no-sync` OUT of the remote compatibility-restart path** (`cli.ts` + ~12516-12551 must keep sync ON); scope `--no-sync` strictly to the desktop's + `startIsolatedRuntime` (`localRuntimeConnectionPool.ts:1159-1202`). +4. **Per-channel remote homes keep `ADE_DISABLE_RUNTIME_SERVICE_INSTALL=1`** on the + remote (`remoteBootstrap.ts:248-250`, README "Per-channel layout") so a channel + daemon doesn't fight the stable login service for the socket — unchanged here. +5. **Mobile↔remote:** the phone reaches a remote machine's daemon **directly** over + LAN/Tailscale (not via SSH/desktop). Machine-centric re-key (Phase 4) keys on + `deviceId` which is **per-channel** ADE-home-stable — works for remote machines, + but a box running stable+beta+alpha shows 3 rows (one per channel). Acceptable; + note it. The keychain-token migration (Phase 4.2) covers remote pairings too. + +### Phase 6 — Docs & public-site terminology overhaul (full restructure) +Reconcile ALL documentation + user-facing lingo to the new model. **Drop "daemon" +and "socket" as user-facing terms everywhere; standardize on `brain` + `runtime`.** +1. **README glossary.** Add a glossary section to the top-level `README.md` defining + the new canonical vocabulary (brain, runtime, machine, project, DB, lane, client, + catalog, channel, ADE Mobile) — mirror §1 of this doc. This becomes the single + source of truth other docs link to. +2. **Internal docs (`docs/`):** rewrite to the new model and fix EVERY grievance in + §11 — `ARCHITECTURE.md`, `PRD.md`, `README.md`, `features/sync-and-multi-device/*`, + `features/remote-runtime/*`, `features/ade-code/*`, `apps/ade-cli/README.md`. + "daemon"→"brain"/"runtime"; remove "project-specific sync host" overstatement + (G2); fix "one daemon per machine" hard claim (G5); document per-project-port + mobile reality (G7) + channel-aware service identity (G8); brain==host==cluster + owner glossary (G3). **Add a user-facing "Connect your phone" guide** in + `sync-and-multi-device/ios-companion.md`: connect to a **machine** (ComputerName), + set the PIN via the desktop "Pair a phone" panel or `ade brain pin generate`, + pair once → reconnect over LAN/Tailscale — no runtime/socket jargon. +3. **Public docs site (Netlify / `apps/web`):** update all marketing + docs copy + + the `DownloadPage` to the new install model — **"ADE for computers"** (one install + = app + `ade code` TUI + `ade` CLI + brain) + **ADE Mobile** (separate). Remove + the "desktop app vs CLI" framing; drop daemon/socket lingo. + NOTE (memory `feedback_marketing_copy`): public/marketing pages use **recognized + external terms** (e.g. "worktrees" not the internal "lanes"); keep the + ADE-internal vocabulary to the docs/CLI, not the marketing site. +4. **CLI help/output:** the Phase-3 term-audit rename covers `ade` strings; ensure + they match the docs vocabulary exactly (no drift between CLI text and docs). +5. **Cross-link, don't redefine:** README glossary + this doc's §1 are canonical; + every other doc links to them instead of re-defining terms. + +--- + +## 8. Risks, edge cases, open decisions + +- **Device-id sharing is load-bearing.** "Attach reads self as brain without + re-seizing" depends on the standing service and the desktop sharing the same + `sync-device-id` under one `ADE_HOME`. Confirmed they resolve the same layout; + if a service ever runs under a different `ADE_HOME`, a war returns. Guard this. +- **`bootstrap.ts:1269` default flip** is the highest-blast-radius edit — audit + every `createAdeRuntime` caller before landing. +- **Dev-runtime on the SAME project DB** (different build / `ade code --socket`): + even without `forceHostRole`, two processes on one DB with the same `deviceId` + both read as brain and could contend on the host port. Mitigation: `--no-sync` + for isolated/dev runtimes (P1.3); `ADE_HOME` for fully-isolated dev loops. +- **Migration:** flipping the service on for channels means existing beta/alpha + users get a standing service on next launch — ensure clean install/idempotency + and that uninstalling ADE removes the service. +- **Mobile token migration** must be lossless or users must re-pair. + +--- + +## 9. Test strategy + +- **Unit:** cluster election without `forceHostRole` (first→brain, second→viewer, + no epoch flip); `--no-sync` isolated runtime never writes `sync_cluster_state`; + channel-aware systemd unit name / launchd log path; `ade brain` command surface. +- **Integration (isolated, NOT on this machine's live brain):** standing service + survives a simulated client quit; desktop attach yields `child:null` and dispose + doesn't kill; phone discovery collapses two project hosts to one machine row. +- **Manual (on a separate machine or after build):** phone pairs once to a machine, + switches projects without re-pairing; brain survives desktop quit. +- Honor memory: shard test runs; run only related files; real-value tests only. + +### Release-update simulation (FINAL validation gate — run when implementation is done) +Before calling the overhaul done, simulate a REAL electron auto-update against the +user's installed release build, WITHOUT publishing a release. The user runs this +with **ALL ADE shut down** (no app, no brain, no sockets) — a clean-slate baseline, +which is also what makes it safe to do on their primary machine. +1. **Build + notarize** the channel DMG exactly as the GitHub release workflow does + (same electron-builder config: code-sign + Apple notarize + staple). +2. **Replace the installed app in place** the way electron-updater would (atomic + swap of `/Applications/ADE[ Beta].app` with the freshly-built build). +3. **Launch and verify the full upgrade path a real user's electron update produces:** + - app launches → installs/starts the standing brain service (Phase 1); + - brain is always-on, no `forceHostRole` war, single brain per channel; + - `ade brain restart` ran post-update → new build-hash matches (no isolated-runtime + fallback); + - `ade` / `ade code` resolve on PATH; + - phone pairs to the machine (machine-centric) + browses/opens projects; + - remote targets still connect (hardening preserved); + - the "open the app to update" nudge + Settings "Update ADE" button work. +4. Confirm internal workings (brain lifecycle, sockets, DB, sync) are exactly as + expected post-upgrade. **This is the go/no-go gate for the migration.** + +--- + +## 10. Distribution & Updates (discussion + decisions) + +The brain-as-a-standing-service changes how ADE ships and updates. This must be +designed in, not bolted on. + +### Current state (verified) +- **Desktop:** DMG/ZIP from GitHub Releases (`apps/web/.../DownloadPage.tsx:54-57`), + auto-updates via **electron-updater** (`apps/desktop/src/main/services/updates/ + autoUpdateService.ts` — `checkForUpdates`, `quitAndInstall`). Full pipeline exists. +- **CLI is bundled inside the desktop app**, NOT a standalone download. It ships as + `Resources/ade-cli/cli.cjs` + wrapper scripts (electron-builder `extraResources`). + `apps/ade-cli/package.json` is `version: 0.0.0`, `bin: { ade: dist/cli.cjs }`, **not + published to npm**. No `ade`-on-PATH installer found — so "download just the CLI" + is NEW work. +- **The desktop already installs the brain service** (`installServiceBestEffort` → + launchd/systemd, user-level, no sudo). Just force-disabled for beta/alpha (3.6). +- **Mobile:** TestFlight → App Store; installed separately on the phone. + +### Migration: can a plain Electron auto-update do the reorg? YES (with care) +Ship a desktop build with the new code (Phase 1) + service-install flag flipped. +electron-updater delivers it → user restarts → new launch installs the standing +brain service. Existing beta users migrate transparently. Must handle on upgrade: +- **Retire the old/orphaned daemons** (old forceHostRole brain, detached TUI + daemons). Reuse stale-socket shutdown + orphan cleanup + (`parseNativeLanDiscoveryProcessList`, build-hash mismatch retire). +- **Communicate** the new login-item brain + provide an off switch + (`ade brain stop` + a Settings toggle). + +### Install model — ONE computer install + Mobile (decision) +**Two install paths only**, framed honestly: +1. **ADE (for computers)** — ONE install that bundles **everything**: the ADE app + (GUI) + **`ade code` (TUI)** + the **`ade` CLI** + the **brain**. Stop calling it + "the desktop app"; it's "ADE." The CLI/TUI already live inside the app bundle + (`Resources/ade-cli/cli.cjs`), so this is one artifact, not a bundle of separate + downloads. +2. **ADE Mobile** — the iOS app, installed separately on the phone. + +A standalone CLI download is **dropped for v1** (see "Deferred" below). + +### Required net-new piece: put `ade` / `ade code` on PATH +For "you also get the CLI/TUI" to be real, the install must drop an `ade` shim on +the user's PATH (the VS Code "Install 'code' in PATH" pattern). **None exists +today** (no symlink/shim mechanism found). Build it into the install — e.g. symlink +`$ADE_HOME/bin/ade` → the app-bundle CLI wrapper and offer to add it to PATH. +Without this, the bundled CLI is unreachable from a terminal. + +### Updates — ONE path (the app updates everything) +- The app's **electron-updater** updates the GUI **and** the bundled CLI/TUI **and** + (via `ade brain restart`) the brain — **in one go**. Surfaces: the existing + top-right auto-update prompt + a **Settings "Update ADE" button** that does + check→download→install→`brain restart`. +- **Build-hash sync wrinkle (must handle):** after the app updates in place + (electron-updater replaces `/Applications/ADE.app` atomically, same path), the + standing brain still runs the **old** binary → new-app ↔ old-brain build-hash + mismatch → it would spin up an isolated runtime and lose the always-on brain. + Fix: the brain service `ExecStart` points at the app-bundle CLI path; on update + completion the app fires **`ade brain restart`** so the brain re-execs the new + binary and the hash matches. + +### Update nudge for terminal-only users (open the app to update) +The app is the **only** updater, and that's fine — terminal users who never open +the GUI just get nudged to it: +- Let the **always-on brain do ONE periodic version check** (against GitHub + Releases) and cache an "update available" flag. Every client reads it — no + per-command network calls. +- Surface it: the **`ade code` splash screen** ("New ADE version available — open + the ADE app to update"), a **desktop banner**, optionally the **mobile app**. +- Never block; just point them at the app. + +### Deferred (NOT in v1): standalone CLI download +A real standalone CLI (curl / Homebrew / npm + a self-updating bundled binary) is +**deferred** to a later phase for headless servers / terminal-first users. This +does **not** affect **remote VPS runtimes** — those are bootstrapped over SSH by the +app (`remoteBootstrap`), not a CLI download — so dropping the standalone CLI now is +a deferral, not a regression. + +### Why the service is disabled for beta/alpha today (context) +No code comment states it (added in commit `3be658856`); inferred from placement +(`main.ts:254`, grouped with the channel `ADE_HOME` isolation defaults). Rationale: +a `KeepAlive` service auto-relaunches forever — undesirable for frequently-updated +pre-release channels — AND the installers are channel-blind today (systemd unit +`ade-runtime.service`, launchd logs `~/.ade/...`), so enabling it for beta/alpha +would have **collided with a co-installed stable service**. I.e., the disable +exists largely because of the very collision bugs Phase 2 fixes. Once service +identity is channel-aware (Phase 2), enabling per channel is safe — each channel +runs its own isolated brain (own `ADE_HOME`, own port, own service label). + +### Decisions (confirmed with user) +- [x] **Install model: ONE computer install ("ADE") bundling app + `ade code` TUI + + `ade` CLI + brain, plus ADE Mobile.** Standalone CLI dropped for v1. ✅ +- [x] **Brain auto-starts at login** (launchd `RunAtLoad` + `KeepAlive`), always up + once installed, until logout/shutdown/explicit stop. Phone can connect anytime. ✅ +- [x] **One update path: the app (electron-updater) updates everything** + a Settings + "Update ADE" button; terminal-only users get a nudge to open the app. Standalone + CLI + self-updater deferred. ✅ +- [x] **Install puts `ade`/`ade code` on PATH** (net-new shim; required for the + bundled CLI/TUI to be usable). ✅ + +### Brain enable/disable/update lifecycle (user-facing — build these) +- **Comes up when:** auto-installed + started on the **first app launch** + (`installServiceBestEffort`). After that it's always-on. +- **Always-on once enabled:** login service (launchd `RunAtLoad` + `KeepAlive`); + survives app/terminal close; relaunches on crash; restarts at login. +- **Control surface (the bundled `ade` provides these in the terminal):** + - `ade brain start` — enable + run (loads the channel service). + - `ade brain stop` — disable + stop (unloads it; stays off across logins until + `start`). Must `launchctl bootout`/unload, not just kill (KeepAlive relaunches). + - `ade brain status` — running? port? paired devices? + - `ade brain restart` — re-exec with the current on-disk binary (apply updates). + - Desktop Settings: a "Run ADE in the background" toggle calling start/stop. +- **One brain per channel per machine.** Different channels (stable/beta) = separate + brains by design. The service `ExecStart` points at the app-bundle CLI path + (single canonical binary per channel — simpler now that only the app installs it). +- **Update rule:** updating the app updates the brain's CODE on disk; the running + brain keeps old code until **`ade brain restart`**, which the app fires + automatically post-update. +- [x] **Migration UX:** one-time notice on upgrade — "ADE now runs a lightweight + background service so your phone can connect anytime; turn it off in Settings" — + plus an off-switch (`ade brain stop` / Settings toggle). Light (pre-launch). + +--- + +## 11. Doc grievances to reconcile (docs are stale — fix at the end) + +- **G1 — "daemon" vs "runtime/brain".** Normalize to brain + runtime everywhere + user-facing; reserve `ade serve`/process-level mentions. +- **G2 — "project-specific sync host" overstated.** `ARCHITECTURE.md` §13.1 + (line 837) / §13.3 (line 849) read as if each project has its own host/socket. + Reality: one machine brain, machine-level catalog, per-project host **ports** + behind it; a true cross-machine reconnect only when a project lives on another + machine. Clarify. +- **G3 — `brain_*` legacy naming.** DB/protocol still say `brain_device_id`; docs + say "host". Glossary: brain == host == cluster owner. +- **G4 — §3.4 "any number of TUI runtimes" vs "desktop is just a client".** Both + true; clarify "processes that open the DB" vs "clients that attach." +- **G5 — "one daemon per machine" stated as hard fact.** The real invariant is + "one **brain** per machine per project DB"; extra runtimes are allowed. The crash + came from violating the brain singleton, not from extra runtimes. +- **G6 — (assistant's earlier error, recorded)** "one sync socket per project" / + "per-project sync host required" was wrong; the brain is per-machine. +- **NEW G7 — mobile is per-project-socket today**, not one websocket; docs imply a + single mobile websocket. Document the per-project-port reality + the future + single-multiplexed-websocket option. +- **NEW G8 — service installers are channel-colliding** (systemd unit name, launchd + logs); docs don't mention per-channel service identity. + +--- + +## Appendix — superseded analysis (history) + +The earlier "one DB per lane" tangent and the A/B/C ("per-machine vs per-runtime +websocket") framing were assistant explorations, not the chosen design. Superseded +by Sections 5–7 (Layer 1). Kept only so the reasoning trail is legible. diff --git a/apps/ade-cli/README.md b/apps/ade-cli/README.md index 066c3762f..a9a7a69c3 100644 --- a/apps/ade-cli/README.md +++ b/apps/ade-cli/README.md @@ -1,16 +1,17 @@ # ADE CLI -`apps/ade-cli` owns the `ade` command, the per-machine ADE runtime, and the terminal `ade code` client. The machine runtime is the source of truth for lanes, agent chats, work sessions, 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. +`apps/ade-cli` owns the `ade` command, the ADE brain, manual runtime entry points, and the terminal `ade code` client. The **brain** is the always-on, machine-owned ADE process for one channel; it is the source of truth for lanes, agent chats, work sessions, PR state, process state, sync, proof artifacts, and the project catalog on a machine. Desktop ADE, `ade code`, the iOS app, and SSH-attached desktops all attach to it. A **manual runtime** is an explicit foreground execution process you start for dev/test work instead of using the automated brain service. ## Modes The `ade` binary has three operating modes: -- **Attached runtime** — the ADE runtime (`ade serve`) listens on `~/.ade/sock/ade.sock` (POSIX) or `\\.\pipe\ade-runtime` (Windows). All other CLI commands and clients open that local endpoint 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 machine-runtime endpoint is available. -- **`ade rpc --stdio`** — attaches to the local machine runtime and bridges its JSON-RPC over stdio. This is the transport the desktop's remote runtime feature spawns over SSH. +- **Attached brain** — the ADE brain listens on `$ADE_HOME/sock/ade.sock` (POSIX) or `\\.\pipe\ade-runtime` (Windows). All other CLI commands and clients open that local endpoint and speak ADE JSON-RPC. +- **Manual runtime** (`ade runtime run`) — a foreground execution process on an explicit endpoint. Sync is always off; use this for dev/test work when you do not want to use the automated stable/beta/alpha brain service. +- **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 machine brain is available. +- **`ade rpc --stdio`** — attaches to the local machine brain 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 machine-runtime endpoint if reachable; auto-spawn `ade serve` in the background if the endpoint does not exist; fall back to headless for commands that don't need shared live state. Add `--socket` to require the endpoint, or `--headless` to force in-process execution. +Default routing for typed commands: prefer the machine brain endpoint if reachable; auto-start the brain when the endpoint does not exist; fall back to headless for commands that don't need shared live state. Add `--socket` to require a specific endpoint, or `--headless` to force in-process execution. ## Machine layout @@ -18,10 +19,10 @@ Default routing for typed commands: prefer the machine-runtime endpoint if reach | Path | Purpose | | --- | --- | -| `~/.ade/` | Per-machine ADE state root. | -| `~/.ade/sock/ade.sock` | ADE runtime local endpoint (POSIX). | +| `~/.ade/` | Per-machine ADE state root for the stable channel. | +| `$ADE_HOME/sock/ade.sock` | ADE brain local endpoint (POSIX). | | `\\.\pipe\ade-runtime` | ADE runtime named-pipe endpoint (Windows). | -| `~/.ade/projects.json` | Project registry. | +| `$ADE_HOME/projects.json` | Project catalog. | | `~/.ade/secrets/` | Machine credential store (`credentials.safe.enc` for desktop safeStorage, `credentials.json.enc` plus `.machine-key` for headless fallback storage, and per-store `*.lock` files). | | `~/.ade/bin/ade` | Bundled static runtime binary (release installs / remote uploads). | | `~/.ade/runtime//` | Native node modules for that runtime binary. | @@ -84,7 +85,7 @@ Three ways to put `ade` on a machine: ## Service manager -The ADE runtime runs as a per-user login service. The implementations live in `src/serviceManager/`. +The ADE brain runs as a per-user login service. The implementations live in `src/serviceManager/`. | Platform | Backend | Service path | | --- | --- | --- | @@ -97,52 +98,52 @@ The default service label is `com.ade.runtime`; channel builds override it via ` Manage the service from the CLI: ```bash -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 } +ade brain start # enable/load the login service +ade brain stop # disable/unload the login service +ade brain status --text # endpoint state, service state, sync state +ade brain restart # re-exec after an app update -# Runtime wrappers (same backend): +# Compatibility wrappers (same backend): ade runtime install-service ade runtime uninstall-service ade runtime service-status --text ade runtime status --text # Phone pairing: -ade sync pin generate -ade sync pin set --pin 123456 -ade sync pin clear +ade brain pin generate +ade brain pin set 123456 +ade brain pin clear ``` -`resolveAdeServeCommand()` builds the service command from the current `ade` binary path so the installed service launches the same ADE channel that ran the install. After a packaged app update, ADE refreshes this service so the runtime re-execs the updated bundled CLI instead of leaving clients attached to an older build hash. +The service manager builds the launch command from the current `ade` binary path so the installed service launches the same ADE channel that ran the install. After a packaged app update, ADE refreshes this service so the brain re-execs the updated bundled CLI instead of leaving clients attached to an older build hash. -## Foreground runtime +## Internal process command -`ade serve` runs the ADE runtime in the foreground. Use it for development or when the system service is disabled. +To make your own runtime, run `ade runtime run` on an explicit endpoint. Sync is always off so the manual runtime cannot claim brain authority; use a separate `ADE_HOME` when you also want full machine-state isolation. ```bash -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 the phone sync service for this run +ADE_HOME=/tmp/ade-dev-runtime ade runtime run --socket /tmp/ade-dev-runtime.sock +ade --socket /tmp/ade-dev-runtime.sock projects list --text +ade code --socket /tmp/ade-dev-runtime.sock ``` -## Runtime lifecycle +## Brain lifecycle -Prefer `ade serve --install-service`, `ade serve --uninstall-service`, and `ade serve --service-status` for runtime lifecycle automation. Use `ade runtime status --text` for a compact endpoint and sync health check, and `ade sync pin ...` for phone pairing: +Prefer `ade brain start`, `ade brain stop`, `ade brain status`, and `ade brain restart` for user-facing lifecycle control. Use `ade brain pin ...` for phone pairing: ```bash -ade runtime status --text # endpoint state, service state, sync state -ade serve --install-service # refresh the login service after an update -ade sync pin generate # generate a phone pairing PIN -ade sync pin set --pin 123456 -ade sync pin clear +ade brain status --text # endpoint state, service state, sync state +ade brain restart # refresh the login service after an update +ade brain pin generate # generate a phone pairing PIN +ade brain pin set 123456 +ade brain pin clear ``` -Older command aliases remain available for scripts, but docs and examples use the runtime/sync vocabulary. +Older `ade runtime ...` and `ade sync pin ...` command aliases remain available for scripts, but docs and examples use the brain vocabulary. ## Project registry -The ADE runtime 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. +The ADE brain owns a per-machine project catalog at `$ADE_HOME/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: @@ -205,7 +206,7 @@ The `sync.connectToBrain`, `sync.disconnectFromBrain`, and `sync.transferBrainTo `ade code` launches the terminal-native ADE Work chat (Ink + React, in `src/tuiClient/`). Default behavior: ```bash -ade code # attach to the machine runtime, auto-spawn it if missing +ade code # attach to the machine brain, 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 local endpoint @@ -218,19 +219,20 @@ See `docs/features/ade-code/README.md` for the full attach/embedded handshake, s ## `ade rpc --stdio` -`ade rpc --stdio` attaches to the local machine runtime (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 rpc --stdio` attaches to the local machine brain (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` -`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 runtime is not running, the desktop spawns and waits for it via `LocalRuntimeConnectionPool`. +`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 brain; if the brain is not running, the desktop spawns and waits for it via `LocalRuntimeConnectionPool`. ## CLI surface (selected) ```bash ade desktop -ade serve --service-status -ade serve --install-service -ade serve --uninstall-service +ade brain status --text +ade brain start +ade brain stop +ade brain restart ade auth status ade doctor --json ade projects list --text @@ -328,7 +330,7 @@ Use typed commands first. They validate common arguments and provide stable JSON Output modes are explicit: `--text` for human-readable summaries, `--json` (default for piped output) for stable JSON, and `--pretty` for pretty-printed JSON. -`--socket` requires the ADE runtime endpoint 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. +`--socket` requires a specific ADE local endpoint and fails fast when it is missing. Without `--socket`, the CLI auto-attaches to the brain when reachable and falls back to headless for commands that can run that way. ## `ade auth` and `ade doctor` diff --git a/apps/ade-cli/package-lock.json b/apps/ade-cli/package-lock.json index 322865225..a45c86c48 100644 --- a/apps/ade-cli/package-lock.json +++ b/apps/ade-cli/package-lock.json @@ -10,6 +10,7 @@ "dependencies": { "@anthropic-ai/claude-agent-sdk": "^0.2.139", "@cursor/sdk": "^1.0.13", + "@factory/droid-sdk": "^0.2.0", "@linear/sdk": "^84.0.0", "@openai/codex": "0.133.0", "@opencode-ai/sdk": "^1.15.5", @@ -792,6 +793,42 @@ "node": ">=18" } }, + "node_modules/@factory/droid-sdk": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/@factory/droid-sdk/-/droid-sdk-0.2.0.tgz", + "integrity": "sha512-m8Srp98pTvu5jAZtZpX6/Ojut6KV3CiqUGh0MXBUcsuKzsDw8hODZEbPTocxP6MrT0rsoKpqCS9SRTt0m+9cqw==", + "license": "Apache-2.0", + "dependencies": { + "@modelcontextprotocol/sdk": "^1.29.0", + "uuid": "^11.1.0", + "zod": "^3.24.0" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@factory/droid-sdk/node_modules/uuid": { + "version": "11.1.1", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-11.1.1.tgz", + "integrity": "sha512-vIYxrBCC/N/K+Js3qSN88go7kIfNPssr/hHCesKCQNAjmgvYS2oqr69kIufEG+O4+PfezOH4EbIeHCfFov8ZgQ==", + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], + "license": "MIT", + "bin": { + "uuid": "dist/esm/bin/uuid" + } + }, + "node_modules/@factory/droid-sdk/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" + } + }, "node_modules/@fastify/busboy": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/@fastify/busboy/-/busboy-2.1.1.tgz", @@ -6976,6 +7013,28 @@ "dev": true, "optional": true }, + "@factory/droid-sdk": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/@factory/droid-sdk/-/droid-sdk-0.2.0.tgz", + "integrity": "sha512-m8Srp98pTvu5jAZtZpX6/Ojut6KV3CiqUGh0MXBUcsuKzsDw8hODZEbPTocxP6MrT0rsoKpqCS9SRTt0m+9cqw==", + "requires": { + "@modelcontextprotocol/sdk": "^1.29.0", + "uuid": "^11.1.0", + "zod": "^3.24.0" + }, + "dependencies": { + "uuid": { + "version": "11.1.1", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-11.1.1.tgz", + "integrity": "sha512-vIYxrBCC/N/K+Js3qSN88go7kIfNPssr/hHCesKCQNAjmgvYS2oqr69kIufEG+O4+PfezOH4EbIeHCfFov8ZgQ==" + }, + "zod": { + "version": "3.25.76", + "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", + "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==" + } + } + }, "@fastify/busboy": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/@fastify/busboy/-/busboy-2.1.1.tgz", diff --git a/apps/ade-cli/package.json b/apps/ade-cli/package.json index ad0899e3d..41e09aa7e 100644 --- a/apps/ade-cli/package.json +++ b/apps/ade-cli/package.json @@ -26,6 +26,7 @@ "dependencies": { "@anthropic-ai/claude-agent-sdk": "^0.2.139", "@cursor/sdk": "^1.0.13", + "@factory/droid-sdk": "^0.2.0", "@linear/sdk": "^84.0.0", "@openai/codex": "0.133.0", "@opencode-ai/sdk": "^1.15.5", diff --git a/apps/ade-cli/scripts/build-static.mjs b/apps/ade-cli/scripts/build-static.mjs index f11bd8d41..17175928c 100644 --- a/apps/ade-cli/scripts/build-static.mjs +++ b/apps/ade-cli/scripts/build-static.mjs @@ -207,6 +207,8 @@ function __adeSeaResolveRuntimeRoot() { } var __adeSeaRuntimeRoot = __adeSeaResolveRuntimeRoot(); if (__adeSeaRuntimeRoot) { + process.env.ADE_RESOLVED_RUNTIME_ROOT = __adeSeaRuntimeRoot; + if (!process.env.ADE_RUNTIME_ROOT) process.env.ADE_RUNTIME_ROOT = __adeSeaRuntimeRoot; var __adeSeaRuntimeNodeModules = __adeSeaPath.join(__adeSeaRuntimeRoot, "node_modules"); var __adeSeaNodePath = process.env.NODE_PATH || ""; var __adeSeaNodePathParts = __adeSeaNodePath.split(__adeSeaPath.delimiter).filter(Boolean); @@ -271,6 +273,8 @@ async function main() { await fs.rm(workDir, { recursive: true, force: true }); await fs.mkdir(workDir, { recursive: true }); const seaEntryPath = await writeSeaEntry(workDir); + const sourceNodeBinary = process.env.ADE_STATIC_NODE_BINARY || process.execPath; + await assertSeaCapableNodeBinary(sourceNodeBinary); const seaConfigPath = path.join(workDir, "sea-config.json"); const blobPath = path.join(workDir, "ade.blob"); @@ -282,10 +286,8 @@ async function main() { useSnapshot: false, }; await fs.writeFile(seaConfigPath, `${JSON.stringify(seaConfig, null, 2)}\n`, "utf8"); - await run(process.execPath, ["--experimental-sea-config", seaConfigPath]); + await run(sourceNodeBinary, ["--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); diff --git a/apps/ade-cli/scripts/package-native-deps.mjs b/apps/ade-cli/scripts/package-native-deps.mjs index a8e46a57e..e6e3fa645 100644 --- a/apps/ade-cli/scripts/package-native-deps.mjs +++ b/apps/ade-cli/scripts/package-native-deps.mjs @@ -207,6 +207,16 @@ async function writeManifest(bundleRoot, target, packages) { await fs.writeFile(path.join(bundleRoot, "manifest.json"), `${JSON.stringify(manifest, null, 2)}\n`, "utf8"); } +async function copyBuiltTuiClient(bundleRoot) { + const source = path.join(packageRoot, "dist", "tuiClient", "cli.mjs"); + if (!(await exists(source))) { + throw new Error("Missing built ADE Code TUI module. Run `npm run build` before packaging native runtime dependencies."); + } + const destination = path.join(bundleRoot, "tuiClient", "cli.mjs"); + await fs.mkdir(path.dirname(destination), { recursive: true }); + await fs.copyFile(source, destination); +} + async function chmodRuntimeExecutables(bundleRoot, target) { const executablePaths = [ path.join(bundleRoot, "node_modules", "opencode-ai", "bin", "opencode.exe"), @@ -260,6 +270,7 @@ async function main() { copied.push(packageName); } } + await copyBuiltTuiClient(bundleRoot); await chmodRuntimeExecutables(bundleRoot, args.target); await writeManifest(bundleRoot, args.target, copied); diff --git a/apps/ade-cli/src/cli.test.ts b/apps/ade-cli/src/cli.test.ts index b3b687903..14f38fbe4 100644 --- a/apps/ade-cli/src/cli.test.ts +++ b/apps/ade-cli/src/cli.test.ts @@ -16,6 +16,7 @@ import { parseCliArgs, readRuntimeIdleExitMs, renderLaneGraph, + resolveAdeCodeModulePath, resolveRoots, shouldAutoRegisterProjectForPlan, shouldEnforceMachineRuntimeBuildCompatibility, @@ -258,10 +259,47 @@ describe("ADE CLI", () => { label: "sync pin generate", steps: [{ key: "result", method: "sync.generatePin" }], }); + expect(buildCliPlan(["brain", "pin", "set", "123456"])).toEqual({ + kind: "execute", + label: "sync pin set", + steps: [ + { key: "result", method: "sync.setPin", params: { pin: "123456" } }, + ], + }); + expect(buildCliPlan(["brain", "pin", "clear"])).toEqual({ + kind: "execute", + label: "sync pin clear", + steps: [{ key: "result", method: "sync.clearPin" }], + }); expect(buildCliPlan(["runtime", "status"])).toEqual({ kind: "runtime", rest: ["status"], }); + expect(() => buildCliPlan(["runtime", "run"])).toThrow( + "ade runtime run requires --socket .", + ); + expect( + buildCliPlan(["runtime", "run", "--socket", "/tmp/ade.sock"]), + ).toEqual({ + kind: "serve", + rest: ["--socket", "/tmp/ade.sock", "--no-sync"], + }); + expect( + buildCliPlan(["runtime", "--socket", "/tmp/ade.sock", "run", "--no-sync"]), + ).toEqual({ + kind: "serve", + rest: ["--socket", "/tmp/ade.sock", "--no-sync"], + }); + const parsedRuntimeRun = parseCliArgs([ + "--socket", + "/tmp/global.sock", + "runtime", + "run", + ]); + expect(buildCliPlan(parsedRuntimeRun.command, parsedRuntimeRun.options)).toEqual({ + kind: "serve", + rest: ["--socket", "/tmp/global.sock", "--no-sync"], + }); expect( buildCliPlan(["runtime", "start", "--socket", "/tmp/ade.sock"]), ).toEqual({ @@ -517,6 +555,26 @@ describe("ADE CLI", () => { } }); + it("resolves ade code from packaged runtime resources", () => { + const runtimeRoot = fs.mkdtempSync(path.join(os.tmpdir(), "ade-code-runtime-")); + const modulePath = path.join(runtimeRoot, "tuiClient", "cli.mjs"); + fs.mkdirSync(path.dirname(modulePath), { recursive: true }); + fs.writeFileSync(modulePath, "export async function runAdeCodeCli() { return 0; }\n"); + try { + withEnv( + { + ADE_RUNTIME_ROOT: runtimeRoot, + ADE_RESOLVED_RUNTIME_ROOT: undefined, + }, + () => { + expect(resolveAdeCodeModulePath()).toBe(modulePath); + }, + ); + } finally { + fs.rmSync(runtimeRoot, { recursive: true, force: true }); + } + }); + it("preserves command-local value flags that overlap global flags", () => { const parsed = parseCliArgs([ "files", diff --git a/apps/ade-cli/src/cli.ts b/apps/ade-cli/src/cli.ts index 9ff290b0f..cd9e88bfd 100644 --- a/apps/ade-cli/src/cli.ts +++ b/apps/ade-cli/src/cli.ts @@ -260,6 +260,10 @@ function isSourceCheckoutCliEntryPath(modulePath: string): boolean { ); } +function isPackagedElectronCliRuntime(): boolean { + return Boolean(process.versions.electron) && !isSourceCheckoutCliEntryPath(CLI_ENTRY_PATH); +} + function automationsCliEnabled(): boolean { const override = readAutomationsEnvOverride(process.env); if (override !== null) return override; @@ -422,9 +426,9 @@ const ADE_BANNER = String.raw` const TOP_LEVEL_HELP = `${ADE_BANNER} Agent-focused command-line interface for ADE. - ADE CLI commands operate through the machine ADE runtime by default. - If the runtime is not running, the CLI starts it, registers the selected - project, and routes project actions through that runtime. + ADE CLI commands operate through the machine ADE brain by default. + The brain is the always-on ADE process for this machine: it owns the project + catalog, sync endpoint, and execution authority for the channel. $ ade help Display help for a command $ ade auth status Check local ADE CLI readiness @@ -435,13 +439,13 @@ const TOP_LEVEL_HELP = `${ADE_BANNER} Build a shareable deeplink (copies to clipboard) $ ade linear install Register ADE as Linear's "Open in coding tool" target $ ade skill list | show Browse ADE's bundled agent skills (local) - $ ade runtime start | stop | status Manage the machine runtime - $ ade serve Run the ADE runtime in foreground + $ ade brain start | stop | status Manage the background ADE brain + $ ade runtime run --socket Run a manual runtime for dev/test work $ ade rpc --stdio Speak ADE JSON-RPC over stdin/stdout - $ ade init [path] Register a project with this machine runtime + $ ade init [path] Register a project with this machine brain $ ade projects list List projects registered on this machine $ ade sync status | pin generate Manage machine sync and phone pairing - $ ade doctor Inspect project, runtime, and tool availability + $ ade doctor Inspect project, brain, 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 status @@ -476,7 +480,7 @@ 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 machine runtime service and run an in-process ADE runtime. + --headless Skip the machine brain and run an in-process ADE runtime. --socket Require a live ADE endpoint; 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. @@ -922,7 +926,7 @@ const HELP_BY_COMMAND: Record = { ADE Desktop Launch the installed ADE desktop app. The desktop app attaches to the normal - machine runtime and starts it if needed. + machine brain and starts it if needed. $ ade desktop $ ade desktop open @@ -971,7 +975,7 @@ const HELP_BY_COMMAND: Record = { ADE Skills Browse ADE's bundled, version-locked agent skills directly from the bundled - resources. This is a local command that does NOT require the machine runtime — + resources. This is a local command that does NOT require the machine brain — it is the tamper-proof backstop for agents that can't natively discover ADE's skills. @@ -985,32 +989,38 @@ const HELP_BY_COMMAND: Record = { --json Structured JSON output (default). `, runtime: `${ADE_BANNER} - ADE Runtime + ADE Runtime Compatibility - Manage the normal machine ADE runtime used by desktop, ade code, and - runtime-backed CLI commands. + Run an explicit manual runtime, or use compatibility endpoint commands for + older scripts. Prefer "ade brain" for the automated always-on service. + $ ade runtime run --socket /tmp/ade-dev.sock $ ade runtime status --text $ ade runtime start $ ade runtime stop Notes: - "start" launches the runtime in the background if it is missing. - "stop" shuts down the runtime on the selected endpoint. - Use "ade serve" when you want to run the runtime in the foreground. + "run" starts a foreground manual runtime on the selected endpoint. + Manual runtimes always run with sync off so they cannot claim brain + authority. + "start" and "stop" are compatibility endpoint commands; use "ade brain" + for service-managed lifecycle commands. `, brain: `${ADE_BANNER} - ADE Runtime Legacy Alias + ADE Brain - Legacy alias kept for existing scripts. Prefer "ade runtime" for lifecycle - commands and "ade sync pin" for phone pairing. + Manage the always-on, machine-owned ADE brain for this channel. The brain is + the background ADE process that carries the local RPC endpoint, sync + websocket, project catalog, and executor authority. Clients attach to it. - $ ade runtime status --text - $ ade runtime start - $ ade runtime stop - $ ade sync pin generate - $ ade sync pin set 123456 - $ ade sync pin clear + $ ade brain status --text + $ ade brain show --text + $ ade brain start + $ ade brain stop + $ ade brain restart + $ ade brain pin generate + $ ade brain pin set 123456 + $ ade brain pin clear Notes: "start" enables and loads the login service. @@ -1018,19 +1028,15 @@ const HELP_BY_COMMAND: Record = { Pairing PIN commands are aliases for the machine sync PIN. `, serve: `${ADE_BANNER} - ADE Runtime - - Runs the machine-scoped ADE runtime in the foreground. The runtime listens on - the local endpoint and can lazily serve any project registered with "ade init". + ADE Internal Brain Process - $ ade serve - $ ade serve --socket ~/.ade/sock/ade.sock - $ ade serve --port 8787 + Internal/debug command that runs the brain process in the foreground. Most + users should use "ade brain start", "ade brain stop", and "ade brain status". Flags: - --socket Local endpoint to listen on. + --socket Local RPC endpoint 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 runtime run. + --no-sync Disable machine sync discovery for this foreground brain process. --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. @@ -1038,8 +1044,8 @@ const HELP_BY_COMMAND: Record = { rpc: `${ADE_BANNER} ADE JSON-RPC - Attaches to the machine ADE runtime and speaks ADE JSON-RPC over stdio. - If the runtime is not running, ADE starts it before accepting requests. This + Attaches to the machine ADE brain and speaks ADE JSON-RPC over stdio. + If the brain is not running, ADE starts it before accepting requests. This mode is used by SSH transports. $ ade rpc --stdio @@ -1047,7 +1053,7 @@ const HELP_BY_COMMAND: Record = { init: `${ADE_BANNER} ADE Project Init - Registers a project with this machine runtime and creates its .ade directory + Registers a project with this machine brain and creates its .ade directory if needed. $ ade init @@ -1228,7 +1234,7 @@ const HELP_BY_COMMAND: Record = { Run tab Run tab commands mirror ADE process definitions and runtime state. They use - the machine ADE runtime when live process state is needed. + the machine ADE brain when live process state is needed. $ ade run defs --text List configured run commands $ ade run ps --lane --text List process runtime state @@ -10410,7 +10416,10 @@ function hasHelpFlag(args: string[]): boolean { return false; } -function buildCliPlan(command: string[]): CliPlan { +function buildCliPlan( + command: string[], + options: Pick = { socketPath: null }, +): CliPlan { const args = [...command]; if (args[0] === "--version" || args[0] === "-v") { return { kind: "help", text: `ade ${VERSION}\n` }; @@ -10531,6 +10540,22 @@ function buildCliPlan(command: string[]): CliPlan { } } if (primary === "runtime") { + const runtimeArgs = [...args]; + const sub = firstStandalonePositional(runtimeArgs) ?? "status"; + if (sub === "run" || sub === "foreground") { + const socketPath = readValue([...runtimeArgs], ["--socket"]) ?? options.socketPath; + if (!socketPath) { + throw new CliUsageError("ade runtime run requires --socket ."); + } + if (!readValue([...runtimeArgs], ["--socket"])) { + runtimeArgs.push("--socket", socketPath); + } + const syncDisabled = readFlag([...runtimeArgs], ["--no-sync"]); + if (!syncDisabled) { + runtimeArgs.push("--no-sync"); + } + return { kind: "serve", rest: runtimeArgs }; + } return { kind: "runtime", rest: args }; } if (primary === "brain") { @@ -10800,14 +10825,12 @@ function buildAdeCodeArgs(rest: string[], options: GlobalOptions): string[] { "--require-socket", ] : []), + ...(isPackagedElectronCliRuntime() ? ["--prefer-service-repair"] : []), ...rest, ]; } -async function runAdeCode( - rest: string[], - options: GlobalOptions, -): Promise<{ output: string; exitCode: number }> { +function resolveAdeCodeModulePath(): string { const sourceModule = path.join( CLI_PACKAGE_ROOT, "src", @@ -10817,7 +10840,32 @@ async function runAdeCode( 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 runtimeRoot = + process.env.ADE_RUNTIME_ROOT?.trim() || + process.env.ADE_RESOLVED_RUNTIME_ROOT?.trim() || + null; + const runtimeModule = runtimeRoot + ? path.join(runtimeRoot, "tuiClient", "cli.mjs") + : null; + const candidates = [ + runtimeModule, + builtModule, + isSourceCliEntryPath(CLI_ENTRY_PATH) ? sourceModule : null, + ].filter((candidate): candidate is string => Boolean(candidate)); + for (const candidate of candidates) { + if (fs.existsSync(candidate)) return candidate; + } + throw new Error( + "ADE Code TUI module is missing. Run `npm --prefix apps/ade-cli run build` " + + "or reinstall ADE so the packaged runtime includes tuiClient/cli.mjs.", + ); +} + +async function runAdeCode( + rest: string[], + options: GlobalOptions, +): Promise<{ output: string; exitCode: number }> { + const modulePath = resolveAdeCodeModulePath(); const { runAdeCodeCli } = await import(pathToFileURL(modulePath).href); const exitCode = await runAdeCodeCli(buildAdeCodeArgs(rest, options)); return { output: "", exitCode }; @@ -12480,6 +12528,61 @@ function isRuntimeShutdownCloseError(error: unknown): boolean { return message.includes("socket closed") || message.includes("runtime endpoint closed"); } +function shouldRepairMachineRuntimeServiceBeforeSpawn( + socketPath: string, + socketPathOverride?: string | null, +): boolean { + return !socketPathOverride?.trim() + && process.env.ADE_DISABLE_RUNTIME_SERVICE_INSTALL !== "1" + && isPackagedElectronCliRuntime() + && !socketPath.startsWith("tcp://") + && !isAdeRuntimeNamedPipePath(socketPath) + && !isEphemeralRuntimeSocketPath(socketPath); +} + +async function repairMachineRuntimeServiceConnection(args: { + socketPath: string; + options: GlobalOptions; + expectedBuildHash: string | null; + enforceBuildCompatibility: boolean; +}): Promise { + let client: SocketJsonRpcClient | null = null; + try { + const { installRuntimeService, uninstallRuntimeService } = await import("./serviceManager"); + const result = installRuntimeService(); + if (!result.ok) return null; + client = await SocketJsonRpcClient.connect( + args.socketPath, + args.options.timeoutMs, + "ADE runtime endpoint", + ); + const runtimeInfo = await initializeMachineRuntimeDaemon( + client, + args.options, + ); + const mismatch = machineRuntimeMismatchReason( + runtimeInfo, + args.expectedBuildHash, + args.options.role, + { enforceBuildCompatibility: args.enforceBuildCompatibility }, + ); + if (mismatch) { + uninstallRuntimeService(); + client.close(); + return null; + } + const repaired = client; + client = null; + return repaired; + } catch { + return null; + } finally { + try { + client?.close(); + } catch {} + } +} + async function spawnMachineRuntimeDaemon( socketPath: string, options: GlobalOptions, @@ -12535,6 +12638,19 @@ async function connectMachineRuntimeDaemon( const expectedBuildHash = isTcpSocket || !enforceBuildCompatibility ? null : await resolveExpectedMachineRuntimeBuildHash(); + const preferServiceRepair = shouldRepairMachineRuntimeServiceBeforeSpawn( + socketPath, + socketPathOverride, + ); + const repairServiceConnection = async (): Promise => { + if (!preferServiceRepair) return null; + return repairMachineRuntimeServiceConnection({ + socketPath, + options, + expectedBuildHash, + enforceBuildCompatibility, + }); + }; try { const client = await SocketJsonRpcClient.connect( socketPath, @@ -12559,6 +12675,8 @@ async function connectMachineRuntimeDaemon( ); } await shutdownMachineRuntimeDaemon(client); + const repaired = await repairServiceConnection(); + if (repaired) return repaired; const spawned = await spawnMachineRuntimeDaemon(socketPath, options); if (!spawned) { throw new Error( @@ -12591,6 +12709,8 @@ async function connectMachineRuntimeDaemon( return client; } catch (firstError) { if (!allowSpawn) throw firstError; + const repaired = await repairServiceConnection(); + if (repaired) return repaired; const spawned = await spawnMachineRuntimeDaemon(socketPath, options); if (!spawned) throw firstError; try { @@ -12661,7 +12781,7 @@ async function runRuntimeCommand( packageChannel: runtimeInfo.packageChannel, projectRoot: runtimeInfo.projectRoot, pid: runtimeInfo.pid, - message: "ADE runtime is running.", + message: "ADE runtime endpoint is running.", }; } finally { client.close(); @@ -12695,7 +12815,7 @@ async function runRuntimeCommand( packageChannel: runtimeInfo?.packageChannel ?? null, projectRoot: runtimeInfo?.projectRoot ?? null, pid: runtimeInfo?.pid ?? null, - message: "ADE runtime is running.", + message: "ADE runtime endpoint is running.", }; } finally { client.close(); @@ -12719,7 +12839,7 @@ async function runRuntimeCommand( ok: true, running: false, socketPath, - message: "ADE runtime stopped.", + message: "ADE runtime endpoint stopped.", }; } catch (error) { return { @@ -12745,7 +12865,7 @@ async function runRuntimeCommand( } throw new CliUsageError( - "runtime supports status, start, stop, install-service, uninstall-service, or service-status.", + "runtime supports status, start, stop, install-service, uninstall-service, or service-status. Prefer ade brain for the service-managed lifecycle.", ); } @@ -12824,13 +12944,13 @@ async function runBrainCommand( stopped, started, message: started.ok - ? "ADE runtime restarted." + ? "ADE brain restarted." : started.message, }; } throw new CliUsageError( - "Legacy runtime alias supports status, start, stop, restart, or pin. Prefer ade runtime.", + "brain supports status, show, start, stop, restart, or pin.", ); } @@ -12961,7 +13081,7 @@ async function runServe( const { getRuntimeServiceStatus } = await import("./serviceManager"); return getRuntimeServiceStatus(); } - const removeRuntimeProcessErrorBoundary = installRuntimeProcessErrorBoundary("ade serve"); + const removeRuntimeProcessErrorBoundary = installRuntimeProcessErrorBoundary("ADE brain"); const [ { resolveMachineAdeLayout }, { ProjectRegistry }, @@ -12994,7 +13114,7 @@ async function runServe( ).projectId; } catch (error) { process.stderr.write( - `ade serve could not register ADE_PROJECT_ROOT for phone sync: ${ + `ADE brain could not register ADE_PROJECT_ROOT for phone sync: ${ error instanceof Error ? error.message : String(error) }\n`, ); @@ -13232,13 +13352,13 @@ async function runServe( : scopeRegistry.resolveActiveSyncHost(); void syncHostStartup.catch((error: unknown) => { process.stderr.write( - `ade serve sync host failed: ${error instanceof Error ? error.message : String(error)}\n`, + `ADE brain sync host failed: ${error instanceof Error ? error.message : String(error)}\n`, ); }); } process.stderr.write( - `ade serve listening on ${socketPath}${tcpUrl ? ` and ${tcpUrl}` : ""}\n`, + `ADE brain listening on ${socketPath}${tcpUrl ? ` and ${tcpUrl}` : ""}\n`, ); const stopParentMonitor = monitorRuntimeParentProcess(finish); @@ -15546,7 +15666,7 @@ async function runCli( argv: string[], ): Promise<{ output: string; exitCode: number }> { const parsed = parseCliArgs(argv); - const plan = buildCliPlan(parsed.command); + const plan = buildCliPlan(parsed.command, parsed.options); if (plan.kind === "help") return { output: plan.text.endsWith("\n") ? plan.text : `${plan.text}\n`, @@ -15792,6 +15912,7 @@ export { parseCliArgs, readRuntimeIdleExitMs, renderLaneGraph, + resolveAdeCodeModulePath, resolveRoots, runCli, summarizeExecution, diff --git a/apps/ade-cli/src/serviceManager/common.ts b/apps/ade-cli/src/serviceManager/common.ts index 465d0c7ee..e4c42c34a 100644 --- a/apps/ade-cli/src/serviceManager/common.ts +++ b/apps/ade-cli/src/serviceManager/common.ts @@ -54,7 +54,6 @@ const RUNTIME_ENV_PASSTHROUGH = [ "ADE_HOME", "ADE_PACKAGE_CHANNEL", "ADE_DESKTOP_APP_NAME", - "ADE_DISABLE_RUNTIME_SERVICE_INSTALL", "ADE_RUNTIME_SERVICE_NAME", ] as const; diff --git a/apps/ade-cli/src/tuiClient/__tests__/cli.test.tsx b/apps/ade-cli/src/tuiClient/__tests__/cli.test.tsx index 9b40f68d7..ad38cb17e 100644 --- a/apps/ade-cli/src/tuiClient/__tests__/cli.test.tsx +++ b/apps/ade-cli/src/tuiClient/__tests__/cli.test.tsx @@ -43,6 +43,10 @@ describe("ade code CLI entrypoint", () => { ); }); + it("accepts packaged service-repair opt-in", () => { + expect(parseArgs(["--prefer-service-repair"]).preferServiceRepair).toBe(true); + }); + it("passes Ctrl+C handling through to ADE Code instead of Ink", async () => { await expect(runAdeCodeCli(["--embedded"])).resolves.toBe(0); diff --git a/apps/ade-cli/src/tuiClient/__tests__/connection.test.ts b/apps/ade-cli/src/tuiClient/__tests__/connection.test.ts index 107b03140..a9b12efa4 100644 --- a/apps/ade-cli/src/tuiClient/__tests__/connection.test.ts +++ b/apps/ade-cli/src/tuiClient/__tests__/connection.test.ts @@ -26,10 +26,24 @@ const childProcess = vi.hoisted(() => { }; }); +const runtimeService = vi.hoisted(() => ({ + installRuntimeService: vi.fn(() => ({ + ok: false, + serviceName: "com.ade.runtime", + action: "install" as const, + path: null as string | null, + message: "not installed", + })), +})); + vi.mock("node:child_process", () => ({ spawn: childProcess.spawn, })); +vi.mock("../../serviceManager", () => ({ + installRuntimeService: runtimeService.installRuntimeService, +})); + const embedded = vi.hoisted(() => { const requests: Array<{ jsonrpc: string; id: number; method: string; params?: unknown; envRole?: string }> = []; const runtime = { @@ -132,6 +146,14 @@ describe("connectToAde embedded mode", () => { childProcess.spawn.mockClear(); childProcess.child.unref.mockClear(); childProcess.spawn.mockImplementation(() => childProcess.child); + runtimeService.installRuntimeService.mockClear(); + runtimeService.installRuntimeService.mockReturnValue({ + ok: false, + serviceName: "com.ade.runtime", + action: "install", + path: null, + message: "not installed", + }); }); afterEach(() => { @@ -435,6 +457,47 @@ describe("connectToAde embedded mode", () => { expect(client.close).toHaveBeenCalledTimes(1); }); + it("repairs the packaged service before spawning an unmanaged machine daemon", async () => { + useMissingMachineSocket(); + runtimeService.installRuntimeService.mockReturnValue({ + ok: true, + serviceName: "com.ade.runtime", + action: "install", + path: "/tmp/com.ade.runtime.plist", + message: "installed", + }); + const client = mockAttachedClient(); + + const connection = await connectToAde({ project, preferServiceRepair: true }); + try { + expect(connection.mode).toBe("attached"); + } finally { + await connection.close(); + } + + expect(runtimeService.installRuntimeService).toHaveBeenCalledTimes(1); + expect(childProcess.spawn).not.toHaveBeenCalled(); + expect(client.close).toHaveBeenCalledTimes(1); + }); + + it("falls back to spawning when packaged service repair fails", async () => { + const socketPath = useMissingMachineSocket(); + const client = mockAttachedClient(); + + const connection = await connectToAde({ project, preferServiceRepair: true }); + try { + expect(connection.mode).toBe("attached"); + } finally { + await connection.close(); + } + + expect(runtimeService.installRuntimeService).toHaveBeenCalledTimes(1); + expect(childProcess.spawn).toHaveBeenCalledTimes(1); + const spawnCall = childProcess.spawn.mock.calls[0] as unknown[] | undefined; + expect(spawnCall?.[1]).toEqual(expect.arrayContaining(["serve", "--socket", socketPath])); + expect(client.close).toHaveBeenCalledTimes(1); + }); + it("keeps the script entrypoint argv shape when a CLI script is resolved", async () => { const socketPath = useMissingMachineSocket(); const entrypointDir = fs.mkdtempSync( diff --git a/apps/ade-cli/src/tuiClient/app.tsx b/apps/ade-cli/src/tuiClient/app.tsx index 2557fc50c..6ceb1d4c1 100644 --- a/apps/ade-cli/src/tuiClient/app.tsx +++ b/apps/ade-cli/src/tuiClient/app.tsx @@ -642,6 +642,7 @@ type AdeCodeAppProps = { forceEmbedded?: boolean; requireSocket?: boolean; socketPath?: string | null; + preferServiceRepair?: boolean; }; type RefreshStateOptions = { @@ -2566,7 +2567,7 @@ function resolveCenterPaneWidth(columns: number, drawerOpen: boolean, rightPaneW ); } -export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath }: AdeCodeAppProps) { +export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath, preferServiceRepair }: AdeCodeAppProps) { const { exit } = useApp(); const [columns, rows] = useTerminalDimensions(); useTerminalAlternateScreen(); @@ -5941,7 +5942,7 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath } let cancelled = false; void (async () => { try { - const conn = await connectToAde({ project, forceEmbedded, requireSocket, socketPath }); + const conn = await connectToAde({ project, forceEmbedded, requireSocket, socketPath, preferServiceRepair }); if (cancelled) { await conn.close(); return; @@ -5996,7 +5997,7 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath } connectionRef.current = null; void conn?.close().catch(() => {}); }; - }, [forceEmbedded, project, requireSocket, signalActiveTerminalForExit, signalActiveTerminalForExitSync, socketPath]); + }, [forceEmbedded, preferServiceRepair, project, requireSocket, signalActiveTerminalForExit, signalActiveTerminalForExitSync, socketPath]); // Stable handle to the latest refreshState so the chat-event subscription can // call it without listing refreshState as a dependency (its identity churns on @@ -6383,6 +6384,7 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath } forceEmbedded: false, requireSocket: true, socketPath, + preferServiceRepair, }); if (attached.mode !== "attached") { await attached.close().catch(() => {}); @@ -6407,7 +6409,7 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath } })(); }, 3_000); return () => clearInterval(timer); - }, [addNotice, connection, connectionLost, forceEmbedded, mode, project, refreshState, socketPath, streaming]); + }, [addNotice, connection, connectionLost, forceEmbedded, mode, preferServiceRepair, project, refreshState, socketPath, streaming]); const ensureActiveSession = useCallback(async (): Promise => { const conn = connectionRef.current; @@ -7901,7 +7903,7 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath } addNotice(result.message ?? "Desktop route unavailable; launched ADE.", "info"); for (let attempt = 0; attempt < 8; attempt += 1) { await delay(750); - const attached = await connectToAde({ project, forceEmbedded: false, socketPath }).catch(() => null); + const attached = await connectToAde({ project, forceEmbedded: false, socketPath, preferServiceRepair }).catch(() => null); if (!attached || attached.mode !== "attached") { await attached?.close().catch(() => {}); continue; @@ -7924,7 +7926,7 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath } addNotice(result.message ?? "Desktop route unavailable from this runtime.", "error"); } } - }, [activeSession?.provider, addNotice, applyLocalModelArg, displaySessions, loadProviderModels, modelState.provider, pendingSteers, project, refreshAiSetupStatus, refreshState, requestAppExit, scheduleModelStateCommit, sendClaudeModelCommandToTerminal, setChatScrollOffset, socketPath]); + }, [activeSession?.provider, addNotice, applyLocalModelArg, displaySessions, loadProviderModels, modelState.provider, pendingSteers, preferServiceRepair, project, refreshAiSetupStatus, refreshState, requestAppExit, scheduleModelStateCommit, sendClaudeModelCommandToTerminal, setChatScrollOffset, socketPath]); const submitRightForm = useCallback(async ( form: Extract, diff --git a/apps/ade-cli/src/tuiClient/cli.tsx b/apps/ade-cli/src/tuiClient/cli.tsx index 55c4b6db4..234a888b1 100644 --- a/apps/ade-cli/src/tuiClient/cli.tsx +++ b/apps/ade-cli/src/tuiClient/cli.tsx @@ -12,6 +12,7 @@ type CliOptions = { workspaceRoot: string | null; laneHint: string | null; socketPath: string | null; + preferServiceRepair: boolean; }; function readRequiredFlagValue(argv: string[], index: number, flag: string): string { @@ -32,6 +33,7 @@ export function parseArgs(argv: string[]): CliOptions { workspaceRoot: null, laneHint: null, socketPath: null, + preferServiceRepair: false, }; for (let i = 0; i < argv.length; i += 1) { const arg = argv[i]; @@ -39,6 +41,7 @@ export function parseArgs(argv: string[]): CliOptions { else if (arg === "--print-state") options.printState = true; else if (arg === "--embedded") options.forceEmbedded = true; else if (arg === "--require-socket") options.requireSocket = true; + else if (arg === "--prefer-service-repair") options.preferServiceRepair = true; else if (arg === "--project-root") { options.projectRoot = readRequiredFlagValue(argv, i, arg); i += 1; @@ -120,6 +123,7 @@ async function printState(options: CliOptions): Promise { forceEmbedded: options.forceEmbedded, requireSocket: options.requireSocket, socketPath: options.socketPath, + preferServiceRepair: options.preferServiceRepair, }); try { const lanes = await listLanes(connection); @@ -161,6 +165,7 @@ export async function runAdeCodeCli(argv: string[] = process.argv.slice(2)): Pro forceEmbedded={options.forceEmbedded} requireSocket={options.requireSocket} socketPath={options.socketPath} + preferServiceRepair={options.preferServiceRepair} />, { exitOnCtrlC: false }, ); diff --git a/apps/ade-cli/src/tuiClient/connection.ts b/apps/ade-cli/src/tuiClient/connection.ts index d93bcb104..325896e7b 100644 --- a/apps/ade-cli/src/tuiClient/connection.ts +++ b/apps/ade-cli/src/tuiClient/connection.ts @@ -656,12 +656,17 @@ export async function connectToAde(args: { forceEmbedded?: boolean; requireSocket?: boolean; socketPath?: string | null; + preferServiceRepair?: boolean; }): 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; + const preferServiceRepair = + args.preferServiceRepair === true + && !explicitSocketPath + && process.env.ADE_DISABLE_RUNTIME_SERVICE_INSTALL !== "1"; if (args.forceEmbedded && args.requireSocket) { throw new Error("Cannot use embedded mode when an ADE socket is required."); @@ -699,8 +704,21 @@ export async function connectToAde(args: { delayMs: 200, shutdownOnStale: true, }); + const repairService = async (): Promise => { + if (!preferServiceRepair) return null; + try { + const { installRuntimeService } = await import("../serviceManager"); + const result = installRuntimeService(); + if (!result.ok) return null; + return await tryDaemon(25); + } catch { + return null; + } + }; try { if (!fs.existsSync(machineSocketPath)) { + const repaired = await repairService(); + if (repaired) return repaired; const spawned = spawnDaemon(machineSocketPath); return await tryDaemon(spawned ? 25 : 1); } @@ -709,6 +727,8 @@ export async function connectToAde(args: { if (firstError instanceof StaleAdeSocketError) { await new Promise((resolve) => setTimeout(resolve, 200)); } + const repaired = await repairService(); + if (repaired) return repaired; try { const spawned = spawnDaemon(machineSocketPath); if (spawned) return await tryDaemon(25); diff --git a/apps/ade-cli/tsup.config.ts b/apps/ade-cli/tsup.config.ts index 420b29f71..9f5bb4356 100644 --- a/apps/ade-cli/tsup.config.ts +++ b/apps/ade-cli/tsup.config.ts @@ -27,6 +27,7 @@ const tuiNoExternal = [ /^highlight\.js(?:\/.*)?$/, ]; const packageRoot = path.dirname(fileURLToPath(import.meta.url)); +const cliNodeModules = path.join(packageRoot, "node_modules"); 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"; @@ -48,12 +49,13 @@ export default defineConfig([ // @opencode-ai/sdk is ESM-only (no "require" export); force-inline it so // the CJS runtime bundle does not emit a bare require() that packaged // Electron-as-node cannot resolve. - noExternal: ["@opencode-ai/sdk", "yaml"], + noExternal: ["@factory/droid-sdk", "@opencode-ai/sdk", "yaml"], outExtension: () => ({ js: ".cjs" }), external, esbuildOptions(options) { + options.nodePaths = Array.from(new Set([cliNodeModules, ...(options.nodePaths ?? [])])); options.define = { ...(options.define ?? {}), __ADE_VERSION__: JSON.stringify(version), @@ -92,6 +94,7 @@ export default defineConfig([ }), external, esbuildOptions(options) { + options.nodePaths = Array.from(new Set([cliNodeModules, ...(options.nodePaths ?? [])])); options.define = { ...(options.define ?? {}), __ADE_VERSION__: JSON.stringify(version), diff --git a/apps/desktop/scripts/ade-cli-macos-wrapper.sh b/apps/desktop/scripts/ade-cli-macos-wrapper.sh index c19709c7b..18ab81547 100755 --- a/apps/desktop/scripts/ade-cli-macos-wrapper.sh +++ b/apps/desktop/scripts/ade-cli-macos-wrapper.sh @@ -24,13 +24,11 @@ case "$CLI_NAME:$CHANNEL" in 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 diff --git a/apps/desktop/scripts/validate-mac-artifacts.mjs b/apps/desktop/scripts/validate-mac-artifacts.mjs index cecd151a1..f00c6edf0 100644 --- a/apps/desktop/scripts/validate-mac-artifacts.mjs +++ b/apps/desktop/scripts/validate-mac-artifacts.mjs @@ -197,6 +197,9 @@ async function assertRemoteRuntimeBundle(resourcesPath, description) { 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}`); } + if (!stdout.split(/\r?\n/).includes("./tuiClient/cli.mjs")) { + throw new Error(`[release:mac] Remote runtime native archive for ${target} does not contain ./tuiClient/cli.mjs: ${nativeArchivePath}`); + } } if (allowHostOnlyRuntimeResources) { console.warn( diff --git a/apps/desktop/scripts/validate-runtime-resources.mjs b/apps/desktop/scripts/validate-runtime-resources.mjs index de783c309..2d92e4ed0 100644 --- a/apps/desktop/scripts/validate-runtime-resources.mjs +++ b/apps/desktop/scripts/validate-runtime-resources.mjs @@ -1,11 +1,14 @@ import fs from "node:fs/promises"; import path from "node:path"; +import { execFile } from "node:child_process"; import { fileURLToPath } from "node:url"; +import { promisify } from "node:util"; 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"]; +const execFileAsync = promisify(execFile); function currentTarget() { const platform = process.platform === "darwin" ? "darwin" : process.platform === "linux" ? "linux" : process.platform; @@ -44,13 +47,22 @@ async function validateExecutable(filePath, label) { } } +async function validateNativeArchive(filePath, target) { + await statFile(filePath, `remote ADE service native dependency archive ${target}`); + const { stdout } = await execFileAsync("tar", ["-tzf", filePath]); + const entries = stdout.split(/\r?\n/); + if (!entries.some((entry) => entry.startsWith("./node_modules/"))) { + fail(`Remote runtime native archive for ${target} does not contain ./node_modules/: ${filePath}`); + } + if (!entries.includes("./tuiClient/cli.mjs")) { + fail(`Remote runtime native archive for ${target} does not contain ./tuiClient/cli.mjs: ${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}`, - ); + await validateNativeArchive(path.join(runtimeRoot, `ade-${target}.native.tar.gz`), target); } const mode = process.env.ADE_RUNTIME_RESOURCES_ALLOW_HOST_ONLY === "1" ? "host-only local" : "full"; diff --git a/apps/desktop/scripts/validate-win-artifacts.mjs b/apps/desktop/scripts/validate-win-artifacts.mjs index a74fb993a..7f46d2e6e 100644 --- a/apps/desktop/scripts/validate-win-artifacts.mjs +++ b/apps/desktop/scripts/validate-win-artifacts.mjs @@ -379,6 +379,9 @@ async function assertRemoteRuntimeBundle(resourcesPath) { if (!stdout.split(/\r?\n/).some((entry) => entry.startsWith("./node_modules/"))) { fail(`Remote runtime native archive for ${target} does not contain ./node_modules/: ${nativeArchivePath}`); } + if (!stdout.split(/\r?\n/).includes("./tuiClient/cli.mjs")) { + fail(`Remote runtime native archive for ${target} does not contain ./tuiClient/cli.mjs: ${nativeArchivePath}`); + } } } diff --git a/apps/desktop/src/main/main.ts b/apps/desktop/src/main/main.ts index 4b63a4f81..6369e997e 100644 --- a/apps/desktop/src/main/main.ts +++ b/apps/desktop/src/main/main.ts @@ -1058,8 +1058,7 @@ app.whenReady().then(async () => { recentProjects: cleanedRecentProjects, }); const shouldAttemptRuntimeServiceInstall = - machineStateMigration.didRun - && app.isPackaged + app.isPackaged && process.env.NODE_ENV !== "test" && process.env.ADE_DISABLE_RUNTIME_SERVICE_INSTALL !== "1"; const shouldShowRuntimeMigrationNotice = @@ -1186,7 +1185,13 @@ app.whenReady().then(async () => { 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); + const shouldRepairRuntimeServiceOnFallback = + app.isPackaged + && process.env.NODE_ENV !== "test" + && process.env.ADE_DISABLE_RUNTIME_SERVICE_INSTALL !== "1"; + const localRuntimePool = new LocalRuntimeConnectionPool(app.getVersion(), localRuntimeLogger, { + preferServiceRepair: shouldRepairRuntimeServiceOnFallback, + }); if (shouldAttemptRuntimeServiceInstall) { void localRuntimePool.installServiceBestEffort() .then(() => { @@ -1200,8 +1205,6 @@ app.whenReady().then(async () => { 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."); if (machineStateMigration.didRun && app.isPackaged && packagedChannel) { diff --git a/apps/desktop/src/main/services/localRuntime/localRuntimeConnectionPool.ts b/apps/desktop/src/main/services/localRuntime/localRuntimeConnectionPool.ts index 8b6aacd8e..d2e474048 100644 --- a/apps/desktop/src/main/services/localRuntime/localRuntimeConnectionPool.ts +++ b/apps/desktop/src/main/services/localRuntime/localRuntimeConnectionPool.ts @@ -49,6 +49,7 @@ type RuntimeServiceManagerOutput = { type LocalRuntimeConnectionPoolOptions = { disableSync?: boolean; + preferServiceRepair?: boolean; queryServiceStatus?: () => ServiceManagerStatusResult; }; @@ -360,10 +361,24 @@ function isCompatibleRuntimeVersion(args: { class LocalRuntimeCompatibilityError extends Error { readonly pid: number | null; - constructor(message: string, pid: number | null = null) { + readonly runtimeVersion: string | null; + readonly runtimeBuildHash: string | null; + readonly runtimeDefaultRole: string | null; + constructor( + message: string, + runtimeInfo: { + pid?: number | null; + version?: string | null; + buildHash?: string | null; + defaultRole?: string | null; + } = {}, + ) { super(message); this.name = "LocalRuntimeCompatibilityError"; - this.pid = pid; + this.pid = runtimeInfo.pid ?? null; + this.runtimeVersion = runtimeInfo.version ?? null; + this.runtimeBuildHash = runtimeInfo.buildHash ?? null; + this.runtimeDefaultRole = runtimeInfo.defaultRole ?? null; } } @@ -550,6 +565,7 @@ export class LocalRuntimeConnectionPool { checkedAt: null, }; private serviceHealthCheckedAtMs = 0; + private serviceInstallPromise: Promise | null = null; constructor( private readonly appVersion: string, @@ -625,6 +641,15 @@ export class LocalRuntimeConnectionPool { } async installServiceBestEffort(): Promise { + if (this.serviceInstallPromise) return this.serviceInstallPromise; + const install = this.runServiceInstallBestEffort().finally(() => { + if (this.serviceInstallPromise === install) this.serviceInstallPromise = null; + }); + this.serviceInstallPromise = install; + return install; + } + + private async runServiceInstallBestEffort(): Promise { const cliPath = resolveCliScriptPath(); this.serviceInstallStatus = { state: "installing", @@ -1122,6 +1147,9 @@ export class LocalRuntimeConnectionPool { const existing = await this.tryConnect(socketPath); if (existing) return existing; + const repaired = await this.tryRepairServiceConnection(socketPath, "missing"); + if (repaired) return repaired; + const child = this.spawnRuntime(socketPath); try { await waitForSocket(socketPath); @@ -1146,6 +1174,8 @@ export class LocalRuntimeConnectionPool { return { client, child, socketPath }; } catch (error) { if (error instanceof LocalRuntimeCompatibilityError) { + const repaired = await this.tryRepairServiceConnection(socketPath, "incompatible", error); + if (repaired) return repaired; return await this.startIsolatedRuntime(socketPath, error); } this.logger.debug("local_runtime.connect_existing_failed", { @@ -1156,6 +1186,56 @@ export class LocalRuntimeConnectionPool { } } + private async tryRepairServiceConnection( + socketPath: string, + reason: "missing" | "incompatible", + compatibilityError?: LocalRuntimeCompatibilityError, + ): Promise { + if (!this.options.preferServiceRepair) return null; + this.logger.info("local_runtime.service_repair_attempt", { + socketPath, + reason, + pid: compatibilityError?.pid ?? null, + message: compatibilityError?.message ?? null, + }); + await this.installServiceBestEffort(); + const installStatus = this.serviceInstallStatus; + if (installStatus.state !== "installed") { + this.logger.warn("local_runtime.service_repair_skipped", { + socketPath, + reason, + serviceState: installStatus.state, + message: installStatus.message, + }); + return null; + } + try { + await waitForSocket(socketPath); + const client = await this.connectClient(socketPath); + this.ownedRuntimeChild = null; + return { client, child: null, socketPath }; + } catch (error) { + if (error instanceof LocalRuntimeCompatibilityError) { + this.logger.warn("local_runtime.service_repair_connect_failed", { + socketPath, + reason, + error: error.message, + runtimePid: error.pid, + runtimeVersion: error.runtimeVersion, + runtimeBuildHash: error.runtimeBuildHash, + runtimeDefaultRole: error.runtimeDefaultRole, + }); + return null; + } + this.logger.warn("local_runtime.service_repair_connect_failed", { + socketPath, + reason, + error: error instanceof Error ? error.message : String(error), + }); + return null; + } + } + private isolatedRuntimeSocketPath(primarySocketPath: string): string { const buildHash = computeLocalRuntimeBuildHash()?.slice(0, 12) ?? "runtime"; const version = this.appVersion.replace(/[^a-zA-Z0-9_.-]+/g, "-") || "version"; @@ -1236,7 +1316,7 @@ export class LocalRuntimeConnectionPool { closeRuntimeClient(client); throw new LocalRuntimeCompatibilityError( `ADE service version ${runtimeInfo.version} does not match desktop version ${this.appVersion}.`, - runtimeInfo.pid, + runtimeInfo, ); } if (expectedBuildHash && runtimeInfo.buildHash !== expectedBuildHash) { @@ -1249,7 +1329,7 @@ export class LocalRuntimeConnectionPool { closeRuntimeClient(client); throw new LocalRuntimeCompatibilityError( "ADE service build does not match the packaged desktop runtime.", - runtimeInfo.pid, + runtimeInfo, ); } if (runtimeInfo.defaultRole !== "cto") { @@ -1262,7 +1342,7 @@ export class LocalRuntimeConnectionPool { closeRuntimeClient(client); throw new LocalRuntimeCompatibilityError( `ADE service default role ${runtimeInfo.defaultRole ?? "missing"} does not match desktop role cto.`, - runtimeInfo.pid, + runtimeInfo, ); } this.activeClient = client; diff --git a/apps/desktop/src/renderer/components/app/TopBar.test.tsx b/apps/desktop/src/renderer/components/app/TopBar.test.tsx index 767c37b1b..5055ce360 100644 --- a/apps/desktop/src/renderer/components/app/TopBar.test.tsx +++ b/apps/desktop/src/renderer/components/app/TopBar.test.tsx @@ -323,16 +323,33 @@ describe("TopBar", () => { } }); - it("does not poll phone sync before a project is open", async () => { - useAppStore.setState({ project: null } as any); + it("shows phone sync before a project is open without immediate polling", async () => { + useAppStore.setState({ project: null, projectHydrated: true, showWelcome: true } as any); render(); + expect(screen.getByRole("button", { name: "Mobile, not connected" })).toBeTruthy(); + expect(globalThis.window.ade.sync.getStatus).not.toHaveBeenCalled(); await waitFor(() => { expect(globalThis.window.ade.project.listRecent).toHaveBeenCalled(); }); - expect(screen.queryByText("1 phone connected to ADE Desktop")).toBeNull(); - expect(globalThis.window.ade.sync.getStatus).not.toHaveBeenCalled(); + }); + + it("keeps the phone sync drawer open before a project is open", async () => { + useAppStore.setState({ project: null, projectHydrated: true, showWelcome: true } as any); + + render(); + + const mobileButton = screen.getByTitle("Connect a phone to this machine"); + fireEvent.click(mobileButton); + + await act(async () => { + await flushMicrotasks(2); + }); + + expect(screen.getByText("Connect to the ADE mobile app")).toBeTruthy(); + expect(screen.getByTestId("sync-devices-section")).toBeTruthy(); + expect(mobileButton.getAttribute("aria-expanded")).toBe("true"); }); it("does not render recent projects as tabs before a project is open", async () => { diff --git a/apps/desktop/src/renderer/components/app/TopBar.tsx b/apps/desktop/src/renderer/components/app/TopBar.tsx index d961ab8a1..2538b6e7d 100644 --- a/apps/desktop/src/renderer/components/app/TopBar.tsx +++ b/apps/desktop/src/renderer/components/app/TopBar.tsx @@ -1016,7 +1016,7 @@ export function TopBar() { const remoteStatusCount = Math.max(connectedRemoteCount, openRemoteProjectTabs.length); const remoteConnected = connectedRemoteCount > 0; const syncConnected = isSyncConnected(syncSnapshot); - const showSyncControl = workspaceProjectOpen; + const showSyncControl = projectHydrated === true && !remoteBinding; useEffect(() => { openProjectTabRootsRef.current = openProjectTabRoots; @@ -1216,7 +1216,7 @@ export function TopBar() { let started = false; let startupTimer: number | null = null; let disposeSyncEvents: (() => void) | null = null; - if (!project?.rootPath || remoteBinding) { + if (!showSyncControl) { setSyncSnapshot(null); setPhoneSyncOpen(false); return () => { @@ -1268,9 +1268,10 @@ export function TopBar() { }; // Background projects don't broadcast sync-status events (main.ts filters // them to the active project), so we re-run this effect on rootPath change - // and let the delayed startup check pick up the current state. Focus and - // explicit drawer opens still refresh immediately. - }, [phoneSyncOpen, project?.rootPath, remoteBinding]); + // and let the delayed startup check pick up the current state. With no + // project open, sync calls fall back to the machine-level brain service. + // Focus and explicit drawer opens still refresh immediately. + }, [phoneSyncOpen, project?.rootPath, showSyncControl]); const checkForActiveWorkloads = useCallback( async (projectRootPath: string): Promise => { diff --git a/apps/desktop/src/renderer/components/chat/AgentChatPane.test.tsx b/apps/desktop/src/renderer/components/chat/AgentChatPane.test.tsx index 31ada5607..56932899f 100644 --- a/apps/desktop/src/renderer/components/chat/AgentChatPane.test.tsx +++ b/apps/desktop/src/renderer/components/chat/AgentChatPane.test.tsx @@ -5945,8 +5945,10 @@ describe("AgentChatPane submit recovery", () => { fireEvent.change(screen.getByRole("textbox"), { target: { value: "Fix the login bug" } }); fireEvent.click(await screen.findByRole("button", { name: /Send to lanes/i })); - expect(await screen.findByText(/Lane 2 failed to send\./i)).toBeTruthy(); - expect(screen.getByText(/Cleanup could not delete lane lane-child-1/i)).toBeTruthy(); + await waitFor(() => expect(send).toHaveBeenCalledTimes(2), { timeout: 5000 }); + await waitFor(() => expect(deleteLane).toHaveBeenCalledTimes(2), { timeout: 5000 }); + expect(await screen.findByText(/Lane 2 failed to send\./i, {}, { timeout: 5000 })).toBeTruthy(); + expect(await screen.findByText(/Cleanup could not delete lane lane-child-1/i, {}, { timeout: 5000 })).toBeTruthy(); expect(deleteLane).toHaveBeenNthCalledWith(1, { laneId: "lane-child-1", force: true }); expect(deleteLane).toHaveBeenNthCalledWith(2, { laneId: "lane-child-2", force: true }); expect(errorSpy).toHaveBeenCalledWith( diff --git a/apps/web/src/app/pages/DownloadPage.tsx b/apps/web/src/app/pages/DownloadPage.tsx index 66ceb8517..ff075155c 100644 --- a/apps/web/src/app/pages/DownloadPage.tsx +++ b/apps/web/src/app/pages/DownloadPage.tsx @@ -52,7 +52,7 @@ export function DownloadPage() { title: "macOS", icon: , note: "Current beta release target: DMG and ZIP from GitHub Releases.", - hint: "ADE for computers bundles the app, ade CLI, ade code, and the background brain.", + hint: "ADE for computers bundles the app, ade CLI, ade code, and the background ADE brain.", actionHref: LINKS.releases, actionLabel: "Download from releases" }, @@ -104,7 +104,7 @@ export function DownloadPage() {

Get ADE for your computer from GitHub Releases, or build from source. The computer install includes the app, - ade CLI, ade code, and the background brain. ADE runs in Guest Mode without accounts, and can optionally + ade CLI, ade code, and the background ADE brain. ADE runs in Guest Mode without accounts, and can optionally enable hosted or BYOK LLM providers.

diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 8ea1665b6..eb8db1c10 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -6,28 +6,28 @@ Consolidated technical reference for the ADE (Agentic Development Environment) s ## 1. System at a Glance -ADE is a local-first development control plane that orchestrates AI-assisted software engineering across parallel worktrees. The center of the system is a **per-machine ADE runtime** (`apps/ade-cli/`, started with `ade serve`). The machine runtime hosts every project on that machine through a project registry and exposes a multi-project JSON-RPC surface on a Unix socket / Windows named pipe at `~/.ade/sock/ade.sock`. Desktop, the terminal `ade code` client, the iOS app, and SSH-attached desktop windows are all peer **clients** that bind to a runtime — local or remote — and invoke runtime-owned actions through that one surface. +ADE is a local-first development control plane that orchestrates AI-assisted software engineering across parallel worktrees. The center of the system is the **ADE brain**: the always-on, machine-owned ADE process for one channel. The brain hosts every project on that machine through a project registry, exposes a multi-project JSON-RPC surface on the channel's local endpoint, serves the sync websocket for ADE Mobile, and carries executor authority. Desktop, the terminal `ade code` client, the iOS app, and SSH-attached desktop windows are all **clients** that attach to a local brain or remote runtime transport and invoke runtime-owned actions through that one surface. -The runtime owns everything that needs to survive a client closing: worktree-per-lane git isolation, multi-provider agent chat, work-session orchestration, a Linear-integrated CTO agent acting as a team lead, worker delegation, a pipeline builder for visual automations, stacked pull requests with conflict simulation, computer-use proofs, the sync service that replicates projects to other devices, and the per-machine credential store and agent registry. Nothing leaves the user's machine by default: AI work runs through user-authenticated CLIs (Claude Code, Codex), local API-key routes (OpenCode server), or local model endpoints (Ollama, LM Studio, vLLM). +The brain owns everything that needs to survive a client closing: worktree-per-lane git isolation, multi-provider agent chat, work-session orchestration, a Linear-integrated CTO agent acting as a team lead, worker delegation, a pipeline builder for visual automations, stacked pull requests with conflict simulation, computer-use proofs, the sync service that replicates projects to other devices, and the per-machine credential store and agent registry. Nothing leaves the user's machine by default: AI work runs through user-authenticated CLIs (Claude Code, Codex), local API-key routes (OpenCode server), or local model endpoints (Ollama, LM Studio, vLLM). -ADE ships as four runtime/client packages plus the marketing site: +ADE ships as one computer install, ADE Mobile, and the marketing site: -### Runtime topology +### Brain and runtime topology ```mermaid flowchart TB - subgraph LocalMachine["One ADE install on a machine"] + subgraph LocalMachine["One ADE computer install, one channel"] Desktop["Electron desktop app
apps/desktop"] Code["ADE Code TUI
ade code"] Shell["ade CLI
typed commands"] - Runtime["Machine ADE runtime
ade serve
~/.ade/sock/ade.sock"] + Brain["ADE brain
always-on runtime process
$ADE_HOME/sock/ade.sock"] Bridge["Desktop bridge
~/.ade/sock/desktop-bridge.sock"] end - Desktop -->|"runtime RPC"| Runtime - Code -->|"runtime RPC"| Runtime - Shell -->|"runtime RPC"| Runtime - Runtime -->|"Electron-only actions"| Bridge + Desktop -->|"local RPC attach"| Brain + Code -->|"local RPC attach"| Brain + Shell -->|"local RPC attach"| Brain + Brain -->|"Electron-only actions"| Bridge Bridge -->|"WebContentsView, screenshots, browser state"| Desktop subgraph ProjectState["Project .ade state"] @@ -36,13 +36,13 @@ flowchart TB Artifacts[".ade/artifacts + cache
proof, transcripts, packs"] end - Runtime --> Database - Runtime --> Lanes - Runtime --> Artifacts + Brain --> Database + Brain --> Lanes + Brain --> Artifacts - IOS["iOS app
controller client"] <-->|"sync WebSocket
changesets + commands"| Runtime + IOS["ADE Mobile
controller client"] <-->|"machine pairing + sync WebSocket
catalog, changesets, commands"| Brain - DesktopRemote["Desktop window
SSH-bound client"] <-->|"ade rpc --stdio"| RemoteRuntime["Remote machine runtime
ade serve or uploaded ade-* binary"] + DesktopRemote["Desktop window
SSH-bound client"] <-->|"ade rpc --stdio"| RemoteRuntime["Remote runtime transport
uploaded ade-* binary"] RemoteRuntime --> RemoteProject["Remote project .ade state"] ``` @@ -52,10 +52,11 @@ flowchart TB └───────────────────────────────┘ ┌───────────────────────────────────────────────┐ - │ apps/ade-cli (RUNTIME) │ + │ apps/ade-cli (BRAIN + RUNTIME) │ │ ─────────────────────────────────────────────│ - │ `ade serve` machine runtime │ - │ - listens on ~/.ade/sock/ade.sock │ + │ ADE brain process │ + │ - always-on runtime for one channel │ + │ - listens on $ADE_HOME/sock/ade.sock │ │ - login service (launchd / systemd / Win) │ │ - multi-project RPC + project registry │ │ - sync service (cr-sqlite over WebSocket) │ @@ -71,18 +72,18 @@ flowchart TB └───────────────────────────────────────────────┘ ▲ ▲ ▲ ▲ │ local │ local │ WebSocket │ stdio over - │ socket │ socket │ │ SSH + │ local RPC │ local RPC │ │ SSH │ │ │ │ ┌──────────────────┐ ┌──────────────┐ ┌──────────┐ ┌──────────────────┐ │ apps/desktop │ │ ade code TUI │ │ apps/ios │ │ apps/desktop │ │ (Electron, multi-│ │ (apps/ade-cli│ │ SwiftUI │ │ window bound to a│ - │ window — one │ │ /tuiClient) │ │ controller│ │ remote runtime │ + │ window — one │ │ /tuiClient) │ │ controller│ │ remote brain │ │ window/project) │ │ │ │ (never │ │ (RemoteConnection│ │ LocalRuntime- │ │ defaults to │ │ runs │ │ Pool, bootstrap- │ - │ ConnectionPool │ │ machine sock │ │ agents) │ │ uploads bundled │ + │ ConnectionPool │ │ machine brain│ │ agents) │ │ uploads bundled │ │ │ │ │ │ │ │ runtime binary) │ └──────────────────┘ └──────────────┘ └──────────┘ └──────────────────┘ - All clients share the runtime's view of + All clients share the brain's view of projects, lanes, agent chats, work sessions, processes, sync. │ @@ -93,7 +94,7 @@ flowchart TB └─────────────────────────┘ ``` -Live runtime state is replicated between paired devices through cr-sqlite changesets carried over WebSocket; the **sync service runs inside the ADE runtime**, not in the desktop app. The iOS app pairs with a machine — typically the user's primary desktop-class machine. A second desktop on the same network is also a client of that runtime, not a peer host. A desktop window can be re-pointed at a runtime on a remote machine over SSH; the binding is per-window, so the same Electron process can drive a local project in one window and an SSH-bound project in another. The remote path starts `ade rpc --stdio` on the remote and routes runtime actions through the same multi-project JSON-RPC surface. See [features/remote-runtime/README.md](./features/remote-runtime/README.md). +Live runtime state is replicated between paired devices through cr-sqlite changesets carried over WebSocket; the **sync service runs inside the ADE brain**, not in the desktop app. ADE Mobile pairs with a machine — typically the user's primary desktop-class machine — and receives that machine's project catalog from the brain. Today, project switching can reconnect internally to a project-specific sync port, but the user-facing model remains machine → projects. A second desktop on the same network is also a client of that brain, not a peer host. A desktop window can be re-pointed at a runtime on a remote machine over SSH; the binding is per-window, so the same Electron process can drive a local project in one window and an SSH-bound project in another. The remote path starts `ade rpc --stdio` on the remote and routes runtime actions through the same multi-project JSON-RPC surface. See [features/remote-runtime/README.md](./features/remote-runtime/README.md). Source code crosses machines through plain git. ADE does not own a git server. @@ -103,16 +104,17 @@ Product positioning and workflows live in [`docs/PRD.md`](../docs/PRD.md). This ## 2. Apps & Processes -### 2.1 ADE runtime (`apps/ade-cli/`) +### 2.1 ADE brain and runtime (`apps/ade-cli/`) -`apps/ade-cli/` is the runtime — the per-machine source of truth — and the `ade` CLI surface. It ships as one Node binary that runs in several modes. +`apps/ade-cli/` contains the brain process, manual runtime entry points, the `ade` CLI surface, and the `ade code` terminal client. It ships as one Node binary that runs in several modes. **Run modes:** -- **Machine runtime (`ade serve`)** — the normal mode. Boots the multi-project JSON-RPC server, hosts the per-project services on demand, and listens on `~/.ade/sock/ade.sock` (Windows: a named pipe under `\\.\pipe\ade-`, with the hash derived in `apps/desktop/src/shared/adeRuntimeIpc.ts`). Installable / removable as a login service with `ade serve --install-service` / `--uninstall-service` (per-platform installers in `apps/ade-cli/src/serviceManager/`). -- **Single-session CLI** — `ade ` connects to the local runtime over the machine endpoint, dispatches one project-scoped action, and exits. With `--headless`, the CLI bootstraps a project's services directly from the repository instead of going through the machine runtime — used in CI and for one-off scripts. +- **Brain** — the normal mode. Boots the multi-project JSON-RPC server, hosts the per-project services on demand, serves sync, and listens on the channel's local endpoint (POSIX: `$ADE_HOME/sock/ade.sock`; Windows: a named pipe under `\\.\pipe\ade-`, with the hash derived in `apps/desktop/src/shared/adeRuntimeIpc.ts`). Installable / removable as a login service with `ade brain start` / `ade brain stop` (per-platform installers in `apps/ade-cli/src/serviceManager/`). +- **Manual runtime (`ade runtime run`)** — starts a foreground runtime process on an explicit endpoint. Sync is always off so it cannot claim brain authority; use a separate `ADE_HOME` when you also want full machine-state isolation. +- **Single-session CLI** — `ade ` connects to the local brain over the machine endpoint, dispatches one project-scoped action, and exits. With `--headless`, the CLI bootstraps a project's services directly from the repository instead of going through the machine brain — used in CI and for one-off scripts. - **SSH stdio bridge (`ade rpc --stdio`)** — runs a single-session JSON-RPC runtime over stdin/stdout. This is what desktop's `RemoteConnectionPool` execs over SSH after `bootstrapRemoteRuntime` has uploaded a matching `ade-` binary. Exits when the SSH channel closes. -- **Terminal client (`ade code`)** — launches the Ink + React Work chat (`apps/ade-cli/src/tuiClient/`). Defaults to attaching to `~/.ade/sock/ade.sock` and will start `ade serve` if the endpoint is missing. `ade --socket /path code` requires a specific endpoint; `ade code --embedded` keeps the legacy in-process fallback explicit. +- **Terminal client (`ade code`)** — launches the Ink + React Work chat (`apps/ade-cli/src/tuiClient/`). Defaults to attaching to the machine brain and will start it if the endpoint is missing. `ade --socket /path code` requires a specific endpoint; `ade code --embedded` keeps the in-process runtime fallback explicit. **Multi-project RPC.** The runtime exposes runtime-scoped methods (`projects.list/add/remove/touch`, `sync.*`, `runtime/info`, `machineInfo.get`, `runtimeEvents.subscribe/unsubscribe`) directly. Project-scoped operations dispatch through `ade/actions/call` with a `projectId`. Per-project services are spun up lazily by `ProjectScopeRegistry` (`apps/ade-cli/src/services/projects/projectScope.ts`) which calls `createAdeRuntime({ projectRoot, ... })` the first time a project is touched. The project registry (`projectRegistry.ts`) is the durable list of known projects; `machineLayout.ts` resolves machine-wide paths under `~/.ade/`. Wire formats live in `apps/ade-cli/src/multiProjectRpcServer.ts`. @@ -125,7 +127,7 @@ Product positioning and workflows live in [`docs/PRD.md`](../docs/PRD.md). This | `credentials/` | Per-machine credential store. | | `agentRegistry.ts` | Per-machine agent registry. | -**Service managers.** `apps/ade-cli/src/serviceManager/installLaunchd.ts` (macOS), `installSystemd.ts` (Linux), `installWindows.ts` (Windows) register `ade serve` as a login-time service. `index.ts` is the platform router; `common.ts` carries shared types (`ServiceManagerResult`, `ServiceManagerStatusResult`). +**Service managers.** `apps/ade-cli/src/serviceManager/installLaunchd.ts` (macOS), `installSystemd.ts` (Linux), `installWindows.ts` (Windows) register the brain as a login-time service. `index.ts` is the platform router; `common.ts` carries shared types (`ServiceManagerResult`, `ServiceManagerStatusResult`). **Session identity.** The runtime resolves caller role from ADE context env vars and command flags. Role vocabulary: `cto`, `orchestrator`, `agent`, `external`, `evaluator`. @@ -171,7 +173,7 @@ The desktop app is a **client of the runtime**. It owns a trusted main process, **Runtime binding pools.** -- `apps/desktop/src/main/services/localRuntime/localRuntimeConnectionPool.ts` — desktop-side client for the local `ade serve` runtime. Spawns or attaches to the machine endpoint, registers local projects with `projects.add`, dispatches local runtime actions, applies short per-call timeouts for project registration / file actions / event polling, emits a `local_runtime.action_slow` warning log when an action call exceeds 500 ms or throws (the log breaks the total into `ensureProjectMs` / `connectMs` / `daemonCallMs` so a stalled renderer call is debuggable before the IPC timeout fires), and best-effort installs the background service in packaged builds. Local project windows use this binding consistently outside unit tests. +- `apps/desktop/src/main/services/localRuntime/localRuntimeConnectionPool.ts` — desktop-side client for the local brain endpoint. Spawns or attaches to the machine endpoint, registers local projects with `projects.add`, dispatches local runtime actions, applies short per-call timeouts for project registration / file actions / event polling, emits a `local_runtime.action_slow` warning log when an action call exceeds 500 ms or throws (the log breaks the total into `ensureProjectMs` / `connectMs` / `daemonCallMs` so a stalled renderer call is debuggable before the IPC timeout fires), and best-effort installs the background service in packaged builds. Local project windows use this binding consistently outside unit tests. - `apps/desktop/src/main/services/remoteRuntime/` — SSH-bound runtime pool. `remoteTargetRegistry.ts` stores saved machines under `~/.ade/secrets/remote-machines.json` (manual host plus an optional `routes[]` of Tailscale / Bonjour / manual addresses with per-route `lastSucceededAt` and manual-disconnect state); `sshTransport.ts` handles ssh-agent / key based transport with bounded connect/exec timeouts, normalized handshake errors, disabled SSH keepalives, and alternate routes ranked by most-recent success; `remoteBootstrap.ts` does first-connect runtime upload + version/hash negotiation against the bundled `ade-` binary, native deps, and PTY host worker, falls back across alternate ADE channel homes (`.ade`, `.ade-alpha`, `.ade-beta`) when the preferred home has no compatible runtime, treats version / channel / capability skew as `compatibilityWarnings` instead of fatal errors, and records which route succeeded; `remoteConnectionPool.ts` keeps the per-window remote runtime binding alive, gates `projects.*` runtime calls against the connection's `capabilities.machineProjects` flags (missing capabilities reject the call with a self-describing error), reconnects safely on read-only actions (`get*/list*/read*/search*/diagnosticsGet*` and a small allowlist), owns local TCP forwards for remote preview ports, memoizes optional-action fallbacks, and emits eviction notifications when SSH or the JSON-RPC client closes; `remoteConnectionService.ts` listens for those evictions, marks targets as `error`, preserves explicit manual disconnects, pauses automatic reconnect after repeated implicit failures, surfaces the capabilities + compatibility warnings on every `RemoteRuntimeConnectionStatus`, and re-probes saved connections on `powerMonitor.resume` / `unlock-screen`; `runtimeRpcClient.ts` is the JSON-RPC client (per-call timeouts tear the connection down so the pool reconnects rather than dangling the request, and remote errors are now formatted with the original method name plus the JSON-RPC `code` / `message` / `data` for clearer diagnostics); `runtimeDiscovery.ts` runs Bonjour + Tailscale in parallel and returns `{ machines, diagnostics }` so a missing or stuck `tailscale` CLI does not silently swallow LAN discovery. Build outputs (configured in `apps/desktop/tsup.config.ts`): @@ -186,8 +188,8 @@ Build outputs (configured in `apps/desktop/tsup.config.ts`): Terminal-native **Work** chat client (Ink + React) for agents and power users who live in a shell, built into `apps/ade-cli/src/tuiClient/`. It is a peer of the desktop client, not a wrapper around it: it speaks the same multi-project JSON-RPC surface and binds to an ADE runtime the same way. -- **Attached mode** (default): connects to `~/.ade/sock/ade.sock`, or to an explicit socket passed on the parent `ade` invocation. Starts `ade serve` if the socket is missing. -- **Embedded mode**: `--embedded` / `--headless` runs the shared `apps/ade-cli` services in-process without going through a machine runtime. Used when no runtime is reachable. +- **Attached mode** (default): connects to `$ADE_HOME/sock/ade.sock`, or to an explicit endpoint passed on the parent `ade` invocation. Starts the brain if the endpoint is missing. +- **Embedded mode**: `--embedded` / `--headless` runs the shared `apps/ade-cli` services in-process without going through a machine brain. Used when no brain endpoint or manual runtime endpoint is reachable. Shared chat DTOs are imported from `apps/desktop/src/shared/types/*` (never the renderer barrel) so `npm run typecheck` in `apps/ade-cli` covers both typed commands and the TUI. Entry: `apps/ade-cli/src/tuiClient/cli.tsx` → `apps/ade-cli/dist/tuiClient/cli.mjs`, loaded by `ade code`. The built TUI bundle is intended to run in isolation: tsup bundles its Ink/xterm/highlight dependencies and injects ESM shims for `__dirname` / `__filename`; both `apps/ade-cli/scripts/verify-built-cli.mjs` and the desktop artifact validators smoke-import it and run `runAdeCodeCli(["--help"])`. The TUI can hand off to a desktop window via the `app/navigate` JSON-RPC method when a desktop client is attached to the same runtime. @@ -234,7 +236,7 @@ Schema bootstrap in `kvDb.ts` creates ~103 tables. Anchor tables for agents read | `projects` | One row per opened repo. Keyed by `root_path`. | | `lanes` | Worktree-backed units of work. Types: `primary`, `worktree`, `attached`. Supports parent/child stacks, run binding, color/icon/tags. | | `terminal_sessions` | Tracked PTY sessions per lane with transcript path and head SHAs. The `chat_session_id` column (indexed) marks terminals owned by a chat (chat terminal drawer, App Control launch terminal); `ptyService` exposes them through the `ade.terminal.*` IPC and the `terminal` ADE action domain. The `owner_pid` column (indexed) identifies the ADE OS process that owns the live runtime for the row — cross-process reconcile/dispose paths check it before sweeping so concurrent surfaces don't mark each other's live sessions dead. See §3.5. | -| `runtime_processes` | Machine-local process-liveness registry. Every ADE process (desktop main, TUI runtime, `ade serve` daemon) inserts a row on boot keyed by the process incarnation (`pid`, `started_at`) and refreshes `last_seen` on a 5 s heartbeat. The table is excluded from CRR replication because PIDs are only meaningful on the current OS; reconcile / dispose paths cross-reference `terminal_sessions.owner_pid` and `owner_process_started_at` against locally known and live rows to tell "row whose local owner crashed" from "row a sibling process is actively managing" without detaching sessions owned by another synced machine. See §3.5. | +| `runtime_processes` | Machine-local process-liveness registry. Every ADE process (desktop main, brain process, TUI runtime) inserts a row on boot keyed by the process incarnation (`pid`, `started_at`) and refreshes `last_seen` on a 5 s heartbeat. The table is excluded from CRR replication because PIDs are only meaningful on the current OS; reconcile / dispose paths cross-reference `terminal_sessions.owner_pid` and `owner_process_started_at` against locally known and live rows to tell "row whose local owner crashed" from "row a sibling process is actively managing" without detaching sessions owned by another synced machine. See §3.5. | | `session_deltas` | Post-session diff stats + touched files + failure lines. Input to pack generation. | | `operations` | Audit log of every significant mutation (git, pack updates). Pre/post HEAD SHAs enable undo. | | `process_definitions` / `process_runtime` / `process_runs` | Managed-process lifecycle (derived from `ade.yaml`). | @@ -296,7 +298,7 @@ Types for these tables are split into domain modules under `apps/desktop/src/sha ### 3.4 Cross-process ownership -ADE is a multi-process system on a single machine: the desktop main process, the `ade serve` daemon, and any number of TUI runtimes can all be live against the same project DB simultaneously. To prevent one process from disposing or reconciling another's live PTYs and SDK sessions, every long-lived row gets an `owner_pid` / `owner_process_started_at` identity and every process maintains a heartbeat in the machine-local `runtime_processes` table. +ADE is a multi-process system on a single machine: the desktop main process, the brain process, and any number of manual/TUI runtimes can all be live against the same project DB simultaneously. To prevent one process from disposing or reconciling another's live PTYs and SDK sessions, every long-lived row gets an `owner_pid` / `owner_process_started_at` identity and every process maintains a heartbeat in the machine-local `runtime_processes` table. `apps/desktop/src/main/services/runtime/processRegistryService.ts` is the per-process registrar. @@ -307,7 +309,7 @@ ADE is a multi-process system on a single machine: the desktop main process, the `ptyService.create()` records `processRegistry.pid` and `processRegistry.startedAt` on the new `terminal_sessions` row's owner columns. `sessionService.reconcileStaleRunningSessions()` accepts both live owners and known local owners: rows with live local owners are left alone, rows with known but no-longer-live local owners can be swept to `detached`, and rows with unknown owner identity are preserved because they may have been synced from another machine. Dispose paths run the same ownership check before tearing down runtimes a sibling still manages. -Roles are open-ended strings; today's vocabulary is `desktop-main`, `ade-serve-daemon`, and `tui-runtime`. The desktop main process constructs the registry in `main.ts` and threads it into `ptyService`, `sessionService`, and reconcile callers via the per-project context. +Roles are open-ended strings; today's vocabulary is `desktop-main`, `ade-serve-daemon` for the brain process role, and `tui-runtime`. The desktop main process constructs the registry in `main.ts` and threads it into `ptyService`, `sessionService`, and reconcile callers via the per-project context. The `ade-serve-daemon` literal is retained in live `runtime_processes` rows until the internal role vocabulary is migrated. ### 3.5 Migration strategy @@ -531,7 +533,7 @@ Most services described here live under `apps/desktop/src/main/services/ | `keybindings/` | `keybindingsService.ts` | User keybindings read/write. | | `lanes/` | `laneService.ts`, `laneEnvironmentService.ts`, `laneTemplateService.ts`, `laneProxyService.ts`, `portAllocationService.ts`, `autoRebaseService.ts`, `rebaseSuggestionService.ts`, `laneLaunchContext.ts`, `oauthRedirectService.ts`, `runtimeDiagnosticsService.ts` | Worktree lifecycle, env bootstrap, templates, reverse proxy, port leases, auto-rebase, suggestions, OAuth redirect, diagnostics. | | `logging/` | `logger.ts` | File-backed structured logger. | -| `localRuntime/` | `localRuntimeConnectionPool.ts` | Desktop-side client for the local `ade serve` daemon. Spawns or attaches to the machine socket, registers local projects with `projects.add`, dispatches local runtime actions with per-call timeouts where needed, emits `local_runtime.action_slow` warn logs (with `ensureProjectMs` / `connectMs` / `daemonCallMs` breakdown) whenever a call exceeds 500 ms or throws, polls runtime events, and installs the background service best-effort in packaged builds. | +| `localRuntime/` | `localRuntimeConnectionPool.ts` | Desktop-side client for the local brain endpoint. Spawns or attaches to the machine endpoint, registers local projects with `projects.add`, dispatches local runtime actions with per-call timeouts where needed, emits `local_runtime.action_slow` warn logs (with `ensureProjectMs` / `connectMs` / `daemonCallMs` breakdown) whenever a call exceeds 500 ms or throws, polls runtime events, and installs the background service best-effort in packaged builds. | | `macosVm/` | `macosVmService.ts`, `macosVmStores.ts`, `rfbDirectClient.ts`, `credentialsStore.ts`, `runtimeBootstrap.ts`, `macosVmRecovery.ts` | Lane-tied macOS VM lifecycle and GUI control. `macosVmService.ts` uses Lume, mounts direct lane roots when safe (otherwise a sanitized rsync mirror), and exposes screenshot/click/type/select through headless VNC or visible-window fallbacks. `macosVmStores.ts` owns JSON persistence for VM records, the global lease, and VNC credentials. `credentialsStore.ts` keeps guest user credentials in the macOS Keychain (`/usr/bin/security`, service `ade-macos-vm-` / account `ade-cli`); renderers only see a summary. `runtimeBootstrap.ts` installs the in-guest ade-runtime over SSH+SCP with a five-phase progress signal (`ssh-probe`, `write-script`, `scp-script`, `run-script`, `verify-marker`). `macosVmRecovery.ts` is a standalone CLI cleanup path for stale records / lease / VNC credentials when the desktop surface cannot reach them. | | `onboarding/` | `onboardingService.ts`, `onboardingSuggestedConfig.ts` | First-run flow, defaults detection, existing lane discovery. `onboardingSuggestedConfig.ts` contains pure workflow parsing and suggested `.ade/ade.yaml` generation. | | `opencode/` | `openCodeRuntime.ts`, `openCodeServerManager.ts`, `openCodeBinaryManager.ts`, `openCodeInventory.ts`, `openCodeModelCatalog.ts` | OpenCode server spawn, binary resolution, model discovery. | @@ -921,7 +923,7 @@ Related sync docs: [Sync and multi-device](./features/sync-and-multi-device/READ ``` ADE/ ├── apps/ -│ ├── ade-cli/ # ADE runtime (`ade serve`), `ade` CLI, `ade code` terminal client +│ ├── ade-cli/ # ADE brain, manual runtime entry points, `ade` CLI, `ade code` │ ├── desktop/ # Electron client (multi-window; local + SSH-bound runtime bindings) │ ├── ios/ # Native SwiftUI controller (WebSocket to ADE machine) │ └── web/ # Marketing + download landing (Vite + React) diff --git a/docs/PRD.md b/docs/PRD.md index 3c77981f9..627ea9697 100644 --- a/docs/PRD.md +++ b/docs/PRD.md @@ -1,6 +1,6 @@ # ADE — Product Requirements -ADE is a **per-machine local-first ADE runtime** for AI-assisted software engineering. The runtime owns projects, git-worktree lanes of work, multi-provider agent chat, work sessions, a persistent CTO agent, worker delegation, pipeline automations, PR stacking, conflict simulation, computer-use proofs, and multi-device sync. Three first-party clients attach to it as peers: the **Electron desktop app** (multi-window, one window per project, optionally bound to a remote runtime over SSH), the **`ade code` terminal client**, and the **iOS app**. The same `ade` CLI is also used directly from any shell. +ADE is a **per-machine local-first development system** for AI-assisted software engineering. Its center is the **brain**: the always-on, machine-owned ADE process for a channel. The brain owns projects, git-worktree lanes of work, multi-provider agent chat, work sessions, a persistent CTO agent, worker delegation, pipeline automations, PR stacking, conflict simulation, computer-use proofs, the sync websocket, and the project catalog. Three first-party clients attach to it: the **Electron desktop app** (multi-window, one window per project, optionally bound to a remote runtime over SSH), the **`ade code` terminal client**, and the **iOS app**. The same `ade` CLI is also used directly from any shell. This doc is the entry point. Every major feature and concept is linked to its detailed breakdown in [`features/`](./features/). For how the pieces fit together, read [ARCHITECTURE.md](./ARCHITECTURE.md) next. @@ -8,24 +8,24 @@ This doc is the entry point. Every major feature and concept is linked to its de ## What ADE Is -ADE is a single-user development control plane that runs an **ADE runtime on each machine** (`apps/ade-cli/`, started with `ade serve`, listening on `~/.ade/sock/ade.sock`, installable as a login service via launchd / systemd / Windows). The machine runtime hosts **multiple projects** through a project registry; project-scoped operations dispatch through the multi-project JSON-RPC surface (`projects.*`, `sync.*`, `ade/actions/call` with a `projectId`). +ADE is a single-user development control plane that runs one **ADE brain per machine per channel** (`apps/ade-cli/`, listening on the channel's local RPC endpoint, installable as a login service via launchd / systemd / Windows). The brain hosts **multiple projects** through a project registry; project-scoped operations dispatch through the multi-project JSON-RPC surface (`projects.*`, `sync.*`, `ade/actions/call` with a `projectId`). -The clients of that runtime are equal: +The clients of that brain are equal: -- **Electron desktop** (`apps/desktop/`) — multi-window UI. Local windows attach to the local runtime through `LocalRuntimeConnectionPool`. Windows can also be bound to a remote machine over SSH; that path runs `ade rpc --stdio` on the remote and routes runtime-backed APIs through `RemoteConnectionPool`. Runtime-bound windows do not retry project work against desktop-local handlers; the remaining in-process services are for pre-binding desktop flows, Electron-only side effects, diagnostics, and tests. -- **ADE Code (`ade code`)** — terminal-native Work chat (Ink + React) in `apps/ade-cli/src/tuiClient/`. Defaults to attaching to the machine-runtime endpoint; starts `ade serve` if missing. `--embedded` keeps the legacy in-process fallback explicit. +- **Electron desktop** (`apps/desktop/`) — multi-window UI. Local windows attach to the local brain through `LocalRuntimeConnectionPool`. Windows can also be bound to a remote machine over SSH; that path runs `ade rpc --stdio` on the remote and routes runtime-backed APIs through `RemoteConnectionPool`. Runtime-bound windows do not retry project work against desktop-local handlers; the remaining in-process services are for pre-binding desktop flows, Electron-only side effects, diagnostics, and tests. +- **ADE Code (`ade code`)** — terminal-native Work chat (Ink + React) in `apps/ade-cli/src/tuiClient/`. Defaults to attaching to the machine brain; starts the brain if missing. `--embedded` keeps the in-process runtime fallback explicit. - **iOS app** (`apps/ios/`) — SwiftUI controller; pairs with an ADE machine over WebSocket. The phone never runs agents. -- **SSH-attached desktop** — a desktop window pointed at a remote runtime is the same client as a local window; the runtime machine is what differs. +- **SSH-attached desktop** — a desktop window pointed at a remote machine is the same client as a local window; the remote machine's brain is authoritative for its projects. The primary unit of work inside any project is a **lane**: an isolated git worktree + per-lane process pool + agent session. Many lanes run concurrently — each with its own chat, its own processes, its own PR. Lanes compose into **stacks** (dependency chains) and can be delegated to CTO workers or automation rules when the work needs durable routing. -Layered on top, all owned by the runtime: +Layered on top, all owned by the brain: - **Agents** — chat, CTO operator, workers. Multi-provider (Anthropic, OpenAI, Claude Code CLI, Codex, OpenCode, Cursor). Tool-aware. CTO worker adapter types: `claude-local`, `codex-local`, `process`. - **Automations** — rule-based background workflows triggered by events, cron, webhooks. - **Computer use** — control plane that fans out to Computer Use, agent-browser, or local fallback for UI automation proofs. - **ADE browser** — project-scoped built-in browser with persistent project profiles, tab/session ownership, hidden-tab agent actions, diagnostics, traces, and explicit proof promotion. - **Linear** — first-class two-way integration owned by the CTO agent. -- **Multi-device sync** — cr-sqlite CRDT replication, owned by the sync service inside the ADE runtime. The iOS app and any controller desktops connect through the same sync service. +- **Multi-device sync** — cr-sqlite CRDT replication, owned by the sync service inside the brain. The iOS app and any controller desktops connect through the same sync service. - **Remote runtime** — the desktop ships per-platform `ade-` binaries plus native deps under `apps/desktop/resources/runtime/`; `bootstrapRemoteRuntime` uploads them on first SSH connect. Headless installs use `curl … install.sh | sh`. ADE is the control plane. It owns ADE Browser automation for its built-in project browser, while OS-level computer-use still runs through dedicated backends and ADE normalizes their artifacts. @@ -36,8 +36,10 @@ ADE is the control plane. It owns ADE Browser automation for its built-in projec | Concept | Summary | Doc | | --- | --- | --- | -| Machine runtime | The per-machine ADE runtime (`ade serve`, `~/.ade/sock/ade.sock`). Hosts every project; desktop, `ade code`, and iOS attach as clients. Installable as a launchd / systemd / Windows login service. | [remote-runtime/README.md](./features/remote-runtime/README.md) | -| Project | One repo entry in the runtime's project registry. Identified by stable hash of root path; addressed in the multi-project RPC by `projectId`. | [remote-runtime/README.md](./features/remote-runtime/README.md) | +| Brain | The always-on, machine-owned ADE process for one channel. Hosts every project; desktop, `ade code`, and iOS attach as clients. Installable as a launchd / systemd / Windows login service. | [remote-runtime/README.md](./features/remote-runtime/README.md) | +| Runtime | ADE execution machinery: processes/services that open DBs and run agents, PTYs, git, and orchestration. A runtime process can host the brain role; manual/headless runtimes can exist for isolated commands and tests. | [remote-runtime/README.md](./features/remote-runtime/README.md) | +| Manual runtime | A foreground runtime process started explicitly with `ade runtime run --socket `. Sync is always off; used for dev/test work instead of the automated stable/beta/alpha brain service. | [remote-runtime/README.md](./features/remote-runtime/README.md) | +| Project | One repo entry in the brain's project registry. Identified by stable hash of root path; addressed in the multi-project RPC by `projectId`. | [remote-runtime/README.md](./features/remote-runtime/README.md) | | Lane | Isolated git worktree + per-lane process pool + agent session for one task. | [lanes/README.md](./features/lanes/README.md) | | Stack | Dependency chain of lanes → stacked PRs. | [lanes/stacking.md](./features/lanes/stacking.md) | | Agent | Typed persona with identity, tool tier, budget, and session log. CTO + workers + chat agents. | [agents/README.md](./features/agents/README.md) | @@ -50,14 +52,19 @@ ADE is the control plane. It owns ADE Browser automation for its built-in projec | Term | Meaning | | --- | --- | -| ADE runtime | The `ade serve` service plus runtime-side project, lane, agent chat, work session, sync, and proof services. | -| Machine runtime | The ADE runtime running on a specific machine and reachable at that machine's ADE endpoint. | -| Remote runtime | A machine runtime reached over SSH by a desktop window through `ade rpc --stdio`. | +| Brain | The always-on, machine-owned ADE process for one channel. It carries the local RPC endpoint, sync websocket, project catalog, pairing authority, and executor authority. Some existing code/protocol fields still say `host` or `brain_*`. | +| Runtime | ADE execution machinery: processes/services that open DBs and run agents, PTYs, git, and orchestration. A runtime process can host the brain role, but "brain" names authority/lifecycle, not a category of launchable runtimes. | +| Manual runtime | A foreground runtime process started explicitly with `ade runtime run --socket `. Sync is always off so it cannot claim brain authority; use a separate `ADE_HOME` when you also want full machine-state isolation. | +| Machine | A physical computer with a stable per-channel ADE identity and project catalog. | +| Channel | A release lane: stable, beta, alpha, or dev. Each channel isolates state under its own ADE home. | +| ADE home | The machine state root for a channel (`~/.ade`, `~/.ade-beta`, `~/.ade-alpha`, or a dev override). Holds project catalog, secrets, runtime resources, and endpoints. | +| Remote runtime | A runtime reached over SSH by a desktop window through `ade rpc --stdio`; the remote machine's brain is authoritative for its projects. | | Desktop bridge | Narrow Electron-main side channel for services that require real Electron UI APIs, such as ADE Browser. | -| Sync service | Runtime-owned WebSocket + cr-sqlite service that pairs controllers, replicates ADE DB state, routes mobile commands, and manages phone PIN pairing. | -| Client | A UI or CLI surface attached to an ADE runtime: desktop, `ade code`, iOS, or an SSH-attached desktop window. | +| Sync service | Brain-owned WebSocket + cr-sqlite service that pairs controllers, replicates ADE DB state, routes mobile commands, and manages phone PIN pairing. | +| Client | A UI or CLI surface attached to an ADE brain or runtime transport: desktop, `ade code`, iOS, or an SSH-attached desktop window. | | Controller | A client that reads runtime state and sends commands without running agents itself; the iOS app is always a controller. | -| Project | A registered repository root known to a machine runtime and addressed by `projectId`. | +| Catalog | The machine-level list of projects the brain serves to clients and ADE Mobile. | +| Project | A registered repository root known to a machine brain and addressed by `projectId`. | | Lane | A task-scoped git worktree with its own process pool, agent chat, work sessions, and PR flow. | | Worktree | The filesystem checkout backing a lane, usually under `.ade/worktrees//`. | | Stack | A dependency chain of lanes that maps to stacked PRs. | @@ -69,19 +76,18 @@ ADE is the control plane. It owns ADE Browser automation for its built-in projec | Worker | A delegated agent launched by the CTO or automation rules for scoped execution. | | Proof | A normalized artifact proving UI or workflow execution: screenshot, recording, trace, or log. | | Remote target | A saved SSH-reachable machine that can host a remote runtime. | -| Remote project | A project registered with the remote target's machine runtime. | +| Remote project | A project registered with the remote target's brain. | | CRDT / CRR | The cr-sqlite replication model used for synced ADE database tables. | -| Project registry | The machine-runtime catalog at `~/.ade/projects.json` that maps root paths to stable `projectId`s. | -| Brain | Legacy terminology for the machine runtime or sync authority. Keep it only when naming compatibility commands, legacy RPC methods, or internal fields such as `brain_device_id`. | +| Project registry | The machine catalog at `$ADE_HOME/projects.json` that maps root paths to stable `projectId`s. | --- ## Feature Index -### Runtime and clients +### Brain, runtime, and clients -- [**Remote Runtime**](./features/remote-runtime/README.md) — Remote access to an ADE runtime. Multi-project registry, machine endpoint, login-service install, SSH bootstrap of the cross-platform `ade-` runtime binaries shipped under `apps/desktop/resources/runtime/`. Owns sync. -- [**ADE Code**](./features/ade-code/README.md) — Terminal-native Work chat (Ink + React) inside `apps/ade-cli`. Default attaches to the machine-runtime endpoint and starts `ade serve` if missing. Same JSON-RPC surface as the desktop app and the iOS controller. +- [**Remote Runtime**](./features/remote-runtime/README.md) — Remote access to an ADE runtime. Multi-project registry, machine endpoint, login-service install, SSH bootstrap of the cross-platform `ade-` runtime binaries shipped under `apps/desktop/resources/runtime/`. A remote machine's brain is authoritative for its projects. +- [**ADE Code**](./features/ade-code/README.md) — Terminal-native Work chat (Ink + React) inside `apps/ade-cli`. Default attaches to the machine brain and starts it if missing. Same JSON-RPC surface as the desktop app and the iOS controller. ### Work execution @@ -119,14 +125,14 @@ ADE is the control plane. It owns ADE Browser automation for its built-in projec ## Cross-Cutting Architecture -For the system-wide picture — runtime + clients, processes, data plane, IPC, security, build/test/deploy — read [**ARCHITECTURE.md**](./ARCHITECTURE.md). +For the system-wide picture — brain role, runtime machinery, clients, processes, data plane, IPC, security, build/test/deploy — read [**ARCHITECTURE.md**](./ARCHITECTURE.md). Quick pointers: -- **ADE runtime**: `apps/ade-cli/` — `ade serve` is the per-machine source of truth for projects, lanes, agent chats, work sessions, processes, sync, and proof. Endpoint: `~/.ade/sock/ade.sock`. Login-service installers: `apps/ade-cli/src/serviceManager/installLaunchd.ts` (macOS), `installSystemd.ts` (Linux), `installWindows.ts` (Windows). Multi-project RPC: `apps/ade-cli/src/multiProjectRpcServer.ts`. Project registry/scope: `apps/ade-cli/src/services/projects/`. Sync service: `apps/ade-cli/src/services/sync/`. Credentials, agent registry, runtime-side service surfaces: `apps/ade-cli/src/services/`. +- **ADE brain and execution machinery**: `apps/ade-cli/` — the brain is the per-machine source of truth for projects, lanes, agent chats, work sessions, processes, sync, and proof. Execution services inside that process do the work. Endpoint: `$ADE_HOME/sock/ade.sock`. Login-service installers: `apps/ade-cli/src/serviceManager/installLaunchd.ts` (macOS), `installSystemd.ts` (Linux), `installWindows.ts` (Windows). Multi-project RPC: `apps/ade-cli/src/multiProjectRpcServer.ts`. Project registry/scope: `apps/ade-cli/src/services/projects/`. Sync service: `apps/ade-cli/src/services/sync/`. Credentials, agent registry, service surfaces: `apps/ade-cli/src/services/`. - **Desktop client**: `apps/desktop/` — Electron main + preload + renderer. Multi-window. `LocalRuntimeConnectionPool` (`apps/desktop/src/main/services/localRuntime/`) speaks to the local runtime; `RemoteConnectionPool` (`apps/desktop/src/main/services/remoteRuntime/`) speaks to a runtime over SSH after `bootstrapRemoteRuntime` uploads the bundled `ade-` binary. `preload.ts` routes runtime-backed APIs through those pools. In-process desktop services remain only for flows that have no runtime binding yet, Electron-only side effects, diagnostics, and tests. - **Terminal client**: `apps/ade-cli/src/tuiClient/` — `ade code` Ink + React Work chat. -- **iOS client**: `apps/ios/` — SwiftUI controller over WebSocket to the ADE runtime's sync service. +- **iOS client**: `apps/ios/` — SwiftUI controller over WebSocket to the ADE brain's sync service. - **Renderer components**: `apps/desktop/src/renderer/components//`. - **Shared types + IPC contract**: `apps/desktop/src/shared/` (consumed by the desktop client and re-imported by the ADE CLI runtime). New runtime-facing types: `apps/desktop/src/shared/types/remoteRuntime.ts`, `core.ts`. - **Data**: SQLite + cr-sqlite. `.ade/` per project (the runtime owns these files regardless of which client is attached), `~/.ade/` global. diff --git a/docs/README.md b/docs/README.md index 218ccd31a..00c040897 100644 --- a/docs/README.md +++ b/docs/README.md @@ -2,13 +2,13 @@ Navigation map for the internal docs. **Start with [PRD.md](./PRD.md).** -The mental model up front: ADE is a **per-machine ADE runtime** (`apps/ade-cli/`, run as `ade serve`) that owns projects, lanes, agent chats, work sessions, processes, sync, and proof. The desktop app, the terminal `ade code` client, the iOS app, and SSH-attached desktop windows are all peer **clients** of that runtime. Read the entry-point docs in that order: +The mental model up front: ADE has a **brain** — the always-on, machine-owned ADE process for one channel. The brain owns projects, lanes, agent chats, work sessions, processes, sync, proof, and the machine project catalog. The desktop app, the terminal `ade code` client, the iOS app, and SSH-attached desktop windows are **clients** that attach to it. Read the entry-point docs in that order: ## Reading order -1. [**PRD.md**](./PRD.md) — product scope, runtime + clients model, concepts, feature index. -2. [**ARCHITECTURE.md**](./ARCHITECTURE.md) — apps, runtime/client topology, data plane, IPC, services catalog, security, build/test/deploy. -3. [**features/**](./features/) — per-feature subfolders, each with a `README.md` + detail docs. Start with `remote-runtime/`, `ade-code/`, and `sync-and-multi-device/` for the runtime+clients picture. +1. [**PRD.md**](./PRD.md) — product scope, brain role, runtime machinery, clients model, concepts, feature index. +2. [**ARCHITECTURE.md**](./ARCHITECTURE.md) — apps, brain/client topology, data plane, IPC, services catalog, security, build/test/deploy. +3. [**features/**](./features/) — per-feature subfolders, each with a `README.md` + detail docs. Start with `remote-runtime/`, `ade-code/`, and `sync-and-multi-device/` for the brain+clients picture. 4. [**playbooks/**](./playbooks/) — operational workflows agents can follow directly. ## Layout diff --git a/docs/features/sync-and-multi-device/remote-commands.md b/docs/features/sync-and-multi-device/remote-commands.md index 6bed113f3..666685f0c 100644 --- a/docs/features/sync-and-multi-device/remote-commands.md +++ b/docs/features/sync-and-multi-device/remote-commands.md @@ -2,7 +2,7 @@ Remote commands are the execution channel for controllers. A controller (another desktop acting as a peer, or the iOS app) sends a `command` -envelope to the ADE runtime (`ade serve`); the runtime +envelope to the ADE brain; the brain's in-process services resolves it through `syncRemoteCommandService`, runs the underlying action against its in-process services, and replies with `command_ack` and then `command_result`. @@ -12,6 +12,11 @@ Source file: `apps/ade-cli/src/services/sync/syncRemoteCommandService.ts` `apps/desktop/src/main/services/sync/syncRemoteCommandService.ts` is a one-line re-export of the canonical module. +Terminology note: **brain** is the always-on machine-owned ADE process. +Some wire and code identifiers also say `host` or `syncHost` because +those names predate the current glossary; they refer to the brain/sync +authority unless this document explicitly says otherwise. + ## Shape ### Invocation @@ -31,7 +36,7 @@ A controller sends: } ``` -The host responds in two envelopes: +The brain responds in two envelopes: ```ts // command_ack — receipt and preliminary disposition @@ -71,29 +76,29 @@ type SyncRemoteCommandDescriptor = { type SyncRemoteCommandPolicy = { viewerAllowed: boolean; // can a read-only controller invoke? - requiresApproval?: boolean; // host prompts operator before executing + requiresApproval?: boolean; // brain prompts operator before executing localOnly?: boolean; // never sent over the wire; local-only queueable?: boolean; // queue locally if offline, replay on reconnect }; ``` -The scope label matters because the daemon hosts **multiple projects** +The scope label matters because the brain serves **multiple projects** at once. `runtime`-scoped commands (machine-wide diagnostics, project catalog reads, settings) run without a project binding. `project`-scoped commands (everything that mutates lane / chat / PR state inside a -project) require the host to have an active project AND the caller to -have bundled a matching `projectId` on the envelope. The host enforces +project) require the brain to have an active project AND the caller to +have bundled a matching `projectId` on the envelope. The brain enforces this with explicit error codes: -- `code: missing_project` — host has a project open but the command did +- `code: missing_project` — the brain has a project open but the command did not include `projectId`. Re-select the project on the controller and retry. -- `code: project_not_open` — caller asked for a project the host does +- `code: project_not_open` — caller asked for a project the brain does not currently have open. Drive a `project_switch_request` first. -Controllers read `SyncRemoteCommandDescriptor`s from the host (via the +Controllers read `SyncRemoteCommandDescriptor`s from the brain (via the `getSupportedActions` / `getDescriptors` surface) and gate UI -accordingly — the host policy and scope are always authoritative. +accordingly — the brain's policy and scope are always authoritative. ## Registry @@ -124,15 +129,15 @@ Listed in order of appearance in the registry: - `listTemplates`, `getDefaultTemplate` - `initEnv`, `getEnvStatus`, `applyTemplate` - `presence.announce`, `presence.release` — controller marks a lane - as currently open / no longer open; the host decorates + as currently open / no longer open; the brain decorates `LaneSummary.devicesOpen` with a 60 s TTL and fans out updates via - `brain_status`. + the brain-status broadcast (`brain_status`). `lanes.reparent` accepts `{ laneId, newParentLaneId, stackBaseBranchRef? }`. The optional base ref is trimmed before -dispatch; when present, the host resolves it in the project repo +dispatch; when present, the brain resolves it in the project repo preferring `origin/`, persists it as the lane's `base_ref`, -and rebases the lane onto that resolved branch. When omitted, the host +and rebases the lane onto that resolved branch. When omitted, the brain uses the selected parent lane's current branch. **Work** (`work.*`) @@ -149,7 +154,7 @@ uses the selected parent lane's current branch. `chat.modelCatalog` accepts `{ mode?, refreshProvider? }` where `mode` is `"cached" | "refresh-stale" | "force"` (default `"cached"`) and `refreshProvider` is `"opencode" | "cursor" | "droid" | "lmstudio" | -"ollama"`. The host returns the full provider-grouped catalog used by +"ollama"`. The brain returns the full provider-grouped catalog used by the desktop and TUI ModelPickers and the iOS Work model sheet; only explicit `force` / `refresh-stale` calls trigger a runtime probe. @@ -235,7 +240,7 @@ Each action has a dedicated parse function (e.g. `parseCreateLaneArgs`, `requireService`. 3. Coerces optional fields through `asTrimmedString`, `asOptionalNumber`, `asOptionalBoolean`, `asStringArray`. -4. Returns the typed args object expected by the host service. +4. Returns the typed args object expected by the brain's in-process service. Helpers (`asTrimmedString`, `asStringArray`, `requireString`, etc.) live at the top of the file. A non-conforming args object causes the parser @@ -244,7 +249,7 @@ error reaches the controller as `command_result.error.message`. ## Handler bodies -Handlers are thin glue onto host services. Most look like: +Handlers are thin glue onto the brain's in-process services. Most look like: ```ts register("lanes.create", @@ -327,8 +332,8 @@ A handful have more logic: legacy desktop banner. `defer` clamps the requested minutes to `[5, 7 days]` before computing the absolute `until` ISO string. - **`lanes.presence.announce` / `lanes.presence.release`** — handled - in `syncHostService` directly (not in the remote command - registry); the host upserts a per-lane `DeviceMarker` map and + in `syncHostService` directly (not in the remote command registry); + the brain upserts a per-lane `DeviceMarker` map and decorates outgoing `LaneSummary` payloads with `devicesOpen`. ### Lane response decoration @@ -342,7 +347,7 @@ therefore see up-to-date presence without a separate query. ## Service dependencies -`createSyncRemoteCommandService` takes a long list of optional host +`createSyncRemoteCommandService` takes a long list of optional runtime services: ```ts @@ -370,7 +375,7 @@ Optional services that are missing cause their dependent actions to throw `" not available."` at call time. The `requireService` helper centralises that check. This pattern lets a narrower runtime construct only the services it can actually back without crashing at -command registration — useful for headless `ade serve` setups that, for +command registration — useful for headless/manual runtime setups that, for example, intentionally skip the chat service. ## Supported-action discovery @@ -385,7 +390,7 @@ execute(payload: SyncCommandPayload): Promise; ``` Controllers typically read descriptors at connection time, cache -them, and refresh on `brain_status` changes. The iOS Lanes / +them, and refresh on brain-status broadcasts (`brain_status`). The iOS Lanes / Files / Work / PRs tabs use this to render action buttons only for commands the current runtime supports under the current policy. @@ -443,32 +448,32 @@ issue, this flag drives whether `prService` injects `Fixes IDENT` (closes the issue when the PR merges) or `Refs IDENT` (links without closing) into the PR body via `ensureLinearPrReference`. -`brain_status` envelopes carry the host's `LinearConnectionStatus`, +Brain-status (`brain_status`) envelopes carry the brain's `LinearConnectionStatus`, which now includes optional `organizationId`, `organizationName`, `organizationUrlKey`, and `organizationLogoUrl` fields populated by -the host when the Linear workspace is connected. Controllers use +the brain when the Linear workspace is connected. Controllers use these to render the workspace brand on Linear-related surfaces without fetching them separately. `parseChatModelsArgs` accepts `{ provider, activateRuntime? }`. When `chat.create` is missing an explicit model, `resolveChatCreateArgs` forwards `activateRuntime: true` only for the `opencode` provider so -the host actually launches the OpenCode probe server before resolving +the brain actually launches the OpenCode probe server before resolving a default model. All other providers use passive (cache-only) resolution; see the chat README for the passive/active contract. ## Gotchas -- **`chat.models` returns the host's model catalog.** A controller - must not hardcode model IDs. The host is authoritative about +- **`chat.models` returns the brain's model catalog.** A controller + must not hardcode model IDs. The brain is authoritative about which models are wired up, which providers have credentials, and what the default model is. - **`lanes.delete` and `lanes.archive` are queueable.** A disconnected controller can enqueue deletes that replay on reconnect. Be aware when reasoning about "why did this lane disappear" — check the command queue, not just the local DB. -- **`prs.createFromLane` requires the host's GitHub token.** On a - headless `ade serve` host with no `ADE_GITHUB_TOKEN` / +- **`prs.createFromLane` requires the brain's GitHub token.** On a + headless/manual runtime with no `ADE_GITHUB_TOKEN` / `GITHUB_TOKEN` / `GH_TOKEN`, the command fails with a clear error before reaching GitHub. This is deliberate fail-fast behavior. - **`work.runQuickCommand` always creates a PTY.** There is no @@ -477,13 +482,13 @@ see the chat README for the passive/active contract. `work.stopRuntime`. A daemon configured without a real PTY service (rare; only used in some headless test harnesses) will surface `pty service not available` for this command. -- **`work.startCliSession` provider list is host-controlled.** The +- **`work.startCliSession` provider list is brain-controlled.** The controller cannot pass `command` / `args` / `startupCommand` - overrides — the host derives those from the provider name through + overrides — the brain derives those from the provider name through `buildTrackedCliLaunchCommand`. To add a new provider you extend `apps/desktop/src/shared/cliLaunch.ts` and the `parseCliProvider` allowlist together; a phone client that hardcodes - the new id without a host update will get a "requires provider" + the new id without a brain update will get a "requires provider" error. - **`files.writeTextAtomic` does not invoke git hooks or editors.** It writes atomically to the lane worktree and that is all. @@ -494,9 +499,9 @@ see the chat README for the passive/active contract. `ensureMobileFileMutationsAllowed`, checking `FilesWorkspace.mobileReadOnly` before sending a `writeText`, `createFile`, `createDirectory`, `rename`, or `deletePath` request. - The host's `MOBILE_MUTATING_FILE_ACTIONS` set mirrors this list so + The brain's `MOBILE_MUTATING_FILE_ACTIONS` set mirrors this list so a hostile controller cannot bypass it. -- **`requireService` throws lazily.** A host missing a service does +- **`requireService` throws lazily.** A runtime missing a service does not cause registration to fail; it causes the first invocation of a command that needs that service to fail with a specific message. Tests should exercise each command path rather than assume diff --git a/scripts/package-channel.mjs b/scripts/package-channel.mjs index 698f58778..01e584bf4 100644 --- a/scripts/package-channel.mjs +++ b/scripts/package-channel.mjs @@ -328,7 +328,6 @@ function buildChannel(repoRoot, channel, options) { ADE_CLI_VERSION: appVersion, ADE_DESKTOP_APP_NAME: config.productName, ADE_HOME: config.adeHome, - ADE_DISABLE_RUNTIME_SERVICE_INSTALL: "1", ADE_RUNTIME_RESOURCES_ALLOW_HOST_ONLY: "1", }; @@ -355,7 +354,6 @@ function buildChannel(repoRoot, channel, options) { `-c.mac.extendInfo.LSEnvironment.ADE_PACKAGE_CHANNEL=${channel}`, `-c.mac.extendInfo.LSEnvironment.ADE_DESKTOP_APP_NAME=${config.productName}`, `-c.mac.extendInfo.LSEnvironment.ADE_HOME=${config.adeHome}`, - "-c.mac.extendInfo.LSEnvironment.ADE_DISABLE_RUNTIME_SERVICE_INSTALL=1", `-c.mac.extendInfo.NSAppleEventsUsageDescription=${APPLE_EVENTS_USAGE_DESCRIPTION}`, ], { cwd: desktopRoot, env, dryRun: options.dryRun });