fix(cli): harden daemon control surface and stop CLI from writing the store

Addresses review findings on PR #53 (on top of the rebase onto main).

- doctor: stop opening/migrating SQLite. The daemon is the sole store
  writer/migrator (architecture.md §7); the CLI must not run migrations or
  open a second writer against a DB a live daemon owns. doctor now reports
  database-file presence and gains --json.
- stop: only remove running.json when it still belongs to the PID we
  stopped, so a concurrent `ao start` that wrote a new run-file is not
  clobbered into looking stopped.
- httpd: gate POST /shutdown to loopback callers with no Origin header,
  closing the CSRF / DNS-rebinding vector against an unauthenticated,
  state-changing endpoint.
- start: detach the spawned daemon into its own session/process group so a
  Ctrl-C while `ao start` waits for readiness doesn't also kill it.
- cli: exit 2 for usage errors (bad flag / arg count) vs 1 for runtime
  failures.
- daemon: unexport newLogger (only used in-package).
- tests: /shutdown guard (cross-origin + rebinding) and stop run-file
  ownership guard.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
itrytoohard 2026-06-01 01:55:14 +05:30
parent 0d8ffcd17a
commit 2d00e4675d
13 changed files with 316 additions and 47 deletions

View File

@ -10,6 +10,6 @@ import (
func main() { func main() {
if err := cli.Execute(); err != nil { if err := cli.Execute(); err != nil {
fmt.Fprintln(os.Stderr, err) fmt.Fprintln(os.Stderr, err)
os.Exit(1) os.Exit(cli.ExitCode(err))
} }
} }

View File

@ -8,9 +8,14 @@ import (
func newCompletionCommand() *cobra.Command { func newCompletionCommand() *cobra.Command {
return &cobra.Command{ return &cobra.Command{
Use: "completion [bash|zsh|fish|powershell]", Use: "completion [bash|zsh|fish|powershell]",
Short: "Generate shell completion scripts", Short: "Generate shell completion scripts",
Args: cobra.ExactArgs(1), 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"}, ValidArgs: []string{"bash", "zsh", "fish", "powershell"},
RunE: func(cmd *cobra.Command, args []string) error { RunE: func(cmd *cobra.Command, args []string) error {
root := cmd.Root() root := cmd.Root()

View File

@ -2,13 +2,15 @@ package cli
import ( import (
"context" "context"
"errors"
"fmt" "fmt"
"io/fs"
"os" "os"
"path/filepath"
"github.com/spf13/cobra" "github.com/spf13/cobra"
"github.com/aoagents/agent-orchestrator/backend/internal/config" "github.com/aoagents/agent-orchestrator/backend/internal/config"
"github.com/aoagents/agent-orchestrator/backend/internal/storage/sqlite"
) )
type doctorLevel string type doctorLevel string
@ -20,34 +22,53 @@ const (
) )
type doctorCheck struct { type doctorCheck struct {
Level doctorLevel Level doctorLevel `json:"level"`
Name string Name string `json:"name"`
Message string 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 { func newDoctorCommand(ctx *commandContext) *cobra.Command {
return &cobra.Command{ var asJSON bool
cmd := &cobra.Command{
Use: "doctor", Use: "doctor",
Short: "Run local AO health checks", Short: "Run local AO health checks",
RunE: func(cmd *cobra.Command, args []string) error { RunE: func(cmd *cobra.Command, args []string) error {
checks := ctx.runDoctor(cmd.Context()) checks := ctx.runDoctor(cmd.Context())
for _, check := range checks { failures := 0
if _, err := fmt.Fprintf(cmd.OutOrStdout(), "%s %s: %s\n", check.Level, check.Name, check.Message); err != nil {
return err
}
}
var failures int
for _, check := range checks { for _, check := range checks {
if check.Level == doctorFail { if check.Level == doctorFail {
failures++ 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 { if failures > 0 {
return fmt.Errorf("doctor found %d failing check(s)", failures) return fmt.Errorf("doctor found %d failing check(s)", failures)
} }
return nil return nil
}, },
} }
cmd.Flags().BoolVar(&asJSON, "json", false, "Output health checks as JSON")
return cmd
} }
func (c *commandContext) runDoctor(ctx context.Context) []doctorCheck { 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}) checks = append(checks, doctorCheck{Level: doctorPass, Name: "data-dir", Message: cfg.DataDir})
} }
store, err := sqlite.Open(cfg.DataDir) checks = append(checks, checkStore(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"})
}
st, err := c.inspectDaemon(ctx) st, err := c.inspectDaemon(ctx)
if err != nil { if err != nil {
@ -103,6 +118,31 @@ func (c *commandContext) runDoctor(ctx context.Context) []doctorCheck {
return checks 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 { func (c *commandContext) checkTool(name string, required bool) doctorCheck {
path, err := c.deps.LookPath(name) path, err := c.deps.LookPath(name)
if err == nil { if err == nil {

View File

@ -22,6 +22,10 @@ func startProcess(cfg processStartConfig) (processHandle, error) {
cmd.Env = cfg.Env cmd.Env = cfg.Env
cmd.Stdout = cfg.Stdout cmd.Stdout = cfg.Stdout
cmd.Stderr = cfg.Stderr 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 { if err := cmd.Start(); err != nil {
return processHandle{}, err return processHandle{}, err
} }

View File

@ -14,3 +14,10 @@ func processAlive(pid int) bool {
err := syscall.Kill(pid, 0) err := syscall.Kill(pid, 0)
return err == nil || errors.Is(err, syscall.EPERM) 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}
}

View File

@ -4,6 +4,7 @@ package cli
import ( import (
"errors" "errors"
"syscall"
"golang.org/x/sys/windows" "golang.org/x/sys/windows"
) )
@ -27,3 +28,9 @@ func processAlive(pid int) bool {
} }
return status == uint32(windows.WAIT_TIMEOUT) 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}
}

View File

@ -3,6 +3,7 @@
package cli package cli
import ( import (
"errors"
"io" "io"
"net/http" "net/http"
"os" "os"
@ -19,6 +20,27 @@ func Execute() error {
return NewRootCommand(DefaultDeps()).Execute() 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 // Deps holds the small set of side effects the CLI needs. Tests replace these
// functions without reaching into process-global state. // functions without reaching into process-global state.
type Deps struct { type Deps struct {
@ -103,6 +125,11 @@ func NewRootCommand(deps Deps) *cobra.Command {
root.SetOut(deps.Out) root.SetOut(deps.Out)
root.SetErr(deps.Err) root.SetErr(deps.Err)
root.CompletionOptions.DisableDefaultCmd = true 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(newDaemonCommand())
root.AddCommand(newStartCommand(ctx)) root.AddCommand(newStartCommand(ctx))

View File

@ -115,8 +115,14 @@ func (c *commandContext) waitForStopped(ctx context.Context, pid int, runFilePat
return daemonStatus{State: "stopped", RunFile: runFilePath, DataDir: dataDir}, nil return daemonStatus{State: "stopped", RunFile: runFilePath, DataDir: dataDir}, nil
} }
if !alive { if !alive {
if err := runfile.Remove(runFilePath); err != nil { // Only remove the run-file if it still belongs to the process we
return daemonStatus{}, err // 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 return daemonStatus{State: "stopped", RunFile: runFilePath, DataDir: dataDir}, nil
} }

View File

@ -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)
}
}

View File

@ -27,7 +27,7 @@ func Run() error {
return err return err
} }
log := NewLogger() log := newLogger()
// Fail fast if a live daemon already owns the handshake file. A run-file // 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 // left by a crashed predecessor (dead PID) is treated as stale and
@ -119,8 +119,8 @@ func Run() error {
return runErr 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. // 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})) return slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelDebug}))
} }

View File

@ -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)
}
})
}
}

View File

@ -6,6 +6,7 @@ package httpd
import ( import (
"log/slog" "log/slog"
"net"
"net/http" "net/http"
"os" "os"
@ -76,11 +77,22 @@ func mountHealth(r chi.Router) {
r.Get("/readyz", handleReadyz) 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) { func mountControl(r chi.Router, deps ControlDeps) {
if deps.RequestShutdown == nil { if deps.RequestShutdown == nil {
return 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{ writeJSON(w, http.StatusAccepted, map[string]any{
"status": "shutting_down", "status": "shutting_down",
"service": daemonmeta.ServiceName, "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 // handleHealthz is the liveness probe: it answers 200 as long as the process is
// up and serving. It does no dependency checks by design. // up and serving. It does no dependency checks by design.
func handleHealthz(w http.ResponseWriter, _ *http.Request) { func handleHealthz(w http.ResponseWriter, _ *http.Request) {

View File

@ -14,10 +14,13 @@ What works now:
- `ao start` starts the daemon in the background and waits for `/readyz`. - `ao start` starts the daemon in the background and waits for `/readyz`.
- `ao status` and `ao status --json` report stopped, stale, unhealthy, - `ao status` and `ao status --json` report stopped, stale, unhealthy,
not-ready, or ready daemon state. 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 daemon` is the hidden internal daemon entrypoint used by `ao start`.
- `ao doctor` checks config, data dir, SQLite migrations, daemon state, and - `ao doctor` (and `ao doctor --json`) checks config, data dir, the database
local tool availability for `git`, `tmux`, and `zellij`. 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 - `ao completion` generates shell completions for `bash`, `zsh`, `fish`, and
`powershell`. `powershell`.
- `ao version` and `ao --version` print build metadata. - `ao version` and `ao --version` print build metadata.
@ -52,8 +55,9 @@ What is intentionally not implemented yet:
Next steps: Next steps:
1. Add `/api/v1/projects` on the daemon over a small project service. 1. Wire the existing project manager/controller shell into the daemon with a
2. Implement `ao project list/add/show/remove`. 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, 3. Wire production Session Manager dependencies: project-backed repo resolver,
tmux/zellij runtime registry, first agent adapter, and AgentMessenger. tmux/zellij runtime registry, first agent adapter, and AgentMessenger.
4. Add `/api/v1/sessions`, then implement `ao spawn`, `ao session ...`, and 4. Add `/api/v1/sessions`, then implement `ao spawn`, `ao session ...`, and
@ -281,8 +285,8 @@ Acceptance criteria for the foundation:
## Implementation Readiness ## Implementation Readiness
This section records what the CLI can connect to in the current codebase and 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 what still needs to be built. Inventory date: 2026-05-31 after merging
`0672dbb`. `origin/main` at `438b830`.
### Implemented Foundation ### Implemented Foundation
@ -298,8 +302,9 @@ Implemented commands:
supports `--json` and `--timeout`. supports `--json` and `--timeout`.
- `ao status` reports stopped/stale/unhealthy/not-ready/ready states and - `ao status` reports stopped/stale/unhealthy/not-ready/ready states and
supports `--json`. supports `--json`.
- `ao doctor` checks config, data dir, SQLite open/migrations, daemon state, and - `ao doctor` checks config, data dir, database-file presence, daemon state, and
local tool availability for `git`, `tmux`, and `zellij`. 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` - `ao completion` generates `bash`, `zsh`, `fish`, and `powershell`
completions. completions.
- `ao version` prints build metadata. - `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 | | 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. | | 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. | | 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. | | 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 | | Gap | Blocks |
|---|---| |---|---|
| Cobra dependency and CLI packages. | All CLI commands. | | Product API client package with run-file discovery. | `project`, `spawn`, `session`, `send`, `events list`, richer `status`. |
| 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. |
| Shutdown mechanism choice: PID signal now, optional `POST /api/v1/daemon/shutdown` later. | `ao stop` polish and cross-platform behavior. | | 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. | | 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`. | | 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. | | 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 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 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 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 completion` | Implemented | Cobra generators. | None for foundation. |
| `ao version` | Implemented | Build metadata can be injected with `-ldflags`. | Release tooling needs to set metadata. | | `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 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 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. | | `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`, 1. Build CLI foundation around the daemon only: `daemon`, `start`, `stop`,
`status`, `doctor`, `completion`, `version`. `status`, `doctor`, `completion`, `version`.
2. Add `/api/v1/projects` over a small project service, then implement 2. Wire the existing project manager/controller shell into the daemon with a
`project list/add/show/remove`. durable SQLite-backed store, then implement `project list/add/show/remove`.
3. Wire production Session Manager dependencies: project-backed repo resolver, 3. Wire production Session Manager dependencies: project-backed repo resolver,
tmux/zellij runtime registry, first agent adapter, and AgentMessenger. tmux/zellij runtime registry, first agent adapter, and AgentMessenger.
4. Add `/api/v1/sessions` and implement `spawn`, `session list/show/kill/restore`, 4. Add `/api/v1/sessions` and implement `spawn`, `session list/show/kill/restore`,