feat(cli): ao start fetches + opens the desktop app; deprecate npm as an app channel (Track A) (#2201)

* docs: grounded spec for ao start bootstrapper + npm deprecation

Real-codebase-verified implementation spec (Track A: launcher + app-state
marker + release asset wiring). Replaces the somthing.md draft's aspirational
assumptions with file:line ground truth: correct bundle name (Agent
Orchestrator.app), publish repo (aoagents/agent-orchestrator), draft-release
+ asset-rename gaps, unsigned-build reality, and the already-wired
update-electron-app updater. Includes a dependency-ordered task breakdown
for AO execution.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* docs: reframe spec around Go ao start subcommand (not a JS launcher)

The npm package ships the existing Go cobra CLI (backend/cmd/ao); this effort
rewrites the `ao start` subcommand to fetch+open the desktop app. Corrections:
- releases land on AgentWrapper/agent-orchestrator (aoagents was the temporary
  rewrite home; forge publisher must be repointed)
- ao start stops starting the daemon; the frontend owns the daemon
- adds the real Go CLI command surface (1.7), the npm-delivery gap for the Go
  binary (1.6), and the legacy first-boot import decision (6.4)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* docs: separate prod vs test release targets in ao start spec

Dev/test loop must never cut a production release. Download repo + forge
publisher + npm scope are now build-time overridable:
- prod: AgentWrapper/agent-orchestrator + real package name
- test: harshitsinghbhandari/agent-orchestrator (fork) + @theharshitsingh/ao
T3/T5 now release+test against the fork and the test scope, with prod cut as a
separate gated step.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* feat(cli): rewrite ao start to fetch + open the desktop app (T1)

ao start no longer starts the daemon (the desktop app owns it). It now
resolves the installed app (~/.ao/app-state.json marker -> stat -> known-
location scan), fetches the latest release zip and ditto-unpacks it on macOS
when absent, opens it with --installed-via=npm-bootstrap, and prints an honest
deprecation notice. releaseRepo is build-time overridable (-ldflags) so test
builds fetch from the fork. Windows/Linux fetch/open are stubbed for T6/T7.

Review fixes folded in: download() copies deps.HTTPClient and drops its 2s
loopback-probe timeout (a real release asset is hundreds of MB), and fetchApp
clears ~/.ao/staging before unpacking. Regression test covers the timeout.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* feat(desktop): write ~/.ao/app-state.json marker on launch (T4)

The app is the sole writer of the marker ao start reads to locate the bundle.
New frontend/src/main/app-state.ts does an atomic temp+rename write mirroring
the daemon's runfile.Write, preserving installedAt/installSource across launches
and refreshing appPath/version/lastReconciledAt. main.ts hooks it into
app.whenReady ordered relocate (macOS) -> write marker -> createWindow, both
non-fatal. Bundle path is derived from process.execPath (not app.getAppPath,
which is the asar path); JSON keys match start.go's appState reader exactly.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* ci(desktop-release): overridable repo, published release, stable asset aliases (T3)

Make the bootstrapper's constant releases/latest/download URL resolve:
- forge publisher repo reads AO_RELEASE_REPO (default AgentWrapper/agent-
  orchestrator); the workflow sets it to github.repository so a fork run
  publishes to the fork and never to prod
- draft:false so the release is immediately live (constant URL needs it)
- add ubuntu-latest to the matrix (issue #2191)
- post-publish steps upload stable space-free aliases
  (agent-orchestrator-darwin-arm64.zip, agent-orchestrator-win32-x64.exe)
  matching exactly what start.go fetches

Review fix: the alias upload targeted GITHUB_REF_NAME (the git tag), but
publisher-github creates the release as v<package.json version>; retargeted to
that and relaxed the guard so workflow_dispatch also produces aliases.

Known gaps (documented inline): macOS x64 needs an Intel runner (macos-latest is
arm64-only); the Linux stable asset name awaits the deb/rpm-vs-AppImage decision.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* chore: format with prettier [skip ci]

* feat(npm): deliver Go ao binary as @aoagents/ao via per-platform packages (T2)

Ship the ao CLI to npm with ZERO install scripts using the esbuild model:
- packages/ao: pure-JS @aoagents/ao with a bin/ao.js shim that resolves and
  execs the matching @aoagents/ao-<platform>-<arch> optionalDependency
- four platform packages (darwin-arm64/x64, win32-x64, linux-x64), each os/cpu
  gated so npm installs only the host's; binary cross-compiled CGO-free
  (modernc.org/sqlite), gitignored, shipped via files
- build-binaries.sh cross-compiles all four; releaseRepo keeps its prod default

Not added as root workspaces on purpose: os/cpu-restricted members make root
`npm ci` fail EBADPLATFORM (CI's api-drift job runs it). Packages publish
standalone; the shim was verified in a published-like layout.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* chore(npm): set @aoagents/ao packages to 0.10.0 (above existing 0.9.5)

The existing @aoagents/ao on npm is 0.9.5; the launcher must publish a higher
version so npm latest advances and existing users get the new fetch-and-open
binary on update. All five packages plus the four optionalDeps pins set to
0.10.0.

* chore: format with prettier [skip ci]

* fix(start): capture install provenance before macOS relocation

moveToApplicationsFolder() relaunches the app from /Applications without
forwarding the --installed-via arg, and code past a successful move never
runs in the staging instance. The post-move instance therefore wrote
installSource="unknown", and writeAppStateMarker's sticky logic then locked
it there, losing the npm-bootstrap provenance in the exact path it exists for.

Write the marker before relocation when --installed-via is present so the
source is persisted while the arg is still available; the post-move launch
preserves it (sticky installSource) while refreshing appPath to /Applications.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* chore: format with prettier [skip ci]

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
This commit is contained in:
Harshit Singh Bhandari 2026-06-26 19:31:56 +05:30 committed by GitHub
parent acd811dce3
commit 514946fd8b
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
21 changed files with 1688 additions and 410 deletions

View File

@ -1,18 +1,32 @@
name: Desktop release
# Builds and publishes the Electron desktop app via electron-forge.
# Generates a GitHub Release (draft) with installers + update manifests.
# Triggered by a `desktop-v*` tag or manually.
# Generates a GitHub Release with installers + update manifests, then uploads
# stable, version-free asset aliases so `ao start`'s constant
# releases/latest/download/<name> URL resolves (spec §8, §11.4).
#
# Each target OS builds on its own runner so the bundled `ao` daemon is compiled
# natively for that platform. build-daemon.mjs keys the binary off the build
# host's platform, so cross-OS packaging (e.g. building the Windows installer on
# macOS) would ship a non-Windows binary named `ao` and the app could not launch
# the daemon (issues #235/#256). The per-OS matrix keeps host == target.
# the daemon (issues #235/#256). The per-OS matrix keeps host == target, and the
# Linux daemon is built on the Linux runner (AgentWrapper/agent-orchestrator#2191).
#
# ⚠️ Until macOS code signing + notarization secrets are configured (see
# frontend/docs/desktop-release.md), published builds are UNSIGNED and will
# NOT auto-update on macOS. The workflow still produces installable artifacts.
#
# ── Cutting a FORK test release (dev loop) ────────────────────────────────────
# Test releases go to harshitsinghbhandari/agent-orchestrator (the fork), never
# to AgentWrapper. To cut one, on the FORK:
# - push a `desktop-v*` tag (e.g. `git push fork desktop-v0.0.0-test1`), or
# - run this workflow via `workflow_dispatch` from the fork's Actions tab.
# AO_RELEASE_REPO MUST be set to the fork for a test run. It is derived
# automatically below from `github.repository`, so a fork run publishes to the
# fork and a run on AgentWrapper publishes to AgentWrapper, with no edit. The
# matching test `ao` binary is built with
# `-ldflags -X ...cli.releaseRepo=harshitsinghbhandari/agent-orchestrator` so it
# fetches from the same fork (spec §6.3).
on:
push:
@ -25,7 +39,7 @@ jobs:
strategy:
fail-fast: false
matrix:
os: [macos-latest, windows-latest]
os: [macos-latest, windows-latest, ubuntu-latest]
runs-on: ${{ matrix.os }}
permissions:
contents: write
@ -50,6 +64,10 @@ jobs:
run: npm run publish
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
# Release target: the repo this run lives on. On the fork this is
# harshitsinghbhandari/agent-orchestrator; on AgentWrapper it is prod.
# forge.config.ts parses "owner/repo" into the publisher's repository.
AO_RELEASE_REPO: ${{ github.repository }}
# macOS signing + notarization — add as repository secrets and
# set osxSign/osxNotarize in forge.config.ts to enable.
CSC_LINK: ${{ secrets.CSC_LINK }}
@ -57,3 +75,57 @@ jobs:
APPLE_ID: ${{ secrets.APPLE_ID }}
APPLE_APP_SPECIFIC_PASSWORD: ${{ secrets.APPLE_APP_SPECIFIC_PASSWORD }}
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
# Upload stable, version-free asset aliases for this runner's platform so
# `ao start`'s constant releases/latest/download/<name> URL resolves. The
# forge makers emit versioned, productName-derived filenames; we copy them
# to the space-free names `ao start` fetches and upload to the v<version>
# release the publish step just created, with --clobber (so re-runs replace).
#
# Arch-coverage note: each runner only builds its host arch, so macos-latest
# (Apple silicon) produces darwin-arm64 only. The darwin-x64 alias needs an
# Intel macOS runner (e.g. macos-13) and is NOT produced here (gap, spec §8).
# The Linux stable name is undecided (spec §11.3), so we build the deb/rpm
# but intentionally upload no Linux alias yet.
- name: Upload stable asset aliases (macOS)
if: matrix.os == 'macos-latest'
shell: bash
run: |
# electron-forge publisher-github creates the release as v<package.json
# version> (PublisherGithub.js: `${tagPrefix ?? 'v'}${version}`), NOT the
# triggering git tag. Target that release, not GITHUB_REF_NAME, or the
# upload hits a non-existent release.
tag="v$(node -p "require('./package.json').version")"
arch="$(uname -m)"
case "$arch" in
arm64) alias_name="agent-orchestrator-darwin-arm64.zip" ;;
x86_64) alias_name="agent-orchestrator-darwin-x64.zip" ;;
*) echo "unsupported macOS arch: $arch" >&2; exit 1 ;;
esac
# maker-zip output: out/make/zip/darwin/<arch>/<dir-basename>-<version>.zip
src="$(ls out/make/zip/darwin/*/*.zip | head -n1)"
if [ -z "$src" ]; then echo "no macOS zip found under out/make/zip/darwin" >&2; exit 1; fi
cp "$src" "$alias_name"
gh release upload "$tag" "$alias_name" --clobber
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Upload stable asset aliases (Windows)
if: matrix.os == 'windows-latest'
shell: bash
run: |
# Forge publishes to v<version> (see the macOS step), not the git tag.
tag="v$(node -p "require('./package.json').version")"
alias_name="agent-orchestrator-win32-x64.exe"
# NSIS (electron-builder) output: out/make/<productName> Setup <version>.exe
src="$(ls "out/make/"*Setup*.exe | head -n1)"
if [ -z "$src" ]; then echo "no NSIS installer found under out/make" >&2; exit 1; fi
cp "$src" "$alias_name"
gh release upload "$tag" "$alias_name" --clobber
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
# Linux: deb/rpm build on this runner (issue #2191), but the stable asset
# name for the fetch-and-run bootstrapper is still undecided (deb/rpm vs
# AppImage, spec §11.3). No alias is uploaded until that decision lands; the
# versioned deb/rpm are still published by the forge publisher above.

View File

@ -56,7 +56,7 @@
## Features
| Feature | Description |
| :--- | :--- |
| :----------------------------- | :-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| **Agent-Agnostic Platform** | 23+ agent adapters including [Claude Code](https://code.claude.com/docs/en/overview), [OpenAI Codex](https://openai.com/), [Cursor](https://cursor.com/), [OpenCode](https://opencode.ai/), [Aider](https://aider.chat/), [Amp](https://ampcode.com/manual), [Goose](https://goose-docs.ai/), [GitHub Copilot](https://github.com/features/copilot), [Grok](https://x.ai/grok), [Qwen Code](https://github.com/QwenLM/qwen-code), [Kimi Code](https://www.kimi.com/code), [Cline](https://cline.bot/), [Continue](https://www.continue.dev/), [Kiro](https://kiro.dev/), and more |
| **Isolated Workspaces** | Each session spawns into its own git worktree with dedicated runtime |
| **Platform-Native Runtimes** | tmux on Darwin/Linux, conpty on Windows for optimal performance |
@ -80,13 +80,14 @@ Works with 23+ CLI-based coding agents including Claude Code, OpenAI Codex, Curs
### Prerequisites
| Requirement | Minimum | Recommended |
|-------------|---------|-------------|
| ----------- | ------- | ----------- |
| Go | 1.25+ | Latest |
| Node.js | 20+ | Latest LTS |
| Git | Any | Latest |
| pnpm | Any | Latest |
**Optional:**
- `tmux` (Darwin/Linux) - For Unix runtime
- `gh` (GitHub CLI) - For authenticated GitHub API calls
@ -95,7 +96,7 @@ Works with 23+ CLI-based coding agents including Claude Code, OpenAI Codex, Curs
Download the latest release for your platform:
| Platform | Download |
|----------|----------|
| ----------- | ------------------------------------------------------------------------------------------------- |
| **Windows** | [Setup.exe](https://github.com/AgentWrapper/agent-orchestrator/releases/latest) |
| **macOS** | [Agent Orchestrator.dmg](https://github.com/AgentWrapper/agent-orchestrator/releases/latest) |
| **Linux** | [Agent Orchestrator.AppImage](https://github.com/AgentWrapper/agent-orchestrator/releases/latest) |
@ -117,6 +118,7 @@ Agent Orchestrator is a long-running Go daemon built around **inbound/outbound p
**Core mental model:** OBSERVE external facts → UPDATE durable facts → DERIVE display status / ACT
**Key components:**
- **Frontend** - Electron + React UI with TanStack Router/Query and shadcn/ui
- **Backend Daemon** - Go-based HTTP server with controllers, services, and adapters
- **Runtime** - Platform-specific: `tmux` on Darwin/Linux, `conpty` on Windows
@ -130,7 +132,7 @@ For detailed architecture diagrams, data flows, and load-bearing rules, see [arc
## Documentation
| Document | Description |
|----------|-------------|
| -------------------------------------------------------- | ------------------------------------------------------- |
| [Architecture](docs/architecture.md) | System architecture, data flows, and load-bearing rules |
| [Backend Code Structure](docs/backend-code-structure.md) | Package-by-package ownership and dependency rules |
| [AGENTS.md](AGENTS.md) | Contributor and worker-agent contract |
@ -160,7 +162,7 @@ npx @redwoodjs/agent-ci run --all
All configuration is environment-driven. The daemon takes no config file.
| Variable | Default | Purpose |
|----------|---------|---------|
| --------------------- | -------------------- | --------------------------- |
| `AO_PORT` | `3001` | HTTP bind port |
| `AO_REQUEST_TIMEOUT` | `60s` | Per-request timeout |
| `AO_SHUTDOWN_TIMEOUT` | `10s` | Graceful shutdown cap |

View File

@ -135,14 +135,32 @@ func asExit(err error, target **exec.ExitError) bool {
return false
}
// startDaemon brings the daemon up and registers a stop on cleanup.
// startDaemon brings the daemon up and registers a stop on cleanup. `ao start`
// no longer spawns the daemon (the desktop app owns it now), so the e2e suite
// drives the hidden `ao daemon` command directly and polls for readiness.
func (e env) startDaemon(t *testing.T) {
t.Helper()
out, code := e.run(t, "start")
if code != 0 {
t.Fatalf("start failed (exit %d): %s", code, out)
cmd := exec.Command(aoBin, "daemon")
cmd.Env = e.environ("")
if err := cmd.Start(); err != nil {
t.Fatalf("spawn ao daemon: %v", err)
}
t.Cleanup(func() {
e.run(t, "stop")
_, _ = cmd.Process.Wait()
})
deadline := time.Now().Add(10 * time.Second)
for {
out, _ := e.run(t, "status", "--json")
if strings.Contains(out, `"state": "ready"`) {
return
}
if time.Now().After(deadline) {
t.Fatalf("daemon did not become ready within 10s; last status:\n%s", out)
}
time.Sleep(100 * time.Millisecond)
}
t.Cleanup(func() { e.run(t, "stop") })
}
func mustContain(t *testing.T, out, want string) {
@ -224,12 +242,7 @@ func TestE2E_Lifecycle(t *testing.T) {
mustContain(t, out, `"state": "ready"`)
mustContain(t, out, fmt.Sprintf(`"port": %d`, e.port))
// idempotent
if out, code := e.run(t, "start"); code != 0 || !strings.Contains(out, "ready") {
t.Fatalf("idempotent start: exit %d, out %s", code, out)
}
// now the daemon (not the CLI) has created + migrated the store
// the daemon (not the CLI) has created + migrated the store
if _, err := os.Stat(filepath.Join(e.dataDir, "ao.db")); err != nil {
t.Fatalf("daemon should have created ao.db: %v", err)
}

View File

@ -131,144 +131,6 @@ func TestStatusStoppedJSON(t *testing.T) {
}
}
func TestStartReturnsExistingReadyDaemon(t *testing.T) {
cfg := setConfigEnv(t)
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/healthz":
_, _ = fmt.Fprintf(w, `{"status":"ok","service":%q,"pid":%d}`, daemonmeta.ServiceName, os.Getpid())
case "/readyz":
_, _ = fmt.Fprintf(w, `{"status":"ready","service":%q,"pid":%d}`, daemonmeta.ServiceName, os.Getpid())
default:
http.NotFound(w, r)
}
}))
defer srv.Close()
port := serverPort(t, srv.URL)
if err := runfile.Write(cfg.runFile, runfile.Info{PID: os.Getpid(), Port: port, StartedAt: time.Unix(100, 0).UTC()}); err != nil {
t.Fatal(err)
}
var started bool
out, _, err := executeCLI(t, Deps{
ProcessAlive: func(pid int) bool { return pid == os.Getpid() },
StartProcess: func(processStartConfig) error {
started = true
return nil
},
Now: func() time.Time { return time.Unix(110, 0).UTC() },
}, "start", "--json")
if err != nil {
t.Fatal(err)
}
if started {
t.Fatal("start should not spawn when daemon is already ready")
}
if !strings.Contains(out, `"state": "ready"`) {
t.Fatalf("start did not report ready:\n%s", out)
}
}
func TestStartClearsStaleRunFileBeforeSpawning(t *testing.T) {
cfg := setConfigEnv(t)
var spawned atomic.Bool
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if !spawned.Load() {
_, _ = fmt.Fprintf(w, `{"status":"ok","service":"not-ao","pid":4242}`)
return
}
switch r.URL.Path {
case "/healthz":
_, _ = fmt.Fprintf(w, `{"status":"ok","service":%q,"pid":%d}`, daemonmeta.ServiceName, os.Getpid())
case "/readyz":
_, _ = fmt.Fprintf(w, `{"status":"ready","service":%q,"pid":%d}`, daemonmeta.ServiceName, os.Getpid())
default:
http.NotFound(w, r)
}
}))
defer srv.Close()
port := serverPort(t, srv.URL)
if err := runfile.Write(cfg.runFile, runfile.Info{PID: 4242, Port: port, StartedAt: time.Unix(100, 0).UTC()}); err != nil {
t.Fatal(err)
}
out, _, err := executeCLI(t, Deps{
ProcessAlive: func(pid int) bool { return pid == 4242 || pid == os.Getpid() },
StartProcess: func(processStartConfig) error {
info, err := runfile.Read(cfg.runFile)
if err != nil {
t.Fatal(err)
}
if info != nil {
t.Fatalf("stale run-file was not removed before spawn: %#v", info)
}
spawned.Store(true)
if err := runfile.Write(cfg.runFile, runfile.Info{PID: os.Getpid(), Port: port, StartedAt: time.Unix(110, 0).UTC()}); err != nil {
t.Fatal(err)
}
return nil
},
Now: func() time.Time { return time.Unix(120, 0).UTC() },
}, "start", "--json")
if err != nil {
t.Fatal(err)
}
if !strings.Contains(out, `"state": "ready"`) {
t.Fatalf("start did not report ready after clearing stale run-file:\n%s", out)
}
}
func TestStartEmitsCLIInvocationAfterReady(t *testing.T) {
cfg := setConfigEnv(t)
var spawned atomic.Bool
called := make(chan string, 1)
port := 3001
out, _, err := executeCLI(t, Deps{
HTTPClient: &http.Client{Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
switch req.URL.Path {
case "/healthz":
if !spawned.Load() {
return jsonResponse(http.StatusOK, `{"status":"ok","service":"not-ao","pid":4242}`), nil
}
return jsonResponse(http.StatusOK, fmt.Sprintf(`{"status":"ok","service":%q,"pid":%d}`, daemonmeta.ServiceName, os.Getpid())), nil
case "/readyz":
if !spawned.Load() {
return jsonResponse(http.StatusOK, `{"status":"not_ready","service":"not-ao","pid":4242}`), nil
}
return jsonResponse(http.StatusOK, fmt.Sprintf(`{"status":"ready","service":%q,"pid":%d}`, daemonmeta.ServiceName, os.Getpid())), nil
case "/internal/telemetry/cli-invoked":
called <- req.URL.Path
return jsonResponse(http.StatusAccepted, ""), nil
default:
return jsonResponse(http.StatusNotFound, ""), nil
}
})},
ProcessAlive: func(pid int) bool { return pid == os.Getpid() },
StartProcess: func(processStartConfig) error {
spawned.Store(true)
return runfile.Write(cfg.runFile, runfile.Info{PID: os.Getpid(), Port: port, StartedAt: time.Unix(110, 0).UTC()})
},
Now: func() time.Time { return time.Unix(120, 0).UTC() },
}, "start", "--json")
if err != nil {
t.Fatal(err)
}
if !strings.Contains(out, `"state": "ready"`) {
t.Fatalf("start did not report ready:\n%s", out)
}
select {
case path := <-called:
if path != "/internal/telemetry/cli-invoked" {
t.Fatalf("telemetry path = %q, want /internal/telemetry/cli-invoked", path)
}
default:
t.Fatal("start did not emit CLI invocation after readiness")
}
}
func TestStopRemovesStaleRunFile(t *testing.T) {
cfg := setConfigEnv(t)
if err := runfile.Write(cfg.runFile, runfile.Info{PID: 999999, Port: 3001, StartedAt: time.Unix(100, 0).UTC()}); err != nil {
@ -429,28 +291,6 @@ func TestStopRefusesUnverifiedLivePID(t *testing.T) {
}
}
func TestStartDoesNotSpawnWhenLiveProbeFails(t *testing.T) {
cfg := setConfigEnv(t)
if err := runfile.Write(cfg.runFile, runfile.Info{PID: 4242, Port: closedPort(t), StartedAt: time.Unix(100, 0).UTC()}); err != nil {
t.Fatal(err)
}
var started bool
_, _, err := executeCLI(t, Deps{
ProcessAlive: func(pid int) bool { return pid == 4242 },
StartProcess: func(processStartConfig) error {
started = true
return nil
},
}, "start", "--timeout", "1ns", "--json")
if err == nil {
t.Fatal("start should fail instead of spawning over a live unverified PID")
}
if started {
t.Fatal("start spawned while run-file PID was still alive")
}
}
type testConfig struct {
runFile string
dataDir string

View File

@ -2,196 +2,330 @@ package cli
import (
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"path/filepath"
"time"
"runtime"
"github.com/spf13/cobra"
"github.com/aoagents/agent-orchestrator/backend/internal/config"
"github.com/aoagents/agent-orchestrator/backend/internal/legacyimport"
"github.com/aoagents/agent-orchestrator/backend/internal/runfile"
"github.com/aoagents/agent-orchestrator/backend/internal/storage/sqlite"
)
const defaultStartTimeout = 10 * time.Second
// releaseRepo is the GitHub "owner/repo" that `ao start` fetches the desktop app
// from. It defaults to the production target and is overridable at build time so
// a test binary fetches from the fork without a source edit:
//
// go build -ldflags "-X github.com/aoagents/agent-orchestrator/backend/internal/cli.releaseRepo=harshitsinghbhandari/agent-orchestrator" ./cmd/ao
//
// Mirrors how version.go's Version var is stamped by release tooling.
var releaseRepo = "AgentWrapper/agent-orchestrator"
// appBundleName is the macOS bundle directory name produced by electron-forge
// (spaced, per frontend/forge.config.ts).
const appBundleName = "Agent Orchestrator.app"
// appStateFileName is the marker the desktop app writes under ~/.ao on every
// launch (spec §5). `ao start` is a read-only consumer of it.
const appStateFileName = "app-state.json"
// appState mirrors the app-written ~/.ao/app-state.json marker (spec §5). Only
// the desktop app writes it; `ao start` reads it as a fast-path hint and never
// trusts appPath without stat-ing it (invariant 2).
type appState struct {
SchemaVersion int `json:"schemaVersion"`
AppPath string `json:"appPath"`
Version string `json:"version"`
InstalledAt string `json:"installedAt"`
LastReconciledAt string `json:"lastReconciledAt"`
InstallSource string `json:"installSource"`
}
type startOptions struct {
timeout time.Duration
logFile string
json bool
}
// startResult is the JSON shape emitted with --json: what `ao start` resolved,
// whether it fetched, whether it opened, and the resulting bundle path.
type startResult struct {
Resolved bool `json:"resolved"`
Fetched bool `json:"fetched"`
Opened bool `json:"opened"`
AppPath string `json:"appPath"`
}
func newStartCommand(ctx *commandContext) *cobra.Command {
opts := startOptions{timeout: defaultStartTimeout}
opts := startOptions{}
cmd := &cobra.Command{
Use: "start",
Short: "Start the AO daemon",
Short: "Fetch (if needed) and open the Agent Orchestrator desktop app",
Long: "Fetch (if needed) and open the Agent Orchestrator desktop app.\n\n" +
"The desktop app now owns the daemon, state, and updates. `ao start` no\n" +
"longer runs a daemon: it resolves the installed app (or downloads the\n" +
"latest release), opens it, and exits.",
Args: noArgs,
RunE: func(cmd *cobra.Command, args []string) error {
st, err := ctx.startDaemon(cmd.Context(), opts)
if err != nil {
return err
}
ctx.emitCLIInvoked(cmd.Context(), cmd)
if opts.json {
return writeJSON(cmd.OutOrStdout(), st)
}
if st.State == stateReady {
_, err = fmt.Fprintf(cmd.OutOrStdout(), "AO daemon ready (pid %d, port %d)\n", st.PID, st.Port)
return err
}
return writeStatus(cmd, st)
return ctx.runStart(cmd.Context(), cmd, opts)
},
}
cmd.Flags().DurationVar(&opts.timeout, "timeout", defaultStartTimeout, "How long to wait for daemon readiness")
cmd.Flags().StringVar(&opts.logFile, "log-file", "", "Daemon log file path")
cmd.Flags().BoolVar(&opts.json, "json", false, "Output start result as JSON")
return cmd
}
func (c *commandContext) startDaemon(ctx context.Context, opts startOptions) (daemonStatus, error) {
// TODO(spec §6.4): legacy first-boot import now belongs to the desktop app; standalone `ao import` still available.
// runStart implements the spec §6.1 algorithm: resolve the installed app, fetch
// it if absent, open it, then print the deprecation notice. It never blocks or
// supervises the launched app.
func (c *commandContext) runStart(ctx context.Context, cmd *cobra.Command, opts startOptions) error {
out := cmd.OutOrStdout()
res := startResult{}
appPath, err := c.resolveApp()
if err != nil {
return err
}
res.Resolved = appPath != ""
if appPath == "" {
appPath, err = c.fetchApp(ctx)
if err != nil {
return err
}
res.Fetched = true
}
res.AppPath = appPath
opened, err := c.openApp(ctx, appPath)
if err != nil {
return err
}
res.Opened = opened
if opts.json {
return writeJSON(out, res)
}
c.printDeprecationNotice(out)
if !opened {
c.printManualOpen(out, appPath)
}
return nil
}
// resolveApp returns the path to a usable desktop bundle, or "" when none is
// found (spec §6.2). Resolution order is fixed: marker path -> stat -> known
// location scan. It never compares versions (invariant 5).
func (c *commandContext) resolveApp() (string, error) {
if p := c.markerAppPath(); p != "" && isUsableBundle(p) {
return p, nil
}
for _, p := range appScanLocations() {
if isUsableBundle(p) {
return p, nil
}
}
return "", nil
}
// appScanLocations is the known-location scan source. It is a package var so
// tests can point the scan at a temp bundle instead of real system paths.
var appScanLocations = knownAppLocations
// markerAppPath reads ~/.ao/app-state.json and returns its recorded appPath, or
// "" if the marker is missing/unreadable. It does not stat the path; callers do.
func (c *commandContext) markerAppPath() string {
dir, err := aoStateDir()
if err != nil {
return ""
}
data, err := os.ReadFile(filepath.Join(dir, appStateFileName))
if err != nil {
return ""
}
var st appState
if err := json.Unmarshal(data, &st); err != nil {
return ""
}
return st.AppPath
}
// aoStateDir resolves the canonical ~/.ao home, honoring AO_DATA_DIR exactly as
// the daemon's config does (the marker lives beside running.json under ~/.ao).
func aoStateDir() (string, error) {
cfg, err := config.Load()
if err != nil {
return daemonStatus{}, err
return "", err
}
// running.json lives directly under ~/.ao; the marker sits beside it.
return filepath.Dir(cfg.RunFilePath), nil
}
st, err := c.inspectDaemon(ctx)
if err != nil {
return daemonStatus{}, err
// knownAppLocations lists the platform's standard install paths to scan when the
// marker misses (covers website installs and stale markers, spec §6.2).
func knownAppLocations() []string {
switch runtime.GOOS {
case "darwin":
paths := []string{filepath.Join("/Applications", appBundleName)}
if home, err := os.UserHomeDir(); err == nil {
paths = append(paths, filepath.Join(home, "Applications", appBundleName))
}
if st.State == stateReady {
return st, nil
}
if st.State != stateStopped && st.State != stateStale {
ready, waitErr := c.waitForReady(ctx, opts.timeout)
if waitErr == nil {
return ready, nil
}
return daemonStatus{}, fmt.Errorf("daemon process exists but did not become ready: %w", waitErr)
}
if st.State == stateStale {
if err := runfile.Remove(cfg.RunFilePath); err != nil {
return daemonStatus{}, err
}
}
// First-boot opt-in: before launching the daemon (so the import runs with the
// store unlocked and the daemon as sole writer afterwards), offer to import a
// legacy AO install. Declining or any import failure is non-fatal — the
// daemon still starts and the user can run `ao import` later.
c.maybeFirstBootImport(ctx, cfg)
exe, err := c.deps.Executable()
if err != nil {
return daemonStatus{}, fmt.Errorf("resolve executable: %w", err)
}
logPath := opts.logFile
if logPath == "" {
logPath = filepath.Join(filepath.Dir(cfg.RunFilePath), "daemon.log")
}
if err := os.MkdirAll(filepath.Dir(logPath), 0o750); err != nil {
return daemonStatus{}, fmt.Errorf("create log dir: %w", err)
}
logFile, err := os.OpenFile(logPath, os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0o600)
if err != nil {
return daemonStatus{}, fmt.Errorf("open daemon log: %w", err)
}
defer func() { _ = logFile.Close() }()
if err := c.deps.StartProcess(processStartConfig{
Path: exe,
Args: []string{"daemon"},
Env: os.Environ(),
Stdout: logFile,
Stderr: logFile,
}); err != nil {
return daemonStatus{}, fmt.Errorf("start daemon: %w", err)
}
ready, err := c.waitForReady(ctx, opts.timeout)
if err != nil {
return daemonStatus{}, fmt.Errorf("%w; see daemon log %s", err, logPath)
}
return ready, nil
}
// maybeFirstBootImport offers to import a legacy AO install the first time the
// daemon is started against an empty rewrite database. It is best-effort: every
// failure path degrades to "start the daemon fresh" so a broken or absent legacy
// store can never block startup. A non-interactive boot (Electron/headless)
// never auto-imports; it prints a one-line hint to run `ao import` explicitly.
func (c *commandContext) maybeFirstBootImport(ctx context.Context, cfg config.Config) {
root := legacyimport.DefaultLegacyRootDir()
if !legacyimport.HasLegacyData(root) {
return
}
store, err := sqlite.Open(cfg.DataDir)
if err != nil {
return // the daemon will surface a real store error on its own open
}
defer func() { _ = store.Close() }()
projects, err := store.ListProjects(ctx)
if err != nil || len(projects) > 0 {
// Already imported (or populated) — don't offer again.
return
}
out := c.deps.Out
if !stdinIsInteractive(c.deps.In) {
_, _ = fmt.Fprintf(out, "Found existing AO projects at %s. Run `ao import` to bring them in.\n", root)
return
}
ok, err := confirm(c.deps.In, out, "Found existing AO projects and sessions. Import them now?", true)
if err != nil || !ok {
_, _ = fmt.Fprintln(out, "Continuing fresh. Run `ao import` later to bring in your existing data.")
return
}
rep, err := legacyimport.Run(ctx, store, legacyimport.Options{Root: root, DataDir: cfg.DataDir})
if err != nil {
_, _ = fmt.Fprintf(out, "Import failed: %v\nContinuing fresh; legacy data is untouched. Retry with `ao import`.\n", err)
return
}
_ = writeImportSummary(out, rep)
}
func (c *commandContext) waitForReady(ctx context.Context, timeout time.Duration) (daemonStatus, error) {
if timeout <= 0 {
timeout = defaultStartTimeout
}
deadline := c.deps.Now().Add(timeout)
var last daemonStatus
var lastErr error
for {
select {
case <-ctx.Done():
return daemonStatus{}, ctx.Err()
return paths
default:
// Windows/Linux scan locations land in T6/T7.
return nil
}
}
st, err := c.inspectDaemon(ctx)
// isUsableBundle reports whether p stats as a usable app bundle. On macOS a
// bundle is a directory; the filesystem is the source of truth (invariant 2).
func isUsableBundle(p string) bool {
if p == "" {
return false
}
info, err := os.Stat(p)
if err != nil {
lastErr = err
} else {
last = st
if st.State == stateReady {
return st, nil
return false
}
if runtime.GOOS == "darwin" {
return info.IsDir()
}
return true
}
// fetchApp downloads the latest desktop release for this platform, unpacks it
// into a staging dir under ~/.ao/staging, and returns the bundle path (spec
// §6.3). Windows/Linux are tracked as T6/T7.
func (c *commandContext) fetchApp(ctx context.Context) (string, error) {
if runtime.GOOS != "darwin" {
return "", fmt.Errorf("ao start: fetch not yet implemented for %s (tracked as spec T6/T7)", runtime.GOOS)
}
asset, err := darwinAssetName()
if err != nil {
return "", err
}
url := downloadURL(asset)
stateDir, err := aoStateDir()
if err != nil {
return "", err
}
staging := filepath.Join(stateDir, "staging")
// Clear any stale or partial prior unpack so ditto extracts into a clean dir
// (a leftover bundle could otherwise merge with the new one).
if err := os.RemoveAll(staging); err != nil {
return "", fmt.Errorf("clear staging dir: %w", err)
}
if err := os.MkdirAll(staging, 0o750); err != nil {
return "", fmt.Errorf("create staging dir: %w", err)
}
zipPath := filepath.Join(staging, asset)
if err := c.download(ctx, url, zipPath); err != nil {
return "", fmt.Errorf("download %s: %w", url, err)
}
// ditto preserves the .app code signature; plain unzip corrupts it (spec §6.3).
if out, err := c.deps.CommandOutput(ctx, "ditto", "-x", "-k", zipPath, staging); err != nil {
return "", fmt.Errorf("ditto unpack: %w: %s", err, out)
}
appPath := filepath.Join(staging, appBundleName)
if !isUsableBundle(appPath) {
return "", fmt.Errorf("ao start: %s not found in unpacked release at %s", appBundleName, staging)
}
return appPath, nil
}
// download streams url to dst using the injected HTTP client.
func (c *commandContext) download(ctx context.Context, url, dst string) error {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
return err
}
// deps.HTTPClient carries a short (2s) timeout sized for loopback daemon
// probes; a release asset is hundreds of MB. Copy the client and drop the
// timeout, relying on ctx for cancellation. The Transport is preserved so
// tests still reach their httptest server.
client := *c.deps.HTTPClient
client.Timeout = 0
resp, err := client.Do(req)
if err != nil {
return err
}
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("unexpected status %s", resp.Status)
}
f, err := os.OpenFile(dst, os.O_CREATE|os.O_TRUNC|os.O_WRONLY, 0o600)
if err != nil {
return err
}
defer func() { _ = f.Close() }()
if _, err := io.Copy(f, resp.Body); err != nil {
return err
}
return f.Close()
}
// darwinAssetName maps Go's runtime.GOARCH to the release asset name. The
// release pipeline publishes "x64" for amd64 (spec §6.3, §8).
func darwinAssetName() (string, error) {
arch, err := assetArch(runtime.GOARCH)
if err != nil {
return "", err
}
return fmt.Sprintf("agent-orchestrator-darwin-%s.zip", arch), nil
}
// assetArch maps a Go GOARCH to the release-asset arch token.
func assetArch(goarch string) (string, error) {
switch goarch {
case "arm64":
return "arm64", nil
case "amd64":
return "x64", nil
default:
return "", fmt.Errorf("ao start: unsupported architecture %q", goarch)
}
}
if !c.deps.Now().Before(deadline) {
if lastErr != nil {
return daemonStatus{}, fmt.Errorf("daemon did not become ready within %s: %w", timeout, lastErr)
// downloadURL builds the constant releases/latest/download URL for asset.
func downloadURL(asset string) string {
return fmt.Sprintf("https://github.com/%s/releases/latest/download/%s", releaseRepo, asset)
}
return daemonStatus{}, fmt.Errorf("daemon did not become ready within %s (last state: %s)", timeout, last.State)
// openApp launches the resolved bundle detached and reports whether it launched
// (spec §6.5). It passes --installed-via=npm-bootstrap so the app can record the
// install source in its marker. It never waits on the app.
func (c *commandContext) openApp(ctx context.Context, appPath string) (bool, error) {
if runtime.GOOS != "darwin" {
// Non-darwin open lands in T6/T7; treat as "not opened" so the caller
// prints manual-open instructions.
return false, nil
}
c.deps.Sleep(100 * time.Millisecond)
// `open` returns immediately; --args forwards the rest to the app.
if out, err := c.deps.CommandOutput(ctx, "open", appPath, "--args", "--installed-via=npm-bootstrap"); err != nil {
return false, fmt.Errorf("open %s: %w: %s", appPath, err, out)
}
return true, nil
}
// printDeprecationNotice explains the new role of the npm `ao` binary. Keep it
// honest: Track B (live auto-update) is not done, so it does not promise it.
func (c *commandContext) printDeprecationNotice(w io.Writer) {
_, _ = fmt.Fprint(w, "Agent Orchestrator is now a desktop app, and the npm `ao` is just its launcher.\n"+
"The app is distributed from the website and GitHub Releases; it owns the daemon and updates itself.\n"+
"You can keep running `ao start` to fetch (if needed) and open it.\n")
}
// printManualOpen tells the user how to open the bundle when `ao start` could
// not launch it for them (non-darwin, or a failed launch handled upstream).
func (c *commandContext) printManualOpen(w io.Writer, appPath string) {
_, _ = fmt.Fprintf(w, "Could not open the app automatically. Open it manually: %s\n", appPath)
}

View File

@ -0,0 +1,197 @@
package cli
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"reflect"
"runtime"
"testing"
"time"
)
// writeMarker writes a ~/.ao/app-state.json marker pointing at appPath into the
// configured state dir (AO_RUN_FILE's directory).
func writeMarker(t *testing.T, cfg testConfig, appPath string) {
t.Helper()
st := appState{SchemaVersion: 1, AppPath: appPath, InstallSource: "npm-bootstrap"}
data, err := json.Marshal(st)
if err != nil {
t.Fatal(err)
}
dir := filepath.Dir(cfg.runFile)
if err := os.MkdirAll(dir, 0o750); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(dir, appStateFileName), data, 0o600); err != nil {
t.Fatal(err)
}
}
// makeBundle creates a directory that stats as a usable bundle on every OS.
func makeBundle(t *testing.T, name string) string {
t.Helper()
p := filepath.Join(t.TempDir(), name)
if err := os.MkdirAll(p, 0o750); err != nil {
t.Fatal(err)
}
return p
}
func TestResolveApp_MarkerHit(t *testing.T) {
cfg := setConfigEnv(t)
bundle := makeBundle(t, appBundleName)
writeMarker(t, cfg, bundle)
// No scan locations: a hit must come from the marker.
t.Cleanup(swapScanLocations(func() []string { return nil }))
c := &commandContext{deps: Deps{}.withDefaults()}
got, err := c.resolveApp()
if err != nil {
t.Fatal(err)
}
if got != bundle {
t.Fatalf("resolveApp = %q, want marker path %q", got, bundle)
}
}
func TestResolveApp_MarkerMissThenScanHit(t *testing.T) {
cfg := setConfigEnv(t)
// Marker points at a path that does not exist -> must fall through to scan.
writeMarker(t, cfg, filepath.Join(t.TempDir(), "gone", appBundleName))
scanBundle := makeBundle(t, appBundleName)
t.Cleanup(swapScanLocations(func() []string { return []string{scanBundle} }))
c := &commandContext{deps: Deps{}.withDefaults()}
got, err := c.resolveApp()
if err != nil {
t.Fatal(err)
}
if got != scanBundle {
t.Fatalf("resolveApp = %q, want scan path %q", got, scanBundle)
}
}
func TestResolveApp_ScanMissReturnsEmpty(t *testing.T) {
setConfigEnv(t) // no marker written
t.Cleanup(swapScanLocations(func() []string {
return []string{filepath.Join(t.TempDir(), "nope", appBundleName)}
}))
c := &commandContext{deps: Deps{}.withDefaults()}
got, err := c.resolveApp()
if err != nil {
t.Fatal(err)
}
if got != "" {
t.Fatalf("resolveApp = %q, want empty", got)
}
}
func TestAssetArchMapping(t *testing.T) {
cases := map[string]struct {
want string
wantErr bool
}{
"arm64": {want: "arm64"},
"amd64": {want: "x64"},
"386": {wantErr: true},
}
for goarch, tc := range cases {
got, err := assetArch(goarch)
if tc.wantErr {
if err == nil {
t.Errorf("assetArch(%q) = %q, want error", goarch, got)
}
continue
}
if err != nil {
t.Errorf("assetArch(%q): unexpected error %v", goarch, err)
}
if got != tc.want {
t.Errorf("assetArch(%q) = %q, want %q", goarch, got, tc.want)
}
}
}
func TestDownloadURLUsesReleaseRepo(t *testing.T) {
orig := releaseRepo
releaseRepo = "owner/repo"
t.Cleanup(func() { releaseRepo = orig })
got := downloadURL("agent-orchestrator-darwin-arm64.zip")
want := "https://github.com/owner/repo/releases/latest/download/agent-orchestrator-darwin-arm64.zip"
if got != want {
t.Fatalf("downloadURL = %q, want %q", got, want)
}
}
func TestOpenApp_ArgConstruction(t *testing.T) {
if runtime.GOOS != "darwin" {
t.Skip("openApp launches via `open` only on darwin")
}
var gotName string
var gotArgs []string
c := &commandContext{deps: Deps{
CommandOutput: func(_ context.Context, name string, args ...string) ([]byte, error) {
gotName = name
gotArgs = args
return nil, nil
},
}.withDefaults()}
opened, err := c.openApp(context.Background(), "/Applications/Agent Orchestrator.app")
if err != nil {
t.Fatal(err)
}
if !opened {
t.Fatal("openApp reported not opened")
}
if gotName != "open" {
t.Fatalf("command = %q, want open", gotName)
}
wantArgs := []string{"/Applications/Agent Orchestrator.app", "--args", "--installed-via=npm-bootstrap"}
if !reflect.DeepEqual(gotArgs, wantArgs) {
t.Fatalf("args = %v, want %v", gotArgs, wantArgs)
}
}
// TestDownload_IgnoresShortClientTimeout proves download() does not inherit the
// 2s deps.HTTPClient timeout (sized for loopback probes), which would otherwise
// fail every real release download. The server responds after a delay that
// exceeds the injected client's tiny timeout; download must still succeed.
func TestDownload_IgnoresShortClientTimeout(t *testing.T) {
const body = "release-zip-bytes"
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
time.Sleep(150 * time.Millisecond)
_, _ = w.Write([]byte(body))
}))
t.Cleanup(srv.Close)
c := &commandContext{deps: Deps{
// 50ms timeout: if download honored this, the 150ms server would fail it.
HTTPClient: &http.Client{Timeout: 50 * time.Millisecond},
}.withDefaults()}
dst := filepath.Join(t.TempDir(), "out.zip")
if err := c.download(context.Background(), srv.URL, dst); err != nil {
t.Fatalf("download failed (short client timeout leaked into large-asset path?): %v", err)
}
got, err := os.ReadFile(dst)
if err != nil {
t.Fatal(err)
}
if string(got) != body {
t.Fatalf("downloaded %q, want %q", got, body)
}
}
// swapScanLocations replaces the scan-location seam and returns a restore func.
func swapScanLocations(fn func() []string) func() {
orig := appScanLocations
appScanLocations = fn
return func() { appScanLocations = orig }
}

View File

@ -118,6 +118,7 @@ flowchart LR
```
**Input (`LaunchConfig`):**
- `SessionID` — AO session identifier
- `ProjectID` — AO project identifier
- `Prompt` — User prompt to send
@ -159,6 +160,7 @@ flowchart TD
```
**Requirements:**
- Preserve all user hooks
- Deduplicate AO hook entries
- Make AO hooks machine-portable (use `ao hooks ...` command)
@ -169,11 +171,13 @@ flowchart TD
Builds a command to resume an existing session:
**Input (`RestoreConfig`):**
- `Session` — Complete session record with metadata
- `Permissions` — Permission mode to apply
- `SystemPrompt` — System prompt to re-apply
**Output:**
- `cmd` — Restore command argv
- `ok` — True if restore is supported
- `err` — Error if restore fails
@ -243,7 +247,7 @@ sequenceDiagram
AO supports hooks for these agent events:
| Event | Purpose | Activity State |
|-------|---------|----------------|
| ------------------ | --------------------- | ------------------ |
| `SessionStart` | Session initialized | - |
| `UserPromptSubmit` | User submitted prompt | `active` |
| `AssistantMessage` | Assistant response | - |
@ -288,6 +292,7 @@ flowchart TD
```
**Critical rules:**
1. **Always exit 0** — Hook failure must never break the user's agent
2. **Log failures** — Append to `hooks.log` under `AO_DATA_DIR`
3. **Best-effort delivery** — The daemon may be temporarily unavailable
@ -798,7 +803,7 @@ backend/internal/adapters/agent/
### Adapter Capabilities
| Agent | Permissions | System Prompt | Restore | Hooks |
|-------|-------------|---------------|---------|-------|
| ----------- | ----------- | ------------- | ------- | ----------------- |
| Claude Code | ✓ | ✓ | ✓ | ✓ |
| Codex | ✓ | ✓ | ✓ | ✓ (session flags) |
| Cursor | ✓ | ✓ | ✗ | ✗ |

View File

@ -0,0 +1,450 @@
# `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.

View File

@ -134,12 +134,14 @@ graph TD
```
**Belongs here:**
- Shared IDs: `ProjectID`, `SessionID`, `IssueID`
- Enums and status vocabulary
- Durable fact records used across packages
- PR, tracker, project, session vocabulary
**Does NOT belong here:**
- HTTP request/response DTOs
- CLI output shapes
- OpenAPI wrapper types
@ -169,11 +171,13 @@ graph LR
```
**Belongs here:**
- Interfaces consumed by core packages, implemented by adapters
- Capability structs: `RuntimeConfig`, `WorkspaceConfig`, `SpawnConfig`
- Vocabulary at the boundary between core and adapters
**Does NOT belong here:**
- Resource read models (belongs in `service/*`)
- HTTP request/response DTOs (belongs in `httpd`)
- sqlc rows (belongs in `storage/sqlite`)
@ -182,7 +186,7 @@ graph LR
**Key Port Interfaces:**
| Port | Purpose | Implementations |
|------|---------|-----------------|
| ---------------- | ----------------------- | ----------------------- |
| `Runtime` | Process isolation | `tmux`, `conpty` |
| `Workspace` | Git worktree management | `gitworktree` |
| `Agent` | Agent launching | 23+ agent adapters |
@ -243,6 +247,7 @@ graph LR
```
**Belongs here:**
- Resource use cases called by HTTP controllers and CLI
- Resource read models and command/result types
- Display-model assembly (e.g., session status derivation)
@ -250,6 +255,7 @@ graph LR
- Small store interfaces consumed by the service
**Does NOT belong here:**
- Low-level runtime/workspace/agent process control
- Raw sqlc generated rows as public results
- HTTP routing, path parsing, status-code decisions
@ -284,12 +290,14 @@ graph TD
```
**Belongs here:**
- Multi-step session mutations with rollback
- Resource sequencing (workspace → runtime → agent)
- Resource teardown safety and cleanup
- Internal errors: not found, terminated, not restorable
**Does NOT belong here:**
- HTTP request decoding
- CLI formatting
- Controller-facing list/get read-model assembly
@ -340,11 +348,13 @@ graph LR
```
**Belongs here:**
- Updates to lifecycle-owned session facts
- Guardrails around runtime/activity observations
- Lifecycle-triggered agent nudges for actionable PR facts
**Does NOT belong here:**
- Display status persistence (use service layer instead)
- HTTP/CLI DTOs
- Direct adapter implementation details
@ -385,15 +395,18 @@ graph TD
```
**Current observation packages:**
- `internal/observe/scm` — SCM (GitHub) observer loop
- `internal/observe/reaper` — Runtime liveness observation loop
**Belongs here:**
- Polling loops and observation logic
- External state transformation into domain facts
- Observation error handling and retry logic
**Does NOT belong here:**
- Product workflow decisions (belongs in service layer)
- Direct storage writes (use lifecycle instead)
@ -420,6 +433,7 @@ graph TD
```
**Belongs here:**
- Connection setup and PRAGMAs
- Goose migrations
- sqlc queries and generated code
@ -427,6 +441,7 @@ graph TD
- Transactions and CDC-triggered persistence behavior
**Does NOT belong here:**
- HTTP response types
- CLI output formatting
- Product display status rules
@ -454,11 +469,13 @@ graph LR
```
**Belongs here:**
- Event type definitions for the CDC stream
- Poller and broadcaster logic
- Subscriber fan-out behavior
**Does NOT belong here:**
- Terminal byte streams (belongs in `internal/terminal`)
- Product workflow decisions (belongs in service layer)
- Database schema ownership (belongs in `storage/sqlite`)
@ -485,11 +502,13 @@ graph TD
```
**Belongs here:**
- Per-client attachment lifecycle
- Input/output framing independent of HTTP
- PTY-backed attach handling and terminal protocol tests
**Does NOT belong here:**
- HTTP-specific concerns (belongs in `httpd`)
- HTTP routing or WebSocket upgrade logic
@ -520,6 +539,7 @@ graph TD
```
**Belongs here:**
- Routing and middleware
- HTTP request decoding and response encoding
- Path/query parameter handling
@ -529,6 +549,7 @@ graph TD
- WebSocket upgrade handling for terminal mux
**Does NOT belong here:**
- Direct adapter or SQLite store access
- Application read models shared with CLI (belongs in `service/*`)
@ -556,12 +577,14 @@ graph LR
```
**Belongs here:**
- Daemon discovery
- HTTP API calls
- Command output formatting
- Process control: start/stop/status/doctor
**Does NOT belong here:**
- Duplicate daemon business logic (put in daemon service/API)
- Direct storage, runtime, or adapter access
@ -592,12 +615,14 @@ graph TD
```
**Adapter principles:**
- Adapters are leaves in the import graph
- Adapters translate external behavior into AO ports/domain concepts
- Adapters should not own product workflows
- All adapter-written files must be gitignored
**Good dependencies:**
```
session_manager → ports.Runtime
adapters/runtime/tmux → ports + domain
@ -606,6 +631,7 @@ daemon → adapters + services + storage
```
**Avoid:**
```
domain → adapters
service/session → adapters/runtime/tmux
@ -635,14 +661,16 @@ graph TD
```
**Belongs here:**
- Production dependency construction
- Adapter registration
- Startup/shutdown sequencing
- Cross-component wiring
**Does NOT belong here:**
- Business logic (belongs in service, lifecycle, or manager packages)
- Adapter implementation details (belongs in adapters/*)
- Adapter implementation details (belongs in adapters/\*)
---
@ -669,6 +697,7 @@ graph LR
```
**Key environment variables:**
- `AO_PORT` — HTTP bind port (default: 3001)
- `AO_REQUEST_TIMEOUT` — Per-request timeout (default: 60s)
- `AO_SHUTDOWN_TIMEOUT` — Graceful shutdown cap (default: 10s)
@ -769,6 +798,7 @@ graph TD
```
**Key patterns:**
- All arrows point downward (no cycles)
- Adapters and domain are leaves
- CLI and HTTPD don't touch storage directly
@ -790,6 +820,7 @@ flowchart LR
```
**Steps:**
1. Add controller in `httpd/controllers/`
2. Call a `service/*` package
3. Update OpenAPI generation
@ -808,6 +839,7 @@ flowchart TD
```
**Steps:**
1. Add shared IDs/vocabulary to `domain`
2. Create use cases in `service/<resource>`
3. Add storage in `storage/sqlite`
@ -828,6 +860,7 @@ flowchart LR
```
**Steps:**
1. Implement a `ports` interface under `adapters/<capability>/<impl>`
2. For agents: implement hooks with gitignored files
3. Wire in `daemon`
@ -929,12 +962,14 @@ func (s *Service) Create(ctx context.Context, cfg Config) (MyResource, error) {
6. **Daemon** wires it all — composition root
**Always ask:**
- Does this belong in domain (shared concept)?
- Does this belong in ports (shared capability)?
- Does this belong in service (use case)?
- Does this belong in adapters (external system)?
**Never:**
- Put HTTP types in domain
- Put display status in storage
- Put business logic in CLI

View File

@ -2,6 +2,22 @@ import type { ForgeConfig } from "@electron-forge/shared-types";
import { VitePlugin } from "@electron-forge/plugin-vite";
import MakerNSIS from "./makers/maker-nsis";
// Default GitHub release target (production). aoagents was the temporary rewrite
// home; releases land on AgentWrapper (spec §1.1).
const DEFAULT_RELEASE_REPO = "AgentWrapper/agent-orchestrator";
// parseReleaseRepo turns an "owner/repo" string (from AO_RELEASE_REPO) into the
// publisher-github { owner, name } shape, falling back to the production default
// when unset or malformed.
function parseReleaseRepo(value: string | undefined): { owner: string; name: string } {
const [owner, name] = (value || DEFAULT_RELEASE_REPO).split("/");
if (!owner || !name) {
const [defOwner, defName] = DEFAULT_RELEASE_REPO.split("/");
return { owner: defOwner, name: defName };
}
return { owner, name };
}
const config: ForgeConfig = {
packagerConfig: {
asar: true,
@ -82,10 +98,22 @@ const config: ForgeConfig = {
publishers: [
{
name: "@electron-forge/publisher-github",
// Release target is build-time overridable so a fork run publishes to the
// fork without a source edit. AO_RELEASE_REPO is "owner/repo"; it defaults
// to the production target. The dev/test loop sets
// AO_RELEASE_REPO=harshitsinghbhandari/agent-orchestrator (spec §1.1, §8).
// Note: aoagents/agent-orchestrator was the temporary rewrite home and is
// intentionally NOT the default; releases land on AgentWrapper.
config: {
repository: { owner: "aoagents", name: "agent-orchestrator" },
repository: parseReleaseRepo(process.env.AO_RELEASE_REPO),
prerelease: false,
draft: true,
// draft:false so the release is immediately live, which is what the
// `ao start` bootstrapper's constant
// releases/latest/download/<asset> URL needs to 302-resolve. A draft
// release 404s on that URL (spec §2.7, §8, §11.4). Prod may later
// switch to a draft + manual-finalize flow; for now the bootstrapper
// needs a published release.
draft: false,
},
},
],

View File

@ -35,6 +35,7 @@ import { buildTelemetryBootstrap } from "./shared/telemetry";
import { createBrowserViewHost, type BrowserViewHost } from "./main/browser-view-host";
import { connectSupervisor, type SupervisorLinkHandle } from "./main/supervisor-link";
import { shouldLinkOnAttach } from "./main/daemon-owner";
import { writeAppStateMarker } from "./main/app-state";
// Globals injected at compile time by @electron-forge/plugin-vite.
declare const MAIN_WINDOW_VITE_DEV_SERVER_URL: string | undefined;
@ -819,7 +820,86 @@ function initAutoUpdates(): void {
updateElectronApp();
}
app.whenReady().then(() => {
// Resolve the bundle path `ao start` will later `open` and stat as a usable app.
// On macOS process.execPath is .../Agent Orchestrator.app/Contents/MacOS/<exe>;
// the thing `ao start` opens is the enclosing `.app` directory, so walk up three
// levels (MacOS -> Contents -> .app). app.getAppPath() is WRONG here: it returns
// the app.asar archive path inside the bundle, not the bundle itself.
// On win32/linux there is no .app wrapper, so record execPath; a richer
// resolveApp() for those platforms lands in T6/T7.
function resolveBundlePath(): string {
if (process.platform === "darwin") {
return path.resolve(process.execPath, "..", "..", "..");
}
return process.execPath;
}
// `ao start` opens the app with `--installed-via=<value>` so the app can record
// how it arrived on first marker creation. Parse it out of argv; absent => the
// marker defaults installSource to "unknown".
function parseInstalledVia(argv: string[]): string | undefined {
const flag = argv.find((a) => a.startsWith("--installed-via="));
return flag ? flag.slice("--installed-via=".length) : undefined;
}
// Write ~/.ao/app-state.json so `ao start`'s resolveApp() can find this bundle
// (spec §7.1). The app is the sole writer (invariant 3) and writes every launch.
// A failure here must NOT block startup, so the caller wraps this in try/catch;
// we still surface it via the log.
async function writeAppStateOnLaunch(): Promise<void> {
// Reuse the same ~/.ao resolution as running.json; the marker lives beside it
// (the Go side computes its dir as dirname(RunFilePath)). runFilePath() returns
// null only when the home dir is unresolvable, in which case we cannot place
// the marker; the caller's try/catch logs it.
const runFile = runFilePath();
if (!runFile) {
throw new Error("cannot resolve ~/.ao run-file path; skipping app-state marker");
}
const stateDir = path.dirname(runFile);
await writeAppStateMarker({
stateDir,
appPath: resolveBundlePath(),
version: app.getVersion(),
installedVia: parseInstalledVia(process.argv),
now: () => new Date(),
});
}
app.whenReady().then(async () => {
// Capture install provenance BEFORE relocation. moveToApplicationsFolder()
// relaunches from /Applications WITHOUT forwarding our --installed-via arg, and
// code past a successful move never runs in this instance, so a post-move-only
// write would record installSource="unknown" and the sticky logic in
// writeAppStateMarker would then lock it there forever. Writing now (only when
// the arg is present, i.e. the npm-bootstrap launch) persists the source so the
// post-move instance preserves it while refreshing appPath to /Applications.
if (parseInstalledVia(process.argv)) {
try {
await writeAppStateOnLaunch();
} catch (err) {
console.error("failed to write pre-relocation app-state marker:", err);
}
}
if (process.platform === "darwin" && app.isPackaged) {
try {
// On success this restarts the app from /Applications, so code past
// here only runs when no move happened (already there, or declined).
app.moveToApplicationsFolder();
} catch (err) {
console.error("relocation to Applications failed:", err);
}
}
// Refresh the marker post-relocation so appPath records the final bundle path;
// the sticky installSource preserves the value captured above. A marker-write
// failure is non-fatal: log and continue so the app still boots.
try {
await writeAppStateOnLaunch();
} catch (err) {
console.error("failed to write app-state marker:", err);
}
registerRendererProtocol();
createWindow();
void startDaemon();

View File

@ -0,0 +1,128 @@
// @vitest-environment node
import { describe, it, expect, beforeEach, afterEach } from "vitest";
import { mkdtemp, readFile, readdir, rm } from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { APP_STATE_FILE_NAME, writeAppStateMarker, type AppStateMarker } from "./app-state";
// The exact key set the Go reader (start.go `appState`) unmarshals.
const GO_READER_KEYS = ["schemaVersion", "appPath", "version", "installedAt", "lastReconciledAt", "installSource"];
async function readMarker(dir: string): Promise<AppStateMarker> {
const raw = await readFile(path.join(dir, APP_STATE_FILE_NAME), "utf8");
return JSON.parse(raw) as AppStateMarker;
}
describe("writeAppStateMarker", () => {
let dir: string;
beforeEach(async () => {
dir = await mkdtemp(path.join(os.tmpdir(), "ao-app-state-"));
});
afterEach(async () => {
await rm(dir, { recursive: true, force: true });
});
it("first write sets installedAt + installSource from installedVia", async () => {
const t = new Date("2026-06-26T10:00:00.000Z");
await writeAppStateMarker({
stateDir: dir,
appPath: "/Applications/Agent Orchestrator.app",
version: "0.0.0",
installedVia: "npm-bootstrap",
now: () => t,
});
const m = await readMarker(dir);
expect(m.schemaVersion).toBe(1);
expect(m.appPath).toBe("/Applications/Agent Orchestrator.app");
expect(m.version).toBe("0.0.0");
expect(m.installedAt).toBe("2026-06-26T10:00:00.000Z");
expect(m.lastReconciledAt).toBe("2026-06-26T10:00:00.000Z");
expect(m.installSource).toBe("npm-bootstrap");
});
it("second write PRESERVES installedAt/installSource and updates appPath/version/lastReconciledAt", async () => {
await writeAppStateMarker({
stateDir: dir,
appPath: "/tmp/staging/Agent Orchestrator.app",
version: "0.0.0",
installedVia: "npm-bootstrap",
now: () => new Date("2026-06-26T10:00:00.000Z"),
});
// Second launch: app relocated, version bumped, different install arg.
await writeAppStateMarker({
stateDir: dir,
appPath: "/Applications/Agent Orchestrator.app",
version: "1.2.3",
installedVia: "github",
now: () => new Date("2026-06-26T11:30:00.000Z"),
});
const m = await readMarker(dir);
// Preserved from first creation.
expect(m.installedAt).toBe("2026-06-26T10:00:00.000Z");
expect(m.installSource).toBe("npm-bootstrap");
// Refreshed.
expect(m.appPath).toBe("/Applications/Agent Orchestrator.app");
expect(m.version).toBe("1.2.3");
expect(m.lastReconciledAt).toBe("2026-06-26T11:30:00.000Z");
});
it("written JSON keys exactly match the Go reader struct", async () => {
await writeAppStateMarker({
stateDir: dir,
appPath: "/Applications/Agent Orchestrator.app",
version: "0.0.0",
installedVia: "npm-bootstrap",
now: () => new Date("2026-06-26T10:00:00.000Z"),
});
const raw = await readFile(path.join(dir, APP_STATE_FILE_NAME), "utf8");
const keys = Object.keys(JSON.parse(raw) as Record<string, unknown>);
expect(keys.sort()).toEqual([...GO_READER_KEYS].sort());
// Trailing newline, mirroring runfile.Write.
expect(raw.endsWith("}\n")).toBe(true);
});
it("installedVia undefined => installSource 'unknown'", async () => {
await writeAppStateMarker({
stateDir: dir,
appPath: "/Applications/Agent Orchestrator.app",
version: "0.0.0",
now: () => new Date("2026-06-26T10:00:00.000Z"),
});
const m = await readMarker(dir);
expect(m.installSource).toBe("unknown");
});
it("atomic write leaves no temp file behind", async () => {
await writeAppStateMarker({
stateDir: dir,
appPath: "/Applications/Agent Orchestrator.app",
version: "0.0.0",
installedVia: "npm-bootstrap",
now: () => new Date("2026-06-26T10:00:00.000Z"),
});
const entries = await readdir(dir);
expect(entries).toEqual([APP_STATE_FILE_NAME]);
expect(entries.some((e) => e.startsWith(".app-state-"))).toBe(false);
});
it("creates the state dir when it does not exist", async () => {
const nested = path.join(dir, "does", "not", "exist");
await writeAppStateMarker({
stateDir: nested,
appPath: "/Applications/Agent Orchestrator.app",
version: "0.0.0",
now: () => new Date("2026-06-26T10:00:00.000Z"),
});
const m = await readMarker(nested);
expect(m.appPath).toBe("/Applications/Agent Orchestrator.app");
});
});

View File

@ -0,0 +1,99 @@
import { mkdir, readFile, rename, writeFile } from "node:fs/promises";
import path from "node:path";
/**
* The marker the desktop app writes under ~/.ao on every launch (spec §5).
* It is the fast-path hint `ao start` reads to locate the installed bundle.
* The Go reader is backend/internal/cli/start.go `appState`; the JSON keys
* below MUST match its struct tags exactly (camelCase).
*/
export interface AppStateMarker {
schemaVersion: number;
appPath: string;
version: string;
installedAt: string;
lastReconciledAt: string;
installSource: string;
}
/** Current marker format version (spec §5, schemaVersion field). */
const SCHEMA_VERSION = 1;
/** File name of the marker under the ~/.ao state dir. */
export const APP_STATE_FILE_NAME = "app-state.json";
export interface WriteAppStateOptions {
/** Directory the marker lives in (dirname of running.json, i.e. ~/.ao). */
stateDir: string;
/** Bundle path as of this launch (the macOS .app, or the platform exe). */
appPath: string;
/** app.getVersion(). */
version: string;
/**
* How the app was installed, captured ONLY on first marker creation from
* `ao start`'s --installed-via arg. Subsequent launches preserve the value
* already on disk. Defaults to "unknown" when absent on first creation.
*/
installedVia?: string;
/** Injectable clock so tests can assert deterministic timestamps. */
now: () => Date;
}
/**
* Read a marker already on disk, tolerating a missing/garbage file. Returns
* null when the file is absent or unparseable so the caller treats this as a
* first creation (self-healing, spec §5 "self-healing a stale/missing marker").
*/
async function readExisting(file: string): Promise<AppStateMarker | null> {
let raw: string;
try {
raw = await readFile(file, "utf8");
} catch {
return null;
}
try {
return JSON.parse(raw) as AppStateMarker;
} catch {
return null;
}
}
/**
* Write ~/.ao/app-state.json. The app is the SOLE writer (invariant 3) and
* writes on every launch. Mirrors the daemon's proven atomic write
* (backend/internal/runfile/runfile.go Write): a temp file in the same dir
* then an atomic rename, so a concurrent `ao start` reader never observes a
* partial file.
*
* On first creation, installedAt and installSource are captured and then
* preserved across all later launches; appPath, version, and lastReconciledAt
* are refreshed every launch (spec §5 field table).
*/
export async function writeAppStateMarker(opts: WriteAppStateOptions): Promise<void> {
// 0o750: owner rwx, group r-x, world none — matches runfile.Write's dir mode.
await mkdir(opts.stateDir, { recursive: true, mode: 0o750 });
const file = path.join(opts.stateDir, APP_STATE_FILE_NAME);
const existing = await readExisting(file);
const nowIso = opts.now().toISOString();
const marker: AppStateMarker = {
schemaVersion: SCHEMA_VERSION,
appPath: opts.appPath,
version: opts.version,
// Set once on first creation; preserve thereafter.
installedAt: existing?.installedAt ?? nowIso,
// Refreshed on every launch that touches the marker.
lastReconciledAt: nowIso,
installSource: existing?.installSource ?? opts.installedVia ?? "unknown",
};
// Pretty-print + trailing newline to match runfile.Write's MarshalIndent style.
const data = `${JSON.stringify(marker, null, 2)}\n`;
// Temp file in the SAME dir so the rename is atomic (same filesystem). The
// random suffix avoids a collision if two launches race.
const tmp = path.join(opts.stateDir, `.app-state-${process.pid}-${Date.now()}.json`);
await writeFile(tmp, data, { mode: 0o600 });
await rename(tmp, file);
}

10
packages/.gitignore vendored Normal file
View File

@ -0,0 +1,10 @@
# Prebuilt Go binaries (~20MB each) produced by build-binaries.sh and shipped
# in each npm tarball via the package's `files` entry. Never committed.
#
# The repo-root .gitignore has a bare `bin/` rule that would otherwise ignore
# this whole directory tree (including the JS shim). Re-include the dirs and the
# shim, then ignore only the compiled binaries.
!*/bin/
!ao/bin/ao.js
*/bin/ao
*/bin/ao.exe

View File

@ -0,0 +1,15 @@
{
"name": "@aoagents/ao-darwin-arm64",
"version": "0.10.0",
"description": "Agent Orchestrator CLI binary for macOS arm64.",
"license": "MIT",
"os": [
"darwin"
],
"cpu": [
"arm64"
],
"files": [
"bin/ao"
]
}

View File

@ -0,0 +1,15 @@
{
"name": "@aoagents/ao-darwin-x64",
"version": "0.10.0",
"description": "Agent Orchestrator CLI binary for macOS x64.",
"license": "MIT",
"os": [
"darwin"
],
"cpu": [
"x64"
],
"files": [
"bin/ao"
]
}

View File

@ -0,0 +1,15 @@
{
"name": "@aoagents/ao-linux-x64",
"version": "0.10.0",
"description": "Agent Orchestrator CLI binary for Linux x64.",
"license": "MIT",
"os": [
"linux"
],
"cpu": [
"x64"
],
"files": [
"bin/ao"
]
}

View File

@ -0,0 +1,15 @@
{
"name": "@aoagents/ao-win32-x64",
"version": "0.10.0",
"description": "Agent Orchestrator CLI binary for Windows x64.",
"license": "MIT",
"os": [
"win32"
],
"cpu": [
"x64"
],
"files": [
"bin/ao.exe"
]
}

65
packages/ao/bin/ao.js Executable file
View File

@ -0,0 +1,65 @@
#!/usr/bin/env node
// Pure-Node shim: resolve the per-platform optionalDependency that holds the
// prebuilt Go `ao` binary for this host, then exec it transparently.
// Zero install scripts; zero third-party deps. The binary is delivered by npm
// installing only the matching `@aoagents/ao-<platform>-<arch>` package (its
// os/cpu fields gate the rest out).
"use strict";
const { spawnSync } = require("node:child_process");
const path = require("node:path");
// npm cpu names match process.arch (x64/arm64); npm os names match
// process.platform (darwin/win32/linux). Our platform packages are named
// `@aoagents/ao-<platform>-<arch>` to mirror that exactly.
const platform = process.platform;
const arch = process.arch;
const pkg = `@aoagents/ao-${platform}-${arch}`;
const binName = platform === "win32" ? "ao.exe" : "ao";
function resolveBinary() {
// require.resolve the platform package's package.json to find its install
// dir (works whether hoisted to a parent node_modules or nested), then join
// the binary path. The platform package ships the binary under bin/.
let pkgJsonPath;
try {
pkgJsonPath = require.resolve(`${pkg}/package.json`);
} catch {
return null;
}
return path.join(path.dirname(pkgJsonPath), "bin", binName);
}
const binary = resolveBinary();
if (!binary) {
process.stderr.write(
`@aoagents/ao: no prebuilt binary for ${platform}-${arch}.\n` +
`The optional dependency ${pkg} is not installed, which usually means\n` +
`this platform is unsupported. Supported: darwin-arm64, darwin-x64,\n` +
`win32-x64, linux-x64.\n`,
);
process.exit(1);
}
const result = spawnSync(binary, process.argv.slice(2), { stdio: "inherit" });
if (result.error) {
if (result.error.code === "ENOENT") {
process.stderr.write(
`@aoagents/ao: binary not found at ${binary}.\n` +
`Reinstall @aoagents/ao to restore the platform package.\n`,
);
} else {
process.stderr.write(`@aoagents/ao: failed to run binary: ${result.error.message}\n`);
}
process.exit(1);
}
// Propagate signal-terminations as a conventional 128+signal code, else the
// child's own exit code.
if (result.signal) {
process.exit(1);
}
process.exit(result.status === null ? 1 : result.status);

18
packages/ao/package.json Normal file
View File

@ -0,0 +1,18 @@
{
"name": "@aoagents/ao",
"version": "0.10.0",
"description": "Agent Orchestrator CLI. Resolves and runs the prebuilt Go binary for your platform.",
"license": "MIT",
"bin": {
"ao": "./bin/ao.js"
},
"files": [
"bin/"
],
"optionalDependencies": {
"@aoagents/ao-darwin-arm64": "0.10.0",
"@aoagents/ao-darwin-x64": "0.10.0",
"@aoagents/ao-linux-x64": "0.10.0",
"@aoagents/ao-win32-x64": "0.10.0"
}
}

42
packages/build-binaries.sh Executable file
View File

@ -0,0 +1,42 @@
#!/usr/bin/env bash
# Cross-compile the Go `ao` binary (backend/cmd/ao) for every supported
# platform and drop each into the matching platform package's bin/ dir.
#
# Run this from any cwd before `npm publish`. It is the ONLY way the binaries
# get into the platform packages; they are gitignored and produced here, then
# shipped in each npm tarball via that package's `files` entry.
#
# CGO-free build (modernc.org/sqlite driver) so cross-compilation needs no C
# toolchain. Prod build: no -ldflags, so cli.releaseRepo keeps its default
# (AgentWrapper/agent-orchestrator).
set -euo pipefail
# Repo layout: this script lives at <repo>/packages/build-binaries.sh.
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
REPO_ROOT="$(cd "${SCRIPT_DIR}/.." && pwd)"
BACKEND_DIR="${REPO_ROOT}/backend"
# pkg_dir : npm_os : npm_arch : GOOS : GOARCH : bin_name
TARGETS=(
"ao-darwin-arm64:darwin:arm64:darwin:arm64:ao"
"ao-darwin-x64:darwin:x64:darwin:amd64:ao"
"ao-win32-x64:win32:x64:windows:amd64:ao.exe"
"ao-linux-x64:linux:x64:linux:amd64:ao"
)
echo "Building ao binaries from ${BACKEND_DIR}/cmd/ao"
for t in "${TARGETS[@]}"; do
IFS=":" read -r pkg npm_os npm_arch goos goarch bin <<<"$t"
out="${SCRIPT_DIR}/${pkg}/bin/${bin}"
mkdir -p "${SCRIPT_DIR}/${pkg}/bin"
echo " -> ${pkg} (GOOS=${goos} GOARCH=${goarch}) -> bin/${bin}"
(cd "${BACKEND_DIR}" && CGO_ENABLED=0 GOOS="${goos}" GOARCH="${goarch}" \
go build -o "${out}" ./cmd/ao)
chmod 0755 "${out}"
done
echo "Done. Built binaries:"
for t in "${TARGETS[@]}"; do
IFS=":" read -r pkg _ _ _ _ bin <<<"$t"
file "${SCRIPT_DIR}/${pkg}/bin/${bin}"
done