diff --git a/backend/internal/adapters/agent/claudecode/claudecode.go b/backend/internal/adapters/agent/claudecode/claudecode.go index c107c620..bc3b5437 100644 --- a/backend/internal/adapters/agent/claudecode/claudecode.go +++ b/backend/internal/adapters/agent/claudecode/claudecode.go @@ -234,9 +234,15 @@ func (p *Plugin) GetRestoreCommand(ctx context.Context, cfg ports.RestoreConfig) if err != nil { return nil, false, err } - cmd = make([]string, 0, 5) + cmd = make([]string, 0, 7) cmd = append(cmd, binary) appendPermissionFlags(&cmd, cfg.Permissions) + if cfg.SystemPrompt != "" { + // --resume rebuilds the system prompt from the current flags (it is + // not stored in the transcript), so standing instructions must be + // re-appended or a restored orchestrator loses its role. + cmd = append(cmd, "--append-system-prompt", cfg.SystemPrompt) + } cmd = append(cmd, "--resume", sessionID) return cmd, true, nil } diff --git a/backend/internal/adapters/agent/claudecode/claudecode_test.go b/backend/internal/adapters/agent/claudecode/claudecode_test.go index 6cddab27..bec96a67 100644 --- a/backend/internal/adapters/agent/claudecode/claudecode_test.go +++ b/backend/internal/adapters/agent/claudecode/claudecode_test.go @@ -386,6 +386,26 @@ func TestGetRestoreCommandReadsAgentSessionID(t *testing.T) { } } +func TestGetRestoreCommandReappendsSystemPrompt(t *testing.T) { + // --resume rebuilds the system prompt from flags, so standing instructions + // (e.g. the orchestrator role) must be re-appended on restore. + cmd, ok, err := (&Plugin{resolvedBinary: "claude"}).GetRestoreCommand(context.Background(), ports.RestoreConfig{ + Permissions: ports.PermissionModeBypassPermissions, + SystemPrompt: "You are an orchestrator.", + Session: ports.SessionRef{ + ID: "sess-r", + Metadata: map[string]string{ports.MetadataKeyAgentSessionID: "claude-native-1"}, + }, + }) + if err != nil || !ok { + t.Fatalf("restore = (ok=%v, err=%v), want ok", ok, err) + } + want := []string{"claude", "--permission-mode", "bypassPermissions", "--append-system-prompt", "You are an orchestrator.", "--resume", "claude-native-1"} + if !reflect.DeepEqual(cmd, want) { + t.Fatalf("restore cmd\nwant: %#v\n got: %#v", want, cmd) + } +} + func TestGetRestoreCommandFallsBackToDerivedUUID(t *testing.T) { // No agentSessionId captured (pre-hook session) → derive deterministically // from the AO session id, the explicit fallback. diff --git a/backend/internal/adapters/runtime/zellij/commands.go b/backend/internal/adapters/runtime/zellij/commands.go index bc7ed5b9..5b035b69 100644 --- a/backend/internal/adapters/runtime/zellij/commands.go +++ b/backend/internal/adapters/runtime/zellij/commands.go @@ -61,7 +61,11 @@ func deleteSessionArgs(id string) []string { } func attachArgs(id string) []string { - return []string{"attach", id} + return []string{ + "attach", id, + "options", + "--pane-frames", "false", + } } func handleIDValue(sessionID, paneID string) string { @@ -96,7 +100,7 @@ func shellLaunchSpecFor(shellPath string) shellLaunchSpec { func layoutString(workspacePath, shellPath string, shellArgs []string, shellCommand string) string { return "layout {\n" + " cwd " + kdlQuote(workspacePath) + "\n" + - " pane command=" + kdlQuote(shellPath) + " name=" + kdlQuote(agentPaneName) + " {\n" + + " pane command=" + kdlQuote(shellPath) + " name=" + kdlQuote(agentPaneName) + " borderless=true {\n" + " args " + kdlJoin(shellArgs) + " " + kdlQuote(shellCommand) + "\n" + " }\n" + "}\n" diff --git a/backend/internal/adapters/runtime/zellij/zellij.go b/backend/internal/adapters/runtime/zellij/zellij.go index cfb12aaa..938815f6 100644 --- a/backend/internal/adapters/runtime/zellij/zellij.go +++ b/backend/internal/adapters/runtime/zellij/zellij.go @@ -22,10 +22,12 @@ import ( ) const ( - defaultTimeout = 5 * time.Second - minMajor = 0 - minMinor = 44 - minPatch = 3 + defaultTimeout = 5 * time.Second + defaultZellijTerm = "xterm-256color" + defaultZellijColor = "truecolor" + minMajor = 0 + minMinor = 44 + minPatch = 3 ) var sessionIDPattern = regexp.MustCompile(`^[a-zA-Z0-9_-]+$`) @@ -79,9 +81,7 @@ type execRunner struct{} func (execRunner) Run(ctx context.Context, env []string, name string, args ...string) ([]byte, error) { cmd := exec.CommandContext(ctx, name, args...) - if len(env) > 0 { - cmd.Env = append(os.Environ(), env...) - } + cmd.Env = zellijCommandEnv(os.Environ(), env) return cmd.CombinedOutput() } @@ -134,6 +134,13 @@ func (r *Runtime) Create(ctx context.Context, cfg ports.RuntimeConfig) (ports.Ru if err := r.ensureSupportedVersion(ctx); err != nil { return ports.RuntimeHandle{}, err } + // Zellij keeps exited sessions in a resurrection cache. A previous partial + // spawn can therefore make `attach --create-background` fail with "Session + // already exists" even though AO has no usable runtime handle. Clear any + // same-name runtime state before creating the new AO-owned session. + if err := r.Destroy(ctx, ports.RuntimeHandle{ID: id}); err != nil { + return ports.RuntimeHandle{}, err + } layoutPath, err := r.writeLayout(cfg) if err != nil { @@ -243,9 +250,6 @@ func (r *Runtime) AttachCommand(handle ports.RuntimeHandle) ([]string, error) { } args := append([]string{}, r.baseArgs()...) args = append(args, attachArgs(id)...) - if r.socketDir == "" { - return append([]string{r.binary}, args...), nil - } return attachCommandWithEnv(r.binary, r.socketDir, args...), nil } @@ -326,21 +330,33 @@ func (r *Runtime) baseArgs() []string { } func (r *Runtime) env() []string { + env := zellijColorEnv(nil) if r.socketDir == "" { - return nil + return env } - return []string{"ZELLIJ_SOCKET_DIR=" + r.socketDir} + return append(env, "ZELLIJ_SOCKET_DIR="+r.socketDir) } func attachCommandWithEnv(binary, socketDir string, args ...string) []string { - if socketDir == "" { - return append([]string{binary}, args...) + env := zellijColorEnv(nil) + if socketDir != "" { + env = append(env, "ZELLIJ_SOCKET_DIR="+socketDir) } if runtime.GOOS == "windows" { command := strings.Builder{} - command.WriteString("$env:ZELLIJ_SOCKET_DIR = ") - command.WriteString(psQuote(socketDir)) - command.WriteString("; & ") + command.WriteString("Remove-Item Env:NO_COLOR -ErrorAction SilentlyContinue; ") + for _, pair := range env { + key, value, ok := strings.Cut(pair, "=") + if !ok { + continue + } + command.WriteString("$env:") + command.WriteString(key) + command.WriteString(" = ") + command.WriteString(psQuote(value)) + command.WriteString("; ") + } + command.WriteString("& ") command.WriteString(psQuote(binary)) for _, arg := range args { command.WriteByte(' ') @@ -348,7 +364,55 @@ func attachCommandWithEnv(binary, socketDir string, args ...string) []string { } return []string{"powershell.exe", "-NoLogo", "-NoProfile", "-Command", command.String()} } - return append([]string{"env", "ZELLIJ_SOCKET_DIR=" + socketDir, binary}, args...) + argv := []string{"env", "-u", "NO_COLOR"} + argv = append(argv, env...) + argv = append(argv, binary) + return append(argv, args...) +} + +func zellijCommandEnv(base, overrides []string) []string { + env := zellijColorEnv(append([]string(nil), base...)) + for _, pair := range overrides { + env = upsertEnv(env, pair) + } + return env +} + +func zellijColorEnv(env []string) []string { + if runtime.GOOS == "windows" { + return env + } + env = removeEnv(env, "NO_COLOR") + env = upsertEnv(env, "TERM="+defaultZellijTerm) + env = upsertEnv(env, "COLORTERM="+defaultZellijColor) + return env +} + +func upsertEnv(env []string, pair string) []string { + key, _, ok := strings.Cut(pair, "=") + if !ok { + return env + } + prefix := key + "=" + for i, current := range env { + if strings.HasPrefix(current, prefix) { + env[i] = pair + return env + } + } + return append(env, pair) +} + +func removeEnv(env []string, key string) []string { + prefix := key + "=" + out := env[:0] + for _, current := range env { + if strings.HasPrefix(current, prefix) { + continue + } + out = append(out, current) + } + return out } func zellijSessionName(id domain.SessionID) (string, error) { diff --git a/backend/internal/adapters/runtime/zellij/zellij_test.go b/backend/internal/adapters/runtime/zellij/zellij_test.go index f91879db..784cda43 100644 --- a/backend/internal/adapters/runtime/zellij/zellij_test.go +++ b/backend/internal/adapters/runtime/zellij/zellij_test.go @@ -26,6 +26,66 @@ func TestNewDefaultsToPortableShell(t *testing.T) { } } +func TestZellijCommandEnvNormalizesBrowserTerminalColors(t *testing.T) { + got := zellijCommandEnv( + []string{"NO_COLOR=1", "TERM=dumb", "COLORTERM=", "KEEP=yes"}, + []string{"ZELLIJ_SOCKET_DIR=/tmp/zj"}, + ) + + if runtime.GOOS == "windows" { + if !contains(got, "NO_COLOR=1") { + t.Fatalf("windows env = %#v, want NO_COLOR preserved", got) + } + return + } + + if containsKey(got, "NO_COLOR") { + t.Fatalf("NO_COLOR survived env normalization: %#v", got) + } + for _, want := range []string{"KEEP=yes", "TERM=xterm-256color", "COLORTERM=truecolor", "ZELLIJ_SOCKET_DIR=/tmp/zj"} { + if !contains(got, want) { + t.Fatalf("env missing %q in %#v", want, got) + } + } +} + +func expectedZellijEnv(socketDir string) []string { + env := []string{} + if runtime.GOOS != "windows" { + env = append(env, "TERM=xterm-256color", "COLORTERM=truecolor") + } + if socketDir != "" { + env = append(env, "ZELLIJ_SOCKET_DIR="+socketDir) + } + return env +} + +func expectedAttachEnvPrefix() []string { + if runtime.GOOS == "windows" { + return []string{} + } + return []string{"env", "-u", "NO_COLOR", "TERM=xterm-256color", "COLORTERM=truecolor"} +} + +func contains(values []string, want string) bool { + for _, value := range values { + if value == want { + return true + } + } + return false +} + +func containsKey(values []string, key string) bool { + prefix := key + "=" + for _, value := range values { + if strings.HasPrefix(value, prefix) { + return true + } + } + return false +} + func TestCommandBuilders(t *testing.T) { if got, want := versionArgs(), []string{"--version"}; !reflect.DeepEqual(got, want) { t.Fatalf("versionArgs = %#v, want %#v", got, want) @@ -48,6 +108,9 @@ func TestCommandBuilders(t *testing.T) { if got, want := deleteSessionArgs("sess-1"), []string{"delete-session", "--force", "sess-1"}; !reflect.DeepEqual(got, want) { t.Fatalf("deleteSessionArgs = %#v, want %#v", got, want) } + if got, want := attachArgs("sess-1"), []string{"attach", "sess-1", "options", "--pane-frames", "false"}; !reflect.DeepEqual(got, want) { + t.Fatalf("attachArgs = %#v, want %#v", got, want) + } } func TestZellijSessionNameSanitizesIssueRefs(t *testing.T) { @@ -141,7 +204,7 @@ func TestBuildLayoutExportsEnvAndKeepsPaneAlive(t *testing.T) { for _, want := range []string{ `cwd "/tmp/ws"`, - `pane command="/bin/zsh" name="agent"`, + `pane command="/bin/zsh" name="agent" borderless=true`, "export AO_SESSION_ID='sess-1';", "export ODD='can'\\\\''t';", "export PATH='/custom/bin:/usr/bin';", @@ -168,7 +231,7 @@ func TestBuildLayoutUsesPowerShellLaunchOnWindowsShells(t *testing.T) { }}, `C:\Program Files\PowerShell\7\pwsh.exe`) for _, want := range []string{ - `pane command="C:\\Program Files\\PowerShell\\7\\pwsh.exe" name="agent"`, + `pane command="C:\\Program Files\\PowerShell\\7\\pwsh.exe" name="agent" borderless=true`, `args "-NoLogo" "-NoProfile" "-NoExit" "-Command"`, "$env:AO_SESSION_ID = 'sess-1';", "$env:PATH = ", @@ -192,7 +255,7 @@ func TestBuildLayoutUsesCmdLaunchOnCmdShells(t *testing.T) { }}, `C:\Windows\System32\cmd.exe`) for _, want := range []string{ - `pane command="C:\\Windows\\System32\\cmd.exe" name="agent"`, + `pane command="C:\\Windows\\System32\\cmd.exe" name="agent" borderless=true`, `args "/D" "/S" "/K"`, `AO_SESSION_ID=sess-1`, `\"echo\" \"ready\"`, @@ -218,7 +281,7 @@ func TestCreateRejectsInvalidEnvKeys(t *testing.T) { } func TestCreateStartsSessionAndDiscoversPane(t *testing.T) { - fr := &fakeRunner{outputs: [][]byte{[]byte("zellij 0.44.3"), nil, []byte(`[{"id":0,"is_plugin":true,"title":"zellij:tab-bar"},{"id":3,"is_plugin":false,"title":"agent"}]`)}} + fr := &fakeRunner{outputs: [][]byte{[]byte("zellij 0.44.3"), nil, nil, []byte(`[{"id":0,"is_plugin":true,"title":"zellij:tab-bar"},{"id":3,"is_plugin":false,"title":"agent"}]`)}} r := New(Options{Binary: "zellij-test", Timeout: time.Second, Shell: "/bin/zsh", SocketDir: "/tmp/zj", ConfigDir: "/tmp/cfg"}) r.runner = fr @@ -234,23 +297,76 @@ func TestCreateStartsSessionAndDiscoversPane(t *testing.T) { if handle != (ports.RuntimeHandle{ID: "sess-1/terminal_3"}) { t.Fatalf("handle = %+v, want zellij handle", handle) } - if len(fr.calls) != 3 { - t.Fatalf("calls = %d, want 3", len(fr.calls)) + if len(fr.calls) != 4 { + t.Fatalf("calls = %d, want 4", len(fr.calls)) } if got, want := fr.calls[0].args, []string{"--config-dir", "/tmp/cfg", "--version"}; !reflect.DeepEqual(got, want) { t.Fatalf("version args = %#v, want %#v", got, want) } - if got := fr.calls[1].args[:5]; !reflect.DeepEqual(got, []string{"--config-dir", "/tmp/cfg", "attach", "--create-background", "sess-1"}) { + if got, want := fr.calls[1].args, []string{"--config-dir", "/tmp/cfg", "delete-session", "--force", "sess-1"}; !reflect.DeepEqual(got, want) { + t.Fatalf("delete args = %#v, want %#v", got, want) + } + if got := fr.calls[2].args[:5]; !reflect.DeepEqual(got, []string{"--config-dir", "/tmp/cfg", "attach", "--create-background", "sess-1"}) { t.Fatalf("create args prefix = %#v", got) } - if got := fr.calls[2].args; !reflect.DeepEqual(got, append([]string{"--config-dir", "/tmp/cfg"}, listPanesArgs("sess-1")...)) { + if got := fr.calls[3].args; !reflect.DeepEqual(got, append([]string{"--config-dir", "/tmp/cfg"}, listPanesArgs("sess-1")...)) { t.Fatalf("list panes args = %#v", got) } - if got, want := fr.calls[0].env, []string{"ZELLIJ_SOCKET_DIR=/tmp/zj"}; !reflect.DeepEqual(got, want) { + if got, want := fr.calls[0].env, expectedZellijEnv("/tmp/zj"); !reflect.DeepEqual(got, want) { t.Fatalf("env = %#v, want %#v", got, want) } } +func TestCreateClearsStaleSessionBeforeCreating(t *testing.T) { + fr := &fakeRunner{outputs: [][]byte{ + []byte("zellij 0.44.3"), + nil, + nil, + []byte(`[{"id":1,"is_plugin":false,"title":"agent"}]`), + }} + r := New(Options{Binary: "zellij-test", Timeout: time.Second, Shell: "/bin/zsh"}) + r.runner = fr + + if _, err := r.Create(context.Background(), ports.RuntimeConfig{ + SessionID: "sess-1", + WorkspacePath: "/tmp/ws", + Argv: []string{"echo", "ready"}, + }); err != nil { + t.Fatalf("Create: %v", err) + } + + if len(fr.calls) != 4 { + t.Fatalf("calls = %d, want 4", len(fr.calls)) + } + if got, want := fr.calls[1].args, []string{"delete-session", "--force", "sess-1"}; !reflect.DeepEqual(got, want) { + t.Fatalf("delete args = %#v, want %#v", got, want) + } + if got := fr.calls[2].args[:3]; !reflect.DeepEqual(got, []string{"attach", "--create-background", "sess-1"}) { + t.Fatalf("create args prefix = %#v", got) + } +} + +func TestAttachCommandDisablesPaneFrames(t *testing.T) { + r := New(Options{}) + args, err := r.AttachCommand(ports.RuntimeHandle{ID: "sess-1/terminal_0"}) + if err != nil { + t.Fatalf("AttachCommand: %v", err) + } + if runtime.GOOS == "windows" { + joined := strings.Join(args, " ") + for _, want := range []string{"--pane-frames", "false"} { + if !strings.Contains(joined, want) { + t.Fatalf("windows attach command missing %q: %#v", want, args) + } + } + return + } + want := append(expectedAttachEnvPrefix(), "zellij", "attach", "sess-1", "options", "--pane-frames", "false") + if !reflect.DeepEqual(args, want) { + t.Fatalf("AttachCommand = %#v, want %#v", args, want) + } +} + func TestAttachCommandUsesSocketDir(t *testing.T) { r := New(Options{SocketDir: "/tmp/zj"}) args, err := r.AttachCommand(ports.RuntimeHandle{ID: "sess-1/terminal_0"}) @@ -266,9 +382,12 @@ func TestAttachCommandUsesSocketDir(t *testing.T) { } return } - if got, want := args[:3], []string{"env", "ZELLIJ_SOCKET_DIR=/tmp/zj", r.binary}; !reflect.DeepEqual(got, want) { + if got, want := args[:6], []string{"env", "-u", "NO_COLOR", "TERM=xterm-256color", "COLORTERM=truecolor", "ZELLIJ_SOCKET_DIR=/tmp/zj"}; !reflect.DeepEqual(got, want) { t.Fatalf("attach prefix = %#v, want %#v", got, want) } + if got, want := args[6], r.binary; got != want { + t.Fatalf("attach binary = %q, want %q", got, want) + } } func TestFindAgentPaneRetriesTransientErrors(t *testing.T) { diff --git a/backend/internal/adapters/workspace/gitworktree/workspace.go b/backend/internal/adapters/workspace/gitworktree/workspace.go index 4fb928c1..be1d5313 100644 --- a/backend/internal/adapters/workspace/gitworktree/workspace.go +++ b/backend/internal/adapters/workspace/gitworktree/workspace.go @@ -124,6 +124,11 @@ func (w *Workspace) Create(ctx context.Context, cfg ports.WorkspaceConfig) (port if err != nil { return ports.WorkspaceInfo{}, err } + if info, ok, err := w.existingWorktree(ctx, repo, path, cfg); err != nil { + return ports.WorkspaceInfo{}, err + } else if ok { + return info, nil + } if err := w.addWorktree(ctx, repo, path, cfg.Branch, cfg.BaseBranch); err != nil { return ports.WorkspaceInfo{}, err } @@ -218,6 +223,21 @@ func (w *Workspace) Restore(ctx context.Context, cfg ports.WorkspaceConfig) (por return ports.WorkspaceInfo{Path: path, Branch: cfg.Branch, SessionID: cfg.SessionID, ProjectID: cfg.ProjectID}, nil } +func (w *Workspace) existingWorktree(ctx context.Context, repo, path string, cfg ports.WorkspaceConfig) (ports.WorkspaceInfo, bool, error) { + records, err := w.listRecords(ctx, repo) + if err != nil { + return ports.WorkspaceInfo{}, false, err + } + if rec, ok := findWorktree(records, path); ok { + branch := rec.Branch + if branch == "" { + branch = cfg.Branch + } + return ports.WorkspaceInfo{Path: path, Branch: branch, SessionID: cfg.SessionID, ProjectID: cfg.ProjectID}, true, nil + } + return ports.WorkspaceInfo{}, false, nil +} + func (w *Workspace) addWorktree(ctx context.Context, repo, path, branch, baseBranch string) error { // Refuse early if the branch is already checked out in another worktree: // `git worktree add` will fail, but its stderr leaks through as an opaque diff --git a/backend/internal/adapters/workspace/gitworktree/workspace_test.go b/backend/internal/adapters/workspace/gitworktree/workspace_test.go index 9c084b2e..dbaaae7b 100644 --- a/backend/internal/adapters/workspace/gitworktree/workspace_test.go +++ b/backend/internal/adapters/workspace/gitworktree/workspace_test.go @@ -174,6 +174,43 @@ func TestOrchestratorManagedPath(t *testing.T) { }) } +func TestCreateReusesRegisteredWorktreeAtExpectedPath(t *testing.T) { + root := t.TempDir() + repo := t.TempDir() + ws, err := New(Options{ManagedRoot: root, RepoResolver: StaticRepoResolver{"proj": repo}}) + if err != nil { + t.Fatalf("new: %v", err) + } + path := filepath.Join(ws.managedRoot, "proj", "orchestrator", "proj-orchestrator") + cfg := ports.WorkspaceConfig{ + ProjectID: "proj", + SessionID: "proj-1", + Kind: domain.KindOrchestrator, + SessionPrefix: "proj", + Branch: "ao/proj-orchestrator", + } + ws.run = func(_ context.Context, _ string, args ...string) ([]byte, error) { + joined := strings.Join(args, " ") + switch { + case strings.Contains(joined, "check-ref-format"): + return nil, nil + case strings.Contains(joined, "worktree list --porcelain"): + return []byte("worktree " + path + "\nbranch refs/heads/ao/proj-orchestrator\n"), nil + default: + t.Fatalf("unexpected git invocation: %v", args) + return nil, nil + } + } + + info, err := ws.Create(context.Background(), cfg) + if err != nil { + t.Fatalf("Create: %v", err) + } + if info.Path != path || info.Branch != "ao/proj-orchestrator" { + t.Fatalf("info = %#v, want path %q branch ao/proj-orchestrator", info, path) + } +} + // TestValidateConfigRejectsPathEscapingIDs covers review item RB: filepath.Join // in managedPath cleans `..` segments before validateManagedPath sees them, so a // session id of "../other" would stay inside managedRoot while jumping projects. diff --git a/backend/internal/config/config.go b/backend/internal/config/config.go index 1de3b705..d1318789 100644 --- a/backend/internal/config/config.go +++ b/backend/internal/config/config.go @@ -183,28 +183,37 @@ func parsePositiveDuration(name, raw string) (time.Duration, error) { } // resolveRunFilePath picks where running.json lives. An explicit AO_RUN_FILE -// wins; otherwise it sits under the per-user config directory so multiple repos -// share one supervisor handshake location. +// wins; otherwise it sits under the per-user config directory so the CLI and +// Electron supervisor share one handshake location. func resolveRunFilePath() (string, error) { if p, ok := os.LookupEnv("AO_RUN_FILE"); ok && p != "" { return p, nil } - home, err := os.UserHomeDir() + stateDir, err := defaultStateDir() if err != nil { - return "", fmt.Errorf("resolve state dir: %w", err) + return "", err } - return filepath.Join(home, ".ao", "running.json"), nil + return filepath.Join(stateDir, "running.json"), nil } // resolveDataDir picks where durable state (the SQLite DB) lives. An explicit -// AO_DATA_DIR wins; otherwise it defaults to ~/.ao/data/. +// AO_DATA_DIR wins; otherwise it defaults under the same per-user config +// directory as the run-file. func resolveDataDir() (string, error) { if p, ok := os.LookupEnv("AO_DATA_DIR"); ok && p != "" { return p, nil } - home, err := os.UserHomeDir() + stateDir, err := defaultStateDir() + if err != nil { + return "", err + } + return filepath.Join(stateDir, "data"), nil +} + +func defaultStateDir() (string, error) { + configDir, err := os.UserConfigDir() if err != nil { return "", fmt.Errorf("resolve state dir: %w", err) } - return filepath.Join(home, ".ao", "data"), nil + return filepath.Join(configDir, "agent-orchestrator"), nil } diff --git a/backend/internal/config/config_test.go b/backend/internal/config/config_test.go index 49fe9ec4..c85cc931 100644 --- a/backend/internal/config/config_test.go +++ b/backend/internal/config/config_test.go @@ -1,8 +1,8 @@ package config import ( + "os" "path/filepath" - "strings" "testing" "time" ) @@ -10,7 +10,7 @@ import ( func TestLoadDefaults(t *testing.T) { // Clear every recognised var so we observe pure defaults regardless of the // surrounding environment. - for _, k := range []string{"AO_PORT", "AO_REQUEST_TIMEOUT", "AO_SHUTDOWN_TIMEOUT", "AO_RUN_FILE", "AO_DATA_DIR"} { + for _, k := range []string{"AO_PORT", "AO_REQUEST_TIMEOUT", "AO_SHUTDOWN_TIMEOUT", "AO_RUN_FILE", "AO_DATA_DIR", "AO_AGENT", "AO_ALLOWED_ORIGINS"} { t.Setenv(k, "") } @@ -33,14 +33,20 @@ func TestLoadDefaults(t *testing.T) { if cfg.RunFilePath == "" { t.Error("RunFilePath is empty, want a resolved default path") } - if !strings.HasSuffix(cfg.RunFilePath, filepath.Join(".ao", "running.json")) { - t.Errorf("RunFilePath = %q, want .ao/running.json suffix", cfg.RunFilePath) + configDir, err := os.UserConfigDir() + if err != nil { + t.Fatalf("UserConfigDir: %v", err) + } + wantRunFilePath := filepath.Join(configDir, "agent-orchestrator", "running.json") + if cfg.RunFilePath != wantRunFilePath { + t.Errorf("RunFilePath = %q, want %q", cfg.RunFilePath, wantRunFilePath) } if cfg.DataDir == "" { t.Error("DataDir is empty, want a resolved default path") } - if !strings.HasSuffix(cfg.DataDir, filepath.Join(".ao", "data")) { - t.Errorf("DataDir = %q, want .ao/data suffix", cfg.DataDir) + wantDataDir := filepath.Join(configDir, "agent-orchestrator", "data") + if cfg.DataDir != wantDataDir { + t.Errorf("DataDir = %q, want %q", cfg.DataDir, wantDataDir) } } diff --git a/backend/internal/daemon/daemon.go b/backend/internal/daemon/daemon.go index 260ccd55..8f17c3ba 100644 --- a/backend/internal/daemon/daemon.go +++ b/backend/internal/daemon/daemon.go @@ -104,7 +104,7 @@ func Run() error { } srv, err := httpd.NewWithDeps(cfg, log, termMgr, httpd.APIDeps{ - Projects: projectsvc.New(store), + Projects: projectsvc.NewWithDeps(projectsvc.Deps{Store: store, Sessions: sessionSvc}), Sessions: sessionSvc, Reviews: reviewsvc.NewInMemory(), CDC: store, diff --git a/backend/internal/domain/projectconfig.go b/backend/internal/domain/projectconfig.go index 1e6cf32f..ee958b31 100644 --- a/backend/internal/domain/projectconfig.go +++ b/backend/internal/domain/projectconfig.go @@ -78,6 +78,9 @@ func (c ProjectConfig) Validate() error { if err := c.AgentConfig.Validate(); err != nil { return err } + if err := validateNameComponent("sessionPrefix", c.SessionPrefix); err != nil { + return err + } for role, ro := range map[string]RoleOverride{"worker": c.Worker, "orchestrator": c.Orchestrator} { if ro.Harness != "" && !ro.Harness.IsKnown() { return fmt.Errorf("%s.agent: unknown harness %q", role, ro.Harness) @@ -94,6 +97,17 @@ func (c ProjectConfig) Validate() error { return nil } +func validateNameComponent(name, value string) error { + trimmed := strings.TrimSpace(value) + if trimmed == "" { + return nil + } + if strings.ContainsAny(trimmed, `/\`) || trimmed == "." || trimmed == ".." { + return fmt.Errorf("%s: must not contain path separators or traversal components", name) + } + return nil +} + // validateRepoRelative refuses paths that would let a project config escape // its repo root: absolute paths and any ".." segment (before or after Clean). // The same guard runs at spawn time as defense-in-depth, but enforcing it here diff --git a/backend/internal/domain/projectconfig_test.go b/backend/internal/domain/projectconfig_test.go index 76155101..cc080631 100644 --- a/backend/internal/domain/projectconfig_test.go +++ b/backend/internal/domain/projectconfig_test.go @@ -11,6 +11,10 @@ func TestProjectConfigValidate(t *testing.T) { {"empty ok", ProjectConfig{}, false}, {"good agent config", ProjectConfig{AgentConfig: AgentConfig{Model: "m", Permissions: PermissionModeAuto}}, false}, {"bad permission", ProjectConfig{AgentConfig: AgentConfig{Permissions: "yolo"}}, true}, + {"good session prefix", ProjectConfig{SessionPrefix: "ao"}, false}, + {"session prefix with slash", ProjectConfig{SessionPrefix: "ao/project"}, true}, + {"session prefix with backslash", ProjectConfig{SessionPrefix: `ao\project`}, true}, + {"session prefix traversal component", ProjectConfig{SessionPrefix: ".."}, true}, {"good role override", ProjectConfig{Worker: RoleOverride{Harness: HarnessCodex}}, false}, {"unknown role harness", ProjectConfig{Orchestrator: RoleOverride{Harness: "nope"}}, true}, {"bad role agent config", ProjectConfig{Worker: RoleOverride{AgentConfig: AgentConfig{Permissions: "nope"}}}, true}, diff --git a/backend/internal/ports/agent.go b/backend/internal/ports/agent.go index b2f56e9a..93c534b2 100644 --- a/backend/internal/ports/agent.go +++ b/backend/internal/ports/agent.go @@ -123,6 +123,11 @@ type RestoreConfig struct { Config AgentConfig Permissions PermissionMode Session SessionRef + // SystemPrompt carries the session's standing instructions (e.g. the + // orchestrator role). Agent CLIs rebuild their system prompt from flags on + // resume — it is not part of the transcript — so adapters whose CLI has a + // system-prompt flag should re-apply this in their resume command. + SystemPrompt string } // SessionRef identifies an AO session whose agent-owned metadata may be read. diff --git a/backend/internal/service/project/service.go b/backend/internal/service/project/service.go index adc92438..136b06f2 100644 --- a/backend/internal/service/project/service.go +++ b/backend/internal/service/project/service.go @@ -36,9 +36,16 @@ type Manager interface { Remove(ctx context.Context, id domain.ProjectID) (RemoveResult, error) } +// SessionTeardowner is the narrow session-service surface project removal +// needs: stop live project sessions and reclaim managed terminal workspaces. +type SessionTeardowner interface { + TeardownProject(ctx context.Context, project domain.ProjectID) error +} + // Service implements project registration and lookup use-cases for controllers. type Service struct { - store Store + store Store + sessions SessionTeardowner // addMu serialises the whole body of Add. Workspace registration performs // filesystem mutations (git init, .gitignore writes, commits) that are not // covered by the store's own writeMu, so path/id conflict checks plus the @@ -48,9 +55,20 @@ type Service struct { var _ Manager = (*Service)(nil) +// Deps captures optional collaborators for project use-cases. +type Deps struct { + Store Store + Sessions SessionTeardowner +} + // New returns a project service backed by the given durable store. func New(store Store) *Service { - return &Service{store: store} + return NewWithDeps(Deps{Store: store}) +} + +// NewWithDeps returns a project service with optional teardown dependencies. +func NewWithDeps(d Deps) *Service { + return &Service{store: d.Store, sessions: d.Sessions} } // List returns every active registered project. @@ -218,12 +236,26 @@ func resolveGitOriginURL(path string) string { return strings.TrimSpace(string(out)) } -// Remove archives a project registration. +// Remove stops live project sessions, reclaims safe managed workspaces, then +// archives the project registration. The original repository path and durable +// session/history rows are preserved. func (m *Service) Remove(ctx context.Context, id domain.ProjectID) (RemoveResult, error) { if err := validateProjectID(id); err != nil { return RemoveResult{}, err } - ok, err := m.store.ArchiveProject(ctx, string(id), time.Now()) + row, ok, err := m.store.GetProject(ctx, string(id)) + if err != nil { + return RemoveResult{}, apierr.Internal("PROJECT_REMOVE_FAILED", "Failed to remove project") + } + if !ok || !row.ArchivedAt.IsZero() { + return RemoveResult{}, apierr.NotFound("PROJECT_NOT_FOUND", "Unknown project") + } + if m.sessions != nil { + if err := m.sessions.TeardownProject(ctx, id); err != nil { + return RemoveResult{}, err + } + } + ok, err = m.store.ArchiveProject(ctx, string(id), time.Now()) if err != nil { return RemoveResult{}, apierr.Internal("PROJECT_REMOVE_FAILED", "Failed to remove project") } diff --git a/backend/internal/service/project/service_test.go b/backend/internal/service/project/service_test.go index 4d07dce9..fd8e0309 100644 --- a/backend/internal/service/project/service_test.go +++ b/backend/internal/service/project/service_test.go @@ -52,6 +52,16 @@ func wantCode(t *testing.T, err error, code string) { } } +type fakeProjectTeardowner struct { + projects []domain.ProjectID + err error +} + +func (f *fakeProjectTeardowner) TeardownProject(_ context.Context, project domain.ProjectID) error { + f.projects = append(f.projects, project) + return f.err +} + func TestManager_AddListGetRemove(t *testing.T) { ctx := context.Background() m := newManager(t) @@ -99,6 +109,50 @@ func TestManager_AddListGetRemove(t *testing.T) { wantCode(t, err, "PROJECT_NOT_FOUND") } +func TestManager_RemoveTeardownsBeforeArchive(t *testing.T) { + ctx := context.Background() + store, err := sqlite.Open(t.TempDir()) + if err != nil { + t.Fatalf("open store: %v", err) + } + t.Cleanup(func() { _ = store.Close() }) + teardown := &fakeProjectTeardowner{} + m := project.NewWithDeps(project.Deps{Store: store, Sessions: teardown}) + + if _, err := m.Add(ctx, project.AddInput{Path: gitRepo(t), ProjectID: ptr("ao")}); err != nil { + t.Fatalf("Add: %v", err) + } + if _, err := m.Remove(ctx, "ao"); err != nil { + t.Fatalf("Remove: %v", err) + } + if len(teardown.projects) != 1 || teardown.projects[0] != "ao" { + t.Fatalf("teardown projects = %#v, want [ao]", teardown.projects) + } + _, err = m.Get(ctx, "ao") + wantCode(t, err, "PROJECT_NOT_FOUND") +} + +func TestManager_RemoveDoesNotArchiveWhenTeardownFails(t *testing.T) { + ctx := context.Background() + store, err := sqlite.Open(t.TempDir()) + if err != nil { + t.Fatalf("open store: %v", err) + } + t.Cleanup(func() { _ = store.Close() }) + boom := errors.New("teardown failed") + m := project.NewWithDeps(project.Deps{Store: store, Sessions: &fakeProjectTeardowner{err: boom}}) + + if _, err := m.Add(ctx, project.AddInput{Path: gitRepo(t), ProjectID: ptr("ao")}); err != nil { + t.Fatalf("Add: %v", err) + } + if _, err := m.Remove(ctx, "ao"); !errors.Is(err, boom) { + t.Fatalf("Remove err = %v, want teardown failure", err) + } + if got, err := m.Get(ctx, "ao"); err != nil || got.Project == nil || got.Project.ID != "ao" { + t.Fatalf("project after failed remove = %#v, %v; want still active", got, err) + } +} + func TestManager_DefaultsWhenUnconfigured(t *testing.T) { ctx := context.Background() m := newManager(t) diff --git a/backend/internal/service/session/service.go b/backend/internal/service/session/service.go index 51e43396..8512b302 100644 --- a/backend/internal/service/session/service.go +++ b/backend/internal/service/session/service.go @@ -241,6 +241,26 @@ func (s *Service) Cleanup(ctx context.Context, project domain.ProjectID) (Cleanu return out, nil } +// TeardownProject stops every live session in a project, then asks the session +// manager to reclaim terminal workspaces. Dirty worktrees are preserved by Kill +// and Cleanup; callers only see hard teardown failures. +func (s *Service) TeardownProject(ctx context.Context, project domain.ProjectID) error { + recs, err := s.listRecords(ctx, project) + if err != nil { + return err + } + for _, rec := range recs { + if rec.IsTerminated { + continue + } + if _, err := s.Kill(ctx, rec.ID); err != nil { + return err + } + } + _, err = s.Cleanup(ctx, project) + return err +} + // List returns sessions as enriched display models after applying API filters. func (s *Service) List(ctx context.Context, filter ListFilter) ([]domain.Session, error) { recs, err := s.listRecords(ctx, filter.ProjectID) diff --git a/backend/internal/service/session/service_test.go b/backend/internal/service/session/service_test.go index 9af3ceea..58dc8e52 100644 --- a/backend/internal/service/session/service_test.go +++ b/backend/internal/service/session/service_test.go @@ -127,9 +127,12 @@ func TestSessionRenameMissingSessionReturnsNotFound(t *testing.T) { // fakeCommander records Kill/Spawn calls so a test can assert the // clean-orchestrator ordering without wiring a real session engine. type fakeCommander struct { - killed []domain.SessionID - spawned bool - killsAtSpawn int + killed []domain.SessionID + cleanupProjects []domain.ProjectID + killErr error + cleanupErr error + spawned bool + killsAtSpawn int } func (f *fakeCommander) Spawn(_ context.Context, cfg ports.SpawnConfig) (domain.SessionRecord, error) { @@ -141,11 +144,18 @@ func (f *fakeCommander) Restore(context.Context, domain.SessionID) (domain.Sessi return domain.SessionRecord{}, nil } func (f *fakeCommander) Kill(_ context.Context, id domain.SessionID) (bool, error) { + if f.killErr != nil { + return false, f.killErr + } f.killed = append(f.killed, id) return true, nil } func (f *fakeCommander) Send(context.Context, domain.SessionID, string) error { return nil } -func (f *fakeCommander) Cleanup(context.Context, domain.ProjectID) (sessionmanager.CleanupResult, error) { +func (f *fakeCommander) Cleanup(_ context.Context, project domain.ProjectID) (sessionmanager.CleanupResult, error) { + f.cleanupProjects = append(f.cleanupProjects, project) + if f.cleanupErr != nil { + return sessionmanager.CleanupResult{}, f.cleanupErr + } return sessionmanager.CleanupResult{ Cleaned: []domain.SessionID{"mer-1"}, Skipped: []sessionmanager.CleanupSkip{{SessionID: "mer-2", Reason: "workspace has uncommitted changes"}}, @@ -171,6 +181,41 @@ func TestCleanupMapsManagerResult(t *testing.T) { } } +func TestTeardownProjectKillsActiveSessionsThenCleansProject(t *testing.T) { + st := newFakeStore() + st.sessions["mer-1"] = domain.SessionRecord{ID: "mer-1", ProjectID: "mer"} + st.sessions["mer-2"] = domain.SessionRecord{ID: "mer-2", ProjectID: "mer", IsTerminated: true} + st.sessions["other-1"] = domain.SessionRecord{ID: "other-1", ProjectID: "other"} + fc := &fakeCommander{} + svc := &Service{manager: fc, store: st} + + if err := svc.TeardownProject(context.Background(), "mer"); err != nil { + t.Fatalf("TeardownProject: %v", err) + } + if len(fc.killed) != 1 || fc.killed[0] != "mer-1" { + t.Fatalf("killed = %#v, want only mer-1", fc.killed) + } + if len(fc.cleanupProjects) != 1 || fc.cleanupProjects[0] != "mer" { + t.Fatalf("cleanup projects = %#v, want [mer]", fc.cleanupProjects) + } +} + +func TestTeardownProjectStopsOnKillError(t *testing.T) { + st := newFakeStore() + st.sessions["mer-1"] = domain.SessionRecord{ID: "mer-1", ProjectID: "mer"} + boom := errors.New("boom") + fc := &fakeCommander{killErr: boom} + svc := &Service{manager: fc, store: st} + + err := svc.TeardownProject(context.Background(), "mer") + if !errors.Is(err, boom) { + t.Fatalf("TeardownProject err = %v, want boom", err) + } + if len(fc.cleanupProjects) != 0 { + t.Fatalf("cleanup projects = %#v, want none after kill failure", fc.cleanupProjects) + } +} + func TestSpawnOrchestratorCleanKillsActiveOrchestratorsBeforeSpawn(t *testing.T) { st := newFakeStore() st.projects["mer"] = domain.ProjectRecord{ID: "mer"} diff --git a/backend/internal/session_manager/manager.go b/backend/internal/session_manager/manager.go index 9a563e96..1ae83837 100644 --- a/backend/internal/session_manager/manager.go +++ b/backend/internal/session_manager/manager.go @@ -176,10 +176,7 @@ func (m *Manager) Spawn(ctx context.Context, cfg ports.SpawnConfig) (domain.Sess branch := cfg.Branch if branch == "" { - // A fresh, unique branch per session: gitworktree can't add a worktree on - // a branch already checked out elsewhere (e.g. main), so default to one - // derived from the assigned session id. - branch = "ao/" + string(id) + branch = defaultSessionBranch(id, cfg.Kind, sessionPrefix(project)) } ws, err := m.workspace.Create(ctx, ports.WorkspaceConfig{ ProjectID: cfg.ProjectID, @@ -456,9 +453,15 @@ func (m *Manager) Restore(ctx context.Context, id domain.SessionID) (domain.Sess if err := m.prepareWorkspace(ctx, agent, id, ws.Path); err != nil { return domain.SessionRecord{}, fmt.Errorf("restore %s: %w", id, err) } + // The system prompt is derived, not persisted: recompute it so a restored + // session keeps its standing instructions across the relaunch. + systemPrompt, err := m.buildSystemPrompt(ctx, rec.Kind, rec.ProjectID) + if err != nil { + return domain.SessionRecord{}, fmt.Errorf("restore %s: system prompt: %w", id, err) + } // Restore re-applies the project's resolved agent config so a configured // model/permissions carry across a restore, matching fresh spawn. - argv, err := restoreArgv(ctx, agent, id, ws.Path, meta, effectiveAgentConfig(rec.Kind, project.Config)) + argv, err := restoreArgv(ctx, agent, id, ws.Path, meta, systemPrompt, effectiveAgentConfig(rec.Kind, project.Config)) if err != nil { return domain.SessionRecord{}, fmt.Errorf("restore %s: %w", id, err) } @@ -577,30 +580,60 @@ func seedRecord(cfg ports.SpawnConfig, now time.Time) domain.SessionRecord { } } +func defaultSessionBranch(id domain.SessionID, kind domain.SessionKind, prefix string) string { + if kind == domain.KindOrchestrator { + return "ao/" + prefix + "-orchestrator" + } + // A fresh, unique branch per worker session: gitworktree can't add a worktree + // on a branch already checked out elsewhere (e.g. main), so default to one + // derived from the assigned session id. + return "ao/" + string(id) +} + func buildPrompt(cfg ports.SpawnConfig) string { return cfg.Prompt } +// orchestratorKickoffPrompt is the default first turn for an orchestrator +// spawned without an explicit prompt. The role definition rides the system +// prompt, and an interactive agent launched with only a system prompt sits at +// an empty input box; this gives it a turn to act on. +const orchestratorKickoffPrompt = "Get oriented: review the current repo state and any active worker sessions, then report your status and wait for direction." + // buildSpawnTexts returns the user-facing prompt and the system prompt to // deliver separately to the agent. Orchestrator role instructions and worker // coordination hints are placed in the system prompt so they are treated as // standing instructions rather than part of the human's task request. func (m *Manager) buildSpawnTexts(ctx context.Context, cfg ports.SpawnConfig) (prompt, systemPrompt string, err error) { prompt = buildPrompt(cfg) + systemPrompt, err = m.buildSystemPrompt(ctx, cfg.Kind, cfg.ProjectID) + if err != nil { + return "", "", err + } + if cfg.Kind == domain.KindOrchestrator && prompt == "" { + prompt = orchestratorKickoffPrompt + } + return prompt, systemPrompt, nil +} - switch cfg.Kind { +// buildSystemPrompt derives the standing instructions for a session of the +// given kind from current store state. Restore recomputes them through here +// rather than persisting them, so a restored worker points at the orchestrator +// that is active now, not the one from its original spawn. +func (m *Manager) buildSystemPrompt(ctx context.Context, kind domain.SessionKind, projectID domain.ProjectID) (string, error) { + switch kind { case domain.KindOrchestrator: - systemPrompt = orchestratorPrompt(cfg.ProjectID) + return orchestratorPrompt(projectID), nil case domain.KindWorker: - orchestratorID, ok, lookupErr := m.activeOrchestratorSessionID(ctx, cfg.ProjectID) - if lookupErr != nil { - return "", "", lookupErr + orchestratorID, ok, err := m.activeOrchestratorSessionID(ctx, projectID) + if err != nil { + return "", err } if ok { - systemPrompt = workerOrchestratorPrompt(orchestratorID) + return workerOrchestratorPrompt(orchestratorID), nil } } - return prompt, systemPrompt, nil + return "", nil } func (m *Manager) activeOrchestratorSessionID(ctx context.Context, project domain.ProjectID) (domain.SessionID, bool, error) { @@ -819,7 +852,7 @@ func (m *Manager) prepareWorkspace(ctx context.Context, agent ports.Agent, id do // restoreArgv builds the argv to relaunch a torn-down session: the agent's // native resume command when it can continue the session, else a fresh launch. // The agent signals via ok=false (e.g. no native session id captured yet). -func restoreArgv(ctx context.Context, agent ports.Agent, id domain.SessionID, workspacePath string, meta domain.SessionMetadata, agentConfig ports.AgentConfig) ([]string, error) { +func restoreArgv(ctx context.Context, agent ports.Agent, id domain.SessionID, workspacePath string, meta domain.SessionMetadata, systemPrompt string, agentConfig ports.AgentConfig) ([]string, error) { ref := ports.SessionRef{ ID: string(id), WorkspacePath: workspacePath, @@ -836,6 +869,7 @@ func restoreArgv(ctx context.Context, agent ports.Agent, id domain.SessionID, wo SessionID: string(id), WorkspacePath: workspacePath, Prompt: meta.Prompt, + SystemPrompt: systemPrompt, Config: agentConfig, Permissions: agentConfig.Permissions, }) diff --git a/backend/internal/session_manager/manager_test.go b/backend/internal/session_manager/manager_test.go index cc7832e2..01ee199c 100644 --- a/backend/internal/session_manager/manager_test.go +++ b/backend/internal/session_manager/manager_test.go @@ -634,6 +634,79 @@ func TestSpawnOrchestrator_UsesCoordinatorPrompt(t *testing.T) { if strings.Contains(agent.lastLaunch.Prompt, "You are the human-facing coordinator") { t.Fatalf("coordinator role must not be in the user prompt:\n%s", agent.lastLaunch.Prompt) } + + // A promptless orchestrator still needs a first turn: with the role in the + // system prompt only, an interactive agent would idle at an empty input box. + if agent.lastLaunch.Prompt != orchestratorKickoffPrompt { + t.Fatalf("prompt = %q, want kick-off prompt", agent.lastLaunch.Prompt) + } +} + +// TestRestore_OrchestratorRederivesSystemPrompt: the system prompt is derived, +// not persisted, so a restored orchestrator must get its role instructions +// recomputed and handed to the agent's native resume command. +func TestRestore_OrchestratorRederivesSystemPrompt(t *testing.T) { + st := newFakeStore() + st.sessions["mer-1"] = domain.SessionRecord{ + ID: "mer-1", ProjectID: "mer", Kind: domain.KindOrchestrator, IsTerminated: true, + Metadata: domain.SessionMetadata{WorkspacePath: "/ws/mer-1", Branch: "b", AgentSessionID: "agent-x"}, + } + agent := &recordingAgent{} + lookPath := func(string) (string, error) { return "/bin/true", nil } + m := New(Deps{Runtime: &fakeRuntime{}, Agents: singleAgent{agent: agent}, Workspace: &fakeWorkspace{}, Store: st, Messenger: &fakeMessenger{}, Lifecycle: &fakeLCM{store: st}, LookPath: lookPath}) + + if _, err := m.Restore(ctx, "mer-1"); err != nil { + t.Fatal(err) + } + if !strings.Contains(agent.lastRestore.SystemPrompt, "You are the human-facing coordinator for project mer") { + t.Fatalf("restore system prompt missing coordinator role:\n%s", agent.lastRestore.SystemPrompt) + } +} + +// TestRestore_FallbackLaunchCarriesSystemPrompt: when the agent has no native +// session to resume, the fresh-launch fallback must carry the re-derived +// system prompt alongside the persisted task prompt. +func TestRestore_FallbackLaunchCarriesSystemPrompt(t *testing.T) { + st := newFakeStore() + st.sessions["mer-1"] = domain.SessionRecord{ + ID: "mer-1", ProjectID: "mer", Kind: domain.KindOrchestrator, IsTerminated: true, + Metadata: domain.SessionMetadata{WorkspacePath: "/ws/mer-1", Branch: "b", Prompt: "kick off"}, + } + agent := &recordingAgent{} + lookPath := func(string) (string, error) { return "/bin/true", nil } + m := New(Deps{Runtime: &fakeRuntime{}, Agents: singleAgent{agent: agent}, Workspace: &fakeWorkspace{}, Store: st, Messenger: &fakeMessenger{}, Lifecycle: &fakeLCM{store: st}, LookPath: lookPath}) + + if _, err := m.Restore(ctx, "mer-1"); err != nil { + t.Fatal(err) + } + if !strings.Contains(agent.lastLaunch.SystemPrompt, "You are the human-facing coordinator for project mer") { + t.Fatalf("fallback launch system prompt missing coordinator role:\n%s", agent.lastLaunch.SystemPrompt) + } + if agent.lastLaunch.Prompt != "kick off" { + t.Fatalf("fallback launch prompt = %q, want persisted task prompt", agent.lastLaunch.Prompt) + } +} + +// TestRestore_WorkerPointsAtCurrentOrchestrator: a restored worker's +// coordination hint must reference the orchestrator active at restore time, +// not the one from its original spawn. +func TestRestore_WorkerPointsAtCurrentOrchestrator(t *testing.T) { + st := newFakeStore() + st.sessions["mer-9"] = domain.SessionRecord{ID: "mer-9", ProjectID: "mer", Kind: domain.KindOrchestrator} + st.sessions["mer-1"] = domain.SessionRecord{ + ID: "mer-1", ProjectID: "mer", Kind: domain.KindWorker, IsTerminated: true, + Metadata: domain.SessionMetadata{WorkspacePath: "/ws/mer-1", Branch: "b", AgentSessionID: "agent-x"}, + } + agent := &recordingAgent{} + lookPath := func(string) (string, error) { return "/bin/true", nil } + m := New(Deps{Runtime: &fakeRuntime{}, Agents: singleAgent{agent: agent}, Workspace: &fakeWorkspace{}, Store: st, Messenger: &fakeMessenger{}, Lifecycle: &fakeLCM{store: st}, LookPath: lookPath}) + + if _, err := m.Restore(ctx, "mer-1"); err != nil { + t.Fatal(err) + } + if !strings.Contains(agent.lastRestore.SystemPrompt, `ao send --session mer-9`) { + t.Fatalf("restore system prompt missing current orchestrator contact:\n%s", agent.lastRestore.SystemPrompt) + } } // TestRestore_RefusesIncompleteHandle covers Bug 2: a terminated row whose diff --git a/frontend/forge.config.ts b/frontend/forge.config.ts index 3cdccc83..ac376a57 100644 --- a/frontend/forge.config.ts +++ b/frontend/forge.config.ts @@ -8,6 +8,7 @@ const config: ForgeConfig = { name: "Agent Orchestrator", executableName: "agent-orchestrator", appCategoryType: "public.app-category.developer-tools", + extraResource: "daemon", // macOS signing + notarization — set CSC_LINK/CSC_KEY_PASSWORD and // APPLE_ID/APPLE_APP_SPECIFIC_PASSWORD/APPLE_TEAM_ID in CI. // See frontend/docs/desktop-release.md. diff --git a/frontend/package.json b/frontend/package.json index 545e4276..f5ae6033 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -10,11 +10,14 @@ "url": "https://github.com/aoagents/agent-orchestrator" }, "scripts": { + "build:daemon": "node ./scripts/build-daemon.mjs", "dev": "electron-forge start", "dev:web": "VITE_NO_ELECTRON=1 vite --config vite.renderer.config.ts", + "prepackage": "npm run build:daemon", "package": "electron-forge package", + "premake": "npm run build:daemon", "make": "electron-forge make", - "publish": "electron-forge publish", + "publish": "npm run build:daemon && electron-forge publish", "typecheck": "tsc --noEmit", "test": "vitest run --config vite.renderer.config.ts", "test:e2e": "playwright test", diff --git a/frontend/scripts/build-daemon.mjs b/frontend/scripts/build-daemon.mjs new file mode 100644 index 00000000..120892b8 --- /dev/null +++ b/frontend/scripts/build-daemon.mjs @@ -0,0 +1,28 @@ +import { rmSync, mkdirSync } from "node:fs"; +import { dirname, join, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; +import { spawnSync } from "node:child_process"; + +const scriptsDir = dirname(fileURLToPath(import.meta.url)); +const frontendRoot = resolve(scriptsDir, ".."); +const repoRoot = resolve(frontendRoot, ".."); +const backendRoot = join(repoRoot, "backend"); +const outDir = join(frontendRoot, "daemon"); +const outPath = join(outDir, process.platform === "win32" ? "ao.exe" : "ao"); + +rmSync(outDir, { recursive: true, force: true }); +mkdirSync(outDir, { recursive: true }); + +const result = spawnSync("go", ["build", "-o", outPath, "./cmd/ao"], { + cwd: backendRoot, + stdio: "inherit", +}); + +if (result.error) { + console.error(`failed to start go build: ${result.error.message}`); + process.exit(1); +} + +if (result.status !== 0) { + process.exit(result.status ?? 1); +} diff --git a/frontend/src/main.ts b/frontend/src/main.ts index f0fe3de4..ca05ed6f 100644 --- a/frontend/src/main.ts +++ b/frontend/src/main.ts @@ -1,10 +1,12 @@ import { app, BrowserWindow, dialog, ipcMain, net, protocol, shell, type OpenDialogOptions } from "electron"; import { updateElectronApp } from "update-electron-app"; import { spawn, type ChildProcessWithoutNullStreams } from "node:child_process"; +import { existsSync } from "node:fs"; import { readFile } from "node:fs/promises"; import os from "node:os"; import path from "node:path"; import { pathToFileURL } from "node:url"; +import { resolveDaemonLaunch } from "./shared/daemon-launch"; import { createListenPortScanner, defaultRunFilePath, parseRunFile } from "./shared/daemon-discovery"; import type { DaemonStatus } from "./shared/daemon-status"; @@ -149,8 +151,14 @@ function startDaemon(): DaemonStatus { return daemonStatus; } - const command = process.env.AO_DAEMON_COMMAND; - if (!command) { + const launch = resolveDaemonLaunch( + process.env, + app.isPackaged, + process.resourcesPath, + app.getAppPath(), + process.platform, + ); + if (!launch) { setDaemonStatus({ state: "stopped", message: "AO_DAEMON_COMMAND is not configured; renderer uses loopback REST when available.", @@ -158,6 +166,14 @@ function startDaemon(): DaemonStatus { return daemonStatus; } + if (launch.source === "bundled" && !existsSync(launch.command)) { + setDaemonStatus({ + state: "error", + message: `Bundled AO daemon binary was not found at ${launch.command}. Rebuild the desktop package.`, + }); + return daemonStatus; + } + setDaemonStatus({ state: "starting" }); // Capture the spawned handle locally so the async lifecycle listeners act only @@ -168,10 +184,10 @@ function startDaemon(): DaemonStatus { // runs the command through /bin/sh, a plain kill() would only signal the shell // wrapper and orphan the real daemon (which keeps holding the port). Killing // the whole group via killDaemon() reaches the daemon and any PTY children. - const child = spawn(command, [], { - cwd: app.getAppPath(), + const child = spawn(launch.command, launch.args, { + cwd: launch.cwd, env: process.env, - shell: true, + shell: launch.shell, detached: true, }); daemonProcess = child; @@ -315,6 +331,7 @@ function initAutoUpdates(): void { app.whenReady().then(() => { registerRendererProtocol(); createWindow(); + startDaemon(); initAutoUpdates(); app.on("activate", () => { diff --git a/frontend/src/renderer/components/ProjectSettingsForm.test.tsx b/frontend/src/renderer/components/ProjectSettingsForm.test.tsx new file mode 100644 index 00000000..b37c18ca --- /dev/null +++ b/frontend/src/renderer/components/ProjectSettingsForm.test.tsx @@ -0,0 +1,164 @@ +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { render, screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const { getMock, putMock } = vi.hoisted(() => ({ + getMock: vi.fn(), + putMock: vi.fn(), +})); + +vi.mock("../lib/api-client", () => ({ + apiClient: { + GET: getMock, + PUT: putMock, + }, + apiErrorMessage: (error: unknown) => { + if (error instanceof Error) return error.message; + if (typeof error === "object" && error !== null && "message" in error) { + return String((error as { message: unknown }).message); + } + return "Request failed"; + }, +})); + +import { ProjectSettingsForm } from "./ProjectSettingsForm"; + +function renderSettings(projectId = "proj-1") { + const queryClient = new QueryClient({ + defaultOptions: { + queries: { retry: false }, + mutations: { retry: false }, + }, + }); + render( + + + , + ); + return queryClient; +} + +async function chooseOption(trigger: HTMLElement, optionName: string) { + await userEvent.click(trigger); + await userEvent.click(await screen.findByRole("option", { name: optionName })); +} + +beforeEach(() => { + getMock.mockReset(); + putMock.mockReset(); + putMock.mockResolvedValue({ data: { project: {} }, error: undefined }); +}); + +describe("ProjectSettingsForm", () => { + it("loads the current project settings and saves the exposed fields without dropping hidden config", async () => { + getMock.mockResolvedValue({ + data: { + status: "ok", + project: { + id: "proj-1", + name: "Project One", + kind: "single_repo", + path: "/repo/project-one", + repo: "git@github.com:acme/project-one.git", + defaultBranch: "main", + config: { + defaultBranch: "develop", + sessionPrefix: "po", + env: { FOO: "bar" }, + symlinks: [".env"], + postCreate: ["npm install"], + worker: { + agent: "codex", + agentConfig: { model: "worker-model" }, + }, + orchestrator: { agent: "claude-code" }, + agentConfig: { + model: "claude-opus-4-5", + permissions: "auto", + }, + }, + }, + }, + error: undefined, + }); + + renderSettings(); + + expect(await screen.findByText("git@github.com:acme/project-one.git")).toBeInTheDocument(); + expect(screen.getByLabelText("Default branch")).toHaveValue("develop"); + expect(screen.getByLabelText("Session prefix")).toHaveValue("po"); + expect(screen.getByLabelText("Model override")).toHaveValue("claude-opus-4-5"); + + const workerAgent = screen.getByRole("combobox", { name: "Default worker agent" }); + const orchestratorAgent = screen.getByRole("combobox", { name: "Default orchestrator agent" }); + const permissionMode = screen.getByRole("combobox", { name: "Permission mode" }); + expect(workerAgent).toHaveTextContent("codex"); + expect(orchestratorAgent).toHaveTextContent("claude-code"); + expect(permissionMode).toHaveTextContent("Auto"); + + await userEvent.clear(screen.getByLabelText("Default branch")); + await userEvent.type(screen.getByLabelText("Default branch"), "release"); + await userEvent.clear(screen.getByLabelText("Session prefix")); + await userEvent.type(screen.getByLabelText("Session prefix"), "rel"); + await userEvent.clear(screen.getByLabelText("Model override")); + await userEvent.type(screen.getByLabelText("Model override"), "gpt-5-codex"); + await chooseOption(workerAgent, "opencode"); + await chooseOption(orchestratorAgent, "goose"); + await chooseOption(permissionMode, "Bypass permissions"); + + await userEvent.click(screen.getByRole("button", { name: "Save changes" })); + + await waitFor(() => expect(putMock).toHaveBeenCalledTimes(1)); + expect(putMock).toHaveBeenCalledWith("/api/v1/projects/{id}/config", { + params: { path: { id: "proj-1" } }, + body: { + config: { + defaultBranch: "release", + sessionPrefix: "rel", + env: { FOO: "bar" }, + symlinks: [".env"], + postCreate: ["npm install"], + worker: { + agent: "opencode", + agentConfig: { model: "worker-model" }, + }, + orchestrator: { agent: "goose" }, + agentConfig: { + model: "gpt-5-codex", + permissions: "bypass-permissions", + }, + }, + }, + }); + expect(await screen.findByText("Saved.")).toBeInTheDocument(); + }); + + it("shows the daemon validation message when save fails", async () => { + getMock.mockResolvedValue({ + data: { + status: "ok", + project: { + id: "proj-1", + name: "Project One", + kind: "single_repo", + path: "/repo/project-one", + repo: "", + defaultBranch: "main", + }, + }, + error: undefined, + }); + putMock.mockResolvedValue({ + data: undefined, + error: { message: "invalid permissions" }, + }); + + renderSettings(); + + await userEvent.click(await screen.findByRole("button", { name: "Save changes" })); + + expect(await screen.findByText("invalid permissions")).toBeInTheDocument(); + expect(screen.queryByText("Saved.")).not.toBeInTheDocument(); + }); +}); diff --git a/frontend/src/renderer/components/ProjectSettingsForm.tsx b/frontend/src/renderer/components/ProjectSettingsForm.tsx index 43e2dc25..a2ed7a48 100644 --- a/frontend/src/renderer/components/ProjectSettingsForm.tsx +++ b/frontend/src/renderer/components/ProjectSettingsForm.tsx @@ -12,8 +12,7 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from ". type Project = components["schemas"]["Project"]; type ProjectConfig = components["schemas"]["ProjectConfig"]; -// Agents the daemon registers (see SpawnWorkerModal). Empty = "use the daemon -// default". Kept short — the spawn modal owns the full list. +// 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 = [ @@ -139,13 +138,13 @@ function SettingsBody({ project, projectId, onSaved }: { project: Project; proje placeholder="main" /> - + setForm((f) => ({ ...f, sessionPrefix: e.target.value }))} - placeholder="ao/" + placeholder="ao" /> @@ -156,11 +155,16 @@ function SettingsBody({ project, projectId, onSaved }: { project: Project; proje Agents - - setForm((f) => ({ ...f, workerAgent: v }))} /> + + setForm((f) => ({ ...f, workerAgent: v }))} + /> - + setForm((f) => ({ ...f, orchestratorAgent: v }))} /> @@ -174,8 +178,9 @@ function SettingsBody({ project, projectId, onSaved }: { project: Project; proje placeholder="(agent default)" /> - + setForm((f) => ({ ...f, permissions: v }))} /> @@ -200,10 +205,18 @@ function SettingsBody({ project, projectId, onSaved }: { project: Project; proje ); } -function PermissionModeSelect({ value, onChange }: { value: string; onChange: (value: string) => void }) { +function PermissionModeSelect({ + id, + value, + onChange, +}: { + id: string; + value: string; + onChange: (value: string) => void; +}) { return ( onChange(v === "__default__" ? "" : v)}> - + @@ -218,11 +231,11 @@ function PermissionModeSelect({ value, onChange }: { value: string; onChange: (v ); } -function AgentSelect({ value, onChange }: { value: string; onChange: (value: string) => void }) { +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 ( onChange(v === "__default__" ? "" : v)}> - + diff --git a/frontend/src/renderer/components/SessionsBoard.tsx b/frontend/src/renderer/components/SessionsBoard.tsx index 6649e1d5..6dcd622c 100644 --- a/frontend/src/renderer/components/SessionsBoard.tsx +++ b/frontend/src/renderer/components/SessionsBoard.tsx @@ -76,6 +76,9 @@ export function SessionsBoard({ projectId }: SessionsBoardProps) { const workspaceQuery = useWorkspaceQuery(); const all = workspaceQuery.data ?? []; const workspaces = projectId ? all.filter((w) => w.id === projectId) : all; + // Project board leads with the project's name; the cross-project board + // (home) keeps the generic title. + const boardTitle = (projectId && workspaces[0]?.name) || "Board"; const sessions = workspaces.flatMap((w) => workerSessions(w.sessions)); const byZone = new Map(); diff --git a/frontend/src/renderer/components/Sidebar.test.tsx b/frontend/src/renderer/components/Sidebar.test.tsx new file mode 100644 index 00000000..cd71be74 --- /dev/null +++ b/frontend/src/renderer/components/Sidebar.test.tsx @@ -0,0 +1,91 @@ +import { SidebarProvider } from "@/components/ui/sidebar"; +import { render, screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { Sidebar } from "./Sidebar"; +import type { WorkspaceSummary } from "../types/workspace"; + +const { navigateMock } = vi.hoisted(() => ({ navigateMock: vi.fn() })); + +vi.mock("@tanstack/react-router", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + useNavigate: () => navigateMock, + useParams: () => ({}), + useRouterState: ({ select }: { select: (state: { location: { pathname: string } }) => unknown }) => + select({ location: { pathname: "/" } }), + }; +}); + +const workspace: WorkspaceSummary = { + id: "proj-1", + name: "Project One", + path: "/repo/project-one", + sessions: [], +}; + +function renderSidebar(onRemoveProject = vi.fn().mockResolvedValue(undefined)) { + render( + + + , + ); + return onRemoveProject; +} + +beforeEach(() => { + navigateMock.mockReset(); + vi.spyOn(window, "confirm").mockReturnValue(true); + vi.spyOn(window, "alert").mockImplementation(() => undefined); +}); + +afterEach(() => { + vi.restoreAllMocks(); +}); + +describe("Sidebar", () => { + it("confirms project removal before calling the remove handler", async () => { + const user = userEvent.setup(); + const onRemoveProject = renderSidebar(); + + await user.click(screen.getByLabelText("Project actions for Project One")); + await user.click(await screen.findByRole("menuitem", { name: "Remove project" })); + + expect(window.confirm).toHaveBeenCalledWith( + "Remove project Project One? This stops its live sessions and removes it from the sidebar, but keeps the repository folder and stored history on disk.", + ); + await waitFor(() => expect(onRemoveProject).toHaveBeenCalledTimes(1)); + }); + + it("does not remove the project when confirmation is cancelled", async () => { + vi.mocked(window.confirm).mockReturnValue(false); + const user = userEvent.setup(); + const onRemoveProject = renderSidebar(); + + await user.click(screen.getByLabelText("Project actions for Project One")); + await user.click(await screen.findByRole("menuitem", { name: "Remove project" })); + + expect(onRemoveProject).not.toHaveBeenCalled(); + }); + + it("hides the worker count in every state that reveals project actions", () => { + renderSidebar(); + + const projectRow = screen.getByText("Project One").closest("button"); + const count = screen.getByText("0"); + + if (!projectRow) throw new Error("Project row button not found"); + expect(projectRow).toHaveClass("group-hover/menu-item:pr-[34px]"); + expect(projectRow).toHaveClass("group-focus-within/menu-item:pr-[34px]"); + expect(projectRow).toHaveClass("group-has-data-[state=open]/menu-item:pr-[34px]"); + expect(count).toHaveClass("group-hover/menu-item:opacity-0"); + expect(count).toHaveClass("group-focus-within/menu-item:opacity-0"); + expect(count).toHaveClass("group-has-data-[state=open]/menu-item:opacity-0"); + }); +}); diff --git a/frontend/src/renderer/components/Sidebar.tsx b/frontend/src/renderer/components/Sidebar.tsx index 8760f8ee..affc9e1a 100644 --- a/frontend/src/renderer/components/Sidebar.tsx +++ b/frontend/src/renderer/components/Sidebar.tsx @@ -1,5 +1,16 @@ import { useNavigate, useParams, useRouterState } from "@tanstack/react-router"; -import { ChevronRight, GitPullRequest, Moon, Plus, Search, Settings, Sun, Waypoints } from "lucide-react"; +import { + ChevronRight, + GitPullRequest, + Moon, + MoreHorizontal, + Plus, + Search, + Settings, + Sun, + Trash2, + Waypoints, +} from "lucide-react"; import { useState } from "react"; import { attentionZone, @@ -97,6 +108,7 @@ export function Sidebar({ daemonStatus, workspaceError, workspaces, onCreateProj const selection = useSelection(); const eventsConnection = useEventsConnection(); const { state } = useSidebar(); + const isCollapsed = state === "collapsed"; const theme = useUiStore((s) => s.theme); const toggleTheme = useUiStore((s) => s.toggleTheme); // Disclosure state: projects are expanded by default; a project id present in @@ -202,6 +214,7 @@ export function Sidebar({ daemonStatus, workspaceError, workspaces, onCreateProj onToggle={() => toggleCollapsed(workspace.id)} /> ))} + {isCollapsed && } )} @@ -211,8 +224,8 @@ export function Sidebar({ daemonStatus, workspaceError, workspaces, onCreateProj {/* Footer (project-sidebar__footer) — single Settings menu. Divergence (user-requested 2026-06-10): the trigger stretches the full row width (flex-1) with a uniform 7px footer inset on all sides (reference uses - 12px top, 0 bottom, content-hugging button). The icon rail swaps it - for the old rail footer: New project (+ expand toggle off macOS). */} + 12px top, 0 bottom, content-hugging button). The icon rail keeps the + icon-only settings action plus expand toggle (off macOS). */} @@ -273,7 +286,47 @@ export function Sidebar({ daemonStatus, workspaceError, workspaces, onCreateProj - + + + + + + + + + + Settings + + + + {theme === "dark" ? : } + {theme === "dark" ? "Light mode" : "Dark mode"} + + + + + Pull requests + + + + Search + ⌘K + + {selection.activeProjectId && ( + <> + + selection.goSettings(selection.activeProjectId!)}> + + Project settings + + > + )} + + {!isMac && ( @@ -324,6 +377,25 @@ function ProjectItem({ } }; + const removeProject = async () => { + setRemoveError(null); + const confirmed = window.confirm( + `Remove project ${workspace.name}? This stops its live sessions and removes it from the sidebar, but keeps the repository folder and stored history on disk.`, + ); + if (!confirmed) return; + + setIsRemoving(true); + try { + await onRemoveProject(); + } catch (err) { + const message = err instanceof Error ? err.message : "Could not remove project"; + setRemoveError(message); + window.alert(message); + } finally { + setIsRemoving(false); + } + }; + return ( {/* project-sidebar__proj-row */} @@ -439,3 +511,45 @@ function CreateProjectButton({ onCreateProject }: Pick ); } + +function CreateProjectListItem({ onCreateProject }: Pick) { + const [error, setError] = useState(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 ( + + + + + + + + {isChoosingPath ? "Opening…" : "New project"} + + {error && ( + + {error} + + )} + + ); +} diff --git a/frontend/src/renderer/components/SpawnWorkerModal.test.tsx b/frontend/src/renderer/components/SpawnWorkerModal.test.tsx deleted file mode 100644 index b9d5588e..00000000 --- a/frontend/src/renderer/components/SpawnWorkerModal.test.tsx +++ /dev/null @@ -1,77 +0,0 @@ -import { render, screen, waitFor } from "@testing-library/react"; -import userEvent from "@testing-library/user-event"; -import { describe, expect, it, vi } from "vitest"; -import { TooltipProvider } from "./ui/tooltip"; -import { SpawnWorkerModal } from "./SpawnWorkerModal"; -import type { WorkspaceSummary } from "../types/workspace"; - -const workspaces: WorkspaceSummary[] = [{ id: "proj-1", name: "my-app", path: "/p", type: "main", sessions: [] }]; - -function renderModal(onCreateTask = vi.fn().mockResolvedValue(undefined), onOpenChange = () => undefined) { - render( - - - , - ); - return onCreateTask; -} - -describe("SpawnWorkerModal", () => { - // Regression: "Based on main" must NOT send branch:"main" — git refuses a - // second worktree on a checked-out branch, so the daemon 409s. Omitting it - // lets the daemon mint a fresh ao/. - it("omits the base branch from the spawn payload", async () => { - const user = userEvent.setup(); - const onCreateTask = renderModal(); - - await user.type(await screen.findByLabelText("Prompt"), "do the thing"); - await user.click(screen.getByRole("button", { name: /Spawn worker/ })); - - expect(onCreateTask).toHaveBeenCalledWith( - expect.objectContaining({ projectId: "proj-1", prompt: "do the thing", branch: undefined }), - ); - }); - - it("requires a non-empty prompt before it can spawn", async () => { - const onCreateTask = renderModal(); - expect(screen.getByRole("button", { name: /Spawn worker/ })).toBeDisabled(); - expect(onCreateTask).not.toHaveBeenCalled(); - }); - - // Regression: a failed spawn (e.g. 409 BRANCH_CHECKED_OUT_ELSEWHERE) must - // keep the modal open with the daemon's message inline and the input intact, - // disable submit only while in flight, and allow re-submitting. - it("keeps the modal open and shows the daemon error when the spawn fails", async () => { - const user = userEvent.setup(); - const onOpenChange = vi.fn(); - let rejectSpawn!: (reason: Error) => void; - const onCreateTask = vi.fn( - () => - new Promise((_, reject) => { - rejectSpawn = reject; - }), - ); - renderModal(onCreateTask, onOpenChange); - - await user.type(await screen.findByLabelText("Prompt"), "do the thing"); - await user.click(screen.getByRole("button", { name: /Spawn worker/ })); - expect(screen.getByRole("button", { name: /Spawn worker/ })).toBeDisabled(); - - rejectSpawn(new Error("branch already checked out at ~/Projects/skills")); - - expect(await screen.findByRole("alert")).toHaveTextContent("branch already checked out at ~/Projects/skills"); - expect(onOpenChange).not.toHaveBeenCalled(); - expect(screen.getByLabelText("Prompt")).toHaveValue("do the thing"); - - onCreateTask.mockResolvedValueOnce(undefined); - await user.click(screen.getByRole("button", { name: /Spawn worker/ })); - await waitFor(() => expect(onOpenChange).toHaveBeenCalledWith(false)); - expect(onCreateTask).toHaveBeenCalledTimes(2); - }); -}); diff --git a/frontend/src/renderer/components/SpawnWorkerModal.tsx b/frontend/src/renderer/components/SpawnWorkerModal.tsx deleted file mode 100644 index e87b7ab7..00000000 --- a/frontend/src/renderer/components/SpawnWorkerModal.tsx +++ /dev/null @@ -1,304 +0,0 @@ -import * as Dialog from "@radix-ui/react-dialog"; -import { ChevronDown, CornerDownLeft, X } from "lucide-react"; -import { FormEvent, useEffect, useState } from "react"; -import type { AgentProvider, WorkspaceSummary } from "../types/workspace"; -import { Button } from "./ui/button"; -import { cn } from "../lib/utils"; - -const agentOptions: { value: AgentProvider; label: string }[] = [ - { value: "claude-code", label: "claude-code" }, - { value: "codex", label: "codex" }, - { value: "opencode", label: "opencode" }, - { value: "amp", label: "amp" }, - { value: "goose", label: "goose" }, - { value: "kiro", label: "kiro" }, - { value: "kimi", label: "kimi" }, - { value: "crush", label: "crush" }, - { value: "vibe", label: "vibe" }, -]; - -const basedOnTabs = ["Branch", "Issue", "Pull Request"] as const; -// The project's default branch — selecting it in "Based on" means "new session -// branch off the default", not "check out the default branch itself". -const BASE_BRANCH = "main"; -type BasedOn = (typeof basedOnTabs)[number]; - -const NAME_RULE = /^[a-z0-9-]+$/; - -type SpawnWorkerModalProps = { - open: boolean; - onOpenChange: (open: boolean) => void; - workspaces: WorkspaceSummary[]; - defaultProjectId?: string; - onCreateTask: (input: { - projectId: string; - prompt: string; - branch?: string; - harness?: AgentProvider; - }) => Promise; -}; - -export function SpawnWorkerModal({ - open, - onOpenChange, - workspaces, - defaultProjectId, - onCreateTask, -}: SpawnWorkerModalProps) { - const fallbackProjectId = defaultProjectId ?? workspaces[0]?.id ?? ""; - const [name, setName] = useState(""); - const [projectId, setProjectId] = useState(fallbackProjectId); - const [agent, setAgent] = useState("claude-code"); - const [basedOn, setBasedOn] = useState("Branch"); - const [branch, setBranch] = useState(BASE_BRANCH); - const [tab, setTab] = useState<"Prompt" | "Workspace">("Prompt"); - const [prompt, setPrompt] = useState(""); - const [error, setError] = useState(null); - const [isSubmitting, setIsSubmitting] = useState(false); - - // Reset to the launching project each time the dialog opens. - useEffect(() => { - if (open) { - setProjectId(fallbackProjectId); - setError(null); - } - }, [open, fallbackProjectId]); - - const selectedWorkspace = workspaces.find((workspace) => workspace.id === projectId) ?? workspaces[0]; - const branchOptions = Array.from( - new Set([BASE_BRANCH, ...(selectedWorkspace?.sessions.map((session) => session.branch).filter(Boolean) ?? [])]), - ); - const nameValid = name === "" || NAME_RULE.test(name); - const canSubmit = prompt.trim().length > 0 && projectId !== "" && nameValid && !isSubmitting; - - const submit = async (event?: FormEvent) => { - event?.preventDefault(); - if (!canSubmit) return; - setError(null); - setIsSubmitting(true); - try { - // The API's `branch` field means "check out this exact branch in the - // session worktree" — valid for resuming an existing session branch, but - // never for the base branch itself (git refuses a second worktree on a - // checked-out branch; daemon manager.go). "Based on main" therefore - // OMITS branch so the daemon mints a fresh ao/ off the - // project's default branch. - const trimmedBranch = branch.trim(); - await onCreateTask({ - projectId, - prompt: prompt.trim(), - branch: - basedOn === "Branch" && trimmedBranch !== "" && trimmedBranch !== BASE_BRANCH ? trimmedBranch : undefined, - harness: agent, - }); - setName(""); - setPrompt(""); - setBranch(BASE_BRANCH); - onOpenChange(false); - } catch (err) { - setError(err instanceof Error ? err.message : "Could not spawn worker"); - } finally { - setIsSubmitting(false); - } - }; - - return ( - - - - { - if ((event.metaKey || event.ctrlKey) && event.key === "Enter") { - event.preventDefault(); - void submit(); - } - }} - > - - - New worker - - - - - - - - - setName(event.target.value)} - placeholder="worker-name" - value={name} - /> - - Worker names allow letters, numbers, and hyphens. - - - - ({ value: workspace.id, label: workspace.name }))} - /> - setAgent(value as AgentProvider)} - value={agent} - options={agentOptions} - /> - - - - Based on - - {basedOnTabs.map((option) => ( - setBasedOn(option)} - type="button" - > - {option} - - ))} - - - - {basedOn === "Branch" ? ( - ({ value: option, label: option }))} - /> - ) : ( - - {basedOn === "Issue" ? "Pick an issue to start from." : "Pick a pull request to start from."} - - )} - - - - - - {(["Prompt", "Workspace"] as const).map((option) => ( - setTab(option)} - type="button" - > - {option} - - ))} - - {tab === "Prompt" ? ( - setPrompt(event.target.value)} - placeholder="What should this worker do?" - value={prompt} - /> - ) : ( - - ~/.rc/wt/{selectedWorkspace?.name ?? "project"}/{name || "worker-name"} - - )} - - - {error && ( - - {error} - - )} - - - - Spawn worker - - - - - - - - - - ); -} - -function SelectRow({ - label, - "aria-label": ariaLabel, - value, - options, - onChange, -}: { - label: string; - "aria-label": string; - value: string; - options: { value: string; label: string }[]; - onChange: (value: string) => void; -}) { - return ( - - {label} - - - - - ); -} - -function SelectControl({ - "aria-label": ariaLabel, - value, - options, - onChange, - className, -}: { - "aria-label": string; - value: string; - options: { value: string; label: string }[]; - onChange: (value: string) => void; - className?: string; -}) { - return ( - - onChange(event.target.value)} - value={value} - > - {options.map((option) => ( - - {option.label} - - ))} - - - - ); -} diff --git a/frontend/src/renderer/components/ui/breadcrumb.tsx b/frontend/src/renderer/components/ui/breadcrumb.tsx new file mode 100644 index 00000000..9358e185 --- /dev/null +++ b/frontend/src/renderer/components/ui/breadcrumb.tsx @@ -0,0 +1,42 @@ +import { ChevronRightIcon } from "lucide-react"; +import * as React from "react"; + +import { cn } from "@/lib/utils"; + +function Breadcrumb({ ...props }: React.ComponentProps<"nav">) { + return ; +} + +function BreadcrumbList({ className, ...props }: React.ComponentProps<"ol">) { + return ; +} + +function BreadcrumbItem({ className, ...props }: React.ComponentProps<"li">) { + return ; +} + +function BreadcrumbPage({ className, ...props }: React.ComponentProps<"span">) { + return ( + + ); +} + +function BreadcrumbSeparator({ children, className, ...props }: React.ComponentProps<"li">) { + return ( + svg]:size-3.5", className)} + {...props} + > + {children ?? } + + ); +} + +export { Breadcrumb, BreadcrumbItem, BreadcrumbList, BreadcrumbPage, BreadcrumbSeparator }; diff --git a/frontend/src/renderer/components/ui/card.tsx b/frontend/src/renderer/components/ui/card.tsx index 601fe5e6..5e1131ca 100644 --- a/frontend/src/renderer/components/ui/card.tsx +++ b/frontend/src/renderer/components/ui/card.tsx @@ -6,7 +6,10 @@ function Card({ className, ...props }: React.ComponentProps<"div">) { return ( ); diff --git a/frontend/src/renderer/components/ui/select.tsx b/frontend/src/renderer/components/ui/select.tsx index 88ede14f..e8fd1f0b 100644 --- a/frontend/src/renderer/components/ui/select.tsx +++ b/frontend/src/renderer/components/ui/select.tsx @@ -29,7 +29,7 @@ function SelectTrigger({ data-slot="select-trigger" data-size={size} className={cn( - "flex w-fit items-center justify-between gap-2 rounded-md border border-input bg-transparent px-3 py-2 text-sm whitespace-nowrap shadow-xs transition-[color,box-shadow] outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 data-[placeholder]:text-muted-foreground data-[size=default]:h-9 data-[size=sm]:h-8 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2 dark:bg-input/30 dark:hover:bg-input/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 [&_svg:not([class*='text-'])]:text-muted-foreground", + "flex w-fit items-center justify-between gap-2 rounded-md border border-border bg-transparent px-3 py-2 text-sm whitespace-nowrap shadow-xs transition-[color,box-shadow] outline-none focus-visible:border-border-strong focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 data-[placeholder]:text-muted-foreground data-[size=default]:h-9 data-[size=sm]:h-8 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2 dark:bg-input/30 dark:hover:bg-input/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 [&_svg:not([class*='text-'])]:text-muted-foreground", className, )} {...props} @@ -54,7 +54,7 @@ function SelectContent({ ; - /** Open the spawn-worker modal, optionally pre-selecting a project. */ - openSpawn: (projectId?: string) => void; createProject: (input: { path: string }) => Promise; - createTask: (input: { projectId: string; prompt: string; branch?: string; harness?: AgentProvider }) => Promise; }; const ShellContext = createContext(null); diff --git a/frontend/src/renderer/routes/_shell.tsx b/frontend/src/renderer/routes/_shell.tsx index ba911ba1..65674f44 100644 --- a/frontend/src/renderer/routes/_shell.tsx +++ b/frontend/src/renderer/routes/_shell.tsx @@ -1,17 +1,16 @@ -import { createFileRoute, Outlet, useNavigate } from "@tanstack/react-router"; +import { createFileRoute, Outlet, useNavigate, useParams } from "@tanstack/react-router"; import { useQueryClient } from "@tanstack/react-query"; import { useCallback, useEffect, useState } from "react"; import { ShellTopbar } from "../components/ShellTopbar"; import { Sidebar } from "../components/Sidebar"; import { SidebarProvider } from "../components/ui/sidebar"; -import { SpawnWorkerModal } from "../components/SpawnWorkerModal"; import { TitlebarNav } from "../components/TitlebarNav"; import { useDaemonStatus } from "../hooks/useDaemonStatus"; import { useWorkspaceQuery, workspaceQueryKey, workspaceQueryOptions } from "../hooks/useWorkspaceQuery"; import { apiClient, apiErrorMessage } from "../lib/api-client"; import { ShellProvider } from "../lib/shell-context"; import { readStoredTheme, type Theme, useUiStore } from "../stores/ui-store"; -import { toAgentProvider, toSessionStatus, type AgentProvider, type WorkspaceSummary } from "../types/workspace"; +import type { WorkspaceSummary } from "../types/workspace"; export const Route = createFileRoute("/_shell")({ // Prefetch the workspace list for the whole shell (parent loaders run before @@ -35,18 +34,12 @@ function errorMessage(error: unknown) { // instead of Zustand. The daemon-status effect runs here exactly once. function ShellLayout() { const navigate = useNavigate(); + const params = useParams({ strict: false }) as { projectId?: string }; const queryClient = useQueryClient(); const workspaceQuery = useWorkspaceQuery(); const workspaces = workspaceQuery.data ?? []; const daemonStatus = useDaemonStatus(queryClient); const { theme, setTheme, isSidebarOpen, toggleSidebar } = useUiStore(); - const [spawnOpen, setSpawnOpen] = useState(false); - const [spawnProjectId, setSpawnProjectId] = useState(undefined); - - const openSpawn = useCallback((projectId?: string) => { - setSpawnProjectId(projectId); - setSpawnOpen(true); - }, []); const updateWorkspaces = useCallback( (updater: (workspaces: WorkspaceSummary[]) => WorkspaceSummary[]) => { @@ -74,49 +67,18 @@ function ShellLayout() { [navigate, updateWorkspaces], ); - const createTask = useCallback( - async (input: { projectId: string; prompt: string; branch?: string; harness?: AgentProvider }) => { - const { data, error } = await apiClient.POST("/api/v1/sessions", { - body: { - projectId: input.projectId, - kind: "worker", - harness: input.harness, - prompt: input.prompt, - branch: input.branch || undefined, - }, - }); - if (error || !data?.session) throw new Error(error ? apiErrorMessage(error) : "No session returned"); + const removeProject = useCallback( + async (projectId: string) => { + const { error } = await apiClient.DELETE("/api/v1/projects/{id}", { params: { path: { id: projectId } } }); + if (error) throw new Error(apiErrorMessage(error)); - const session = data.session; - updateWorkspaces((current) => - current.map((item) => - item.id === input.projectId - ? { - ...item, - sessions: [ - { - id: session.id, - terminalHandleId: session.terminalHandleId, - workspaceId: item.id, - workspaceName: item.name, - title: input.prompt, - provider: toAgentProvider(session.harness), - branch: input.branch ?? "", - status: toSessionStatus(session.status, session.isTerminated), - updatedAt: "now", - }, - ...item.sessions.filter((existing) => existing.id !== session.id), - ], - } - : item, - ), - ); - void navigate({ - to: "/projects/$projectId/sessions/$sessionId", - params: { projectId: input.projectId, sessionId: session.id }, - }); + updateWorkspaces((current) => current.filter((item) => item.id !== projectId)); + await queryClient.invalidateQueries({ queryKey: workspaceQueryKey }); + if (params.projectId === projectId) { + void navigate({ to: "/" }); + } }, - [navigate, updateWorkspaces], + [navigate, params.projectId, queryClient, updateWorkspaces], ); useEffect(() => { diff --git a/frontend/src/renderer/styles.css b/frontend/src/renderer/styles.css index 8ca8dfa0..10ad8ba8 100644 --- a/frontend/src/renderer/styles.css +++ b/frontend/src/renderer/styles.css @@ -265,7 +265,7 @@ select { .resize-handle:hover::after, body.is-resizing-x .resize-handle::after { - background: var(--color-accent); + background: var(--border); } body.is-resizing-x { @@ -484,7 +484,6 @@ body.is-resizing-x [data-slot="sidebar-container"] { flex-direction: column; overflow: hidden; background: var(--bg); - border-left: 1px solid var(--border); } .session-inspector__tabs { @@ -573,7 +572,7 @@ body.is-resizing-x [data-slot="sidebar-container"] { .session-inspector__resize-handle:hover::after, .session-inspector__resize-handle:focus-visible::after, .session-inspector__resize-handle[data-separator="active"]::after { - background: var(--accent); + background: var(--border); } /* Collapse/expand animation for the inspector panel: rrp v4 drives panel diff --git a/frontend/src/renderer/test/setup.ts b/frontend/src/renderer/test/setup.ts index 2495811c..6ad62a0c 100644 --- a/frontend/src/renderer/test/setup.ts +++ b/frontend/src/renderer/test/setup.ts @@ -45,6 +45,11 @@ Object.defineProperty(window, "localStorage", { HTMLCanvasElement.prototype.getContext = (() => ({})) as unknown as typeof HTMLCanvasElement.prototype.getContext; +Element.prototype.hasPointerCapture = (() => false) as typeof Element.prototype.hasPointerCapture; +Element.prototype.setPointerCapture = (() => undefined) as typeof Element.prototype.setPointerCapture; +Element.prototype.releasePointerCapture = (() => undefined) as typeof Element.prototype.releasePointerCapture; +Element.prototype.scrollIntoView = (() => undefined) as typeof Element.prototype.scrollIntoView; + window.ao = { app: { getVersion: async () => "0.0.0-test", diff --git a/frontend/src/shared/daemon-launch.test.ts b/frontend/src/shared/daemon-launch.test.ts new file mode 100644 index 00000000..a85f8ff5 --- /dev/null +++ b/frontend/src/shared/daemon-launch.test.ts @@ -0,0 +1,56 @@ +import { describe, expect, it } from "vitest"; +import { resolveDaemonLaunch } from "./daemon-launch"; + +describe("resolveDaemonLaunch", () => { + it("uses AO_DAEMON_COMMAND when configured", () => { + expect(resolveDaemonLaunch({ AO_DAEMON_COMMAND: "/tmp/ao daemon" }, false, "/resources", "/app", "darwin")).toEqual( + { + command: "/tmp/ao daemon", + args: [], + cwd: "/app", + shell: true, + source: "configured", + }, + ); + }); + + it("runs the backend daemon from source in dev without an explicit command", () => { + expect(resolveDaemonLaunch({}, false, "/resources", "/repo/frontend", "darwin")).toEqual({ + command: "go", + args: ["run", "./cmd/ao", "daemon"], + cwd: "/repo/frontend/../backend", + shell: false, + source: "dev", + }); + }); + + it("uses the bundled daemon binary for packaged macOS/Linux builds", () => { + expect( + resolveDaemonLaunch({}, true, "/Applications/Agent Orchestrator.app/Contents/Resources", "/app", "darwin"), + ).toEqual({ + command: "/Applications/Agent Orchestrator.app/Contents/Resources/daemon/ao", + args: ["daemon"], + cwd: "/Applications/Agent Orchestrator.app/Contents/Resources", + shell: false, + source: "bundled", + }); + }); + + it("uses the bundled daemon exe for packaged Windows builds", () => { + expect( + resolveDaemonLaunch( + {}, + true, + "C:\\Program Files\\AO\\resources", + "C:\\Program Files\\AO\\resources\\app.asar", + "win32", + ), + ).toEqual({ + command: "C:\\Program Files\\AO\\resources/daemon/ao.exe", + args: ["daemon"], + cwd: "C:\\Program Files\\AO\\resources", + shell: false, + source: "bundled", + }); + }); +}); diff --git a/frontend/src/shared/daemon-launch.ts b/frontend/src/shared/daemon-launch.ts new file mode 100644 index 00000000..c2381710 --- /dev/null +++ b/frontend/src/shared/daemon-launch.ts @@ -0,0 +1,52 @@ +export type DaemonLaunchSpec = { + command: string; + args: string[]; + cwd: string; + shell: boolean; + source: "configured" | "bundled" | "dev"; +}; + +function joinPath(...segments: string[]): string { + return segments.map((segment) => segment.replace(/[/\\]+$/, "")).join("/"); +} + +export function bundledDaemonBinaryName(platform: NodeJS.Platform): string { + return platform === "win32" ? "ao.exe" : "ao"; +} + +export function resolveDaemonLaunch( + env: Record, + isPackaged: boolean, + resourcesPath: string, + appPath: string, + platform: NodeJS.Platform, +): DaemonLaunchSpec | null { + const configuredCommand = env.AO_DAEMON_COMMAND?.trim(); + if (configuredCommand) { + return { + command: configuredCommand, + args: [], + cwd: appPath, + shell: true, + source: "configured", + }; + } + + if (!isPackaged) { + return { + command: "go", + args: ["run", "./cmd/ao", "daemon"], + cwd: joinPath(appPath, "..", "backend"), + shell: false, + source: "dev", + }; + } + + return { + command: joinPath(resourcesPath, "daemon", bundledDaemonBinaryName(platform)), + args: ["daemon"], + cwd: resourcesPath, + shell: false, + source: "bundled", + }; +}
- {basedOn === "Issue" ? "Pick an issue to start from." : "Pick a pull request to start from."} -
- ~/.rc/wt/{selectedWorkspace?.name ?? "project"}/{name || "worker-name"} -
- {error} -