diff --git a/backend/cmd/ao/main.go b/backend/cmd/ao/main.go index 1ee35d622..d1ea897c9 100644 --- a/backend/cmd/ao/main.go +++ b/backend/cmd/ao/main.go @@ -10,6 +10,6 @@ import ( func main() { if err := cli.Execute(); err != nil { fmt.Fprintln(os.Stderr, err) - os.Exit(1) + os.Exit(cli.ExitCode(err)) } } diff --git a/backend/internal/cli/completion.go b/backend/internal/cli/completion.go index 61b9483ef..f4575de09 100644 --- a/backend/internal/cli/completion.go +++ b/backend/internal/cli/completion.go @@ -8,9 +8,14 @@ import ( func newCompletionCommand() *cobra.Command { return &cobra.Command{ - Use: "completion [bash|zsh|fish|powershell]", - Short: "Generate shell completion scripts", - Args: cobra.ExactArgs(1), + Use: "completion [bash|zsh|fish|powershell]", + Short: "Generate shell completion scripts", + Args: func(cmd *cobra.Command, args []string) error { + if err := cobra.ExactArgs(1)(cmd, args); err != nil { + return usageError{err} + } + return nil + }, ValidArgs: []string{"bash", "zsh", "fish", "powershell"}, RunE: func(cmd *cobra.Command, args []string) error { root := cmd.Root() diff --git a/backend/internal/cli/doctor.go b/backend/internal/cli/doctor.go index 3a452ac19..4c6953f2e 100644 --- a/backend/internal/cli/doctor.go +++ b/backend/internal/cli/doctor.go @@ -2,13 +2,15 @@ package cli import ( "context" + "errors" "fmt" + "io/fs" "os" + "path/filepath" "github.com/spf13/cobra" "github.com/aoagents/agent-orchestrator/backend/internal/config" - "github.com/aoagents/agent-orchestrator/backend/internal/storage/sqlite" ) type doctorLevel string @@ -20,34 +22,53 @@ const ( ) type doctorCheck struct { - Level doctorLevel - Name string - Message string + Level doctorLevel `json:"level"` + Name string `json:"name"` + Message string `json:"message"` +} + +type doctorReport struct { + OK bool `json:"ok"` + Failures int `json:"failures"` + Checks []doctorCheck `json:"checks"` } func newDoctorCommand(ctx *commandContext) *cobra.Command { - return &cobra.Command{ + var asJSON bool + cmd := &cobra.Command{ Use: "doctor", Short: "Run local AO health checks", RunE: func(cmd *cobra.Command, args []string) error { checks := ctx.runDoctor(cmd.Context()) - for _, check := range checks { - if _, err := fmt.Fprintf(cmd.OutOrStdout(), "%s %s: %s\n", check.Level, check.Name, check.Message); err != nil { - return err - } - } - var failures int + failures := 0 for _, check := range checks { if check.Level == doctorFail { failures++ } } + + if asJSON { + if err := writeJSON(cmd.OutOrStdout(), doctorReport{ + OK: failures == 0, Failures: failures, Checks: checks, + }); err != nil { + return err + } + } else { + for _, check := range checks { + if _, err := fmt.Fprintf(cmd.OutOrStdout(), "%s %s: %s\n", check.Level, check.Name, check.Message); err != nil { + return err + } + } + } + if failures > 0 { return fmt.Errorf("doctor found %d failing check(s)", failures) } return nil }, } + cmd.Flags().BoolVar(&asJSON, "json", false, "Output health checks as JSON") + return cmd } func (c *commandContext) runDoctor(ctx context.Context) []doctorCheck { @@ -68,13 +89,7 @@ func (c *commandContext) runDoctor(ctx context.Context) []doctorCheck { checks = append(checks, doctorCheck{Level: doctorPass, Name: "data-dir", Message: cfg.DataDir}) } - store, err := sqlite.Open(cfg.DataDir) - if err != nil { - checks = append(checks, doctorCheck{Level: doctorFail, Name: "sqlite", Message: err.Error()}) - } else { - _ = store.Close() - checks = append(checks, doctorCheck{Level: doctorPass, Name: "sqlite", Message: "opened database and applied migrations"}) - } + checks = append(checks, checkStore(cfg.DataDir)) st, err := c.inspectDaemon(ctx) if err != nil { @@ -103,6 +118,31 @@ func (c *commandContext) runDoctor(ctx context.Context) []doctorCheck { return checks } +// checkStore inspects the SQLite store WITHOUT opening or migrating it. The +// daemon is the sole writer and migrator of the database (architecture.md §7); +// the CLI must never run migrations or open a second writer against a database +// a live daemon may already own. Migrations are validated by the daemon at +// startup and surfaced through /readyz, so doctor only confirms whether the +// database file exists yet. +func checkStore(dataDir string) doctorCheck { + dbPath := filepath.Join(dataDir, "ao.db") + info, err := os.Stat(dbPath) + switch { + case err == nil: + return doctorCheck{ + Level: doctorPass, Name: "sqlite", + Message: fmt.Sprintf("%s (%d bytes); migrations are applied by the daemon at startup", dbPath, info.Size()), + } + case errors.Is(err, fs.ErrNotExist): + return doctorCheck{ + Level: doctorWarn, Name: "sqlite", + Message: "database not created yet; run `ao start` to initialize and migrate it", + } + default: + return doctorCheck{Level: doctorFail, Name: "sqlite", Message: err.Error()} + } +} + func (c *commandContext) checkTool(name string, required bool) doctorCheck { path, err := c.deps.LookPath(name) if err == nil { diff --git a/backend/internal/cli/process.go b/backend/internal/cli/process.go index 3db432090..19c4d19fd 100644 --- a/backend/internal/cli/process.go +++ b/backend/internal/cli/process.go @@ -22,6 +22,10 @@ func startProcess(cfg processStartConfig) (processHandle, error) { cmd.Env = cfg.Env cmd.Stdout = cfg.Stdout cmd.Stderr = cfg.Stderr + // Detach the daemon into its own session/process group so a Ctrl-C in the + // terminal where `ao start` is waiting for readiness doesn't also SIGINT the + // freshly spawned daemon (it would otherwise share the launcher's group). + cmd.SysProcAttr = detachSysProcAttr() if err := cmd.Start(); err != nil { return processHandle{}, err } diff --git a/backend/internal/cli/process_unix.go b/backend/internal/cli/process_unix.go index 61aa333bb..9963d9e9d 100644 --- a/backend/internal/cli/process_unix.go +++ b/backend/internal/cli/process_unix.go @@ -14,3 +14,10 @@ func processAlive(pid int) bool { err := syscall.Kill(pid, 0) return err == nil || errors.Is(err, syscall.EPERM) } + +// detachSysProcAttr puts the daemon in a new session (Setsid) so it is no +// longer in the launcher's foreground process group and won't receive the +// terminal's SIGINT/SIGHUP. +func detachSysProcAttr() *syscall.SysProcAttr { + return &syscall.SysProcAttr{Setsid: true} +} diff --git a/backend/internal/cli/process_windows.go b/backend/internal/cli/process_windows.go index 216431d89..3ff8190a3 100644 --- a/backend/internal/cli/process_windows.go +++ b/backend/internal/cli/process_windows.go @@ -4,6 +4,7 @@ package cli import ( "errors" + "syscall" "golang.org/x/sys/windows" ) @@ -27,3 +28,9 @@ func processAlive(pid int) bool { } return status == uint32(windows.WAIT_TIMEOUT) } + +// detachSysProcAttr starts the daemon in a new process group so it does not +// receive the console's CTRL_C/CTRL_BREAK while `ao start` waits for readiness. +func detachSysProcAttr() *syscall.SysProcAttr { + return &syscall.SysProcAttr{CreationFlags: windows.CREATE_NEW_PROCESS_GROUP} +} diff --git a/backend/internal/cli/root.go b/backend/internal/cli/root.go index c49a0339b..36e83e5a9 100644 --- a/backend/internal/cli/root.go +++ b/backend/internal/cli/root.go @@ -3,6 +3,7 @@ package cli import ( + "errors" "io" "net/http" "os" @@ -19,6 +20,27 @@ func Execute() error { return NewRootCommand(DefaultDeps()).Execute() } +// usageError marks a command-line misuse (bad flag, wrong arg count). It lets +// the process entrypoint return exit code 2 for usage errors versus 1 for +// runtime failures, matching the convention CLIs are scripted against. +type usageError struct{ err error } + +func (e usageError) Error() string { return e.err.Error() } +func (e usageError) Unwrap() error { return e.err } + +// ExitCode maps a CLI error to a process exit code: 2 for usage errors, 1 for +// any other failure, 0 for success. +func ExitCode(err error) int { + if err == nil { + return 0 + } + var ue usageError + if errors.As(err, &ue) { + return 2 + } + return 1 +} + // Deps holds the small set of side effects the CLI needs. Tests replace these // functions without reaching into process-global state. type Deps struct { @@ -103,6 +125,11 @@ func NewRootCommand(deps Deps) *cobra.Command { root.SetOut(deps.Out) root.SetErr(deps.Err) root.CompletionOptions.DisableDefaultCmd = true + // Tag flag-parse failures as usage errors so the entrypoint can exit 2 for + // misuse versus 1 for runtime failures. Subcommands inherit this func. + root.SetFlagErrorFunc(func(_ *cobra.Command, err error) error { + return usageError{err} + }) root.AddCommand(newDaemonCommand()) root.AddCommand(newStartCommand(ctx)) diff --git a/backend/internal/cli/stop.go b/backend/internal/cli/stop.go index 41d42d307..9b00c1c40 100644 --- a/backend/internal/cli/stop.go +++ b/backend/internal/cli/stop.go @@ -115,8 +115,14 @@ func (c *commandContext) waitForStopped(ctx context.Context, pid int, runFilePat return daemonStatus{State: "stopped", RunFile: runFilePath, DataDir: dataDir}, nil } if !alive { - if err := runfile.Remove(runFilePath); err != nil { - return daemonStatus{}, err + // Only remove the run-file if it still belongs to the process we + // stopped. A concurrent `ao start` may have already written a new + // run-file for a different daemon; removing that would corrupt its + // handshake and make a live daemon look stopped. + if info.PID == pid { + if err := runfile.Remove(runFilePath); err != nil { + return daemonStatus{}, err + } } return daemonStatus{State: "stopped", RunFile: runFilePath, DataDir: dataDir}, nil } diff --git a/backend/internal/cli/stop_test.go b/backend/internal/cli/stop_test.go new file mode 100644 index 000000000..85b6a5092 --- /dev/null +++ b/backend/internal/cli/stop_test.go @@ -0,0 +1,83 @@ +package cli + +import ( + "context" + "path/filepath" + "testing" + "time" + + "github.com/aoagents/agent-orchestrator/backend/internal/runfile" +) + +// TestWaitForStoppedKeepsRunFileFromConcurrentStart guards against deleting a +// fresh daemon's handshake: if a concurrent `ao start` replaces running.json +// with a new live PID while we are polling the PID we stopped, waitForStopped +// must report stopped but leave the new run-file intact. +func TestWaitForStoppedKeepsRunFileFromConcurrentStart(t *testing.T) { + dir := t.TempDir() + runFile := filepath.Join(dir, "running.json") + + const stoppedPID, newPID = 1111, 2222 + // running.json now belongs to a different, live daemon. + if err := runfile.Write(runFile, runfile.Info{PID: newPID, Port: 3001, StartedAt: time.Unix(100, 0).UTC()}); err != nil { + t.Fatal(err) + } + + c := &commandContext{deps: Deps{ + ProcessAlive: func(pid int) bool { return pid == newPID }, // stoppedPID is dead + Now: func() time.Time { return time.Unix(200, 0).UTC() }, + Sleep: func(time.Duration) {}, + }.withDefaults()} + + st, err := c.waitForStopped(context.Background(), stoppedPID, runFile, dir, time.Second) + if err != nil { + t.Fatal(err) + } + if st.State != "stopped" { + t.Fatalf("state = %q, want stopped", st.State) + } + + info, err := runfile.Read(runFile) + if err != nil { + t.Fatal(err) + } + if info == nil { + t.Fatal("new daemon's run-file was deleted by stop of a different PID") + } + if info.PID != newPID { + t.Fatalf("run-file PID = %d, want %d (new daemon)", info.PID, newPID) + } +} + +// TestWaitForStoppedRemovesOwnRunFile confirms the normal path still cleans up: +// when the dead PID owns the run-file, it is removed. +func TestWaitForStoppedRemovesOwnRunFile(t *testing.T) { + dir := t.TempDir() + runFile := filepath.Join(dir, "running.json") + + const stoppedPID = 1111 + if err := runfile.Write(runFile, runfile.Info{PID: stoppedPID, Port: 3001, StartedAt: time.Unix(100, 0).UTC()}); err != nil { + t.Fatal(err) + } + + c := &commandContext{deps: Deps{ + ProcessAlive: func(int) bool { return false }, + Now: func() time.Time { return time.Unix(200, 0).UTC() }, + Sleep: func(time.Duration) {}, + }.withDefaults()} + + st, err := c.waitForStopped(context.Background(), stoppedPID, runFile, dir, time.Second) + if err != nil { + t.Fatal(err) + } + if st.State != "stopped" { + t.Fatalf("state = %q, want stopped", st.State) + } + info, err := runfile.Read(runFile) + if err != nil { + t.Fatal(err) + } + if info != nil { + t.Fatalf("own run-file should have been removed, got %#v", info) + } +} diff --git a/backend/internal/daemon/daemon.go b/backend/internal/daemon/daemon.go index fd7dcc7ca..556fe5f09 100644 --- a/backend/internal/daemon/daemon.go +++ b/backend/internal/daemon/daemon.go @@ -27,7 +27,7 @@ func Run() error { return err } - log := NewLogger() + log := newLogger() // Fail fast if a live daemon already owns the handshake file. A run-file // left by a crashed predecessor (dead PID) is treated as stale and @@ -119,8 +119,8 @@ func Run() error { return runErr } -// NewLogger returns the daemon's slog logger. It writes to stderr so supervisors +// newLogger returns the daemon's slog logger. It writes to stderr so supervisors // can capture it separately from any structured stdout protocol added later. -func NewLogger() *slog.Logger { +func newLogger() *slog.Logger { return slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelDebug})) } diff --git a/backend/internal/httpd/control_test.go b/backend/internal/httpd/control_test.go new file mode 100644 index 000000000..3e8456f8f --- /dev/null +++ b/backend/internal/httpd/control_test.go @@ -0,0 +1,52 @@ +package httpd + +import ( + "net/http" + "net/http/httptest" + "testing" + + "github.com/aoagents/agent-orchestrator/backend/internal/config" +) + +// TestShutdownGuard verifies that POST /shutdown only fires for a trusted local +// caller: a loopback Host with no Origin header. A cross-site Origin or a +// non-loopback (DNS-rebinding) Host must be rejected without triggering the +// shutdown side effect. +func TestShutdownGuard(t *testing.T) { + cases := []struct { + name string + host string + origin string + wantStatus int + wantFired bool + }{ + {name: "loopback no origin", host: "127.0.0.1:3001", wantStatus: http.StatusAccepted, wantFired: true}, + {name: "localhost no origin", host: "localhost:3001", wantStatus: http.StatusAccepted, wantFired: true}, + {name: "cross-site origin", host: "127.0.0.1:3001", origin: "https://evil.example", wantStatus: http.StatusForbidden, wantFired: false}, + {name: "rebinding host", host: "evil.example", wantStatus: http.StatusForbidden, wantFired: false}, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + fired := false + r := NewRouterWithControl(config.Config{}, discardLogger(), nil, APIDeps{}, ControlDeps{ + RequestShutdown: func() { fired = true }, + }) + + req := httptest.NewRequest(http.MethodPost, "http://"+tc.host+"/shutdown", nil) + req.Host = tc.host + if tc.origin != "" { + req.Header.Set("Origin", tc.origin) + } + rec := httptest.NewRecorder() + r.ServeHTTP(rec, req) + + if rec.Code != tc.wantStatus { + t.Fatalf("status = %d, want %d", rec.Code, tc.wantStatus) + } + if fired != tc.wantFired { + t.Fatalf("shutdown fired = %v, want %v", fired, tc.wantFired) + } + }) + } +} diff --git a/backend/internal/httpd/router.go b/backend/internal/httpd/router.go index d406b029c..5d132eb48 100644 --- a/backend/internal/httpd/router.go +++ b/backend/internal/httpd/router.go @@ -6,6 +6,7 @@ package httpd import ( "log/slog" + "net" "net/http" "os" @@ -76,11 +77,22 @@ func mountHealth(r chi.Router) { r.Get("/readyz", handleReadyz) } +// mountControl registers the loopback daemon-control endpoints. /shutdown is +// unauthenticated and state-changing, so it is gated by localControlRequest to +// keep a browser the user happens to have open (CSRF / DNS-rebinding) or a +// remote client from being able to kill the daemon. func mountControl(r chi.Router, deps ControlDeps) { if deps.RequestShutdown == nil { return } - r.Post("/shutdown", func(w http.ResponseWriter, _ *http.Request) { + r.Post("/shutdown", func(w http.ResponseWriter, req *http.Request) { + if !localControlRequest(req) { + writeJSON(w, http.StatusForbidden, map[string]any{ + "status": "forbidden", + "service": daemonmeta.ServiceName, + }) + return + } writeJSON(w, http.StatusAccepted, map[string]any{ "status": "shutting_down", "service": daemonmeta.ServiceName, @@ -90,6 +102,29 @@ func mountControl(r chi.Router, deps ControlDeps) { }) } +// localControlRequest reports whether a control request is a trusted local +// caller. The Go CLI client addresses the daemon by its loopback host and +// never sets an Origin header; a cross-site browser fetch always carries an +// Origin, and a DNS-rebinding attempt resolves a non-loopback Host. Rejecting +// either closes the CSRF/rebinding vector while leaving the CLI unaffected. +func localControlRequest(r *http.Request) bool { + if r.Header.Get("Origin") != "" { + return false + } + host := r.Host + if h, _, err := net.SplitHostPort(host); err == nil { + host = h + } + switch host { + case "127.0.0.1", "::1", "localhost": + return true + } + if ip := net.ParseIP(host); ip != nil { + return ip.IsLoopback() + } + return false +} + // handleHealthz is the liveness probe: it answers 200 as long as the process is // up and serving. It does no dependency checks by design. func handleHealthz(w http.ResponseWriter, _ *http.Request) { diff --git a/docs/cli/README.md b/docs/cli/README.md index dd83c306e..d78539a03 100644 --- a/docs/cli/README.md +++ b/docs/cli/README.md @@ -14,10 +14,13 @@ What works now: - `ao start` starts the daemon in the background and waits for `/readyz`. - `ao status` and `ao status --json` report stopped, stale, unhealthy, not-ready, or ready daemon state. -- `ao stop` gracefully stops the daemon using the PID in `running.json`. +- `ao stop` gracefully stops the daemon via the loopback `POST /shutdown` + endpoint, only after verifying the daemon's identity from `running.json`. - `ao daemon` is the hidden internal daemon entrypoint used by `ao start`. -- `ao doctor` checks config, data dir, SQLite migrations, daemon state, and - local tool availability for `git`, `tmux`, and `zellij`. +- `ao doctor` (and `ao doctor --json`) checks config, data dir, the database + file's presence, daemon state, and local tool availability for `git`, `tmux`, + and `zellij`. It never opens or migrates the store — the daemon is the sole + writer/migrator, so doctor only reports whether the database exists yet. - `ao completion` generates shell completions for `bash`, `zsh`, `fish`, and `powershell`. - `ao version` and `ao --version` print build metadata. @@ -52,8 +55,9 @@ What is intentionally not implemented yet: Next steps: -1. Add `/api/v1/projects` on the daemon over a small project service. -2. Implement `ao project list/add/show/remove`. +1. Wire the existing project manager/controller shell into the daemon with a + durable SQLite-backed project store. +2. Implement `ao project list/add/show/remove` against `/api/v1/projects`. 3. Wire production Session Manager dependencies: project-backed repo resolver, tmux/zellij runtime registry, first agent adapter, and AgentMessenger. 4. Add `/api/v1/sessions`, then implement `ao spawn`, `ao session ...`, and @@ -281,8 +285,8 @@ Acceptance criteria for the foundation: ## Implementation Readiness This section records what the CLI can connect to in the current codebase and -what still needs to be built. Inventory date: 2026-05-31 on `main` at -`0672dbb`. +what still needs to be built. Inventory date: 2026-05-31 after merging +`origin/main` at `438b830`. ### Implemented Foundation @@ -298,8 +302,9 @@ Implemented commands: supports `--json` and `--timeout`. - `ao status` reports stopped/stale/unhealthy/not-ready/ready states and supports `--json`. -- `ao doctor` checks config, data dir, SQLite open/migrations, daemon state, and - local tool availability for `git`, `tmux`, and `zellij`. +- `ao doctor` checks config, data dir, database-file presence, daemon state, and + local tool availability for `git`, `tmux`, and `zellij`; supports `--json`. It + does not open or migrate the store (the daemon owns that). - `ao completion` generates `bash`, `zsh`, `fish`, and `powershell` completions. - `ao version` prints build metadata. @@ -331,7 +336,7 @@ they are wired into the daemon and exposed through HTTP. | Area | Existing code | Missing before CLI can use it | |---|---|---| -| Project persistence | `sqlite.Store` has `UpsertProject`, `GetProject`, `ListProjects`, and `ArchiveProject`. | Project domain/service layer, project ID/path/origin validation, and `/api/v1/projects` routes. | +| Project API pieces | `internal/project` has manager/controller DTOs, `/api/v1/projects` routes exist, and `sqlite.Store` has project CRUD. | Durable project-store adapter/wiring in the daemon and CLI commands. The daemon currently constructs the router with nil API deps, so project routes are not product-usable from `ao` yet. | | Session Manager | `backend/internal/session.Manager` implements `Spawn`, `Kill`, `Restore`, `List`, `Get`, `Send`, and `Cleanup`. | Production daemon wiring with real runtime, agent, workspace, messenger, and HTTP routes. | | Runtime adapters | tmux and zellij adapters implement `ports.Runtime` and also have attach/send/output helpers. | Runtime registry wiring in daemon, attach/send abstractions in ports/API, and selection config. | | Workspace adapter | git worktree adapter implements create/destroy/restore/list with safety checks. | Repo resolver backed by registered projects and daemon wiring into Session Manager. | @@ -345,12 +350,10 @@ These are the main gaps before the full initial command set is real. | Gap | Blocks | |---|---| -| Cobra dependency and CLI packages. | All CLI commands. | -| Daemon extraction from `backend/main.go` into `internal/daemon`. | `ao daemon`, `ao start`, tests around daemon startup. | -| CLI process runner and PID signal helpers. | `ao start`, `ao stop`. | -| Loopback HTTP client package with run-file discovery. | `ao status`, later all daemon-backed commands. | +| Product API client package with run-file discovery. | `project`, `spawn`, `session`, `send`, `events list`, richer `status`. | | Shutdown mechanism choice: PID signal now, optional `POST /api/v1/daemon/shutdown` later. | `ao stop` polish and cross-platform behavior. | -| HTTP API route surface under `/api/v1`. | `project`, `spawn`, `session`, `send`, `events list`, richer `status`. | +| Session/send API route surface under `/api/v1`. | `spawn`, `session`, `send`, richer `status`. | +| Project API daemon wiring. | `ao project list/add/show/remove`. | | SSE route for live CDC events plus durable catch-up reads. | `ao events tail`, frontend live updates. | | Agent adapters for supported harnesses (`codex`, `claude-code`, etc.). | `ao spawn`, `ao session restore`. | | AgentMessenger implementation over tmux/zellij. | `ao send`, LCM auto-nudge reactions. | @@ -368,10 +371,10 @@ These are the main gaps before the full initial command set is real. | `ao start` | Implemented | Config, run-file stale check, HTTP readiness probes. | Later: package-manager/service integration if needed. | | `ao stop` | Implemented | Run-file discovery gives PID/port; server exits cleanly on SIGINT/SIGTERM. | Optional later shutdown HTTP route. | | `ao status` | Partially implemented | Run-file, process liveness via PID, `/healthz`, `/readyz`. | Rich project/session summary waits for `/api/v1/projects` and `/api/v1/sessions`. | -| `ao doctor` | Partially implemented | Config resolution, run-file, storage open, runtime binary checks. | Deeper adapter preflights need daemon wiring/config. | +| `ao doctor` | Partially implemented | Config resolution, run-file, database-file presence (no open/migrate), runtime binary checks. | Deeper adapter preflights need daemon wiring/config and should be queried from the daemon, not run in-process. | | `ao completion` | Implemented | Cobra generators. | None for foundation. | | `ao version` | Implemented | Build metadata can be injected with `-ldflags`. | Release tooling needs to set metadata. | -| `ao project list/add/show/remove` | Not yet | SQLite project CRUD exists. | Project service and HTTP routes. CLI must not write SQLite directly. | +| `ao project list/add/show/remove` | Not yet | Project manager/controller route shell and SQLite project CRUD exist. | Durable project-store adapter, daemon API wiring, and CLI HTTP client. CLI must not write SQLite directly. | | `ao spawn` | Not yet | Session Manager exists; runtime/workspace/tracker pieces partly exist. | Agent adapters, registry/config wiring, project lookup, tracker hydration, HTTP route. | | `ao session list/show` | Not yet | Store and Session Manager read model exist. | HTTP routes and response DTOs. | | `ao session attach` | Not yet | tmux/zellij have attach command helpers. | Runtime attach port/API and terminal-launch policy. | @@ -383,8 +386,8 @@ These are the main gaps before the full initial command set is real. 1. Build CLI foundation around the daemon only: `daemon`, `start`, `stop`, `status`, `doctor`, `completion`, `version`. -2. Add `/api/v1/projects` over a small project service, then implement - `project list/add/show/remove`. +2. Wire the existing project manager/controller shell into the daemon with a + durable SQLite-backed store, then implement `project list/add/show/remove`. 3. Wire production Session Manager dependencies: project-backed repo resolver, tmux/zellij runtime registry, first agent adapter, and AgentMessenger. 4. Add `/api/v1/sessions` and implement `spawn`, `session list/show/kill/restore`,