# `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/<arch>/`; Windows `MakerNSIS` → `Agent Orchestrator
Setup.exe` (per-user installer); Linux `maker-deb`/`maker-rpm` →
  `agent-orchestrator-<version>.{deb,rpm}`.
- **No asset-rename step** and **`draft: true`** → a constant
  `releases/latest/download/<stable-name>` 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/<owner>/<repo>/releases/latest/download/<stable-asset>`
(302 → asset; requires non-draft release + stable names, §8). `<owner>/<repo>` 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=<owner>/<repo>` 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 <app> --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=<tag>` 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/<stable-name>` 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.
