feat: require explicit project agents (#355)

* feat: require explicit project agents

* chore: format with prettier [skip ci]

* test: configure integration project agents

* fix: center project agent selection dialog

* fix: keep agent selects neutral before validation

---------

Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
This commit is contained in:
Adil Shaikh 2026-06-21 06:07:53 +05:30 committed by GitHub
parent 5e8c8defbd
commit 72b757b203
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
22 changed files with 540 additions and 206 deletions

View File

@ -21,7 +21,8 @@ progress (what's shipped vs. in flight) see [`docs/STATUS.md`](docs/STATUS.md).
`opencode`, `aider`, `amp`, `goose`, `copilot`, `grok`, `qwen`, `kimi`,
`crush`, `cline`, `droid`, `devin`, `auggie`, `continue`, `kiro`, `kilocode`,
and more), registered through a shared registry with common
activity-dispatch / hook utilities. The default is set by `AO_AGENT`.
activity-dispatch / hook utilities. Worker and orchestrator defaults are set
per project.
- **Isolated workspaces.** Worker and orchestrator sessions spawn into their own
`git worktree` (`backend/internal/adapters/workspace/gitworktree/`), launched
inside a `zellij` runtime adapter (`backend/internal/adapters/runtime/`) so
@ -92,9 +93,10 @@ go build -o /tmp/ao ./cmd/ao
# Register a local git repo as a project. The id defaults to the lowercased
# base of --path; pass --id explicitly when the directory name doesn't match.
/tmp/ao project add --path /path/to/your/repo --id your-repo --name your-repo
/tmp/ao project add --path /path/to/your/repo --id your-repo --name your-repo \
--worker-agent codex --orchestrator-agent codex
# Spawn a worker session running the default agent.
# Spawn a worker session running the project's worker agent.
/tmp/ao spawn --project your-repo --prompt "Refactor the auth module"
# Inspect what's running.
@ -167,7 +169,7 @@ exposing it beyond loopback would be a security regression.
| `AO_SHUTDOWN_TIMEOUT` | `10s` | Graceful-shutdown hard cap. |
| `AO_RUN_FILE` | `<UserConfigDir>/agent-orchestrator/running.json` | PID + port handshake path. |
| `AO_DATA_DIR` | `<UserConfigDir>/agent-orchestrator/data` | SQLite DB, WAL files, managed state. |
| `AO_AGENT` | `claude-code` | Default agent adapter id used by `ao spawn`. |
| `AO_AGENT` | `claude-code` | Compatibility agent adapter id validated at daemon startup. |
| `AO_SESSION_ID` | _(unset)_ | Set inside spawned sessions; read by `ao send` and `ao hooks`. |
| `GITHUB_TOKEN` | _(unset)_ | Used by the GitHub SCM and tracker adapters. Falls back to `gh auth token`. |

View File

@ -222,6 +222,8 @@ func TestE2E_SpawnAndProjectAddDTORoundTrip(t *testing.T) {
"--path", "/repo/mer",
"--id", "demo",
"--name", "Demo",
"--worker-agent", "codex",
"--orchestrator-agent", "claude-code",
"--as-workspace",
})
if err := root.Execute(); err != nil {
@ -238,6 +240,15 @@ func TestE2E_SpawnAndProjectAddDTORoundTrip(t *testing.T) {
if got.Name == nil || *got.Name != "Demo" {
t.Errorf("Name = %v, want %q", got.Name, "Demo")
}
if got.Config == nil {
t.Fatal("Config = nil, want role agent config")
}
if got.Config.Worker.Harness != domain.HarnessCodex {
t.Errorf("Config.Worker.Harness = %q, want codex", got.Config.Worker.Harness)
}
if got.Config.Orchestrator.Harness != domain.HarnessClaudeCode {
t.Errorf("Config.Orchestrator.Harness = %q, want claude-code", got.Config.Orchestrator.Harness)
}
if !got.AsWorkspace {
t.Errorf("AsWorkspace = false, want true (CLI json:\"asWorkspace\" vs AddInput)")
}

View File

@ -18,6 +18,8 @@ type projectAddOptions struct {
path string
id string
name string
workerAgent string
orchestratorAgent string
asWorkspace bool
}
@ -40,6 +42,7 @@ type addProjectRequest struct {
Path string `json:"path"`
ProjectID *string `json:"projectId,omitempty"`
Name *string `json:"name,omitempty"`
Config *projectConfig `json:"config,omitempty"`
AsWorkspace bool `json:"asWorkspace,omitempty"`
}
@ -58,7 +61,7 @@ type projectDetails struct {
Path string `json:"path"`
Repo string `json:"repo"`
DefaultBranch string `json:"defaultBranch"`
DefaultHarness string `json:"agent,omitempty"`
Agent string `json:"agent,omitempty"`
Config *projectConfig `json:"config,omitempty"`
WorkspaceRepos []workspaceRepoDetails `json:"workspaceRepos,omitempty"`
ResolveError string `json:"resolveError,omitempty"`
@ -226,6 +229,12 @@ func newProjectAddCommand(ctx *commandContext) *cobra.Command {
if opts.name != "" {
req.Name = &opts.name
}
if opts.workerAgent != "" || opts.orchestratorAgent != "" {
req.Config = &projectConfig{
Worker: roleOverride{Agent: opts.workerAgent},
Orchestrator: roleOverride{Agent: opts.orchestratorAgent},
}
}
var res projectResult
if err := ctx.postJSON(cmd.Context(), "projects", req, &res); err != nil {
return err
@ -238,6 +247,8 @@ func newProjectAddCommand(ctx *commandContext) *cobra.Command {
f.StringVar(&opts.path, "path", "", "Absolute path to the local git repo (required)")
f.StringVar(&opts.id, "id", "", "Project id (default: derived by the daemon from the path)")
f.StringVar(&opts.name, "name", "", "Display name")
f.StringVar(&opts.workerAgent, "worker-agent", "", "Default worker session agent")
f.StringVar(&opts.orchestratorAgent, "orchestrator-agent", "", "Default orchestrator session agent")
f.BoolVar(&opts.asWorkspace, "as-workspace", false, "Register a parent folder as a workspace project (root-as-repo plus direct child repos)")
return cmd
}
@ -443,7 +454,7 @@ func writeProjectDetails(cmd *cobra.Command, res projectGetResult) error {
{label: "path", value: p.Path},
{label: "repo", value: p.Repo},
{label: "default branch", value: p.DefaultBranch},
{label: "default harness", value: p.DefaultHarness},
{label: "agent", value: p.Agent},
{label: "config", value: formatProjectConfig(p.Config)},
{label: "resolve error", value: p.ResolveError},
}

View File

@ -107,7 +107,7 @@ func TestProjectGet_Success(t *testing.T) {
if capture.method != http.MethodGet || capture.path != "/api/v1/projects/demo" {
t.Fatalf("request = %s %s, want GET /api/v1/projects/demo", capture.method, capture.path)
}
for _, want := range []string{"Project demo (ok)", "name: Demo", "path: /repo/demo", "default branch: main", "default harness: codex"} {
for _, want := range []string{"Project demo (ok)", "name: Demo", "path: /repo/demo", "default branch: main", "agent: codex"} {
if !strings.Contains(out, want) {
t.Fatalf("output missing %q:\n%s", want, out)
}

View File

@ -44,7 +44,7 @@ func newSpawnCommand(ctx *commandContext) *cobra.Command {
Use: "spawn",
Short: "Spawn a worker agent session in a registered project",
Long: "Spawn a worker agent session in a registered project.\n\n" +
"The session runs the chosen agent (default: the daemon's AO_AGENT) in a\n" +
"The session runs the chosen agent in a\n" +
"fresh git worktree. Register the project first with `ao project add`.",
Args: noArgs,
RunE: func(cmd *cobra.Command, args []string) error {
@ -120,7 +120,7 @@ func newSpawnCommand(ctx *commandContext) *cobra.Command {
return pflag.NormalizedName(name)
})
f.StringVar(&opts.project, "project", "", "Project id to spawn the session in (required)")
f.StringVar(&opts.harness, "harness", "", "Agent harness / --agent: claude-code, codex, aider, opencode, grok, droid, amp, agy, crush, cursor, qwen, copilot, goose, auggie, continue, devin, cline, kimi, kiro, kilocode, vibe, pi, autohand (default: the daemon's AO_AGENT)")
f.StringVar(&opts.harness, "harness", "", "Agent harness / --agent: claude-code, codex, aider, opencode, grok, droid, amp, agy, crush, cursor, qwen, copilot, goose, auggie, continue, devin, cline, kimi, kiro, kilocode, vibe, pi, autohand (default: project worker.agent; required if the project has none)")
f.StringVar(&opts.branch, "branch", "", "Branch for the session worktree (default: ao/<session-id>/root)")
f.StringVar(&opts.prompt, "prompt", "", "Initial prompt for the agent")
f.StringVar(&opts.issue, "issue", "", "Issue id to associate with the session")

View File

@ -29,8 +29,9 @@ const (
// DefaultShutdownTimeout is the hard cap on graceful shutdown. After this
// the process exits even if connections are still draining.
DefaultShutdownTimeout = 10 * time.Second
// DefaultAgent is the agent adapter id the daemon wires when AO_AGENT is
// unset. It matches the claude-code adapter's manifest id.
// DefaultAgent is the compatibility value used when AO_AGENT is unset. The
// daemon validates it at startup, but worker/orchestrator spawns resolve from
// explicit requests or project role config instead of falling back to it.
DefaultAgent = "claude-code"
// DefaultTelemetryPostHogHost is the default PostHog ingestion host when
// remote telemetry is enabled and AO_TELEMETRY_POSTHOG_HOST is unset.
@ -85,9 +86,8 @@ type Config struct {
// DataDir is the directory holding durable SQLite state: DB and WAL files.
// It is created on first use by the storage layer.
DataDir string
// Agent is the id of the agent adapter the daemon wires into the Session
// Manager (see DefaultAgent). Selected by AO_AGENT; startSession fails fast
// if no adapter with this id is registered.
// Agent is the compatibility agent adapter id selected by AO_AGENT;
// startSession fails fast if no adapter with this id is registered.
Agent string
// AllowedOrigins are the browser origins granted CORS read access (see
// DefaultAllowedOrigins). Overridden by AO_ALLOWED_ORIGINS.
@ -113,7 +113,7 @@ func (c Config) Addr() string {
// AO_SHUTDOWN_TIMEOUT shutdown deadline (Go duration > 0, default 10s)
// AO_RUN_FILE running.json path (default ~/.ao/running.json)
// AO_DATA_DIR durable state dir (default ~/.ao/data)
// AO_AGENT agent adapter id (default claude-code)
// AO_AGENT compatibility agent id (default claude-code)
// AO_ALLOWED_ORIGINS CORS origins, comma-separated (default DefaultAllowedOrigins)
// AO_TELEMETRY_EVENTS local event capture off|on (default off)
// AO_TELEMETRY_METRICS local metric capture off|on (default off)

View File

@ -116,7 +116,7 @@ func Run() error {
// Wire the controller-facing session service over the same store + LCM, the
// zellij runtime, a gitworktree workspace, the per-session agent resolver
// (AO_AGENT default, validated here), and the agent messenger, then mount it
// (AO_AGENT validated here for compatibility), and the agent messenger, then mount it
// on the API.
sessionSvc, reviewSvc, err := startSession(cfg, runtimeAdapter, store, lcStack.LCM, messenger, telemetrySink, log)
if err != nil {

View File

@ -60,12 +60,9 @@ func (l *lifecycleStack) Stop() {
// startSession builds the controller-facing session service: a session manager
// over the real zellij runtime, a per-session gitworktree workspace, the shared
// store + LCM, the per-session agent resolver (AO_AGENT default), and the
// agent messenger. The returned service is mounted at httpd APIDeps.Sessions.
// store + LCM, the per-session agent resolver, and the agent messenger. The
// returned service is mounted at httpd APIDeps.Sessions.
func startSession(cfg config.Config, runtime *zellij.Runtime, store *sqlite.Store, lcm *lifecycle.Manager, messenger ports.AgentMessenger, telemetry ports.EventSink, log *slog.Logger) (*sessionsvc.Service, reviewsvc.Manager, error) {
// Resolve the default agent once and share it with both the resolver (which
// launches it for an unspecified harness) and the session manager (which
// persists it onto the seed row), so the stored harness matches what runs.
defaultAgent := cfg.Agent
if defaultAgent == "" {
defaultAgent = config.DefaultAgent
@ -94,7 +91,6 @@ func startSession(cfg config.Config, runtime *zellij.Runtime, store *sqlite.Stor
Messenger: messenger,
Lifecycle: lcm,
DataDir: cfg.DataDir,
DefaultHarness: domain.AgentHarness(defaultAgent),
Logger: log,
})
scmProvider, err := newGitHubSCMProvider(log)
@ -179,20 +175,15 @@ func buildAgentRegistry() (*adapters.Registry, error) {
// agentRegistry adapts the generic adapter Registry to ports.AgentResolver: it
// maps a session's harness onto the registered adapter of the same id and
// asserts that adapter drives an agent. An empty harness falls back to the
// daemon's configured default (AO_AGENT), so a spawn that names no harness still
// gets a real agent.
// asserts that adapter drives an agent. Empty harnesses are invalid at the
// session manager boundary and deliberately do not resolve here.
type agentRegistry struct {
reg *adapters.Registry
defaultHarness domain.AgentHarness
}
var _ ports.AgentResolver = agentRegistry{}
func (a agentRegistry) Agent(harness domain.AgentHarness) (ports.Agent, bool) {
if harness == "" {
harness = a.defaultHarness
}
adapter, ok := a.reg.Get(string(harness))
if !ok {
return nil, false
@ -203,10 +194,9 @@ func (a agentRegistry) Agent(harness domain.AgentHarness) (ports.Agent, bool) {
// buildAgentResolver constructs the per-session agent resolver the Session
// Manager consumes (sessionmanager.Deps.Agents): a registry of the shipped
// adapters plus the configured default harness. It fails fast if the default
// does not resolve, so a typo'd AO_AGENT surfaces at startup. The session lane
// plugs this in when it mounts the controller-facing session service at the
// httpd APIDeps.Sessions slot.
// adapters. It still validates AO_AGENT at startup for compatibility with the
// config surface, but worker/orchestrator spawns must provide a resolved
// harness before calling Agent.
func buildAgentResolver(defaultAgent string, log *slog.Logger) (ports.AgentResolver, error) {
if defaultAgent == "" {
defaultAgent = config.DefaultAgent
@ -215,8 +205,8 @@ func buildAgentResolver(defaultAgent string, log *slog.Logger) (ports.AgentResol
if err != nil {
return nil, err
}
resolver := agentRegistry{reg: reg, defaultHarness: domain.AgentHarness(defaultAgent)}
if _, ok := resolver.Agent(""); !ok {
resolver := agentRegistry{reg: reg}
if _, ok := resolver.Agent(domain.AgentHarness(defaultAgent)); !ok {
return nil, fmt.Errorf("configured default agent %q is not a registered adapter", defaultAgent)
}
ids := make([]string, 0)

View File

@ -79,8 +79,7 @@ func TestWiring_WriteFlowsToBroadcaster(t *testing.T) {
// TestWiring_AgentResolverResolvesRealAdapters asserts buildAgentResolver wires a
// real registry-backed per-session resolver: each harness resolves to the
// matching registered adapter, an empty harness falls back to the AO_AGENT
// default, and an unknown harness misses.
// matching registered adapter, while empty and unknown harnesses miss.
func TestWiring_AgentResolverResolvesRealAdapters(t *testing.T) {
log := slog.New(slog.NewTextHandler(io.Discard, nil))
resolver, err := buildAgentResolver("", log) // empty default → claude-code
@ -114,7 +113,6 @@ func TestWiring_AgentResolverResolvesRealAdapters(t *testing.T) {
{domain.HarnessVibe, "vibe"},
{domain.HarnessPi, "pi"},
{domain.HarnessAutohand, "autohand"},
{"", config.DefaultAgent}, // empty harness falls back to the AO_AGENT default
} {
agent, ok := resolver.Agent(tc.harness)
if !ok {
@ -131,6 +129,9 @@ func TestWiring_AgentResolverResolvesRealAdapters(t *testing.T) {
if _, ok := resolver.Agent("definitely-not-an-agent"); ok {
t.Fatal("unknown harness resolved to an agent; want a miss")
}
if _, ok := resolver.Agent(""); ok {
t.Fatal("empty harness resolved to an agent; want a miss")
}
}
// TestWiring_StartSessionBuildsSessionService asserts the daemon's startSession

View File

@ -89,7 +89,15 @@ func newStack(t *testing.T) *stack {
t.Fatal(err)
}
t.Cleanup(func() { _ = store.Close() })
if err := store.UpsertProject(ctx, domain.ProjectRecord{ID: "mer", Path: "/repo/mer", RegisteredAt: time.Now()}); err != nil {
if err := store.UpsertProject(ctx, domain.ProjectRecord{
ID: "mer",
Path: "/repo/mer",
RegisteredAt: time.Now(),
Config: domain.ProjectConfig{
Worker: domain.RoleOverride{Harness: domain.HarnessClaudeCode},
Orchestrator: domain.RoleOverride{Harness: domain.HarnessClaudeCode},
},
}); err != nil {
t.Fatal(err)
}
msg := &captureMessenger{}

View File

@ -452,6 +452,8 @@ func toAPIError(err error) error {
return apierr.Invalid("PROJECT_NOT_RESOLVABLE", "Project is not registered or has no repo — register it with `ao project add`", nil)
case errors.Is(err, sessionmanager.ErrUnknownHarness):
return apierr.Invalid("UNKNOWN_HARNESS", err.Error(), nil)
case errors.Is(err, sessionmanager.ErrMissingHarness):
return apierr.Invalid("AGENT_REQUIRED", err.Error(), nil)
case errors.Is(err, ports.ErrWorkspaceBranchCheckedOutElsewhere):
return apierr.Conflict("BRANCH_CHECKED_OUT_ELSEWHERE", err.Error(), nil)
case errors.Is(err, ports.ErrWorkspaceBranchNotFetched):

View File

@ -432,6 +432,7 @@ func TestToAPIErrorMapsWorkspaceBranchSentinels(t *testing.T) {
{"invalid branch", fmt.Errorf("spawn mer-1: workspace: %w: \"bad!!\" (exit 1)", ports.ErrWorkspaceBranchInvalid), apierr.KindInvalid, "INVALID_BRANCH"},
{"agent binary not found", fmt.Errorf("spawn mer-1: %w", ports.ErrAgentBinaryNotFound), apierr.KindInvalid, "AGENT_BINARY_NOT_FOUND"},
{"unknown harness", fmt.Errorf("spawn: %w: %q", sessionmanager.ErrUnknownHarness, "bogus"), apierr.KindInvalid, "UNKNOWN_HARNESS"},
{"missing harness", fmt.Errorf("spawn: %w: configure project worker.agent or pass --harness", sessionmanager.ErrMissingHarness), apierr.KindInvalid, "AGENT_REQUIRED"},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {

View File

@ -32,6 +32,9 @@ var (
// adapter. The API maps it to a 400 so a typo'd `--harness` is a validation
// error, not an opaque 500.
ErrUnknownHarness = errors.New("session: unknown agent harness")
// ErrMissingHarness means neither the spawn request nor the project's role
// config selected an agent. Worker/orchestrator spawns must be explicit.
ErrMissingHarness = errors.New("session: agent harness required")
)
// Env vars a spawned process reads to learn who it is.
@ -86,10 +89,6 @@ type Manager struct {
messenger ports.AgentMessenger
lcm lifecycleRecorder
dataDir string
// defaultHarness is the daemon's configured default agent (AO_AGENT). A spawn
// that names no harness resolves to it before the seed row is written, so the
// stored/returned harness matches the agent the resolver actually launches.
defaultHarness domain.AgentHarness
clock func() time.Time
// lookPath is exec.LookPath in production; tests substitute a stub so
// they don't need real binaries on PATH. Returns ports.ErrAgentBinaryNotFound
@ -113,11 +112,6 @@ type Deps struct {
// DataDir is exported to spawned agents as AO_DATA_DIR so their hook
// commands can open the same store.
DataDir string
// DefaultHarness is the daemon's configured default agent (AO_AGENT), used to
// resolve a spawn that names no harness. Wiring passes config.DefaultAgent;
// left empty, an unspecified harness stays empty (the resolver still defaults
// it at launch, but the record won't reflect the real agent).
DefaultHarness domain.AgentHarness
Clock func() time.Time
// LookPath overrides exec.LookPath for the pre-launch agent-binary check.
// Production wiring leaves this nil and the manager defaults to
@ -143,7 +137,6 @@ func New(d Deps) *Manager {
messenger: d.Messenger,
lcm: d.Lifecycle,
dataDir: d.DataDir,
defaultHarness: d.DefaultHarness,
clock: d.Clock,
lookPath: d.LookPath,
executable: d.Executable,
@ -179,12 +172,8 @@ func (m *Manager) Spawn(ctx context.Context, cfg ports.SpawnConfig) (domain.Sess
// A per-project role override picks the harness when the spawn names none,
// so a project can default workers to one agent and orchestrators to another.
cfg.Harness = effectiveHarness(cfg.Harness, cfg.Kind, project.Config)
// Resolve an unspecified harness to the daemon default BEFORE the seed row is
// written, so the stored/returned harness matches the agent the resolver
// launches (otherwise a default-agent session persists an empty harness and
// the UI can't tell which agent is running).
if cfg.Harness == "" {
cfg.Harness = m.defaultHarness
return domain.SessionRecord{}, fmt.Errorf("spawn: %w: configure project %s.agent or pass --harness", ErrMissingHarness, roleConfigName(cfg.Kind))
}
// Reject an unknown harness before any durable state is created. Doing this
@ -307,8 +296,8 @@ func (m *Manager) loadProject(ctx context.Context, projectID domain.ProjectID) (
}
// effectiveHarness resolves the harness for a spawn: an explicit harness wins;
// otherwise the project's role override for the session kind applies; otherwise
// it stays empty so the daemon's global default (AO_AGENT) is used downstream.
// otherwise the project's role override for the session kind applies. Empty is
// invalid for new worker/orchestrator launches and is rejected by Spawn.
func effectiveHarness(explicit domain.AgentHarness, kind domain.SessionKind, cfg domain.ProjectConfig) domain.AgentHarness {
if explicit != "" {
return explicit
@ -319,6 +308,13 @@ func effectiveHarness(explicit domain.AgentHarness, kind domain.SessionKind, cfg
return ""
}
func roleConfigName(kind domain.SessionKind) string {
if kind == domain.KindOrchestrator {
return "orchestrator"
}
return "worker"
}
// effectiveAgentConfig merges the role override's agent config over the
// project's base agent config; set override fields win.
func effectiveAgentConfig(kind domain.SessionKind, cfg domain.ProjectConfig) ports.AgentConfig {

View File

@ -223,6 +223,7 @@ func (m *fakeMessenger) Send(_ context.Context, _ domain.SessionID, msg string)
func newManager() (*Manager, *fakeStore, *fakeRuntime, *fakeWorkspace) {
st := newFakeStore()
st.projects["mer"] = domain.ProjectRecord{ID: "mer", Config: testRoleAgents()}
rt := &fakeRuntime{}
ws := &fakeWorkspace{}
// Stub lookPath so the pre-launch agent-binary check passes; the fakeAgent
@ -231,6 +232,12 @@ func newManager() (*Manager, *fakeStore, *fakeRuntime, *fakeWorkspace) {
m := New(Deps{Runtime: rt, Agents: fakeAgents{}, Workspace: ws, Store: st, Messenger: &fakeMessenger{}, Lifecycle: &fakeLCM{store: st}, LookPath: lookPath})
return m, st, rt, ws
}
func testRoleAgents() domain.ProjectConfig {
return domain.ProjectConfig{
Worker: domain.RoleOverride{Harness: domain.HarnessClaudeCode},
Orchestrator: domain.RoleOverride{Harness: domain.HarnessClaudeCode},
}
}
func seedTerminal(st *fakeStore, id domain.SessionID, meta domain.SessionMetadata) {
st.sessions[id] = domain.SessionRecord{ID: id, ProjectID: "mer", Metadata: meta, IsTerminated: true, Activity: domain.Activity{State: domain.ActivityExited}}
}
@ -273,10 +280,11 @@ func TestSpawn_ResolvesProjectConfig(t *testing.T) {
t.Fatal("runtime env missing AO_SESSION_ID")
}
// A project with no stored config yields a zero AgentConfig (adapter defaults).
// A project with no stored config yields a zero AgentConfig (adapter defaults)
// when the spawn explicitly names its agent.
st.projects["bare"] = domain.ProjectRecord{ID: "bare"}
agent.lastConfig = ports.AgentConfig{Model: "stale"}
if _, err := m.Spawn(ctx, ports.SpawnConfig{ProjectID: "bare", Kind: domain.KindWorker}); err != nil {
if _, err := m.Spawn(ctx, ports.SpawnConfig{ProjectID: "bare", Kind: domain.KindWorker, Harness: domain.HarnessCodex}); err != nil {
t.Fatal(err)
}
if !agent.lastConfig.IsZero() {
@ -284,30 +292,38 @@ func TestSpawn_ResolvesProjectConfig(t *testing.T) {
}
}
// TestSpawn_PersistsResolvedDefaultHarness locks the fix for the mislabelled
// agent: a spawn that names no harness must persist the daemon's default agent
// (so the API/UI report what actually runs), while an explicit harness wins.
func TestSpawn_PersistsResolvedDefaultHarness(t *testing.T) {
func TestSpawn_RejectsMissingRoleHarness(t *testing.T) {
st := newFakeStore()
st.projects["mer"] = domain.ProjectRecord{ID: "mer"}
m := New(Deps{
Runtime: &fakeRuntime{}, Agents: fakeAgents{}, Workspace: &fakeWorkspace{}, Store: st,
Messenger: &fakeMessenger{}, Lifecycle: &fakeLCM{store: st},
LookPath: func(string) (string, error) { return "/bin/true", nil },
DefaultHarness: domain.HarnessClaudeCode,
})
if _, err := m.Spawn(ctx, ports.SpawnConfig{ProjectID: "mer", Kind: domain.KindWorker}); err != nil {
t.Fatal(err)
if _, err := m.Spawn(ctx, ports.SpawnConfig{ProjectID: "mer", Kind: domain.KindWorker}); !errors.Is(err, ErrMissingHarness) {
t.Fatalf("worker err = %v, want ErrMissingHarness", err)
}
if got := st.sessions["mer-1"].Harness; got != domain.HarnessClaudeCode {
t.Fatalf("unspecified harness = %q, want resolved default %q", got, domain.HarnessClaudeCode)
if len(st.sessions) != 0 {
t.Fatalf("missing worker harness must not create a session row, got %d", len(st.sessions))
}
if _, err := m.Spawn(ctx, ports.SpawnConfig{ProjectID: "mer", Kind: domain.KindOrchestrator}); !errors.Is(err, ErrMissingHarness) {
t.Fatalf("orchestrator err = %v, want ErrMissingHarness", err)
}
}
func TestSpawn_ExplicitHarnessWinsWithoutProjectRoleHarness(t *testing.T) {
st := newFakeStore()
st.projects["mer"] = domain.ProjectRecord{ID: "mer"}
m := New(Deps{
Runtime: &fakeRuntime{}, Agents: fakeAgents{}, Workspace: &fakeWorkspace{}, Store: st,
Messenger: &fakeMessenger{}, Lifecycle: &fakeLCM{store: st},
LookPath: func(string) (string, error) { return "/bin/true", nil },
})
if _, err := m.Spawn(ctx, ports.SpawnConfig{ProjectID: "mer", Kind: domain.KindWorker, Harness: domain.HarnessCodex}); err != nil {
t.Fatal(err)
}
if got := st.sessions["mer-2"].Harness; got != domain.HarnessCodex {
if got := st.sessions["mer-1"].Harness; got != domain.HarnessCodex {
t.Fatalf("explicit harness = %q, want %q", got, domain.HarnessCodex)
}
}
@ -561,7 +577,10 @@ func TestSpawn_ForwardsResolvedAgentConfigPermissions(t *testing.T) {
st := newFakeStore()
st.projects["mer"] = domain.ProjectRecord{ID: "mer", Config: domain.ProjectConfig{
AgentConfig: domain.AgentConfig{Permissions: domain.PermissionModeAuto},
Worker: domain.RoleOverride{AgentConfig: domain.AgentConfig{Permissions: domain.PermissionModeBypassPermissions}},
Worker: domain.RoleOverride{
Harness: domain.HarnessClaudeCode,
AgentConfig: domain.AgentConfig{Permissions: domain.PermissionModeBypassPermissions},
},
}}
agent := &recordingAgent{}
lookPath := func(string) (string, error) { return "/bin/true", nil }
@ -610,6 +629,7 @@ func TestRestore_ForwardsResolvedAgentConfigPermissions(t *testing.T) {
func TestSpawnWorker_AppendsActiveOrchestratorContact(t *testing.T) {
st := newFakeStore()
st.projects["mer"] = domain.ProjectRecord{ID: "mer", Config: testRoleAgents()}
st.num = 1
st.sessions["mer-1"] = domain.SessionRecord{ID: "mer-1", ProjectID: "mer", Kind: domain.KindOrchestrator}
agent := &recordingAgent{}
@ -646,6 +666,7 @@ func TestSpawnWorker_AppendsActiveOrchestratorContact(t *testing.T) {
func TestSpawnWorker_SkipsTerminatedOrchestratorContact(t *testing.T) {
st := newFakeStore()
st.projects["mer"] = domain.ProjectRecord{ID: "mer", Config: testRoleAgents()}
st.num = 1
st.sessions["mer-1"] = domain.SessionRecord{ID: "mer-1", ProjectID: "mer", Kind: domain.KindOrchestrator, IsTerminated: true}
agent := &recordingAgent{}
@ -666,6 +687,7 @@ func TestSpawnWorker_SkipsTerminatedOrchestratorContact(t *testing.T) {
func TestSpawnOrchestrator_UsesCoordinatorPrompt(t *testing.T) {
st := newFakeStore()
st.projects["mer"] = domain.ProjectRecord{ID: "mer", Config: testRoleAgents()}
agent := &recordingAgent{}
rt := &fakeRuntime{}
ws := &fakeWorkspace{}
@ -878,6 +900,7 @@ func TestRollbackSpawn_FallsBackToKillForLiveRow(t *testing.T) {
// reaper later mistakes for a live session.
func TestSpawn_RejectsMissingAgentBinary(t *testing.T) {
st := newFakeStore()
st.projects["mer"] = domain.ProjectRecord{ID: "mer", Config: testRoleAgents()}
rt := &fakeRuntime{}
ws := &fakeWorkspace{}
notFound := func(name string) (string, error) {
@ -927,6 +950,7 @@ func TestSpawn_RejectsUnknownHarness(t *testing.T) {
// buffer capturing its log output, for the hook PATH pin tests.
func pathPinManager(executable func() (string, error)) (*Manager, *fakeStore, *fakeRuntime, *bytes.Buffer) {
st := newFakeStore()
st.projects["mer"] = domain.ProjectRecord{ID: "mer", Config: testRoleAgents()}
rt := &fakeRuntime{}
logBuf := &bytes.Buffer{}
lookPath := func(string) (string, error) { return "/bin/true", nil }
@ -1019,6 +1043,7 @@ func TestSpawn_ProjectPATHIsPinBase(t *testing.T) {
m, st, rt, _ := pathPinManager(func() (string, error) { return daemonExe, nil })
st.projects["mer"] = domain.ProjectRecord{ID: "mer", Config: domain.ProjectConfig{
Env: map[string]string{"PATH": "/proj/bin"},
Worker: domain.RoleOverride{Harness: domain.HarnessClaudeCode},
}}
if _, err := m.Spawn(ctx, ports.SpawnConfig{ProjectID: "mer", Kind: domain.KindWorker}); err != nil {
t.Fatal(err)

View File

@ -0,0 +1,145 @@
import * as Dialog from "@radix-ui/react-dialog";
import { X } from "lucide-react";
import { useEffect, useState } from "react";
import { AGENT_OPTIONS } from "../lib/agent-options";
import { Button } from "./ui/button";
import { Label } from "./ui/label";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "./ui/select";
export type CreateProjectAgentSelection = {
workerAgent: string;
orchestratorAgent: string;
};
type CreateProjectAgentSheetProps = {
error?: string | null;
isCreating: boolean;
onOpenChange: (open: boolean) => void;
onSubmit: (selection: CreateProjectAgentSelection) => Promise<void>;
open: boolean;
path: string | null;
};
export function CreateProjectAgentSheet({
error,
isCreating,
onOpenChange,
onSubmit,
open,
path,
}: CreateProjectAgentSheetProps) {
const [workerAgent, setWorkerAgent] = useState("");
const [orchestratorAgent, setOrchestratorAgent] = useState("");
const canSubmit = workerAgent !== "" && orchestratorAgent !== "" && !isCreating;
useEffect(() => {
if (!open) {
setWorkerAgent("");
setOrchestratorAgent("");
}
}, [open, path]);
return (
<Dialog.Root open={open} onOpenChange={(next) => !isCreating && onOpenChange(next)}>
<Dialog.Portal>
<Dialog.Overlay className="fixed inset-0 z-50 bg-black/55 data-[state=open]:animate-overlay-in" />
<Dialog.Content className="fixed left-1/2 top-1/2 z-50 w-[min(420px,calc(100vw-32px))] -translate-x-1/2 -translate-y-1/2 rounded-lg border border-border bg-popover p-0 text-popover-foreground shadow-xl data-[state=open]:animate-modal-in">
<div className="flex items-start justify-between gap-4 border-b border-border px-5 py-4">
<div className="min-w-0">
<Dialog.Title className="text-[15px] font-semibold text-foreground">Project agents</Dialog.Title>
<Dialog.Description className="mt-1 break-all text-[12px] text-muted-foreground">
{path ?? ""}
</Dialog.Description>
</div>
<Dialog.Close asChild>
<button
type="button"
className="grid size-7 shrink-0 place-items-center rounded-md text-muted-foreground transition hover:bg-surface hover:text-foreground disabled:pointer-events-none disabled:opacity-50"
aria-label="Close project agents dialog"
disabled={isCreating}
>
<X className="size-4" aria-hidden="true" />
</button>
</Dialog.Close>
</div>
<form
className="space-y-4 px-5 py-4"
onSubmit={(event) => {
event.preventDefault();
if (!canSubmit) return;
void onSubmit({ workerAgent, orchestratorAgent });
}}
>
<div className="grid gap-3 sm:grid-cols-2">
<RequiredAgentField
id="newProjectWorkerAgent"
label="Worker agent"
placeholder="Select worker agent"
value={workerAgent}
onChange={setWorkerAgent}
/>
<RequiredAgentField
id="newProjectOrchestratorAgent"
label="Orchestrator agent"
placeholder="Select orchestrator agent"
value={orchestratorAgent}
onChange={setOrchestratorAgent}
/>
</div>
{error && (
<div className="rounded-md border border-destructive/40 bg-destructive/10 px-3 py-2 text-[12px] leading-5 text-destructive">
{error}
</div>
)}
<div className="flex items-center justify-end gap-2 pt-1">
<Button type="button" variant="ghost" disabled={isCreating} onClick={() => onOpenChange(false)}>
Cancel
</Button>
<Button type="submit" variant="primary" disabled={!canSubmit}>
{isCreating ? "Creating..." : "Create and start"}
</Button>
</div>
</form>
</Dialog.Content>
</Dialog.Portal>
</Dialog.Root>
);
}
export function RequiredAgentField({
id,
invalid = false,
label,
onChange,
placeholder,
value,
}: {
id: string;
invalid?: boolean;
label: string;
onChange: (value: string) => void;
placeholder: string;
value: string;
}) {
return (
<div className="flex flex-col gap-1.5">
<Label htmlFor={id} className="text-[12px] font-medium text-muted-foreground">
{label}
</Label>
<Select value={value} onValueChange={onChange}>
<SelectTrigger id={id} className="h-8 w-full text-[13px]" aria-invalid={invalid || undefined}>
<SelectValue placeholder={placeholder} />
</SelectTrigger>
<SelectContent>
{AGENT_OPTIONS.map((agent) => (
<SelectItem key={agent} value={agent}>
{agent}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
);
}

View File

@ -149,6 +149,10 @@ describe("ProjectSettingsForm", () => {
path: "/repo/project-one",
repo: "",
defaultBranch: "main",
config: {
worker: { agent: "codex" },
orchestrator: { agent: "claude-code" },
},
},
},
error: undefined,
@ -165,4 +169,35 @@ describe("ProjectSettingsForm", () => {
expect(await screen.findByText("invalid permissions")).toBeInTheDocument();
expect(screen.queryByText("Saved.")).not.toBeInTheDocument();
});
it("requires worker and orchestrator agents for existing projects missing role config", async () => {
getMock.mockResolvedValue({
data: {
status: "ok",
project: {
id: "proj-1",
name: "Project One",
kind: "single_repo",
path: "/repo/project-one",
repo: "",
defaultBranch: "main",
config: {},
},
},
error: undefined,
});
renderSettings();
expect(await screen.findByText("Worker and orchestrator agents are required.")).toBeInTheDocument();
expect(screen.getByRole("combobox", { name: "Default worker agent" })).toHaveTextContent("Select worker agent");
expect(screen.getByRole("combobox", { name: "Default orchestrator agent" })).toHaveTextContent(
"Select orchestrator agent",
);
await userEvent.click(screen.getByRole("button", { name: "Save changes" }));
expect(await screen.findAllByText("Worker and orchestrator agents are required.")).toHaveLength(2);
expect(putMock).not.toHaveBeenCalled();
});
});

View File

@ -3,6 +3,7 @@ import { useState } from "react";
import type { components } from "../../api/schema";
import { apiClient, apiErrorMessage } from "../lib/api-client";
import { workspaceQueryKey } from "../hooks/useWorkspaceQuery";
import { RequiredAgentField } from "./CreateProjectAgentSheet";
import { DashboardSubhead } from "./DashboardSubhead";
import { Button } from "./ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "./ui/card";
@ -12,9 +13,6 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from ".
type Project = components["schemas"]["Project"];
type ProjectConfig = components["schemas"]["ProjectConfig"];
// Agents the daemon registers. Empty = "use the daemon default".
const AGENT_OPTIONS = ["claude-code", "codex", "opencode", "amp", "goose", "kiro"] as const;
const PERMISSION_MODE_OPTIONS = [
{ value: "default", label: "Default" },
{ value: "accept-edits", label: "Accept edits" },
@ -78,6 +76,8 @@ function SettingsBody({ project, projectId, onSaved }: { project: Project; proje
reviewerHarness: config.reviewers?.[0]?.harness ?? "",
});
const [savedAt, setSavedAt] = useState<number | null>(null);
const [validationError, setValidationError] = useState<string | null>(null);
const missingRequiredAgent = form.workerAgent === "" || form.orchestratorAgent === "";
const mutation = useMutation({
mutationFn: async () => {
@ -87,8 +87,8 @@ function SettingsBody({ project, projectId, onSaved }: { project: Project; proje
...config,
defaultBranch: form.defaultBranch || undefined,
sessionPrefix: form.sessionPrefix || undefined,
worker: blankToUndefined({ ...config.worker, agent: form.workerAgent || undefined }),
orchestrator: blankToUndefined({ ...config.orchestrator, agent: form.orchestratorAgent || undefined }),
worker: { ...config.worker, agent: form.workerAgent },
orchestrator: { ...config.orchestrator, agent: form.orchestratorAgent },
agentConfig: blankToUndefined({
...config.agentConfig,
model: form.model || undefined,
@ -104,6 +104,7 @@ function SettingsBody({ project, projectId, onSaved }: { project: Project; proje
},
onSuccess: () => {
setSavedAt(Date.now());
setValidationError(null);
void queryClient.invalidateQueries({ queryKey: ["project", projectId] });
onSaved();
},
@ -114,6 +115,12 @@ function SettingsBody({ project, projectId, onSaved }: { project: Project; proje
className="mx-auto flex max-w-2xl flex-col gap-4"
onSubmit={(event) => {
event.preventDefault();
setSavedAt(null);
if (missingRequiredAgent) {
setValidationError("Worker and orchestrator agents are required.");
return;
}
setValidationError(null);
mutation.mutate();
}}
>
@ -159,20 +166,25 @@ function SettingsBody({ project, projectId, onSaved }: { project: Project; proje
<CardTitle className="text-[13px]">Agents</CardTitle>
</CardHeader>
<CardContent className="flex flex-col gap-4">
<Field label="Default worker agent" htmlFor="workerAgent">
<AgentSelect
<RequiredAgentField
id="workerAgent"
value={form.workerAgent}
placeholder="Select worker agent"
label="Default worker agent"
invalid={validationError !== null && form.workerAgent === ""}
onChange={(v) => setForm((f) => ({ ...f, workerAgent: v }))}
/>
</Field>
<Field label="Default orchestrator agent" htmlFor="orchestratorAgent">
<AgentSelect
<RequiredAgentField
id="orchestratorAgent"
value={form.orchestratorAgent}
placeholder="Select orchestrator agent"
label="Default orchestrator agent"
invalid={validationError !== null && form.orchestratorAgent === ""}
onChange={(v) => setForm((f) => ({ ...f, orchestratorAgent: v }))}
/>
</Field>
{missingRequiredAgent && (
<p className="text-[12px] leading-5 text-error">Worker and orchestrator agents are required.</p>
)}
<Field label="Model override" htmlFor="model">
<input
id="model"
@ -211,6 +223,7 @@ function SettingsBody({ project, projectId, onSaved }: { project: Project; proje
<Button type="submit" variant="primary" disabled={mutation.isPending}>
{mutation.isPending ? "Saving…" : "Save changes"}
</Button>
{validationError && <span className="text-[12px] text-error">{validationError}</span>}
{mutation.isError && (
<span className="text-[12px] text-error">
{mutation.error instanceof Error ? mutation.error.message : "Save failed"}
@ -250,25 +263,6 @@ function PermissionModeSelect({
);
}
function AgentSelect({ id, value, onChange }: { id: string; value: string; onChange: (value: string) => void }) {
// "" sentinel → daemon default; Select can't hold an empty value, so map it.
return (
<Select value={value || "__default__"} onValueChange={(v) => onChange(v === "__default__" ? "" : v)}>
<SelectTrigger id={id} className="h-8 w-full text-[13px]">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="__default__">Daemon default</SelectItem>
{AGENT_OPTIONS.map((agent) => (
<SelectItem key={agent} value={agent}>
{agent}
</SelectItem>
))}
</SelectContent>
</Select>
);
}
function ReviewerSelect({ id, value, onChange }: { id: string; value: string; onChange: (value: string) => void }) {
return (
<Select value={value || "__default__"} onValueChange={(v) => onChange(v === "__default__" ? "" : v)}>

View File

@ -26,7 +26,16 @@ const workspace: WorkspaceSummary = {
sessions: [],
};
function renderSidebar(onRemoveProject = vi.fn().mockResolvedValue(undefined)) {
type CreateProjectHandler = (input: { path: string; workerAgent: string; orchestratorAgent: string }) => Promise<void>;
type RemoveProjectHandler = (projectId: string) => Promise<void>;
function renderSidebar({
onCreateProject = vi.fn().mockResolvedValue(undefined) as CreateProjectHandler,
onRemoveProject = vi.fn().mockResolvedValue(undefined) as RemoveProjectHandler,
}: {
onCreateProject?: CreateProjectHandler;
onRemoveProject?: RemoveProjectHandler;
} = {}) {
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false }, mutations: { retry: false } },
});
@ -35,7 +44,7 @@ function renderSidebar(onRemoveProject = vi.fn().mockResolvedValue(undefined)) {
<SidebarProvider>
<Sidebar
daemonStatus={{ state: "running" }}
onCreateProject={vi.fn()}
onCreateProject={onCreateProject}
onRemoveProject={onRemoveProject}
workspaces={[workspace]}
/>
@ -45,6 +54,11 @@ function renderSidebar(onRemoveProject = vi.fn().mockResolvedValue(undefined)) {
return onRemoveProject;
}
async function chooseOption(trigger: HTMLElement, optionName: string) {
await userEvent.click(trigger);
await userEvent.click(await screen.findByRole("option", { name: optionName }));
}
beforeEach(() => {
navigateMock.mockReset();
vi.spyOn(window, "confirm").mockReturnValue(true);
@ -97,6 +111,30 @@ describe("Sidebar", () => {
expect(navigateMock).toHaveBeenCalledWith({ to: "/projects/$projectId", params: { projectId: "proj-1" } });
});
it("requires explicit worker and orchestrator agents when creating a project", async () => {
const user = userEvent.setup();
const onCreateProject = vi.fn().mockResolvedValue(undefined) as CreateProjectHandler;
window.ao!.app.chooseDirectory = vi.fn().mockResolvedValue("/repo/new-project");
renderSidebar({ onCreateProject });
await user.click(screen.getByLabelText("New project"));
expect(await screen.findByText("/repo/new-project")).toBeInTheDocument();
const dialog = screen.getByRole("dialog", { name: "Project agents" });
expect(dialog).toHaveClass("left-1/2", "top-1/2", "-translate-x-1/2", "-translate-y-1/2");
await chooseOption(screen.getByRole("combobox", { name: "Worker agent" }), "codex");
await chooseOption(screen.getByRole("combobox", { name: "Orchestrator agent" }), "claude-code");
await user.click(screen.getByRole("button", { name: "Create and start" }));
await waitFor(() =>
expect(onCreateProject).toHaveBeenCalledWith({
path: "/repo/new-project",
workerAgent: "codex",
orchestratorAgent: "claude-code",
}),
);
});
it("always shows action icons and reserves padding for them", () => {
renderSidebar();

View File

@ -12,7 +12,7 @@ import {
Sun,
Trash2,
} from "lucide-react";
import { useState } from "react";
import { useState, type ReactNode } from "react";
import {
attentionZone,
isOrchestratorSession,
@ -55,6 +55,7 @@ import { OrchestratorIcon } from "./icons";
import aoLogo from "../assets/ao-logo.png";
import { cn } from "../lib/utils";
import { useUiStore } from "../stores/ui-store";
import { CreateProjectAgentSheet, type CreateProjectAgentSelection } from "./CreateProjectAgentSheet";
// The macOS hiddenInset traffic lights and the fixed TitlebarNav overlay live
// in the full-width topbar's left inset (_shell renders the bar above the
@ -74,7 +75,7 @@ type SidebarProps = {
underTopbar?: boolean;
workspaceError?: string;
workspaces: WorkspaceSummary[];
onCreateProject: (input: { path: string }) => Promise<void>;
onCreateProject: (input: { path: string; workerAgent: string; orchestratorAgent: string }) => Promise<void>;
onRemoveProject: (projectId: string) => Promise<void>;
};
@ -591,15 +592,70 @@ function ProjectItem({
}
function CreateProjectButton({ onCreateProject }: Pick<SidebarProps, "onCreateProject">) {
return (
<CreateProjectFlow onCreateProject={onCreateProject}>
{({ disabled, choosePath, label }) => (
<Tooltip>
<TooltipTrigger asChild>
<button
aria-label="New project"
className="grid h-[18px] w-[18px] place-items-center rounded-[4px] text-passive transition-colors hover:bg-interactive-hover hover:text-muted-foreground"
disabled={disabled}
onClick={choosePath}
type="button"
>
<Plus className="h-[13px] w-[13px]" aria-hidden="true" />
</button>
</TooltipTrigger>
<TooltipContent>{label}</TooltipContent>
</Tooltip>
)}
</CreateProjectFlow>
);
}
function CreateProjectListItem({ onCreateProject }: Pick<SidebarProps, "onCreateProject">) {
return (
<CreateProjectFlow onCreateProject={onCreateProject}>
{({ disabled, choosePath, label }) => (
<SidebarMenuItem className="mb-px group-data-[collapsible=icon]:mb-0">
<Tooltip>
<TooltipTrigger asChild>
<button
aria-label="New project"
className="grid h-9 w-full place-items-center rounded-[5px] text-passive transition-colors hover:bg-interactive-hover hover:text-muted-foreground"
disabled={disabled}
onClick={choosePath}
type="button"
>
<Plus className="h-[13px] w-[13px]" aria-hidden="true" />
</button>
</TooltipTrigger>
<TooltipContent>{label}</TooltipContent>
</Tooltip>
</SidebarMenuItem>
)}
</CreateProjectFlow>
);
}
function CreateProjectFlow({
children,
onCreateProject,
}: Pick<SidebarProps, "onCreateProject"> & {
children: (state: { choosePath: () => void; disabled: boolean; label: string }) => ReactNode;
}) {
const [error, setError] = useState<string | null>(null);
const [selectedPath, setSelectedPath] = useState<string | null>(null);
const [isChoosingPath, setIsChoosingPath] = useState(false);
const [isCreating, setIsCreating] = useState(false);
const choosePath = async () => {
setError(null);
setIsChoosingPath(true);
try {
const selectedPath = await aoBridge.app.chooseDirectory();
if (selectedPath) await onCreateProject({ path: selectedPath });
const path = await aoBridge.app.chooseDirectory();
if (path) setSelectedPath(path);
} catch (err) {
setError(err instanceof Error ? err.message : "Could not add project");
} finally {
@ -607,22 +663,38 @@ function CreateProjectButton({ onCreateProject }: Pick<SidebarProps, "onCreatePr
}
};
const createProject = async (selection: CreateProjectAgentSelection) => {
if (!selectedPath) return;
setError(null);
setIsCreating(true);
try {
await onCreateProject({ path: selectedPath, ...selection });
setSelectedPath(null);
} catch (err) {
setError(err instanceof Error ? err.message : "Could not add project");
} finally {
setIsCreating(false);
}
};
const label = isChoosingPath ? "Opening..." : isCreating ? "Creating..." : "New project";
return (
<>
<Tooltip>
<TooltipTrigger asChild>
<button
aria-label="New project"
className="grid h-[18px] w-[18px] place-items-center rounded-[4px] text-passive transition-colors hover:bg-interactive-hover hover:text-muted-foreground"
disabled={isChoosingPath}
onClick={choosePath}
type="button"
>
<Plus className="h-[13px] w-[13px]" aria-hidden="true" />
</button>
</TooltipTrigger>
<TooltipContent>{isChoosingPath ? "Opening…" : "New project"}</TooltipContent>
</Tooltip>
{children({ choosePath: () => void choosePath(), disabled: isChoosingPath || isCreating, label })}
<CreateProjectAgentSheet
error={error}
isCreating={isCreating}
onOpenChange={(open) => {
if (!open) {
setSelectedPath(null);
setError(null);
}
}}
onSubmit={createProject}
open={selectedPath !== null}
path={selectedPath}
/>
{error && (
<span className="sr-only" role="status">
{error}
@ -631,45 +703,3 @@ function CreateProjectButton({ onCreateProject }: Pick<SidebarProps, "onCreatePr
</>
);
}
function CreateProjectListItem({ onCreateProject }: Pick<SidebarProps, "onCreateProject">) {
const [error, setError] = useState<string | null>(null);
const [isChoosingPath, setIsChoosingPath] = useState(false);
const choosePath = async () => {
setError(null);
setIsChoosingPath(true);
try {
const selectedPath = await aoBridge.app.chooseDirectory();
if (selectedPath) await onCreateProject({ path: selectedPath });
} catch (err) {
setError(err instanceof Error ? err.message : "Could not add project");
} finally {
setIsChoosingPath(false);
}
};
return (
<SidebarMenuItem className="mb-px group-data-[collapsible=icon]:mb-0">
<Tooltip>
<TooltipTrigger asChild>
<button
aria-label="New project"
className="grid h-9 w-full place-items-center rounded-[5px] text-passive transition-colors hover:bg-interactive-hover hover:text-muted-foreground"
disabled={isChoosingPath}
onClick={choosePath}
type="button"
>
<Plus className="h-[13px] w-[13px]" aria-hidden="true" />
</button>
</TooltipTrigger>
<TooltipContent>{isChoosingPath ? "Opening…" : "New project"}</TooltipContent>
</Tooltip>
{error && (
<span className="sr-only" role="status">
{error}
</span>
)}
</SidebarMenuItem>
);
}

View File

@ -0,0 +1,25 @@
export const AGENT_OPTIONS = [
"claude-code",
"codex",
"aider",
"opencode",
"grok",
"droid",
"amp",
"agy",
"crush",
"cursor",
"qwen",
"copilot",
"goose",
"auggie",
"continue",
"devin",
"cline",
"kimi",
"kiro",
"kilocode",
"vibe",
"pi",
"autohand",
] as const;

View File

@ -6,7 +6,7 @@ import type { useDaemonStatus } from "../hooks/useDaemonStatus";
// it lives in the shell and is handed down here rather than re-run per route.
export type ShellContextValue = {
daemonStatus: ReturnType<typeof useDaemonStatus>;
createProject: (input: { path: string }) => Promise<void>;
createProject: (input: { path: string; workerAgent: string; orchestratorAgent: string }) => Promise<void>;
};
const ShellContext = createContext<ShellContextValue | null>(null);

View File

@ -10,6 +10,7 @@ import { useWorkspaceQuery, workspaceQueryKey, workspaceQueryOptions } from "../
import { apiClient, apiErrorMessage } from "../lib/api-client";
import { captureRendererEvent, captureRendererException } from "../lib/telemetry";
import { ShellProvider } from "../lib/shell-context";
import { spawnOrchestrator } from "../lib/spawn-orchestrator";
import { readStoredTheme, type Theme, useUiStore } from "../stores/ui-store";
import type { WorkspaceSummary } from "../types/workspace";
@ -51,9 +52,17 @@ function ShellLayout() {
);
const createProject = useCallback(
async (input: { path: string }) => {
async (input: { path: string; workerAgent: string; orchestratorAgent: string }) => {
void captureRendererEvent("ao.renderer.project_add_requested");
const { data, error } = await apiClient.POST("/api/v1/projects", { body: { path: input.path } });
const { data, error } = await apiClient.POST("/api/v1/projects", {
body: {
path: input.path,
config: {
worker: { agent: input.workerAgent },
orchestrator: { agent: input.orchestratorAgent },
},
},
});
if (error) {
const failure = new Error(apiErrorMessage(error));
void captureRendererException(failure, { source: "project-add" });
@ -70,9 +79,20 @@ function ShellLayout() {
};
void captureRendererEvent("ao.renderer.project_add_succeeded", { project_id: workspace.id });
updateWorkspaces((current) => [workspace, ...current.filter((item) => item.id !== workspace.id)]);
try {
const sessionId = await spawnOrchestrator(workspace.id);
await queryClient.invalidateQueries({ queryKey: workspaceQueryKey });
void navigate({
to: "/projects/$projectId/sessions/$sessionId",
params: { projectId: workspace.id, sessionId },
});
} catch (spawnError) {
void navigate({ to: "/projects/$projectId", params: { projectId: workspace.id } });
const message = spawnError instanceof Error ? spawnError.message : "Could not start orchestrator";
throw new Error(`Project added, but orchestrator did not start: ${message}`);
}
},
[navigate, updateWorkspaces],
[navigate, queryClient, updateWorkspaces],
);
const removeProject = useCallback(