# `ao start` Bootstrapper + npm Deprecation: Implementation Spec > **Status:** ready for build (Track A). Grounded against the real codebase on > branch `feat/ao-start-bootstrapper` (= `upstream/main` + PR #2185) on 2026-06-26. > Every "current state" claim carries a `file:line` reference. > > **This is NOT a new JS launcher package.** The `ao` binary that npm ships is the > existing Go cobra CLI (`backend/cmd/ao`). This effort rewrites one subcommand, > `ao start`, to fetch and open the desktop app. Everything else in the CLI is > already wired and rides along. --- ## 0. Goal npm `ao` is the **legacy on-ramp** for users who already have `ao` on their PATH. We are deprecating npm as an app-distribution path: - `npm update` swaps in our **new Go `ao` binary** (the whole CLI), replacing the old one in place. No fresh-install story; the audience is existing users. - The **`ao start`** subcommand is rewritten: instead of starting a daemon, it **fetches the desktop app from GitHub Releases and opens it**. - The **desktop app owns the daemon**, auto-update, relocation, and all state. The CLI becomes a thin client of the app-owned daemon. `ao start` is the one-time bridge that moves a CLI user onto the canonical, auto-updating desktop build. It is dumb about versions: its only job is "is the app present? if not, fetch it; then open it." --- ## 1. Ground truth (what the code actually is today) ### 1.1 App identity and release target | Fact | Value | Source | | ---------------------------- | ---------------------------------------------------------------------- | ------------------------------- | | Product / bundle name | **`Agent Orchestrator.app`** (spaced) | `frontend/forge.config.ts:9,50` | | Bundle id | `dev.agent-orchestrator.desktop` | `frontend/forge.config.ts:8` | | Executable name | `agent-orchestrator` | `frontend/forge.config.ts` | | **Release repo (canonical)** | **`AgentWrapper/agent-orchestrator`** | per release owner | | Forge publisher repo (TODAY) | `aoagents/agent-orchestrator` — **stale, must change to AgentWrapper** | `frontend/forge.config.ts:86` | | GitHub release mode | **`draft: true`**, `prerelease: false` | `frontend/forge.config.ts` | > `aoagents/agent-orchestrator` was the **temporary** home during the rewrite; the > code is now ported and releases land on **`AgentWrapper/agent-orchestrator`**. > The forge publisher still points at `aoagents` and must be corrected (task T3). > The Go **module path** is also `github.com/aoagents/agent-orchestrator`; renaming > the module is a large, separate change and is **out of scope** here (it does not > affect the release/download URL). ### 1.2 Release / build pipeline - Workflow: `.github/workflows/frontend-release.yml`. Triggers: tag `desktop-v*`, `workflow_dispatch`. Build: `npm run publish` → `build:daemon` + `electron-forge publish`. - **Matrix: `[macos-latest, windows-latest]` only** (`:28`) — no Linux; deb/rpm makers configured but never run (upstream issue AgentWrapper/agent-orchestrator#2191). - Maker outputs (today): macOS `@electron-forge/maker-zip` → versioned `.zip` under `out/make/zip/darwin//`; Windows `MakerNSIS` → `Agent Orchestrator Setup.exe` (per-user installer); Linux `maker-deb`/`maker-rpm` → `agent-orchestrator-.{deb,rpm}`. - **No asset-rename step** and **`draft: true`** → a constant `releases/latest/download/` URL cannot resolve until both are fixed. ### 1.3 Versioning - Frontend `frontend/package.json` `version: "0.0.0"`; daemon `backend/internal/cli/version.go:12` `Version = "dev"`; `build-daemon.mjs` runs `go build ./cmd/ao` with **no `-ldflags`**. No real semver anywhere. ### 1.4 Signing / notarization / auto-update - `osxSign`/`osxNotarize` are gated on secrets (`forge.config.ts:24-40`) that are **not set in CI**; the workflow header (`frontend-release.yml:13-15`) says builds are **UNSIGNED**. - **Auto-update is already wired**: `frontend/src/main.ts:14` imports `updateElectronApp` from `update-electron-app`; `initAutoUpdates()` (`main.ts:817`) runs it when `app.isPackaged`. Inert today because builds are unsigned and version is `0.0.0` (its own comment, `main.ts:813-816`). ### 1.5 `~/.ao` state and app lifecycle - Canonical home `~/.ao` (`backend/internal/config/config.go:296`, `frontend/src/shared/daemon-discovery.ts:107`); overrides `AO_DATA_DIR`/`AO_RUN_FILE`. - `userData` pinned to `~/.ao/electron` (`main.ts:64`, before `whenReady`; CLAUDE.md hard rule). - `~/.ao/running.json` is written by the **daemon** (`backend/internal/runfile/runfile.go` `Write`, atomic temp+rename), read by the app (`daemon-discovery.ts parseRunFile`). Only `running.json` exists in `~/.ao` today; **`app-state.json` does not exist yet**. - App startup (`main.ts:822` `whenReady`): `registerRendererProtocol()` → `createWindow()` → `void startDaemon()` → `initAutoUpdates()`. The app already **spawns and owns the daemon** (`startDaemon`, spawns the bundled `ao daemon`). - `app.moveToApplicationsFolder()` is **not used** anywhere (macOS-only). - Login-shell env resolved at startup via `zsh -ilc '… env -0'` (`frontend/src/shared/shell-env.ts:27`). ### 1.6 npm delivery of the Go binary (the packaging gap) - The `ao` binary is `backend/cmd/ao` (`cmd/ao/main.go` → `cli.Execute()`); the same binary serves as both the CLI and `ao daemon`. `build-daemon.mjs` builds it to `frontend/daemon/ao` and bundles it into the desktop app. - **This repo has no npm-registry publish path for the `ao` binary** (only electron-forge → GitHub Releases; no `NPM_TOKEN`, no publish workflow — research confirmed). The old AO npm package shipped `ao` via npm; that delivery mechanism must be **ported/rebuilt here** (task T2). To honor "zero install scripts" (npm v12, est. July 2026, blocks unapproved install scripts), the Go binary should ship via **per-platform `optionalDependencies` packages** (the esbuild/turbo model: a tiny JS `bin` shim execs the right prebuilt binary), not via a `postinstall` download. ### 1.7 The Go `ao` CLI surface (already wired) `backend/cmd/ao/main.go` → `backend/internal/cli`. Cobra root (`root.go:154-202`) registers **all** of: `daemon` (hidden), **`start`**, `stop`, `status`, `doctor`, `spawn`, `send`, `preview`, `hooks`, `launch`, `ptyhost`, `import`, `project`, `session`, `orchestrator`, `review`, `completion`, `version`. These are real (`doctor.go` is 20KB of health checks; `import.go` imports a legacy AO install). The CLI is a thin client: commands "discover the local daemon, call its loopback HTTP API, and format output" (`root.go:1-3`). **Current `ao start` (`start.go:54-119`):** starts the daemon (spawns `ao daemon`, waits for ready) and runs a first-boot legacy import (`maybeFirstBootImport`, `start.go:84`). **This entire behavior is being replaced** (§6). --- ## 2. Decisions locked 1. **Releases land on `AgentWrapper/agent-orchestrator`.** Fix the forge publisher to match; the download URL uses it. 2. **`ao start` = fetch + open the desktop app.** It no longer starts the daemon; the frontend owns the daemon. The current daemon-spawn logic in `start.go` is removed. 3. **npm ships the Go `ao` binary**; existing users update in place. No JS launcher package. 4. **Marker = `~/.ao/app-state.json`**, written only by the app, every launch. 5. **Scope = Track A only** (de-scope auto-update copy; Track B is separate). 6. **All three platforms; Windows installer is NSIS.** 7. **Two release targets, never conflated:** - **Production:** GitHub `AgentWrapper/agent-orchestrator`; npm = the real package name (legacy `ao`). Cutting a prod release is a deliberate, gated step, never part of the dev/test loop. - **Test/dev:** GitHub **`harshitsinghbhandari/agent-orchestrator`** (the fork); npm scope **`@theharshitsingh/ao`**. All `ao start` download/open testing runs against fork releases and the test npm scope. The download repo and npm scope are **build-time overridable** (§6.3, §8) so a test binary fetches from the fork and a prod binary from AgentWrapper, with no code edit between them. --- ## 3. Scope **In scope (Track A):** - Rewrite the Go **`ao start`** subcommand: `resolve → fetch → open` the desktop app, then print a deprecation notice. (`backend/internal/cli/start.go`.) - Decide the fate of `ao start`'s current first-boot legacy import (§6.4). - **App-side:** write `~/.ao/app-state.json` every launch (app is sole writer); own `moveToApplicationsFolder()` relocation (macOS). - **Release wiring:** point forge publisher at `AgentWrapper/agent-orchestrator`, add stable version-free asset names, finalize the draft (or Releases-API fallback), add Linux to the matrix. - **npm delivery** of the Go binary (port the old AO mechanism; zero install scripts via optionalDeps platform packages). - macOS / Windows (NSIS) / Linux (deb/rpm or AppImage) fetch+open paths. **Out of scope:** - Track B: real version stamping, making the wired `update-electron-app` updater live, configuring signing/notarization CI secrets, any copy promising auto-update. - Renaming the Go module path off `aoagents` (separate, large, not needed here). - The other CLI subcommands (already wired; untouched). --- ## 4. Core invariants (load-bearing) 1. **The npm package runs zero install scripts.** No `preinstall`/`install`/ `postinstall`, no `binding.gyp`. Ship the Go binary via per-platform `optionalDependencies` + a JS `bin` shim, not a `postinstall` download. 2. **Filesystem is the source of truth; `app-state.json` is a fast-path hint.** Never trust its recorded path without `stat`-ing it. 3. **The app is the sole writer of `app-state.json`.** `ao start` is read-only with respect to it. This is what makes the npm and website routes converge without an orphaned second copy. 4. **The app owns relocation** (`moveToApplicationsFolder()`), and rewrites the marker path afterward. `ao start` never moves the app. 5. **`ao start` is dumb about versions.** Decision is present-or-absent only; never compares versions. Updating an installed app is the app's own updater's job. 6. **Resolution order is fixed:** marker path → `stat` → known-location scan → fetch. Fetch only when both miss. 7. **Stable, version-free release asset names** so `ao start` uses a constant URL. --- ## 5. The marker contract: `~/.ao/app-state.json` New file, **app-written**, mirroring the daemon's proven atomic write (`backend/internal/runfile/runfile.go`: temp file in same dir → atomic rename). ```json { "schemaVersion": 1, "appPath": "/Applications/Agent Orchestrator.app", "version": "0.0.0", "installedAt": "2026-06-26T10:00:00Z", "lastReconciledAt": "2026-06-26T10:05:00Z", "installSource": "npm-bootstrap" } ``` | Field | Writer | Meaning | | ------------------ | ------ | -------------------------------------------------------------------------------- | | `schemaVersion` | app | Marker format version. | | `appPath` | app | Bundle path as of the last launch. | | `version` | app | `app.getVersion()`. For the tour/migration, NOT for `ao start` update decisions. | | `installedAt` | app | First marker write. | | `lastReconciledAt` | app | Last launch that touched the marker. | | `installSource` | app | `npm-bootstrap` / `website` / `github` / `unknown`; set only on first creation. | **Ownership:** only the app writes it, on **every launch**, self-healing a stale/missing marker no matter how the app arrived. `ao start` only reads it, only after `stat`-ing the path. --- ## 6. The `ao start` subcommand (Go) — the heart of this effort Rewrite `backend/internal/cli/start.go`. Remove the daemon-spawn path (`startDaemon`, `waitForReady`); the frontend owns the daemon now. ### 6.1 New algorithm ``` ao start: app = resolveApp() # marker → stat → known-location scan if app == "": app = fetchApp() # download latest for this platform, place it opened = openApp(app) # launch; pass --installed-via=npm-bootstrap printDeprecationNotice() # the app owns any rich first-run tour if !opened: printManualOpen(app) return nil # never blocks/supervises the app ``` All of this is Go, in the `cli` package, reusing existing deps (`Deps.CommandOutput`, `Deps.LookPath`, `Deps.Executable`) and the `~/.ao` resolution already in `backend/internal/config`. ### 6.2 `resolveApp()` (invariants 2, 5, 6) 1. Read `~/.ao/app-state.json`; if `appPath` `stat`s as a usable bundle, return it. 2. Else scan known locations per platform (covers website installs / stale marker): - macOS: `/Applications/Agent Orchestrator.app`, `~/Applications/…` - Windows: `%LOCALAPPDATA%\Programs\agent-orchestrator\…`, `C:\Program Files\Agent Orchestrator\…` - Linux: `/opt/Agent Orchestrator/…`, `~/.local/bin`, `/usr/bin` 3. Else return empty → caller fetches. Never compare versions. ### 6.3 `fetchApp()` + `openApp()` — platform asymmetry (real design point) Constant URL: `https://github.com///releases/latest/download/` (302 → asset; requires non-draft release + stable names, §8). `/` is **build-time overridable**, not hardcoded: default `AgentWrapper/agent-orchestrator` (prod), overridden to `harshitsinghbhandari/agent-orchestrator` for test builds via a `-ldflags -X …cli.releaseRepo=/` injection (mirrors how the daemon version will be stamped). So the dev loop fetches from the fork; prod fetches from AgentWrapper, with no source edit. - **macOS:** download `.zip` → unpack with **`ditto -x -k`** (preserves the `.app` signature; plain unzip corrupts it) → `open --args --installed-via=npm-bootstrap`. The app relocates itself to `/Applications` on first launch. - **Windows:** the asset is an **NSIS installer `.exe`** (not a runnable bundle). `fetch` downloads it; `open` runs the installer (interactive, or `/S` silent), then `resolveApp()` finds the installed exe and launches it. - **Linux:** `.deb`/`.rpm` need privileged install, or switch the Linux artifact to an **AppImage** (single executable, no install) — better fit for fetch-and-run (decide §11). ### 6.4 The legacy first-boot import `ao start` currently runs `maybeFirstBootImport` (`start.go:84`, imports a legacy AO install before the daemon starts). With the daemon-spawn removed, this must move. Options (decide §11): (a) the **desktop app** runs the import when it first boots its daemon; (b) drop it from `ao start` and rely on the standalone `ao import` command (still wired). Recommended: (a), so the on-ramp still migrates existing data. ### 6.5 Other subcommands / bare `ao` Unchanged — they stay wired and talk to the app-owned daemon's loopback API. Add a one-line deprecation hint to the root long-help noting that npm is now an on-ramp and the app is the home. Do **not** alter `stop`/`status`/`spawn`/etc. behavior. --- ## 7. App-side responsibilities ### 7.1 Marker write + relocation (new) Hook into `app.whenReady()` (`main.ts:822`), **before** `createWindow()`, ordered **relocate → write marker** (the marker must record the post-relocation path): ```ts app.whenReady().then(async () => { if (process.platform === "darwin" && app.isPackaged) { try { app.moveToApplicationsFolder(); } catch { /* declined / not movable */ } // success restarts the app, so code past here runs only if no move happened } await writeAppStateMarker(); // atomic temp+rename, mirror runfile.Write registerRendererProtocol(); createWindow(); void startDaemon(); initAutoUpdates(); }); ``` `writeAppStateMarker()` records `app.getAppPath()`/`app.getVersion()` into `~/.ao/app-state.json`. On first creation, capture `installSource` from the `--installed-via` arg `ao start` passes (else `website`/`github`/`unknown`). ### 7.2 Already done — rely on it Daemon ownership (`main.ts startDaemon` + the #2185 supervisor link, `main/supervisor-link.ts`), login-shell env (`shell-env.ts:27`), and the `userData` pin (`main.ts:64`) are in place. Do not re-implement. --- ## 8. Release / build wiring - **Publisher repo is overridable** (`forge.config.ts:86`): default prod `AgentWrapper/agent-orchestrator`, but read from an env var (e.g. `AO_RELEASE_REPO`) so a fork build publishes to `harshitsinghbhandari/agent-orchestrator`. The dev loop publishes a draft+finalize release **on the fork** and points the test binary's `cli.releaseRepo` at the same fork. Never publish to AgentWrapper from a test run. - **Stable asset names:** add a release-workflow step renaming each maker output to space-free names (`agent-orchestrator-darwin-arm64.zip`, `agent-orchestrator-win32-x64.exe`, the Linux artifact per §11) before upload. - **Finalize the draft:** flip `draft: false` or add a CI publish step; the constant URL only resolves for a published release. - **`.zip` for macOS** unpacked with `ditto`; do not switch to `.tar.gz`. - **Linux in the matrix:** add `ubuntu-latest` (#2191). - **One tag drives versions** once Track B lands. --- ## 9. Track B prerequisites (NOT this effort; keeps v1 copy honest) The `update-electron-app` updater is wired (§1.4) but inert until **both**: real version stamping (bump `package.json`; inject daemon version via `-ldflags -X …cli.Version=` in `build-daemon.mjs`) **and** signed+notarized macOS builds (`CSC_LINK` + `APPLE_*` in CI). Until then, v1 copy must **not** promise auto-update; users self-update by re-running `ao start` or downloading from the website. --- ## 10. Acceptance criteria / test matrix | # | Scenario | Expected | | --- | --------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------- | | 1 | `npm i -g @theharshitsingh/ao` (test scope) | Zero `allow-scripts` warning; nothing listed by `npm approve-scripts --allow-scripts-pending`. | | 2 | `npm i -g @theharshitsingh/ao --ignore-scripts` (v12 sim) | Install succeeds; `ao` runs; `ao start` works (binary delivered via optionalDeps, not a script). | | 3 | Fresh macOS `ao start` | Fetches `.zip`, `ditto`-unpacks, opens `Agent Orchestrator.app`; app relocates to `/Applications`; `~/.ao/app-state.json` records the `/Applications` path. | | 4 | Website install first, then `ao start` | Known-location scan finds it; opens; no second copy fetched. | | 5 | App trashed (marker stale), then `ao start` | Marker `stat` misses → scan misses → re-fetch. | | 6 | App relocated by the app | Marker path rewritten; next `ao start` opens the right path; no orphan. | | 7 | Installed-but-old app, `ao start` | Opens it and exits; does NOT fetch a newer one. | | 8 | Windows `ao start` | Downloads NSIS `.exe`, runs installer, resolves + opens installed exe. | | 9 | Linux `ao start` | Fetches chosen artifact and launches. | | 10 | `ao stop`/`ao status`/`ao spawn` after `ao start` | Work against the app-owned daemon (CLI is a client). | | 11 | Existing CLI user runs `npm update` then `ao start` | New binary in place; `ao start` no longer starts a daemon, it opens the app; their `ao import` data migrates (per §6.4). | > `ao start` opens the app through the calling shell's enriched env, so a green > `ao start` proves nothing about the Dock-launch path. Test the Dock path > separately. --- ## 11. Open decisions (decide before the affected task) 1. **npm delivery mechanism** for the Go binary: per-platform `optionalDependencies` packages (recommended, zero-install-script) vs porting whatever the old AO package did. Test scope is **`@theharshitsingh/ao`**; the **prod package name** (the legacy `ao` users already have) still needs confirming, plus an `NPM_TOKEN` - publish workflow for each. 2. **Legacy first-boot import** (§6.4): move into the desktop app, or drop from `ao start` and rely on `ao import`? 3. **Linux artifact form:** `.deb`/`.rpm` (install) vs **AppImage** (fetch-and-run). 4. **Draft release finalization:** `draft: false` vs a CI publish step. 5. **Signing gate:** gate the launcher on signed+notarized builds, ship against unsigned (Gatekeeper/SmartScreen warnings), or treat signing as a parallel effort meeting at release? 6. **Download integrity:** SHA256 vs HTTPS-only vs `codesign --verify`. 7. **First-run tour + `installSource`:** in-app tour now (no auto-update promise), defer tour but keep `installSource`, or neither? 8. **Website URL** for the deprecation notice copy. 9. **Module-path rename** off `aoagents` — confirm out of scope for this effort. --- ## 12. Task breakdown (for AO execution, dependency-ordered) **Batch 1 — wiring (parallel):** - **T1. Rewrite `ao start` core (Go).** Replace `start.go` daemon-spawn with `resolveApp()` + the macOS fetch/open path + deprecation notice; remove `waitForReady`/daemon logic; decide §11.2. Check: on a mac with the app present, `ao start` opens it and writes nothing; with it absent, it fetches+opens. - **T2. npm delivery of the Go binary.** Per §11.1: optionalDeps platform packages - JS `bin` shim, zero install scripts; publish workflow. **Publish to the `@theharshitsingh/ao` test scope**, not the prod package. Check: `npm i -g @theharshitsingh/ao --ignore-scripts` yields a working `ao`. - **T3. Release repo + asset wiring (override-driven).** Make the forge publisher repo + the `ao start` download repo build-time overridable (§6.3, §8); add the stable-asset rename step; finalize the draft (§11.4); add Linux to the matrix. Check: a `workflow_dispatch` **on the fork** produces a published `harshitsinghbhandari/agent-orchestrator` release whose `releases/latest/download/` 302-resolves. **No prod (AgentWrapper) release is cut during development.** **Batch 2 — app-side + macOS end-to-end (after T1):** - **T4. App-side marker + relocation** (`main.ts whenReady`, §7.1). Check: a packaged launch writes/updates `~/.ao/app-state.json` with the real bundle path. - **T5. macOS `ao start` end-to-end against the FORK release** (needs T3): build the test `ao` with `cli.releaseRepo=harshitsinghbhandari/agent-orchestrator`, install it from `@theharshitsingh/ao`, run `ao start`. Check: acceptance #3–#7 on a mac, fetching from the fork. **Batch 3 — cross-platform + integrity (after T1/T3):** - **T6. Windows path** (NSIS fetch+install+resolve, §6.3). - **T7. Linux path** (§11.3). - **T8. Download integrity** (§11.6). **Batch 4 — rollout:** - **T9.** Deprecation notice / optional tour + `installSource` (§11.7); legacy import placement (§11.2) if not done in T1. > Track B (version stamping, signing, making the updater live) is a separate > effort. Any copy added above must not promise auto-update until it lands.