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() {
if err := cli.Execute(); err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
os.Exit(cli.ExitCode(err))
}
}

View File

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

View File

@ -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 {

View File

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

View File

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

View File

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

View File

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

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

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

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 (
"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) {

View File

@ -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`,