24 KiB
ao start Bootstrapper + npm Deprecation: Implementation Spec
Status: ready for build (Track A). Grounded against the real codebase on
branchfeat/ao-start-bootstrapper(=upstream/main+ PR #2185) on 2026-06-26.
Every "current state" claim carries afile:linereference.This is NOT a new JS launcher package. The
aobinary 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 updateswaps in our new Goaobinary (the whole CLI), replacing the
old one in place. No fresh-install story; the audience is existing users.- The
ao startsubcommand 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-orchestratorwas the temporary home during the rewrite; the
code is now ported and releases land onAgentWrapper/agent-orchestrator.
The forge publisher still points ataoagentsand must be corrected (task T3).
The Go module path is alsogithub.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: tagdesktop-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
underout/make/zip/darwin/<arch>/; WindowsMakerNSIS→Agent Orchestrator Setup.exe(per-user installer); Linuxmaker-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.jsonversion: "0.0.0"; daemon
backend/internal/cli/version.go:12Version = "dev";build-daemon.mjsruns
go build ./cmd/aowith no-ldflags. No real semver anywhere.
1.4 Signing / notarization / auto-update
osxSign/osxNotarizeare 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:14imports
updateElectronAppfromupdate-electron-app;initAutoUpdates()
(main.ts:817) runs it whenapp.isPackaged. Inert today because builds are
unsigned and version is0.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); overridesAO_DATA_DIR/AO_RUN_FILE. userDatapinned to~/.ao/electron(main.ts:64, beforewhenReady; CLAUDE.md
hard rule).~/.ao/running.jsonis written by the daemon (backend/internal/runfile/runfile.go
Write, atomic temp+rename), read by the app (daemon-discovery.ts parseRunFile).
Onlyrunning.jsonexists in~/.aotoday;app-state.jsondoes not exist yet.- App startup (
main.ts:822whenReady):registerRendererProtocol()→
createWindow()→void startDaemon()→initAutoUpdates(). The app already
spawns and owns the daemon (startDaemon, spawns the bundledao 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
aobinary isbackend/cmd/ao(cmd/ao/main.go→cli.Execute()); the
same binary serves as both the CLI andao daemon.build-daemon.mjsbuilds it
tofrontend/daemon/aoand bundles it into the desktop app. - This repo has no npm-registry publish path for the
aobinary (only
electron-forge → GitHub Releases; noNPM_TOKEN, no publish workflow — research
confirmed). The old AO npm package shippedaovia 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-platformoptionalDependenciespackages (the
esbuild/turbo model: a tiny JSbinshim execs the right prebuilt binary), not
via apostinstalldownload.
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
- Releases land on
AgentWrapper/agent-orchestrator. Fix the forge publisher
to match; the download URL uses it. ao start= fetch + open the desktop app. It no longer starts the daemon;
the frontend owns the daemon. The current daemon-spawn logic instart.gois
removed.- npm ships the Go
aobinary; existing users update in place. No JS launcher
package. - Marker =
~/.ao/app-state.json, written only by the app, every launch. - Scope = Track A only (de-scope auto-update copy; Track B is separate).
- All three platforms; Windows installer is NSIS.
- Two release targets, never conflated:
- Production: GitHub
AgentWrapper/agent-orchestrator; npm = the real
package name (legacyao). 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. Allao startdownload/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.
- Production: GitHub
3. Scope
In scope (Track A):
- Rewrite the Go
ao startsubcommand:resolve → fetch → openthe 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.jsonevery launch (app is sole writer);
ownmoveToApplicationsFolder()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-appupdater
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)
- The npm package runs zero install scripts. No
preinstall/install/
postinstall, nobinding.gyp. Ship the Go binary via per-platform
optionalDependencies+ a JSbinshim, not apostinstalldownload. - Filesystem is the source of truth;
app-state.jsonis a fast-path hint.
Never trust its recorded path withoutstat-ing it. - The app is the sole writer of
app-state.json.ao startis read-only with
respect to it. This is what makes the npm and website routes converge without an
orphaned second copy. - The app owns relocation (
moveToApplicationsFolder()), and rewrites the
marker path afterward.ao startnever moves the app. ao startis dumb about versions. Decision is present-or-absent only; never
compares versions. Updating an installed app is the app's own updater's job.- Resolution order is fixed: marker path →
stat→ known-location scan →
fetch. Fetch only when both miss. - Stable, version-free release asset names so
ao startuses 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).
{
"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)
- Read
~/.ao/app-state.json; ifappPathstats as a usable bundle, return it. - 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
- macOS:
- 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 withditto -x -k(preserves the.app
signature; plain unzip corrupts it) →open <app> --args --installed-via=npm-bootstrap.
The app relocates itself to/Applicationson first launch. - Windows: the asset is an NSIS installer
.exe(not a runnable bundle).
fetchdownloads it;openruns the installer (interactive, or/Ssilent),
thenresolveApp()finds the installed exe and launches it. - Linux:
.deb/.rpmneed 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):
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'scli.releaseRepoat 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: falseor add a CI publish step; the constant
URL only resolves for a published release. .zipfor macOS unpacked withditto; 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 startopens the app through the calling shell's enriched env, so a green
ao startproves nothing about the Dock-launch path. Test the Dock path
separately.
11. Open decisions (decide before the affected task)
- 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 legacyaousers already have) still needs confirming, plus anNPM_TOKEN- publish workflow for each.
- Legacy first-boot import (§6.4): move into the desktop app, or drop from
ao startand rely onao import? - Linux artifact form:
.deb/.rpm(install) vs AppImage (fetch-and-run). - Draft release finalization:
draft: falsevs a CI publish step. - 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? - Download integrity: SHA256 vs HTTPS-only vs
codesign --verify. - First-run tour +
installSource: in-app tour now (no auto-update promise),
defer tour but keepinstallSource, or neither? - Website URL for the deprecation notice copy.
- 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 startcore (Go). Replacestart.godaemon-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 startopens it and writes nothing; with it absent, it fetches+opens. - T2. npm delivery of the Go binary. Per §11.1: optionalDeps platform packages
- JS
binshim, zero install scripts; publish workflow. Publish to the
@theharshitsingh/aotest scope, not the prod package. Check:npm i -g @theharshitsingh/ao --ignore-scriptsyields a workingao.
- JS
- T3. Release repo + asset wiring (override-driven). Make the forge publisher
repo + theao startdownload repo build-time overridable (§6.3, §8); add the
stable-asset rename step; finalize the draft (§11.4); add Linux to the matrix.
Check: aworkflow_dispatchon the fork produces a published
harshitsinghbhandari/agent-orchestratorrelease 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.jsonwith the real bundle path. - T5. macOS
ao startend-to-end against the FORK release (needs T3): build the
testaowithcli.releaseRepo=harshitsinghbhandari/agent-orchestrator, install
it from@theharshitsingh/ao, runao 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.