diff --git a/cmd/amux/main.go b/cmd/amux/main.go index fdcbbbfa..ca972732 100644 --- a/cmd/amux/main.go +++ b/cmd/amux/main.go @@ -20,7 +20,6 @@ import ( "github.com/charmbracelet/x/term" "github.com/andyrewlee/amux/internal/app" - "github.com/andyrewlee/amux/internal/cli" "github.com/andyrewlee/amux/internal/logging" "github.com/andyrewlee/amux/internal/safego" ) @@ -32,89 +31,48 @@ var ( date = "unknown" ) -// CLI subcommands that route to the headless CLI. -var cliCommands = map[string]bool{ - "status": true, "doctor": true, "logs": true, - "workspace": true, "agent": true, "session": true, "project": true, - "terminal": true, - "capabilities": true, - "version": true, "help": true, -} - func main() { - // Handle --version flag - if len(os.Args) > 1 && (os.Args[1] == "--version" || os.Args[1] == "-v") { + args := os.Args[1:] + + if isVersionInvocation(args) { fmt.Printf("amux %s (commit: %s, built: %s)\n", version, commit, date) os.Exit(0) } - sub, parseErr := classifyInvocation(os.Args[1:]) - if parseErr != nil { - // Let the headless CLI render the canonical parse error response. - code := cli.Run(os.Args[1:], version, commit, date) - os.Exit(code) - } - - // Route to CLI if a known subcommand is given (even with leading global flags). - if sub != "" { - if cliCommands[sub] { - code := cli.Run(os.Args[1:], version, commit, date) - os.Exit(code) - } - if sub == "tui" { - // Launch TUI unconditionally. - runTUI() - return - } + if len(args) > 0 { + fmt.Fprintln(os.Stderr, unsupportedInvocationMessage(args[0])) + os.Exit(2) } - // No subcommand: TTY → TUI, non-TTY → delegate to headless CLI. - if sub == "" { - launchTUI := shouldLaunchTUI( - term.IsTerminal(os.Stdin.Fd()), - term.IsTerminal(os.Stdout.Fd()), - term.IsTerminal(os.Stderr.Fd()), - ) - if handled, code := handleNoSubcommand(os.Args[1:], launchTUI); handled { - os.Exit(code) - } - runTUI() - return + if !shouldLaunchTUI( + term.IsTerminal(os.Stdin.Fd()), + term.IsTerminal(os.Stdout.Fd()), + term.IsTerminal(os.Stderr.Fd()), + ) { + fmt.Fprintln(os.Stderr, nonInteractiveMessage()) + os.Exit(1) } - // Unknown argument: route through CLI for JSON-aware error handling - code := cli.Run(os.Args[1:], version, commit, date) - os.Exit(code) -} - -func firstCLIArg(args []string) string { - sub, _ := classifyInvocation(args) - return sub + runTUI() } -func classifyInvocation(args []string) (string, error) { - _, rest, err := cli.ParseGlobalFlags(args) - if err != nil { - return "", err - } - if len(rest) == 0 { - return "", nil - } - return rest[0], nil +func isVersionInvocation(args []string) bool { + return len(args) == 1 && (args[0] == "--version" || args[0] == "-v") } func shouldLaunchTUI(stdinIsTTY, stdoutIsTTY, stderrIsTTY bool) bool { return stdinIsTTY && stdoutIsTTY && stderrIsTTY } -func handleNoSubcommand(args []string, launchTUI bool) (bool, int) { - if len(args) > 0 { - return true, cli.Run(args, version, commit, date) - } - if launchTUI { - return false, 0 +func unsupportedInvocationMessage(arg string) string { + if arg == "tui" { + return "run `amux` directly to start the terminal UI." } - return true, cli.Run(args, version, commit, date) + return fmt.Sprintf("unexpected argument %q. Run `amux` to start the terminal UI or `amux --version`.", arg) +} + +func nonInteractiveMessage() string { + return "amux starts an interactive terminal UI and requires stdin, stdout, and stderr to be TTYs." } func runTUI() { diff --git a/cmd/amux/main_test.go b/cmd/amux/main_test.go index 56c3e4f8..8bd41e96 100644 --- a/cmd/amux/main_test.go +++ b/cmd/amux/main_test.go @@ -3,9 +3,6 @@ package main import ( - "encoding/json" - "io" - "os" "strings" "testing" "time" @@ -46,155 +43,50 @@ func TestMouseWheelThrottleIndependent(t *testing.T) { } } -func TestFirstCLIArgSkipsLeadingGlobalFlags(t *testing.T) { +func TestIsVersionInvocation(t *testing.T) { tests := []struct { name string args []string - want string + want bool }{ - { - name: "json status", - args: []string{"--json", "status"}, - want: "status", - }, - { - name: "quiet doctor", - args: []string{"-q", "doctor"}, - want: "doctor", - }, - { - name: "cwd workspace list", - args: []string{"--cwd", "/tmp/repo", "workspace", "list"}, - want: "workspace", - }, - { - name: "timeout logs tail", - args: []string{"--timeout=5s", "logs", "tail"}, - want: "logs", - }, - { - name: "request-id capabilities", - args: []string{"--request-id", "req-1", "capabilities"}, - want: "capabilities", - }, - { - name: "only globals", - args: []string{"--json"}, - want: "", - }, + {name: "long flag", args: []string{"--version"}, want: true}, + {name: "short flag", args: []string{"-v"}, want: true}, + {name: "no args", args: nil, want: false}, + {name: "unexpected command", args: []string{"status"}, want: false}, + {name: "extra args after version", args: []string{"--version", "status"}, want: false}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - if got := firstCLIArg(tt.args); got != tt.want { - t.Fatalf("firstCLIArg() = %q, want %q", got, tt.want) + if got := isVersionInvocation(tt.args); got != tt.want { + t.Fatalf("isVersionInvocation() = %v, want %v", got, tt.want) } }) } } -func TestClassifyInvocation(t *testing.T) { +func TestUnsupportedInvocationMessage(t *testing.T) { tests := []struct { - name string - args []string - wantSub string - wantErr bool + name string + arg string + want string }{ - { - name: "global-only", - args: []string{"--json"}, - wantSub: "", - }, - { - name: "global-prefix-with-subcommand", - args: []string{"--json", "status"}, - wantSub: "status", - }, - { - name: "malformed-timeout", - args: []string{"--timeout=abc"}, - wantErr: true, - }, + {name: "unexpected command", arg: "status", want: `unexpected argument "status"`}, + {name: "tui subcommand hint", arg: "tui", want: "run `amux` directly to start the terminal UI"}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - gotSub, err := classifyInvocation(tt.args) - if tt.wantErr { - if err == nil { - t.Fatalf("classifyInvocation() expected error, got nil") - } - return - } - if err != nil { - t.Fatalf("classifyInvocation() unexpected error: %v", err) - } - if gotSub != tt.wantSub { - t.Fatalf("classifyInvocation() = %q, want %q", gotSub, tt.wantSub) + if got := unsupportedInvocationMessage(tt.arg); !strings.Contains(got, tt.want) { + t.Fatalf("unsupportedInvocationMessage() = %q, want substring %q", got, tt.want) } }) } } -func TestHandleNoSubcommandNonTTYRoutesThroughCLIJSON(t *testing.T) { - code, stdout, stderr := runHandleNoSubcommandCaptured(t, []string{"--json"}, false) - if code != 2 { - t.Fatalf("handleNoSubcommand() code = %d, want 2", code) - } - if strings.TrimSpace(stderr) != "" { - t.Fatalf("expected empty stderr in --json mode, got %q", stderr) - } - - var env struct { - OK bool `json:"ok"` - Error *struct { - Code string `json:"code"` - } `json:"error"` - } - if err := json.Unmarshal([]byte(stdout), &env); err != nil { - t.Fatalf("json.Unmarshal() error = %v\nraw: %s", err, stdout) - } - if env.OK { - t.Fatalf("expected ok=false") - } - if env.Error == nil || env.Error.Code != "usage_error" { - t.Fatalf("expected usage_error, got %#v", env.Error) - } -} - -func TestHandleNoSubcommandTTYSignalsTUIFlow(t *testing.T) { - handled, code := handleNoSubcommand(nil, true) - if handled { - t.Fatalf("expected handled=false when stdin is a TTY") - } - if code != 0 { - t.Fatalf("expected code=0 for TTY path, got %d", code) - } -} - -func TestHandleNoSubcommandTTYWithJSONRoutesThroughCLI(t *testing.T) { - code, stdout, stderr := runHandleNoSubcommandCaptured(t, []string{"--json"}, true) - if code != 2 { - t.Fatalf("handleNoSubcommand() code = %d, want 2", code) - } - if strings.TrimSpace(stderr) != "" { - t.Fatalf("expected empty stderr in --json mode, got %q", stderr) - } - - var env struct { - OK bool `json:"ok"` - Error *struct { - Code string `json:"code"` - } `json:"error"` - } - if err := json.Unmarshal([]byte(stdout), &env); err != nil { - t.Fatalf("json.Unmarshal() error = %v\nraw: %s", err, stdout) - } - if env.OK { - t.Fatalf("expected ok=false") - } - if env.Error == nil || env.Error.Code != "usage_error" { - t.Fatalf("expected usage_error, got %#v", env.Error) +func TestNonInteractiveMessage(t *testing.T) { + if got := nonInteractiveMessage(); !strings.Contains(got, "interactive terminal") { + t.Fatalf("nonInteractiveMessage() = %q, want interactive-terminal guidance", got) } } @@ -206,34 +98,10 @@ func TestShouldLaunchTUIRequiresAllTTYStreams(t *testing.T) { stderrTTY bool want bool }{ - { - name: "all tty", - stdinTTY: true, - stdoutTTY: true, - stderrTTY: true, - want: true, - }, - { - name: "stdout redirected", - stdinTTY: true, - stdoutTTY: false, - stderrTTY: true, - want: false, - }, - { - name: "stdin non tty", - stdinTTY: false, - stdoutTTY: true, - stderrTTY: true, - want: false, - }, - { - name: "stderr non tty", - stdinTTY: true, - stdoutTTY: true, - stderrTTY: false, - want: false, - }, + {name: "all tty", stdinTTY: true, stdoutTTY: true, stderrTTY: true, want: true}, + {name: "stdout redirected", stdinTTY: true, stdoutTTY: false, stderrTTY: true, want: false}, + {name: "stdin non tty", stdinTTY: false, stdoutTTY: true, stderrTTY: true, want: false}, + {name: "stderr non tty", stdinTTY: true, stdoutTTY: true, stderrTTY: false, want: false}, } for _, tt := range tests { @@ -244,45 +112,3 @@ func TestShouldLaunchTUIRequiresAllTTYStreams(t *testing.T) { }) } } - -func runHandleNoSubcommandCaptured(t *testing.T, args []string, stdinIsTTY bool) (int, string, string) { - t.Helper() - - origStdout := os.Stdout - origStderr := os.Stderr - stdoutR, stdoutW, err := os.Pipe() - if err != nil { - t.Fatalf("os.Pipe(stdout) error = %v", err) - } - stderrR, stderrW, err := os.Pipe() - if err != nil { - t.Fatalf("os.Pipe(stderr) error = %v", err) - } - os.Stdout = stdoutW - os.Stderr = stderrW - defer func() { - os.Stdout = origStdout - os.Stderr = origStderr - }() - - handled, code := handleNoSubcommand(args, stdinIsTTY) - if !handled { - t.Fatalf("expected handled=true for non-TTY path") - } - - _ = stdoutW.Close() - _ = stderrW.Close() - - stdoutBytes, readStdoutErr := io.ReadAll(stdoutR) - if readStdoutErr != nil { - t.Fatalf("io.ReadAll(stdout) error = %v", readStdoutErr) - } - stderrBytes, readStderrErr := io.ReadAll(stderrR) - if readStderrErr != nil { - t.Fatalf("io.ReadAll(stderr) error = %v", readStderrErr) - } - _ = stdoutR.Close() - _ = stderrR.Close() - - return code, string(stdoutBytes), string(stderrBytes) -} diff --git a/go.mod b/go.mod index f8bbd370..42e40dd9 100644 --- a/go.mod +++ b/go.mod @@ -14,7 +14,6 @@ require ( github.com/creack/pty v1.1.24 github.com/fsnotify/fsnotify v1.9.0 github.com/mattn/go-runewidth v0.0.20 - golang.org/x/sys v0.41.0 ) require ( @@ -28,4 +27,5 @@ require ( github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect golang.org/x/exp v0.0.0-20260112195511-716be5621a96 // indirect golang.org/x/sync v0.19.0 // indirect + golang.org/x/sys v0.41.0 // indirect ) diff --git a/internal/app/activity/fetch.go b/internal/app/activity/fetch.go index e0d7443c..9bd46125 100644 --- a/internal/app/activity/fetch.go +++ b/internal/app/activity/fetch.go @@ -51,7 +51,7 @@ func FetchTaggedSessions(svc SessionFetcher, infoBySession map[string]SessionInf if !ok && !hasInput { // Lease is refreshed on both input and output events; treat it as a // compatibility fallback when explicit output tags are absent. - // Still needed (2026-03): CLI-created sessions lack output/input tags + // Still needed (2026-03): some older sessions lack output/input tags // until the first PTY activity event writes them. if leaseAt, leaseOK := ParseLastOutputAtTag(row.Tags[tmux.TagSessionLeaseAt]); leaseOK { lastOutputAt = leaseAt diff --git a/internal/app/workspace_service_create_pending_test.go b/internal/app/workspace_service_create_pending_test.go index cb63b0c0..8437ec8b 100644 --- a/internal/app/workspace_service_create_pending_test.go +++ b/internal/app/workspace_service_create_pending_test.go @@ -147,7 +147,7 @@ func TestCreateWorkspaceEmptyBaseResolvesToMainBranch(t *testing.T) { // Switch to a feature branch so HEAD != main, simulating the bug // scenario where the user is on a different workspace branch. - runGit(t, repo, "checkout", "-b", "openclaw") + runGit(t, repo, "checkout", "-b", "feature-ui") var capturedBase string svc := newWorkspaceService(nil, nil, nil, "/tmp/workspaces") diff --git a/internal/cli/agent_id.go b/internal/cli/agent_id.go deleted file mode 100644 index 9f06994a..00000000 --- a/internal/cli/agent_id.go +++ /dev/null @@ -1,59 +0,0 @@ -package cli - -import ( - "errors" - "strings" - - "github.com/andyrewlee/amux/internal/tmux" -) - -var ( - errInvalidAgentID = errors.New("agent_id must be in workspace_id:tab_id format") - errAgentNotFound = errors.New("agent not found") - tmuxSessionsWithTagsForAgentID = tmux.SessionsWithTags -) - -func formatAgentID(workspaceID, tabID string) string { - workspaceID = strings.TrimSpace(workspaceID) - tabID = strings.TrimSpace(tabID) - if workspaceID == "" || tabID == "" { - return "" - } - return workspaceID + ":" + tabID -} - -func parseAgentID(agentID string) (string, string, error) { - parts := strings.SplitN(strings.TrimSpace(agentID), ":", 2) - if len(parts) != 2 { - return "", "", errInvalidAgentID - } - workspaceID := strings.TrimSpace(parts[0]) - tabID := strings.TrimSpace(parts[1]) - if workspaceID == "" || tabID == "" { - return "", "", errInvalidAgentID - } - return workspaceID, tabID, nil -} - -func resolveSessionNameForAgentID(agentID string, opts tmux.Options) (string, error) { - workspaceID, tabID, err := parseAgentID(agentID) - if err != nil { - return "", err - } - rows, err := tmuxSessionsWithTagsForAgentID( - map[string]string{ - "@amux": "1", - "@amux_workspace": workspaceID, - "@amux_tab": tabID, - }, - nil, - opts, - ) - if err != nil { - return "", err - } - if len(rows) == 0 { - return "", errAgentNotFound - } - return rows[0].Name, nil -} diff --git a/internal/cli/agent_id_test.go b/internal/cli/agent_id_test.go deleted file mode 100644 index 59571549..00000000 --- a/internal/cli/agent_id_test.go +++ /dev/null @@ -1,26 +0,0 @@ -package cli - -import "testing" - -func TestFormatAgentID(t *testing.T) { - if got := formatAgentID("ws1", "tab1"); got != "ws1:tab1" { - t.Fatalf("formatAgentID() = %q, want %q", got, "ws1:tab1") - } - if got := formatAgentID("ws1", ""); got != "" { - t.Fatalf("formatAgentID() with missing tab should be empty, got %q", got) - } -} - -func TestParseAgentID(t *testing.T) { - ws, tab, err := parseAgentID("ws1:tab1") - if err != nil { - t.Fatalf("parseAgentID() error = %v", err) - } - if ws != "ws1" || tab != "tab1" { - t.Fatalf("parseAgentID() = (%q,%q), want (%q,%q)", ws, tab, "ws1", "tab1") - } - - if _, _, err := parseAgentID("invalid"); err == nil { - t.Fatalf("expected invalid agent id to fail") - } -} diff --git a/internal/cli/cmd_agent.go b/internal/cli/cmd_agent.go deleted file mode 100644 index dfe1ab5f..00000000 --- a/internal/cli/cmd_agent.go +++ /dev/null @@ -1,189 +0,0 @@ -package cli - -import ( - "errors" - "fmt" - "io" - "time" - - "github.com/andyrewlee/amux/internal/tmux" -) - -var ( - agentCaptureRetryAttempts = 5 - agentCaptureRetryDelay = 120 * time.Millisecond -) - -type agentInfo struct { - SessionName string `json:"session_name"` - AgentID string `json:"agent_id,omitempty"` - WorkspaceID string `json:"workspace_id"` - TabID string `json:"tab_id"` - Type string `json:"type"` -} - -type captureResult struct { - SessionName string `json:"session_name"` - Content string `json:"content"` - Lines int `json:"lines"` - Status string `json:"status,omitempty"` - LatestLine string `json:"latest_line,omitempty"` - Summary string `json:"summary,omitempty"` - NeedsInput bool `json:"needs_input,omitempty"` - InputHint string `json:"input_hint,omitempty"` - SessionExited bool `json:"session_exited,omitempty"` -} - -func cmdAgentList(w, wErr io.Writer, gf GlobalFlags, args []string, version string) int { - const usage = "Usage: amux agent list [--workspace ] [--json]" - fs := newFlagSet("agent list") - workspace := fs.String("workspace", "", "filter by workspace ID") - if err := fs.Parse(args); err != nil { - return returnUsageError(w, wErr, gf, usage, version, err) - } - - svc, code := initServicesOrFail(w, wErr, gf, version) - if code >= 0 { - return code - } - - sessions, err := tmux.ActiveAgentSessionsByActivity(0, svc.TmuxOpts) - if err != nil { - return returnOperationError(w, wErr, gf, version, - ExitInternalError, "list_failed", err, nil, - "failed to list agents: %v", err) - } - - agents := []agentInfo{} - for _, s := range sessions { - if *workspace != "" && s.WorkspaceID != *workspace { - continue - } - agents = append(agents, agentInfo{ - SessionName: s.Name, - AgentID: formatAgentID(s.WorkspaceID, s.TabID), - WorkspaceID: s.WorkspaceID, - TabID: s.TabID, - Type: s.Type, - }) - } - - if gf.JSON { - PrintJSON(w, agents, version) - return ExitOK - } - - PrintHuman(w, func(w io.Writer) { - if len(agents) == 0 { - fmt.Fprintln(w, "No running agents.") - return - } - for _, a := range agents { - if a.AgentID != "" { - fmt.Fprintf(w, " %-40s id=%-24s ws=%-16s tab=%-10s type=%s\n", - a.SessionName, a.AgentID, a.WorkspaceID, a.TabID, a.Type) - continue - } - fmt.Fprintf(w, " %-40s ws=%-16s tab=%-10s type=%s\n", - a.SessionName, a.WorkspaceID, a.TabID, a.Type) - } - }) - return ExitOK -} - -func cmdAgentCapture(w, wErr io.Writer, gf GlobalFlags, args []string, version string) int { - const usage = "Usage: amux agent capture [--lines N] [--json]" - fs := newFlagSet("agent capture") - lines := fs.Int("lines", 50, "number of lines to capture") - sessionName, err := parseSinglePositionalWithFlags(fs, args) - if err != nil { - return returnUsageError(w, wErr, gf, usage, version, err) - } - - if sessionName == "" { - return returnUsageError(w, wErr, gf, usage, version, nil) - } - - if *lines <= 0 { - return returnOperationError(w, wErr, gf, version, - ExitUsage, "invalid_lines", errors.New("--lines must be > 0"), - map[string]any{"lines": *lines}, - "--lines must be > 0") - } - - svc, code := initServicesOrFail(w, wErr, gf, version) - if code >= 0 { - return code - } - - content, ok := captureAgentPaneWithRetry(sessionName, *lines, svc.TmuxOpts) - if !ok { - state, stateErr := tmuxSessionStateFor(sessionName, svc.TmuxOpts) - if stateErr == nil && !state.Exists { - if gf.JSON { - result := captureResult{ - SessionName: sessionName, - Content: "", - Lines: *lines, - Status: "session_exited", - Summary: "Agent session exited before capture.", - SessionExited: true, - } - PrintJSON(w, result, version) - return ExitOK - } - Errorf(wErr, "agent session %s has exited", sessionName) - return ExitNotFound - } - return returnOperationError(w, wErr, gf, version, - ExitNotFound, "capture_failed", errors.New("could not capture pane output"), nil, - "could not capture pane output for session %s", sessionName) - } - - latestLine := latestLineForContent(content) - needsInput, inputHint := detectNeedsInput(content) - result := captureResult{ - SessionName: sessionName, - Content: content, - Lines: *lines, - Status: "captured", - LatestLine: latestLine, - Summary: summarizeWaitResponse("idle", latestLine, needsInput, inputHint), - NeedsInput: needsInput, - InputHint: inputHint, - } - - if gf.JSON { - PrintJSON(w, result, version) - return ExitOK - } - - PrintHuman(w, func(w io.Writer) { - fmt.Fprint(w, content) - if content != "" && content[len(content)-1] != '\n' { - fmt.Fprintln(w) - } - }) - return ExitOK -} - -func captureAgentPaneWithRetry( - sessionName string, - lines int, - opts tmux.Options, -) (string, bool) { - attempts := agentCaptureRetryAttempts - if attempts < 1 { - attempts = 1 - } - for i := 0; i < attempts; i++ { - content, ok := tmuxCapturePaneTail(sessionName, lines, opts) - if ok { - return content, true - } - if i < attempts-1 && agentCaptureRetryDelay > 0 { - time.Sleep(agentCaptureRetryDelay) - } - } - return "", false -} diff --git a/internal/cli/cmd_agent_capture_test.go b/internal/cli/cmd_agent_capture_test.go deleted file mode 100644 index cbd9d35d..00000000 --- a/internal/cli/cmd_agent_capture_test.go +++ /dev/null @@ -1,281 +0,0 @@ -package cli - -import ( - "bytes" - "encoding/json" - "errors" - "testing" - "time" - - "github.com/andyrewlee/amux/internal/tmux" -) - -func TestCaptureAgentPaneWithRetry_EventualSuccess(t *testing.T) { - origCapture := tmuxCapturePaneTail - origAttempts := agentCaptureRetryAttempts - origDelay := agentCaptureRetryDelay - defer func() { - tmuxCapturePaneTail = origCapture - agentCaptureRetryAttempts = origAttempts - agentCaptureRetryDelay = origDelay - }() - - agentCaptureRetryAttempts = 4 - agentCaptureRetryDelay = 0 - - calls := 0 - tmuxCapturePaneTail = func(_ string, _ int, _ tmux.Options) (string, bool) { - calls++ - if calls < 3 { - return "", false - } - return "captured content", true - } - - content, ok := captureAgentPaneWithRetry("session", 40, tmux.Options{}) - if !ok { - t.Fatalf("ok = false, want true") - } - if content != "captured content" { - t.Fatalf("content = %q, want %q", content, "captured content") - } - if calls != 3 { - t.Fatalf("calls = %d, want %d", calls, 3) - } -} - -func TestCaptureAgentPaneWithRetry_AllAttemptsFail(t *testing.T) { - origCapture := tmuxCapturePaneTail - origAttempts := agentCaptureRetryAttempts - origDelay := agentCaptureRetryDelay - defer func() { - tmuxCapturePaneTail = origCapture - agentCaptureRetryAttempts = origAttempts - agentCaptureRetryDelay = origDelay - }() - - agentCaptureRetryAttempts = 3 - agentCaptureRetryDelay = 1 * time.Millisecond - - calls := 0 - tmuxCapturePaneTail = func(_ string, _ int, _ tmux.Options) (string, bool) { - calls++ - return "", false - } - - content, ok := captureAgentPaneWithRetry("session", 40, tmux.Options{}) - if ok { - t.Fatalf("ok = true, want false") - } - if content != "" { - t.Fatalf("content = %q, want empty", content) - } - if calls != 3 { - t.Fatalf("calls = %d, want %d", calls, 3) - } -} - -func TestCmdAgentCaptureJSON_ReturnsSessionExitedWhenMissing(t *testing.T) { - origCapture := tmuxCapturePaneTail - origState := tmuxSessionStateFor - origAttempts := agentCaptureRetryAttempts - origDelay := agentCaptureRetryDelay - defer func() { - tmuxCapturePaneTail = origCapture - tmuxSessionStateFor = origState - agentCaptureRetryAttempts = origAttempts - agentCaptureRetryDelay = origDelay - }() - - agentCaptureRetryAttempts = 1 - agentCaptureRetryDelay = 0 - tmuxCapturePaneTail = func(_ string, _ int, _ tmux.Options) (string, bool) { - return "", false - } - tmuxSessionStateFor = func(_ string, _ tmux.Options) (tmux.SessionState, error) { - return tmux.SessionState{Exists: false, HasLivePane: false}, nil - } - - var out bytes.Buffer - var errOut bytes.Buffer - code := cmdAgentCapture( - &out, - &errOut, - GlobalFlags{JSON: true}, - []string{"session-x", "--lines", "40"}, - "test-v1", - ) - if code != ExitOK { - t.Fatalf("code = %d, want %d", code, ExitOK) - } - - var env Envelope - if err := json.Unmarshal(out.Bytes(), &env); err != nil { - t.Fatalf("failed to decode envelope: %v", err) - } - if !env.OK { - t.Fatalf("expected ok=true, got error=%#v", env.Error) - } - payload, ok := env.Data.(map[string]any) - if !ok { - t.Fatalf("expected map payload, got %T", env.Data) - } - if got, _ := payload["status"].(string); got != "session_exited" { - t.Fatalf("status = %q, want %q", got, "session_exited") - } - if got, _ := payload["summary"].(string); got != "Agent session exited before capture." { - t.Fatalf("summary = %q", got) - } - if got, _ := payload["session_exited"].(bool); !got { - t.Fatalf("session_exited = %v, want true", got) - } - if got, _ := payload["content"].(string); got != "" { - t.Fatalf("content = %q, want empty", got) - } -} - -func TestCmdAgentCaptureJSON_ReturnsCaptureFailedWhenSessionStillExists(t *testing.T) { - origCapture := tmuxCapturePaneTail - origState := tmuxSessionStateFor - origAttempts := agentCaptureRetryAttempts - origDelay := agentCaptureRetryDelay - defer func() { - tmuxCapturePaneTail = origCapture - tmuxSessionStateFor = origState - agentCaptureRetryAttempts = origAttempts - agentCaptureRetryDelay = origDelay - }() - - agentCaptureRetryAttempts = 1 - agentCaptureRetryDelay = 0 - tmuxCapturePaneTail = func(_ string, _ int, _ tmux.Options) (string, bool) { - return "", false - } - tmuxSessionStateFor = func(_ string, _ tmux.Options) (tmux.SessionState, error) { - return tmux.SessionState{Exists: true, HasLivePane: true}, nil - } - - var out bytes.Buffer - var errOut bytes.Buffer - code := cmdAgentCapture( - &out, - &errOut, - GlobalFlags{JSON: true}, - []string{"session-y", "--lines", "40"}, - "test-v1", - ) - if code != ExitNotFound { - t.Fatalf("code = %d, want %d", code, ExitNotFound) - } - - var env Envelope - if err := json.Unmarshal(out.Bytes(), &env); err != nil { - t.Fatalf("failed to decode envelope: %v", err) - } - if env.OK { - t.Fatalf("expected ok=false") - } - if env.Error == nil || env.Error.Code != "capture_failed" { - t.Fatalf("error code = %#v, want capture_failed", env.Error) - } -} - -func TestCmdAgentCaptureJSON_FallsThroughWhenStateCheckFails(t *testing.T) { - origCapture := tmuxCapturePaneTail - origState := tmuxSessionStateFor - origAttempts := agentCaptureRetryAttempts - origDelay := agentCaptureRetryDelay - defer func() { - tmuxCapturePaneTail = origCapture - tmuxSessionStateFor = origState - agentCaptureRetryAttempts = origAttempts - agentCaptureRetryDelay = origDelay - }() - - agentCaptureRetryAttempts = 1 - agentCaptureRetryDelay = 0 - tmuxCapturePaneTail = func(_ string, _ int, _ tmux.Options) (string, bool) { - return "", false - } - tmuxSessionStateFor = func(_ string, _ tmux.Options) (tmux.SessionState, error) { - return tmux.SessionState{}, errors.New("tmux not available") - } - - var out bytes.Buffer - var errOut bytes.Buffer - code := cmdAgentCapture( - &out, - &errOut, - GlobalFlags{JSON: true}, - []string{"session-err", "--lines", "40"}, - "test-v1", - ) - if code != ExitNotFound { - t.Fatalf("code = %d, want %d", code, ExitNotFound) - } - - var env Envelope - if err := json.Unmarshal(out.Bytes(), &env); err != nil { - t.Fatalf("failed to decode envelope: %v", err) - } - if env.OK { - t.Fatalf("expected ok=false") - } - if env.Error == nil || env.Error.Code != "capture_failed" { - t.Fatalf("error code = %#v, want capture_failed", env.Error) - } -} - -func TestCmdAgentCaptureJSON_IncludesSignalsOnSuccess(t *testing.T) { - origCapture := tmuxCapturePaneTail - origAttempts := agentCaptureRetryAttempts - origDelay := agentCaptureRetryDelay - defer func() { - tmuxCapturePaneTail = origCapture - agentCaptureRetryAttempts = origAttempts - agentCaptureRetryDelay = origDelay - }() - - agentCaptureRetryAttempts = 1 - agentCaptureRetryDelay = 0 - tmuxCapturePaneTail = func(_ string, _ int, _ tmux.Options) (string, bool) { - return "Working (2s • esc to interrupt)\nDo you want me to proceed? (y/N)\n", true - } - - var out bytes.Buffer - var errOut bytes.Buffer - code := cmdAgentCapture( - &out, - &errOut, - GlobalFlags{JSON: true}, - []string{"session-z", "--lines", "40"}, - "test-v1", - ) - if code != ExitOK { - t.Fatalf("code = %d, want %d", code, ExitOK) - } - - var env Envelope - if err := json.Unmarshal(out.Bytes(), &env); err != nil { - t.Fatalf("failed to decode envelope: %v", err) - } - if !env.OK { - t.Fatalf("expected ok=true, got error=%#v", env.Error) - } - payload, ok := env.Data.(map[string]any) - if !ok { - t.Fatalf("expected map payload, got %T", env.Data) - } - if got, _ := payload["status"].(string); got != "captured" { - t.Fatalf("status = %q, want %q", got, "captured") - } - if got, _ := payload["latest_line"].(string); got != "Do you want me to proceed? (y/N)" { - t.Fatalf("latest_line = %q", got) - } - if got, _ := payload["summary"].(string); got != "Needs input: Do you want me to proceed? (y/N)" { - t.Fatalf("summary = %q", got) - } - if got, _ := payload["needs_input"].(bool); !got { - t.Fatalf("needs_input = %v, want true", got) - } -} diff --git a/internal/cli/cmd_agent_hooks.go b/internal/cli/cmd_agent_hooks.go deleted file mode 100644 index f029ea62..00000000 --- a/internal/cli/cmd_agent_hooks.go +++ /dev/null @@ -1,22 +0,0 @@ -package cli - -import ( - "github.com/andyrewlee/amux/internal/data" - "github.com/andyrewlee/amux/internal/tmux" -) - -// Test-seam variables: tests that mutate these must NOT use t.Parallel(), -// because they share this package-level mutable state. -var ( - tmuxSessionStateFor = tmux.SessionStateFor - tmuxKillSession = tmux.KillSession - tmuxSendKeys = tmux.SendKeys - tmuxSendInterrupt = tmux.SendInterrupt - tmuxSetSessionTag = tmux.SetSessionTagValue - tmuxCapturePaneTail = tmux.CapturePaneTail - tmuxStartSession = tmuxNewSession - startSendJobProcess = launchSendJobProcessor - appendWorkspaceOpenTabMeta = func(store *data.WorkspaceStore, wsID data.WorkspaceID, tab data.TabInfo) error { - return store.AppendOpenTab(wsID, tab) - } -) diff --git a/internal/cli/cmd_agent_job.go b/internal/cli/cmd_agent_job.go deleted file mode 100644 index f12f3533..00000000 --- a/internal/cli/cmd_agent_job.go +++ /dev/null @@ -1,151 +0,0 @@ -package cli - -import ( - "fmt" - "io" - "time" -) - -func routeAgentJob(w, wErr io.Writer, gf GlobalFlags, args []string, version string) int { - return routeSubcommand(w, wErr, gf, args, version, "agent job", []subcommand{ - {names: []string{"status"}, handler: cmdAgentJobStatus}, - {names: []string{"cancel"}, handler: cmdAgentJobCancel}, - {names: []string{"wait"}, handler: cmdAgentJobWait}, - }) -} - -func cmdAgentJobStatus(w, wErr io.Writer, gf GlobalFlags, args []string, version string) int { - const usage = "Usage: amux agent job status [--json]" - fs := newFlagSet("agent job status") - jobID, err := parseSinglePositionalWithFlags(fs, args) - if err != nil { - return returnUsageError(w, wErr, gf, usage, version, err) - } - if jobID == "" { - return returnUsageError(w, wErr, gf, usage, version, nil) - } - - ctx := &cmdCtx{w: w, wErr: wErr, gf: gf, version: version, cmd: "agent.job.status"} - - store, err := newSendJobStore() - if err != nil { - return ctx.errResult(ExitInternalError, "job_store_failed", err.Error(), nil, fmt.Sprintf("failed to initialize send job store: %v", err)) - } - - job, ok, err := store.get(jobID) - if err != nil { - return ctx.errResult(ExitInternalError, "job_status_failed", err.Error(), map[string]any{"job_id": jobID}, fmt.Sprintf("failed to read job %s: %v", jobID, err)) - } - if !ok { - return ctx.errResult(ExitNotFound, "not_found", "job not found", map[string]any{"job_id": jobID}, fmt.Sprintf("job %s not found", jobID)) - } - - writeJobStatusResult(w, gf, version, job) - return ExitOK -} - -func cmdAgentJobCancel(w, wErr io.Writer, gf GlobalFlags, args []string, version string) int { - const usage = "Usage: amux agent job cancel [--idempotency-key ] [--json]" - fs := newFlagSet("agent job cancel") - idempotencyKey := fs.String("idempotency-key", "", "idempotency key for safe retries") - jobID, err := parseSinglePositionalWithFlags(fs, args) - if err != nil { - return returnUsageError(w, wErr, gf, usage, version, err) - } - if jobID == "" { - return returnUsageError(w, wErr, gf, usage, version, nil) - } - - ctx := &cmdCtx{w: w, wErr: wErr, gf: gf, version: version, cmd: "agent.job.cancel", idemKey: *idempotencyKey} - - if handled, code := ctx.maybeReplay(); handled { - return code - } - - store, err := newSendJobStore() - if err != nil { - return ctx.errResult(ExitInternalError, "job_store_failed", err.Error(), nil, fmt.Sprintf("failed to initialize send job store: %v", err)) - } - - job, ok, canceled, err := store.cancel(jobID) - if err != nil { - return ctx.errResult(ExitInternalError, "job_cancel_failed", err.Error(), map[string]any{"job_id": jobID}, fmt.Sprintf("failed to cancel job %s: %v", jobID, err)) - } - if !ok { - return ctx.errResult(ExitNotFound, "not_found", "job not found", map[string]any{"job_id": jobID}, fmt.Sprintf("job %s not found", jobID)) - } - - result := agentJobCancelResult{ - JobID: job.ID, - Status: string(job.Status), - Canceled: canceled, - } - if gf.JSON { - return ctx.successResult(result) - } - - PrintHuman(w, func(w io.Writer) { - if canceled { - fmt.Fprintf(w, "Canceled job %s\n", job.ID) - return - } - fmt.Fprintf(w, "Job %s is %s; nothing canceled\n", job.ID, job.Status) - }) - return ExitOK -} - -func cmdAgentJobWait(w, wErr io.Writer, gf GlobalFlags, args []string, version string) int { - const usage = "Usage: amux agent job wait [--timeout ] [--interval ] [--json]" - fs := newFlagSet("agent job wait") - timeout := fs.Duration("timeout", 30*time.Second, "max wait duration") - interval := fs.Duration("interval", 200*time.Millisecond, "poll interval") - jobID, err := parseSinglePositionalWithFlags(fs, args) - if err != nil { - return returnUsageError(w, wErr, gf, usage, version, err) - } - if jobID == "" { - return returnUsageError(w, wErr, gf, usage, version, nil) - } - if *timeout <= 0 || *interval <= 0 { - return returnUsageError(w, wErr, gf, usage, version, nil) - } - - ctx := &cmdCtx{w: w, wErr: wErr, gf: gf, version: version, cmd: "agent.job.wait"} - - store, err := newSendJobStore() - if err != nil { - return ctx.errResult(ExitInternalError, "job_store_failed", err.Error(), nil, fmt.Sprintf("failed to initialize send job store: %v", err)) - } - - deadline := time.Now().Add(*timeout) - for { - job, ok, getErr := store.get(jobID) - if getErr != nil { - return ctx.errResult(ExitInternalError, "job_status_failed", getErr.Error(), map[string]any{"job_id": jobID}, fmt.Sprintf("failed to read job %s: %v", jobID, getErr)) - } - if !ok { - return ctx.errResult(ExitNotFound, "not_found", "job not found", map[string]any{"job_id": jobID}, fmt.Sprintf("job %s not found", jobID)) - } - if isTerminalSendJobStatus(job.Status) { - writeJobStatusResult(w, gf, version, job) - if job.Status == sendJobFailed { - return ExitInternalError - } - return ExitOK - } - - if time.Now().After(deadline) { - return ctx.errResult( - ExitInternalError, - "timeout", - "timed out waiting for job completion", - map[string]any{ - "job_id": job.ID, - "status": string(job.Status), - }, - fmt.Sprintf("timed out waiting for job %s completion (status: %s)", job.ID, job.Status), - ) - } - time.Sleep(*interval) - } -} diff --git a/internal/cli/cmd_agent_job_test.go b/internal/cli/cmd_agent_job_test.go deleted file mode 100644 index 77c4e9d4..00000000 --- a/internal/cli/cmd_agent_job_test.go +++ /dev/null @@ -1,240 +0,0 @@ -package cli - -import ( - "bytes" - "encoding/json" - "testing" -) - -func TestCmdAgentJobStatusAndCancelJSON(t *testing.T) { - t.Setenv("HOME", t.TempDir()) - - store, err := newSendJobStore() - if err != nil { - t.Fatalf("newSendJobStore() error = %v", err) - } - job, err := store.create("session-a", "ws-a:tab-a") - if err != nil { - t.Fatalf("store.create() error = %v", err) - } - - var out bytes.Buffer - var errOut bytes.Buffer - statusCode := cmdAgentJobStatus(&out, &errOut, GlobalFlags{JSON: true}, []string{job.ID}, "test-v1") - if statusCode != ExitOK { - t.Fatalf("cmdAgentJobStatus() code = %d, want %d", statusCode, ExitOK) - } - if errOut.Len() != 0 { - t.Fatalf("expected no stderr output in JSON mode, got %q", errOut.String()) - } - - var statusEnv Envelope - if err := json.Unmarshal(out.Bytes(), &statusEnv); err != nil { - t.Fatalf("json.Unmarshal(status) error = %v", err) - } - if !statusEnv.OK { - t.Fatalf("expected status ok=true, got error=%#v", statusEnv.Error) - } - statusData, ok := statusEnv.Data.(map[string]any) - if !ok { - t.Fatalf("expected status payload map, got %T", statusEnv.Data) - } - if got, _ := statusData["status"].(string); got != string(sendJobPending) { - t.Fatalf("status = %q, want %q", got, sendJobPending) - } - - out.Reset() - errOut.Reset() - cancelCode := cmdAgentJobCancel(&out, &errOut, GlobalFlags{JSON: true}, []string{job.ID}, "test-v1") - if cancelCode != ExitOK { - t.Fatalf("cmdAgentJobCancel() code = %d, want %d", cancelCode, ExitOK) - } - if errOut.Len() != 0 { - t.Fatalf("expected no stderr output in JSON mode, got %q", errOut.String()) - } - - var cancelEnv Envelope - if err := json.Unmarshal(out.Bytes(), &cancelEnv); err != nil { - t.Fatalf("json.Unmarshal(cancel) error = %v", err) - } - if !cancelEnv.OK { - t.Fatalf("expected cancel ok=true, got error=%#v", cancelEnv.Error) - } - cancelData, ok := cancelEnv.Data.(map[string]any) - if !ok { - t.Fatalf("expected cancel payload map, got %T", cancelEnv.Data) - } - if got, _ := cancelData["canceled"].(bool); !got { - t.Fatalf("canceled = %v, want true", got) - } - if got, _ := cancelData["status"].(string); got != string(sendJobCanceled) { - t.Fatalf("status after cancel = %q, want %q", got, sendJobCanceled) - } -} - -func TestCmdAgentJobCancelRunningReturnsNoop(t *testing.T) { - t.Setenv("HOME", t.TempDir()) - - store, err := newSendJobStore() - if err != nil { - t.Fatalf("newSendJobStore() error = %v", err) - } - job, err := store.create("session-a", "") - if err != nil { - t.Fatalf("store.create() error = %v", err) - } - if _, err := store.setStatus(job.ID, sendJobRunning, ""); err != nil { - t.Fatalf("store.setStatus() error = %v", err) - } - - var out bytes.Buffer - var errOut bytes.Buffer - code := cmdAgentJobCancel(&out, &errOut, GlobalFlags{JSON: true}, []string{job.ID}, "test-v1") - if code != ExitOK { - t.Fatalf("cmdAgentJobCancel() code = %d, want %d", code, ExitOK) - } - if errOut.Len() != 0 { - t.Fatalf("expected no stderr output in JSON mode, got %q", errOut.String()) - } - - var env Envelope - if err := json.Unmarshal(out.Bytes(), &env); err != nil { - t.Fatalf("json.Unmarshal() error = %v", err) - } - if !env.OK { - t.Fatalf("expected ok=true, got error=%#v", env.Error) - } - payload, ok := env.Data.(map[string]any) - if !ok { - t.Fatalf("expected payload map, got %T", env.Data) - } - if got, _ := payload["canceled"].(bool); got { - t.Fatalf("canceled = %v, want false", got) - } - if got, _ := payload["status"].(string); got != string(sendJobRunning) { - t.Fatalf("status = %q, want %q", got, sendJobRunning) - } -} - -func TestCmdAgentJobCancelJSONIdempotentReplay(t *testing.T) { - t.Setenv("HOME", t.TempDir()) - - store, err := newSendJobStore() - if err != nil { - t.Fatalf("newSendJobStore() error = %v", err) - } - job, err := store.create("session-a", "") - if err != nil { - t.Fatalf("store.create() error = %v", err) - } - - args := []string{job.ID, "--idempotency-key", "idem-job-cancel-1"} - var first bytes.Buffer - var firstErr bytes.Buffer - code := cmdAgentJobCancel(&first, &firstErr, GlobalFlags{JSON: true}, args, "test-v1") - if code != ExitOK { - t.Fatalf("first cmdAgentJobCancel() code = %d, want %d", code, ExitOK) - } - if firstErr.Len() != 0 { - t.Fatalf("expected no stderr output in JSON mode, got %q", firstErr.String()) - } - - var replay bytes.Buffer - var replayErr bytes.Buffer - replayCode := cmdAgentJobCancel(&replay, &replayErr, GlobalFlags{JSON: true}, args, "test-v1") - if replayCode != ExitOK { - t.Fatalf("replay cmdAgentJobCancel() code = %d, want %d", replayCode, ExitOK) - } - if replayErr.Len() != 0 { - t.Fatalf("expected no replay stderr output in JSON mode, got %q", replayErr.String()) - } - if replay.String() != first.String() { - t.Fatalf("replayed output mismatch\nfirst:\n%s\nreplay:\n%s", first.String(), replay.String()) - } -} - -func TestCmdAgentJobWaitCompletedReturnsOK(t *testing.T) { - t.Setenv("HOME", t.TempDir()) - - store, err := newSendJobStore() - if err != nil { - t.Fatalf("newSendJobStore() error = %v", err) - } - job, err := store.create("session-a", "") - if err != nil { - t.Fatalf("store.create() error = %v", err) - } - if _, err := store.setStatus(job.ID, sendJobCompleted, ""); err != nil { - t.Fatalf("store.setStatus() error = %v", err) - } - - var out bytes.Buffer - var errOut bytes.Buffer - code := cmdAgentJobWait( - &out, - &errOut, - GlobalFlags{JSON: true}, - []string{job.ID, "--timeout", "1s", "--interval", "10ms"}, - "test-v1", - ) - if code != ExitOK { - t.Fatalf("cmdAgentJobWait() code = %d, want %d", code, ExitOK) - } - if errOut.Len() != 0 { - t.Fatalf("expected no stderr output in JSON mode, got %q", errOut.String()) - } - var env Envelope - if err := json.Unmarshal(out.Bytes(), &env); err != nil { - t.Fatalf("json.Unmarshal() error = %v", err) - } - if !env.OK { - t.Fatalf("expected ok=true, got error=%#v", env.Error) - } - payload, ok := env.Data.(map[string]any) - if !ok { - t.Fatalf("expected payload map, got %T", env.Data) - } - if got, _ := payload["status"].(string); got != string(sendJobCompleted) { - t.Fatalf("status = %q, want %q", got, sendJobCompleted) - } -} - -func TestCmdAgentJobWaitTimeoutReturnsError(t *testing.T) { - t.Setenv("HOME", t.TempDir()) - - store, err := newSendJobStore() - if err != nil { - t.Fatalf("newSendJobStore() error = %v", err) - } - job, err := store.create("session-a", "") - if err != nil { - t.Fatalf("store.create() error = %v", err) - } - - var out bytes.Buffer - var errOut bytes.Buffer - code := cmdAgentJobWait( - &out, - &errOut, - GlobalFlags{JSON: true}, - []string{job.ID, "--timeout", "40ms", "--interval", "10ms"}, - "test-v1", - ) - if code != ExitInternalError { - t.Fatalf("cmdAgentJobWait() code = %d, want %d", code, ExitInternalError) - } - if errOut.Len() != 0 { - t.Fatalf("expected no stderr output in JSON mode, got %q", errOut.String()) - } - - var env Envelope - if err := json.Unmarshal(out.Bytes(), &env); err != nil { - t.Fatalf("json.Unmarshal() error = %v", err) - } - if env.OK { - t.Fatalf("expected ok=false for timeout") - } - if env.Error == nil || env.Error.Code != "timeout" { - t.Fatalf("expected timeout error, got %#v", env.Error) - } -} diff --git a/internal/cli/cmd_agent_output_signals.go b/internal/cli/cmd_agent_output_signals.go deleted file mode 100644 index 330503a5..00000000 --- a/internal/cli/cmd_agent_output_signals.go +++ /dev/null @@ -1,341 +0,0 @@ -package cli - -import ( - "fmt" - "strings" -) - -// compactAgentOutput strips known TUI chrome lines and collapses output to -// concise non-empty lines suitable for chat notifications. -func compactAgentOutput(content string) string { - lines := strings.Split(content, "\n") - out := make([]string, 0, len(lines)) - dropPromptContinuation := false - for _, raw := range lines { - line := strings.TrimSpace(raw) - if line == "" { - continue - } - if dropPromptContinuation { - if isPromptContinuationLine(raw, line) { - continue - } - dropPromptContinuation = false - } - if shouldDropAgentChromeLine(line) { - if isPromptChromeLine(line) { - dropPromptContinuation = true - } - continue - } - out = append(out, line) - } - return strings.TrimSpace(strings.Join(out, "\n")) -} - -func isPromptChromeLine(line string) bool { - return strings.HasPrefix(line, "› ") || strings.HasPrefix(line, "❯ ") -} - -func isPromptContinuationLine(raw, line string) bool { - if !hasLeadingIndent(raw) { - return false - } - if looksLikeExplicitNeedsInputLine(line) || looksLikeQuestionNeedsInputLine(line) { - return false - } - switch { - case strings.HasPrefix(line, "• "), - strings.HasPrefix(line, "- "), - strings.HasPrefix(line, "* "), - strings.HasPrefix(line, "1."), - strings.HasPrefix(line, "2."), - strings.HasPrefix(line, "3."), - strings.HasPrefix(line, "```"): - return false - default: - return true - } -} - -func hasLeadingIndent(raw string) bool { - if raw == "" { - return false - } - return raw[0] == ' ' || raw[0] == '\t' -} - -func shouldDropAgentChromeLine(line string) bool { - if isAgentProgressNoiseLine(line) { - return true - } - switch { - case strings.HasPrefix(line, "╭"), - strings.HasPrefix(line, "╰"), - strings.HasPrefix(line, "│"), - strings.HasPrefix(line, "─"), - strings.HasPrefix(line, "────────────────"), - strings.HasPrefix(line, "└ "), - strings.HasPrefix(line, "⎿ "), - strings.HasPrefix(line, "↳ Interacted with "), - strings.HasPrefix(line, "› "), - strings.HasPrefix(line, "❯"), - strings.HasPrefix(line, "? for shortcuts"), - strings.HasPrefix(line, "✶ "), - strings.HasPrefix(line, "✻ "), - line == "✻", - line == "|", - strings.HasPrefix(line, "▟"), - strings.HasPrefix(line, "▐"), - strings.HasPrefix(line, "▝"), - strings.HasPrefix(line, "▘"), - strings.HasPrefix(line, "Tip:"), - strings.HasPrefix(line, "• Ran "), - strings.Contains(line, "Claude Code v"), - strings.Contains(line, "· Claude Max"), - strings.HasPrefix(line, "model:"), - strings.HasPrefix(line, "directory:"), - strings.HasPrefix(line, "cwd:"), - strings.HasPrefix(line, "workspace:"), - strings.Contains(line, "chatgpt.com/codex"): - return true - default: - return false - } -} - -func isAgentProgressNoiseLine(line string) bool { - normalized := normalizeStatusLine(line) - lower := strings.ToLower(normalized) - if lower == "" { - return false - } - if strings.HasPrefix(normalized, "Thinking ") { - return true - } - if strings.HasPrefix(normalized, "Working (") && strings.Contains(lower, "esc to interrupt") { - return true - } - if strings.Contains(lower, "esc to interrupt") && !looksLikeNeedsInputLine(normalized) { - return true - } - return false -} - -func normalizeStatusLine(line string) string { - trimmed := strings.TrimSpace(line) - switch { - case strings.HasPrefix(trimmed, "• "): - return strings.TrimSpace(strings.TrimPrefix(trimmed, "• ")) - case strings.HasPrefix(trimmed, "- "): - return strings.TrimSpace(strings.TrimPrefix(trimmed, "- ")) - case strings.HasPrefix(trimmed, "* "): - return strings.TrimSpace(strings.TrimPrefix(trimmed, "* ")) - default: - return trimmed - } -} - -// detectNeedsInput returns true when output indicates the agent is waiting on -// user confirmation or a direct question. -func detectNeedsInput(content string) (bool, string) { - if ok, hint := detectNeedsInputPrompt(content); ok { - return true, hint - } - - lines := strings.Split(content, "\n") - for i := len(lines) - 1; i >= 0; i-- { - line := strings.TrimSpace(lines[i]) - if line == "" || shouldDropAgentChromeLine(line) { - continue - } - if looksLikeQuestionNeedsInputLine(line) { - return true, normalizeNeedsInputHint(line) - } - } - return false, "" -} - -// detectNeedsInputPrompt detects explicit prompt/approval gates that require -// an immediate user response (safe for early-return wait semantics). -func detectNeedsInputPrompt(content string) (bool, string) { - lines := strings.Split(content, "\n") - for i := len(lines) - 1; i >= 0; i-- { - line := strings.TrimSpace(lines[i]) - if line == "" { - continue - } - if shouldDropAgentChromeLine(line) { - continue - } - if looksLikeExplicitNeedsInputLine(line) { - return true, normalizeNeedsInputHint(line) - } - } - return false, "" -} - -func normalizeNeedsInputHint(line string) string { - hint := strings.TrimSpace(line) - lower := strings.ToLower(hint) - if strings.Contains(lower, "bypass permissions on") { - return "Assistant is waiting for local permission-mode selection." - } - return hint -} - -func looksLikeNeedsInputLine(line string) bool { - return looksLikeExplicitNeedsInputLine(line) || looksLikeQuestionNeedsInputLine(line) -} - -func looksLikeExplicitNeedsInputLine(line string) bool { - lower := strings.ToLower(strings.TrimSpace(line)) - if lower == "" { - return false - } - - // Explicit confirmation prompts and permission gates. - markers := []string{ - "(y/n)", - "[y/n]", - "(yes/no)", - "[yes/no]", - "press enter", - "press return", - "press any key", - "do you want", - "would you like", - "should i ", - "which option", - "select an option", - "choose an option", - "awaiting your input", - "waiting for your input", - "needs your approval", - "requires approval", - "permission required", - "bypass permissions on", - } - for _, marker := range markers { - if strings.Contains(lower, marker) { - return true - } - } - return false -} - -// looksLikeQuestionNeedsInputLine detects direct user-facing questions that -// likely require an operator reply. -func looksLikeQuestionNeedsInputLine(line string) bool { - lower := strings.ToLower(strings.TrimSpace(line)) - if lower == "" { - return false - } - // Heuristic fallback for direct assistant questions. - if !strings.HasSuffix(lower, "?") { - return false - } - questionMarkers := []string{ - "do you", - "would you", - "should i", - "can i", - "could you", - "can you", - "should we", - "would you like", - "which option", - "which do you", - "what should", - "where should", - "when should", - "how should", - "choose", - "select", - "proceed", - "continue", - } - for _, marker := range questionMarkers { - if lower == marker || strings.HasPrefix(lower, marker+" ") || strings.HasPrefix(lower, marker+"?") { - return true - } - } - // Also check the last sentence in multi-sentence lines. - if idx := strings.LastIndex(lower, ". "); idx >= 0 { - lastSentence := strings.TrimSpace(lower[idx+2:]) - for _, marker := range questionMarkers { - if lastSentence == marker || strings.HasPrefix(lastSentence, marker+" ") || strings.HasPrefix(lastSentence, marker+"?") { - return true - } - } - } - return false -} - -func lastNonEmptyLine(content string) string { - lines := strings.Split(content, "\n") - for i := len(lines) - 1; i >= 0; i-- { - line := strings.TrimSpace(lines[i]) - if line == "" || shouldDropAgentChromeLine(line) { - continue - } - return line - } - for i := len(lines) - 1; i >= 0; i-- { - line := strings.TrimSpace(lines[i]) - if line != "" { - return line - } - } - return "" -} - -func summarizeWatchEvent( - eventType string, - latestLine string, - needsInput bool, - inputHint string, - idleSeconds float64, -) string { - if needsInput { - if strings.TrimSpace(inputHint) != "" { - return "Needs input: " + strings.TrimSpace(inputHint) - } - return "Needs input" - } - if strings.TrimSpace(latestLine) != "" { - return strings.TrimSpace(latestLine) - } - switch eventType { - case "idle": - return fmt.Sprintf("Idle %.1fs", idleSeconds) - case "heartbeat": - return fmt.Sprintf("Still working (idle %.1fs)", idleSeconds) - default: - return "" - } -} - -func summarizeWaitResponse(status, latestLine string, needsInput bool, inputHint string) string { - if needsInput { - if strings.TrimSpace(inputHint) != "" { - return "Needs input: " + strings.TrimSpace(inputHint) - } - return "Needs input" - } - if strings.TrimSpace(latestLine) != "" { - return strings.TrimSpace(latestLine) - } - switch status { - case "timed_out": - return "Timed out waiting for agent response." - case "session_exited": - return "Agent session exited while waiting." - case "idle": - return "Agent step completed." - case "needs_input": - return "Needs input" - default: - return "" - } -} diff --git a/internal/cli/cmd_agent_output_signals_test.go b/internal/cli/cmd_agent_output_signals_test.go deleted file mode 100644 index 3d264894..00000000 --- a/internal/cli/cmd_agent_output_signals_test.go +++ /dev/null @@ -1,133 +0,0 @@ -package cli - -import "testing" - -func TestCompactAgentOutput_DropsChromeLines(t *testing.T) { - raw := "╭────╮\n│ >_ OpenAI Codex │\nmodel: gpt-5\n• useful line\n? for shortcuts\n" - got := compactAgentOutput(raw) - if got != "• useful line" { - t.Fatalf("compactAgentOutput() = %q, want %q", got, "• useful line") - } -} - -func TestCompactAgentOutput_DropsToolExecutionNoise(t *testing.T) { - raw := "• Ran go test ./...\n└ ok github.com/andyrewlee/amux/internal/cli\n↳ Interacted with background terminal · go test ./...\n⎿ waiting\n• Final summary line" - got := compactAgentOutput(raw) - if got != "• Final summary line" { - t.Fatalf("compactAgentOutput() = %q, want %q", got, "• Final summary line") - } -} - -func TestCompactAgentOutput_DropsBulletedWorkingNoise(t *testing.T) { - raw := "• Working (33s • esc to interrupt)\n• Added parser helper" - got := compactAgentOutput(raw) - if got != "• Added parser helper" { - t.Fatalf("compactAgentOutput() = %q, want %q", got, "• Added parser helper") - } -} - -func TestCompactAgentOutput_DropsClaudeBannerNoise(t *testing.T) { - raw := "✻\n|\n▟█▙ Claude Code v2.1.45\n▐▛███▜▌ Opus 4.6 · Claude Max\n▝▜█████▛▘ ~/.amux/workspaces/amux/refactor\n▘▘ ▝▝\n❯ Review files\n✻ Baking…\n✶ Fermenting…\n• useful line" - got := compactAgentOutput(raw) - if got != "• useful line" { - t.Fatalf("compactAgentOutput() = %q, want %q", got, "• useful line") - } -} - -func TestCompactAgentOutput_DropsPromptWrappedContinuation(t *testing.T) { - raw := "❯ Review uncommitted changes in this workspace and report critical findings\n first.\n• useful line" - got := compactAgentOutput(raw) - if got != "• useful line" { - t.Fatalf("compactAgentOutput() = %q, want %q", got, "• useful line") - } -} - -func TestDetectNeedsInput_ConfirmationPrompt(t *testing.T) { - content := "Plan complete\nDo you want me to proceed? (y/N)" - ok, hint := detectNeedsInput(content) - if !ok { - t.Fatalf("detectNeedsInput() = false, want true") - } - if hint != "Do you want me to proceed? (y/N)" { - t.Fatalf("hint = %q, want %q", hint, "Do you want me to proceed? (y/N)") - } -} - -func TestDetectNeedsInput_QuestionFallback(t *testing.T) { - content := "I can continue with either option A or B. Which do you prefer?" - ok, hint := detectNeedsInput(content) - if !ok { - t.Fatalf("detectNeedsInput() = false, want true") - } - if hint != "I can continue with either option A or B. Which do you prefer?" { - t.Fatalf("hint = %q", hint) - } -} - -func TestDetectNeedsInputPrompt_ExplicitMarker(t *testing.T) { - content := "Plan complete\nDo you want me to proceed? (y/N)" - ok, hint := detectNeedsInputPrompt(content) - if !ok { - t.Fatalf("detectNeedsInputPrompt() = false, want true") - } - if hint != "Do you want me to proceed? (y/N)" { - t.Fatalf("hint = %q, want %q", hint, "Do you want me to proceed? (y/N)") - } -} - -func TestDetectNeedsInputPrompt_DoesNotMatchQuestionFallbackOnly(t *testing.T) { - content := "I can continue with either option A or B. Which do you prefer?" - ok, _ := detectNeedsInputPrompt(content) - if ok { - t.Fatalf("detectNeedsInputPrompt() = true, want false") - } -} - -func TestDetectNeedsInputPrompt_CodexInlinePromptDoesNotTrigger(t *testing.T) { - content := "Working (1m 40s • esc to interrupt)\n› Find and fix a bug in @filename\n? for shortcuts 30% context left" - ok, hint := detectNeedsInputPrompt(content) - if ok { - t.Fatalf("detectNeedsInputPrompt() = true, want false (hint=%q)", hint) - } -} - -func TestDetectNeedsInput_CodexInlinePromptDoesNotTrigger(t *testing.T) { - content := "Working (1m 40s • esc to interrupt)\n› Find and fix a bug in @filename\n? for shortcuts 30% context left" - ok, hint := detectNeedsInput(content) - if ok { - t.Fatalf("detectNeedsInput() = true, want false (hint=%q)", hint) - } -} - -func TestDetectNeedsInputPrompt_NormalizesPermissionSelectorHint(t *testing.T) { - content := "⏵⏵ bypass permissions on (shift+tab to cycle) · esc to interrupt" - ok, hint := detectNeedsInputPrompt(content) - if !ok { - t.Fatalf("detectNeedsInputPrompt() = false, want true") - } - if hint != "Assistant is waiting for local permission-mode selection." { - t.Fatalf("hint = %q", hint) - } -} - -func TestSummarizeWaitResponse_NeedsInputHint(t *testing.T) { - got := summarizeWaitResponse( - "needs_input", - "Assistant is waiting for local permission-mode selection.", - true, - "Assistant is waiting for local permission-mode selection.", - ) - want := "Needs input: Assistant is waiting for local permission-mode selection." - if got != want { - t.Fatalf("summarizeWaitResponse() = %q, want %q", got, want) - } -} - -func TestSummarizeWaitResponse_StatusFallbacks(t *testing.T) { - if got := summarizeWaitResponse("timed_out", "", false, ""); got != "Timed out waiting for agent response." { - t.Fatalf("timed_out summary = %q", got) - } - if got := summarizeWaitResponse("session_exited", "", false, ""); got != "Agent session exited while waiting." { - t.Fatalf("session_exited summary = %q", got) - } -} diff --git a/internal/cli/cmd_agent_run_id_test.go b/internal/cli/cmd_agent_run_id_test.go deleted file mode 100644 index 29617fae..00000000 --- a/internal/cli/cmd_agent_run_id_test.go +++ /dev/null @@ -1,42 +0,0 @@ -package cli - -import ( - "sync" - "testing" -) - -func TestNewAgentTabIDUniqueAcrossConcurrentCalls(t *testing.T) { - const workers = 16 - const perWorker = 128 - total := workers * perWorker - - ids := make(map[string]struct{}, total) - var mu sync.Mutex - var wg sync.WaitGroup - wg.Add(workers) - for i := 0; i < workers; i++ { - go func() { - defer wg.Done() - for j := 0; j < perWorker; j++ { - id := newAgentTabID() - if id == "" { - t.Errorf("newAgentTabID() returned empty string") - return - } - mu.Lock() - if _, exists := ids[id]; exists { - mu.Unlock() - t.Errorf("duplicate tab id generated: %s", id) - return - } - ids[id] = struct{}{} - mu.Unlock() - } - }() - } - wg.Wait() - - if len(ids) != total { - t.Fatalf("generated unique ids = %d, want %d", len(ids), total) - } -} diff --git a/internal/cli/cmd_agent_run_liveness.go b/internal/cli/cmd_agent_run_liveness.go deleted file mode 100644 index 0b12b57a..00000000 --- a/internal/cli/cmd_agent_run_liveness.go +++ /dev/null @@ -1,204 +0,0 @@ -package cli - -import ( - "fmt" - "log/slog" - "strings" - "time" - - "github.com/andyrewlee/amux/internal/tmux" -) - -func verifyStartedAgentSession( - ctx *cmdCtx, - sessionName string, - tmuxOpts tmux.Options, -) int { - state, err := tmuxSessionStateFor(sessionName, tmuxOpts) - if err != nil { - if killErr := tmuxKillSession(sessionName, tmuxOpts); killErr != nil { - slog.Debug("best-effort session kill failed", "session", sessionName, "error", killErr) - } - return ctx.errResult( - ExitInternalError, - "session_lookup_failed", - err.Error(), - map[string]any{"session_name": sessionName}, - fmt.Sprintf("failed to verify session %s: %v", sessionName, err), - ) - } - if state.Exists && state.HasLivePane { - return ExitOK - } - - if err := tmuxKillSession(sessionName, tmuxOpts); err != nil { - slog.Debug("best-effort session kill failed", "session", sessionName, "error", err) - } - msg := fmt.Sprintf("assistant session %s exited before startup completed", sessionName) - return ctx.errResult(ExitInternalError, "session_exited", msg, map[string]any{ - "session_name": sessionName, - }) -} - -var ( - // promptReadyTimeout is how long to wait for the agent TUI to be ready - // before sending the initial --prompt text. - promptReadyTimeout = 30 * time.Second - - // promptPollInterval is how often to check pane output for readiness. - promptPollInterval = 300 * time.Millisecond - - // promptStableRounds is how many consecutive polls must return identical - // output before we consider the TUI fully loaded for non-Codex assistants. - promptStableRounds = 3 - - // promptDeliveryWait bounds how long we wait for visible pane changes after - // sending the initial prompt before considering a single retry (Codex only). - promptDeliveryWait = 2 * time.Second - - // promptDeliveryPollInterval is the poll cadence for prompt delivery checks. - promptDeliveryPollInterval = 100 * time.Millisecond -) - -func sendAgentRunPromptIfRequested( - ctx *cmdCtx, - sessionName, assistantName, prompt string, - tmuxOpts tmux.Options, - beforeSend func(), -) int { - if prompt == "" { - return ExitOK - } - - waitForPaneOutput(sessionName, assistantName, tmuxOpts) - if beforeSend != nil { - beforeSend() - } - - preSendContent, _ := tmuxCapturePaneTail(sessionName, 80, tmuxOpts) - preSendHash := tmux.ContentHash(preSendContent) - - if err := tmuxSendKeys(sessionName, prompt, true, tmuxOpts); err != nil { - return handlePromptSendError(ctx, sessionName, tmuxOpts, err, "send") - } - - if strings.EqualFold(strings.TrimSpace(assistantName), "codex") && - !waitForPromptDelivery(sessionName, preSendHash, tmuxOpts) { - waitForPaneOutput(sessionName, assistantName, tmuxOpts) - if err := tmuxSendKeys(sessionName, prompt, true, tmuxOpts); err != nil { - return handlePromptSendError(ctx, sessionName, tmuxOpts, err, "retry") - } - } - return ExitOK -} - -func handlePromptSendError( - ctx *cmdCtx, - sessionName string, - tmuxOpts tmux.Options, - err error, - action string, -) int { - if killErr := tmuxKillSession(sessionName, tmuxOpts); killErr != nil { - slog.Debug("best-effort session kill failed", "session", sessionName, "error", killErr) - } - return ctx.errResult( - ExitInternalError, - "prompt_send_failed", - err.Error(), - map[string]any{"session_name": sessionName}, - fmt.Sprintf("failed to %s initial prompt to %s: %v", action, sessionName, err), - ) -} - -// waitForPaneOutput polls the tmux pane until the output stabilizes (stops -// changing), meaning the agent TUI has fully loaded and is waiting for input. -// Agents like Codex render a banner immediately but then spend several seconds -// loading the model before the input prompt is ready. We need to wait through -// that entire startup, not just until the first frame appears. -func waitForPaneOutput(sessionName, assistantName string, opts tmux.Options) { - deadline := time.Now().Add(promptReadyTimeout) - var lastContent string - stableCount := 0 - assistantName = strings.ToLower(strings.TrimSpace(assistantName)) - requirePromptMarker := assistantName == "codex" - - for time.Now().Before(deadline) { - text, ok := tmuxCapturePaneTail(sessionName, 20, opts) - if !ok { - // Consecutive stability requires uninterrupted successful captures. - stableCount = 0 - lastContent = "" - time.Sleep(promptPollInterval) - continue - } - trimmed := strings.TrimSpace(text) - if trimmed == "" { - // Blank startup/redraw frames break the consecutive chain. - stableCount = 0 - lastContent = "" - time.Sleep(promptPollInterval) - continue - } - // Use the raw text as a hash — if it hasn't changed, the TUI is stable. - if trimmed == lastContent { - stableCount++ - } else { - lastContent = trimmed - stableCount = 0 - } - if paneReadyForPrompt(trimmed, assistantName) { - if !requirePromptMarker || stableCount >= promptStableRounds { - return - } - time.Sleep(promptPollInterval) - continue - } - if stableCount >= promptStableRounds && !requirePromptMarker { - return - } - time.Sleep(promptPollInterval) - } - slog.Debug( - "prompt readiness timeout reached, sending anyway", - "session", sessionName, - "assistant", assistantName, - ) -} - -func waitForPromptDelivery(sessionName string, baselineHash [16]byte, opts tmux.Options) bool { - deadline := time.Now().Add(promptDeliveryWait) - for time.Now().Before(deadline) { - content, ok := tmuxCapturePaneTail(sessionName, 80, opts) - if ok && tmux.ContentHash(content) != baselineHash { - return true - } - time.Sleep(promptDeliveryPollInterval) - } - return false -} - -func paneReadyForPrompt(content, assistantName string) bool { - lines := strings.Split(content, "\n") - switch assistantName { - case "codex": - return hasPromptLine(lines, "›") - case "claude", "claude-cli": - return hasPromptLine(lines, "❯") - default: - return hasPromptLine(lines, "❯") || hasPromptLine(lines, "›") - } -} - -func hasPromptLine(lines []string, marker string) bool { - for _, raw := range lines { - line := strings.TrimSpace(raw) - if line == "" { - continue - } - if line == marker || strings.HasPrefix(line, marker+" ") { - return true - } - } - return false -} diff --git a/internal/cli/cmd_agent_run_liveness_test.go b/internal/cli/cmd_agent_run_liveness_test.go deleted file mode 100644 index 89c2f7d2..00000000 --- a/internal/cli/cmd_agent_run_liveness_test.go +++ /dev/null @@ -1,232 +0,0 @@ -package cli - -import ( - "bytes" - "context" - "encoding/json" - "errors" - "os/exec" - "testing" - - "github.com/andyrewlee/amux/internal/data" - "github.com/andyrewlee/amux/internal/tmux" -) - -func TestHandlePromptSendErrorHumanMessageIncludesAction(t *testing.T) { - origKillSession := tmuxKillSession - defer func() { - tmuxKillSession = origKillSession - }() - - tmuxKillSession = func(_ string, _ tmux.Options) error { return nil } - - tests := []struct { - action string - want string - }{ - {action: "send", want: "Error: failed to send initial prompt to session-a: send failed\n"}, - {action: "retry", want: "Error: failed to retry initial prompt to session-a: send failed\n"}, - } - - for _, tt := range tests { - t.Run(tt.action, func(t *testing.T) { - var out, errOut bytes.Buffer - ctx := &cmdCtx{ - w: &out, - wErr: &errOut, - gf: GlobalFlags{}, - version: "test-v1", - cmd: "agent.run", - } - - code := handlePromptSendError(ctx, "session-a", tmux.Options{}, errors.New("send failed"), tt.action) - if code != ExitInternalError { - t.Fatalf("handlePromptSendError() code = %d, want %d", code, ExitInternalError) - } - if out.Len() != 0 { - t.Fatalf("expected no stdout output in text mode, got %q", out.String()) - } - if got := errOut.String(); got != tt.want { - t.Fatalf("stderr = %q, want %q", got, tt.want) - } - }) - } -} - -func TestCmdAgentRunSessionExitsBeforeStartupReturnsInternalErrorAndDoesNotPersistTab(t *testing.T) { - t.Setenv("HOME", t.TempDir()) - - svc, err := NewServices("test-v1") - if err != nil { - t.Fatalf("NewServices() error = %v", err) - } - - workspaceRoot := t.TempDir() - ws := data.NewWorkspace("ws-a", "main", "origin/main", workspaceRoot, workspaceRoot) - if _, ok := svc.Config.Assistants[ws.Assistant]; !ok { - replacement := "" - for name := range svc.Config.Assistants { - replacement = name - break - } - if replacement == "" { - t.Fatalf("expected at least one assistant in config") - } - ws.Assistant = replacement - } - if err := svc.Store.Save(ws); err != nil { - t.Fatalf("Store.Save() error = %v", err) - } - - origStartSession := tmuxStartSession - origTagSetter := tmuxSetSessionTag - origKillSession := tmuxKillSession - origStateFor := tmuxSessionStateFor - defer func() { - tmuxStartSession = origStartSession - tmuxSetSessionTag = origTagSetter - tmuxKillSession = origKillSession - tmuxSessionStateFor = origStateFor - }() - - tmuxStartSession = func(_ tmux.Options, _ ...string) (*exec.Cmd, context.CancelFunc) { - return exec.Command("true"), func() {} - } - tmuxSetSessionTag = func(_, _, _ string, _ tmux.Options) error { return nil } - tmuxSessionStateFor = func(_ string, _ tmux.Options) (tmux.SessionState, error) { - return tmux.SessionState{Exists: false, HasLivePane: false}, nil - } - - killCalls := 0 - tmuxKillSession = func(_ string, _ tmux.Options) error { - killCalls++ - return nil - } - - var out bytes.Buffer - var errOut bytes.Buffer - code := cmdAgentRun( - &out, - &errOut, - GlobalFlags{JSON: true}, - []string{"--workspace", string(ws.ID()), "--assistant", ws.Assistant}, - "test-v1", - ) - if code != ExitInternalError { - t.Fatalf("cmdAgentRun() code = %d, want %d", code, ExitInternalError) - } - if errOut.Len() != 0 { - t.Fatalf("expected no stderr output in JSON mode, got %q", errOut.String()) - } - if killCalls != 1 { - t.Fatalf("tmuxKillSession calls = %d, want 1", killCalls) - } - - var env Envelope - if err := json.Unmarshal(out.Bytes(), &env); err != nil { - t.Fatalf("json.Unmarshal() error = %v", err) - } - if env.OK { - t.Fatalf("expected ok=false") - } - if env.Error == nil || env.Error.Code != "session_exited" { - t.Fatalf("expected session_exited, got %#v", env.Error) - } - - loaded, err := svc.Store.Load(ws.ID()) - if err != nil { - t.Fatalf("Store.Load() error = %v", err) - } - if len(loaded.OpenTabs) != 0 { - t.Fatalf("expected no open tabs persisted when session exits early, got %d", len(loaded.OpenTabs)) - } -} - -func TestCmdAgentRunSessionLookupFailureReturnsInternalErrorAndCleansSession(t *testing.T) { - t.Setenv("HOME", t.TempDir()) - - svc, err := NewServices("test-v1") - if err != nil { - t.Fatalf("NewServices() error = %v", err) - } - - workspaceRoot := t.TempDir() - ws := data.NewWorkspace("ws-a", "main", "origin/main", workspaceRoot, workspaceRoot) - if _, ok := svc.Config.Assistants[ws.Assistant]; !ok { - replacement := "" - for name := range svc.Config.Assistants { - replacement = name - break - } - if replacement == "" { - t.Fatalf("expected at least one assistant in config") - } - ws.Assistant = replacement - } - if err := svc.Store.Save(ws); err != nil { - t.Fatalf("Store.Save() error = %v", err) - } - - origStartSession := tmuxStartSession - origTagSetter := tmuxSetSessionTag - origKillSession := tmuxKillSession - origStateFor := tmuxSessionStateFor - defer func() { - tmuxStartSession = origStartSession - tmuxSetSessionTag = origTagSetter - tmuxKillSession = origKillSession - tmuxSessionStateFor = origStateFor - }() - - tmuxStartSession = func(_ tmux.Options, _ ...string) (*exec.Cmd, context.CancelFunc) { - return exec.Command("true"), func() {} - } - tmuxSetSessionTag = func(_, _, _ string, _ tmux.Options) error { return nil } - tmuxSessionStateFor = func(_ string, _ tmux.Options) (tmux.SessionState, error) { - return tmux.SessionState{}, errors.New("tmux lookup failed") - } - - killCalls := 0 - tmuxKillSession = func(_ string, _ tmux.Options) error { - killCalls++ - return nil - } - - var out bytes.Buffer - var errOut bytes.Buffer - code := cmdAgentRun( - &out, - &errOut, - GlobalFlags{JSON: true}, - []string{"--workspace", string(ws.ID()), "--assistant", ws.Assistant}, - "test-v1", - ) - if code != ExitInternalError { - t.Fatalf("cmdAgentRun() code = %d, want %d", code, ExitInternalError) - } - if errOut.Len() != 0 { - t.Fatalf("expected no stderr output in JSON mode, got %q", errOut.String()) - } - if killCalls != 1 { - t.Fatalf("tmuxKillSession calls = %d, want 1", killCalls) - } - - var env Envelope - if err := json.Unmarshal(out.Bytes(), &env); err != nil { - t.Fatalf("json.Unmarshal() error = %v", err) - } - if env.OK { - t.Fatalf("expected ok=false") - } - if env.Error == nil || env.Error.Code != "session_lookup_failed" { - t.Fatalf("expected session_lookup_failed, got %#v", env.Error) - } - - loaded, err := svc.Store.Load(ws.ID()) - if err != nil { - t.Fatalf("Store.Load() error = %v", err) - } - if len(loaded.OpenTabs) != 0 { - t.Fatalf("expected no open tabs persisted on session lookup failure, got %d", len(loaded.OpenTabs)) - } -} diff --git a/internal/cli/cmd_agent_run_persistence_test.go b/internal/cli/cmd_agent_run_persistence_test.go deleted file mode 100644 index cbfaaf3d..00000000 --- a/internal/cli/cmd_agent_run_persistence_test.go +++ /dev/null @@ -1,323 +0,0 @@ -package cli - -import ( - "bytes" - "context" - "encoding/json" - "errors" - "os/exec" - "strconv" - "testing" - - "github.com/andyrewlee/amux/internal/data" - "github.com/andyrewlee/amux/internal/tmux" -) - -func TestCmdAgentRunMetadataSaveFailureReturnsInternalErrorAndCleansSession(t *testing.T) { - t.Setenv("HOME", t.TempDir()) - - svc, err := NewServices("test-v1") - if err != nil { - t.Fatalf("NewServices() error = %v", err) - } - - workspaceRoot := t.TempDir() - ws := data.NewWorkspace("ws-a", "main", "origin/main", workspaceRoot, workspaceRoot) - if _, ok := svc.Config.Assistants[ws.Assistant]; !ok { - replacement := "" - for name := range svc.Config.Assistants { - replacement = name - break - } - if replacement == "" { - t.Fatalf("expected at least one assistant in config") - } - ws.Assistant = replacement - } - if err := svc.Store.Save(ws); err != nil { - t.Fatalf("Store.Save() error = %v", err) - } - - origStartSession := tmuxStartSession - origTagSetter := tmuxSetSessionTag - origKillSession := tmuxKillSession - origStateFor := tmuxSessionStateFor - origAppendTabMeta := appendWorkspaceOpenTabMeta - defer func() { - tmuxStartSession = origStartSession - tmuxSetSessionTag = origTagSetter - tmuxKillSession = origKillSession - tmuxSessionStateFor = origStateFor - appendWorkspaceOpenTabMeta = origAppendTabMeta - }() - - tmuxStartSession = func(_ tmux.Options, _ ...string) (*exec.Cmd, context.CancelFunc) { - return exec.Command("true"), func() {} - } - tmuxSetSessionTag = func(_, _, _ string, _ tmux.Options) error { return nil } - tmuxSessionStateFor = func(_ string, _ tmux.Options) (tmux.SessionState, error) { - return tmux.SessionState{Exists: true, HasLivePane: true}, nil - } - appendWorkspaceOpenTabMeta = func(_ *data.WorkspaceStore, _ data.WorkspaceID, _ data.TabInfo) error { - return errors.New("metadata write failed") - } - - killCalls := 0 - tmuxKillSession = func(_ string, _ tmux.Options) error { - killCalls++ - return nil - } - - var out bytes.Buffer - var errOut bytes.Buffer - code := cmdAgentRun( - &out, - &errOut, - GlobalFlags{JSON: true}, - []string{"--workspace", string(ws.ID()), "--assistant", ws.Assistant}, - "test-v1", - ) - if code != ExitInternalError { - t.Fatalf("cmdAgentRun() code = %d, want %d", code, ExitInternalError) - } - if errOut.Len() != 0 { - t.Fatalf("expected no stderr output in JSON mode, got %q", errOut.String()) - } - if killCalls != 1 { - t.Fatalf("tmuxKillSession calls = %d, want 1", killCalls) - } - - var env Envelope - if err := json.Unmarshal(out.Bytes(), &env); err != nil { - t.Fatalf("json.Unmarshal() error = %v", err) - } - if env.OK { - t.Fatalf("expected ok=false") - } - if env.Error == nil || env.Error.Code != "metadata_save_failed" { - t.Fatalf("expected metadata_save_failed, got %#v", env.Error) - } - - loaded, err := svc.Store.Load(ws.ID()) - if err != nil { - t.Fatalf("Store.Load() error = %v", err) - } - if len(loaded.OpenTabs) != 0 { - t.Fatalf("expected no open tabs persisted on metadata save failure, got %d", len(loaded.OpenTabs)) - } -} - -func TestCmdAgentRunPromptSendFailureReturnsInternalErrorAndDoesNotPersistTab(t *testing.T) { - t.Setenv("HOME", t.TempDir()) - - svc, err := NewServices("test-v1") - if err != nil { - t.Fatalf("NewServices() error = %v", err) - } - - workspaceRoot := t.TempDir() - ws := data.NewWorkspace("ws-a", "main", "origin/main", workspaceRoot, workspaceRoot) - if _, ok := svc.Config.Assistants[ws.Assistant]; !ok { - replacement := "" - for name := range svc.Config.Assistants { - replacement = name - break - } - if replacement == "" { - t.Fatalf("expected at least one assistant in config") - } - ws.Assistant = replacement - } - if err := svc.Store.Save(ws); err != nil { - t.Fatalf("Store.Save() error = %v", err) - } - - origStartSession := tmuxStartSession - origTagSetter := tmuxSetSessionTag - origKillSession := tmuxKillSession - origStateFor := tmuxSessionStateFor - origSendKeys := tmuxSendKeys - defer func() { - tmuxStartSession = origStartSession - tmuxSetSessionTag = origTagSetter - tmuxKillSession = origKillSession - tmuxSessionStateFor = origStateFor - tmuxSendKeys = origSendKeys - }() - - tmuxStartSession = func(_ tmux.Options, _ ...string) (*exec.Cmd, context.CancelFunc) { - return exec.Command("true"), func() {} - } - tmuxSetSessionTag = func(_, _, _ string, _ tmux.Options) error { return nil } - tmuxSessionStateFor = func(_ string, _ tmux.Options) (tmux.SessionState, error) { - return tmux.SessionState{Exists: true, HasLivePane: true}, nil - } - tmuxSendKeys = func(_, _ string, _ bool, _ tmux.Options) error { - return errors.New("send failed") - } - - killCalls := 0 - tmuxKillSession = func(_ string, _ tmux.Options) error { - killCalls++ - return nil - } - - var out bytes.Buffer - var errOut bytes.Buffer - code := cmdAgentRun( - &out, - &errOut, - GlobalFlags{JSON: true}, - []string{"--workspace", string(ws.ID()), "--assistant", ws.Assistant, "--prompt", "hello"}, - "test-v1", - ) - if code != ExitInternalError { - t.Fatalf("cmdAgentRun() code = %d, want %d", code, ExitInternalError) - } - if errOut.Len() != 0 { - t.Fatalf("expected no stderr output in JSON mode, got %q", errOut.String()) - } - if killCalls != 1 { - t.Fatalf("tmuxKillSession calls = %d, want 1", killCalls) - } - - var env Envelope - if err := json.Unmarshal(out.Bytes(), &env); err != nil { - t.Fatalf("json.Unmarshal() error = %v", err) - } - if env.OK { - t.Fatalf("expected ok=false") - } - if env.Error == nil || env.Error.Code != "prompt_send_failed" { - t.Fatalf("expected prompt_send_failed, got %#v", env.Error) - } - - loaded, err := svc.Store.Load(ws.ID()) - if err != nil { - t.Fatalf("Store.Load() error = %v", err) - } - if len(loaded.OpenTabs) != 0 { - t.Fatalf("expected no open tabs persisted when prompt send fails, got %d", len(loaded.OpenTabs)) - } -} - -func TestCmdAgentRunWaitCapturesBaselineBeforePromptSendAfterReadiness(t *testing.T) { - t.Setenv("HOME", t.TempDir()) - - svc, err := NewServices("test-v1") - if err != nil { - t.Fatalf("NewServices() error = %v", err) - } - - workspaceRoot := t.TempDir() - ws := data.NewWorkspace("ws-a", "main", "origin/main", workspaceRoot, workspaceRoot) - assistantName := "claude" - if _, ok := svc.Config.Assistants[assistantName]; !ok { - replacement := "" - for name := range svc.Config.Assistants { - if name != "codex" { - replacement = name - break - } - } - if replacement == "" { - for name := range svc.Config.Assistants { - replacement = name - break - } - } - if replacement == "" { - t.Fatalf("expected at least one assistant in config") - } - assistantName = replacement - } - ws.Assistant = assistantName - if err := svc.Store.Save(ws); err != nil { - t.Fatalf("Store.Save() error = %v", err) - } - - origStartSession := tmuxStartSession - origTagSetter := tmuxSetSessionTag - origStateFor := tmuxSessionStateFor - origSendKeys := tmuxSendKeys - origCapture := tmuxCapturePaneTail - defer func() { - tmuxStartSession = origStartSession - tmuxSetSessionTag = origTagSetter - tmuxSessionStateFor = origStateFor - tmuxSendKeys = origSendKeys - tmuxCapturePaneTail = origCapture - }() - - tmuxStartSession = func(_ tmux.Options, _ ...string) (*exec.Cmd, context.CancelFunc) { - return exec.Command("true"), func() {} - } - tmuxSetSessionTag = func(_, _, _ string, _ tmux.Options) error { return nil } - tmuxSessionStateFor = func(_ string, _ tmux.Options) (tmux.SessionState, error) { - return tmux.SessionState{Exists: true, HasLivePane: true}, nil - } - - var events []string - tmuxCapturePaneTail = func(_ string, lines int, _ tmux.Options) (string, bool) { - events = append(events, "capture:"+strconv.Itoa(lines)) - switch lines { - case 20: - return "❯ ready", true - case 80: - return "before-send", true - default: - return "after-send", true - } - } - tmuxSendKeys = func(_, _ string, _ bool, _ tmux.Options) error { - events = append(events, "send") - return nil - } - - var out, errOut bytes.Buffer - code := cmdAgentRun( - &out, - &errOut, - GlobalFlags{JSON: true}, - []string{ - "--workspace", string(ws.ID()), - "--assistant", assistantName, - "--prompt", "hello", - "--wait", - "--wait-timeout", "1ms", - "--idle-threshold", "1ms", - }, - "test-v1", - ) - if code != ExitOK { - t.Fatalf("cmdAgentRun() code = %d, want %d; stderr=%q", code, ExitOK, errOut.String()) - } - - var env Envelope - if err := json.Unmarshal(out.Bytes(), &env); err != nil { - t.Fatalf("json.Unmarshal() error = %v", err) - } - if !env.OK { - t.Fatalf("expected ok=true, got error %#v", env.Error) - } - - firstSend := -1 - firstCapture100 := -1 - for i, event := range events { - if event == "send" && firstSend == -1 { - firstSend = i - } - if event == "capture:100" && firstCapture100 == -1 { - firstCapture100 = i - } - } - if firstSend == -1 { - t.Fatalf("expected send event, got events=%v", events) - } - if firstCapture100 == -1 { - t.Fatalf("expected capture:100 event, got events=%v", events) - } - if firstCapture100 >= firstSend { - t.Fatalf("capture:100 must happen before send (send=%d capture100=%d events=%v)", firstSend, firstCapture100, events) - } -} diff --git a/internal/cli/cmd_agent_run_prompt_ready_test.go b/internal/cli/cmd_agent_run_prompt_ready_test.go deleted file mode 100644 index 08bf4661..00000000 --- a/internal/cli/cmd_agent_run_prompt_ready_test.go +++ /dev/null @@ -1,222 +0,0 @@ -package cli - -import ( - "testing" - "time" - - "github.com/andyrewlee/amux/internal/tmux" -) - -func TestPaneReadyForPrompt_CodexAndClaude(t *testing.T) { - if !paneReadyForPrompt("loading\n› Improve documentation in @filename", "codex") { - t.Fatalf("expected codex prompt marker to be detected") - } - if paneReadyForPrompt("loading\nmodel: gpt-5", "codex") { - t.Fatalf("expected codex loading banner without prompt marker to be not ready") - } - if !paneReadyForPrompt("header\n❯ ", "claude") { - t.Fatalf("expected claude prompt marker to be detected") - } -} - -func TestWaitForPaneOutput_CodexWaitsForPromptMarker(t *testing.T) { - origCapture := tmuxCapturePaneTail - origTimeout := promptReadyTimeout - origPoll := promptPollInterval - origStable := promptStableRounds - defer func() { - tmuxCapturePaneTail = origCapture - promptReadyTimeout = origTimeout - promptPollInterval = origPoll - promptStableRounds = origStable - }() - - promptReadyTimeout = 60 * time.Millisecond - promptPollInterval = 1 * time.Millisecond - promptStableRounds = 2 - - calls := 0 - tmuxCapturePaneTail = func(_ string, _ int, _ tmux.Options) (string, bool) { - calls++ - if calls <= 3 { - return "model: loading", true - } - return "model: ready\n› Improve documentation in @filename", true - } - - waitForPaneOutput("test-session", "codex", tmux.Options{}) - - if calls < 4 { - t.Fatalf("calls = %d, want >= 4 (must wait for codex prompt marker)", calls) - } -} - -func TestWaitForPaneOutput_NonCodexFallsBackToStableOutput(t *testing.T) { - origCapture := tmuxCapturePaneTail - origTimeout := promptReadyTimeout - origPoll := promptPollInterval - origStable := promptStableRounds - defer func() { - tmuxCapturePaneTail = origCapture - promptReadyTimeout = origTimeout - promptPollInterval = origPoll - promptStableRounds = origStable - }() - - promptReadyTimeout = 60 * time.Millisecond - promptPollInterval = 1 * time.Millisecond - promptStableRounds = 2 - - calls := 0 - tmuxCapturePaneTail = func(_ string, _ int, _ tmux.Options) (string, bool) { - calls++ - return "stable startup screen", true - } - - waitForPaneOutput("test-session", "aider", tmux.Options{}) - - if calls != 3 { - t.Fatalf("calls = %d, want 3 for stable fallback", calls) - } -} - -func TestSendAgentRunPromptIfRequested_CodexRetriesWhenPromptNotDelivered(t *testing.T) { - origCapture := tmuxCapturePaneTail - origSend := tmuxSendKeys - origTimeout := promptReadyTimeout - origPoll := promptPollInterval - origStable := promptStableRounds - origDeliveryWait := promptDeliveryWait - origDeliveryPoll := promptDeliveryPollInterval - defer func() { - tmuxCapturePaneTail = origCapture - tmuxSendKeys = origSend - promptReadyTimeout = origTimeout - promptPollInterval = origPoll - promptStableRounds = origStable - promptDeliveryWait = origDeliveryWait - promptDeliveryPollInterval = origDeliveryPoll - }() - - promptReadyTimeout = 40 * time.Millisecond - promptPollInterval = 1 * time.Millisecond - promptStableRounds = 1 - promptDeliveryWait = 5 * time.Millisecond - promptDeliveryPollInterval = 1 * time.Millisecond - - tmuxCapturePaneTail = func(_ string, _ int, _ tmux.Options) (string, bool) { - // Never changes after send -> force one retry path. - return "› Improve documentation in @filename", true - } - - sendCalls := 0 - tmuxSendKeys = func(_, _ string, _ bool, _ tmux.Options) error { - sendCalls++ - return nil - } - - code := sendAgentRunPromptIfRequested( - &cmdCtx{gf: GlobalFlags{JSON: true}, version: "test-v1", cmd: "agent.run"}, - "session-a", - "codex", - "Reply with READY only.", - tmux.Options{}, - nil, - ) - if code != ExitOK { - t.Fatalf("sendAgentRunPromptIfRequested() code = %d, want %d", code, ExitOK) - } - if sendCalls != 2 { - t.Fatalf("tmuxSendKeys calls = %d, want 2", sendCalls) - } -} - -func TestSendAgentRunPromptIfRequested_NonCodexDoesNotRetry(t *testing.T) { - origCapture := tmuxCapturePaneTail - origSend := tmuxSendKeys - origTimeout := promptReadyTimeout - origPoll := promptPollInterval - origStable := promptStableRounds - defer func() { - tmuxCapturePaneTail = origCapture - tmuxSendKeys = origSend - promptReadyTimeout = origTimeout - promptPollInterval = origPoll - promptStableRounds = origStable - }() - - promptReadyTimeout = 40 * time.Millisecond - promptPollInterval = 1 * time.Millisecond - promptStableRounds = 1 - - tmuxCapturePaneTail = func(_ string, _ int, _ tmux.Options) (string, bool) { - return "❯ ", true - } - - sendCalls := 0 - tmuxSendKeys = func(_, _ string, _ bool, _ tmux.Options) error { - sendCalls++ - return nil - } - - code := sendAgentRunPromptIfRequested( - &cmdCtx{gf: GlobalFlags{JSON: true}, version: "test-v1", cmd: "agent.run"}, - "session-b", - "claude", - "Reply with READY only.", - tmux.Options{}, - nil, - ) - if code != ExitOK { - t.Fatalf("sendAgentRunPromptIfRequested() code = %d, want %d", code, ExitOK) - } - if sendCalls != 1 { - t.Fatalf("tmuxSendKeys calls = %d, want 1", sendCalls) - } -} - -func TestSendAgentRunPromptIfRequested_BeforeSendHookRunsBeforeSend(t *testing.T) { - origCapture := tmuxCapturePaneTail - origSend := tmuxSendKeys - origTimeout := promptReadyTimeout - origPoll := promptPollInterval - origStable := promptStableRounds - defer func() { - tmuxCapturePaneTail = origCapture - tmuxSendKeys = origSend - promptReadyTimeout = origTimeout - promptPollInterval = origPoll - promptStableRounds = origStable - }() - - promptReadyTimeout = 40 * time.Millisecond - promptPollInterval = 1 * time.Millisecond - promptStableRounds = 1 - - tmuxCapturePaneTail = func(_ string, _ int, _ tmux.Options) (string, bool) { - return "❯ ", true - } - - hookCalled := false - tmuxSendKeys = func(_, _ string, _ bool, _ tmux.Options) error { - if !hookCalled { - t.Fatalf("expected beforeSend hook to run before tmuxSendKeys") - } - return nil - } - - code := sendAgentRunPromptIfRequested( - &cmdCtx{gf: GlobalFlags{JSON: true}, version: "test-v1", cmd: "agent.run"}, - "session-c", - "claude", - "Reply with READY only.", - tmux.Options{}, - func() { hookCalled = true }, - ) - if code != ExitOK { - t.Fatalf("sendAgentRunPromptIfRequested() code = %d, want %d", code, ExitOK) - } - if !hookCalled { - t.Fatalf("expected beforeSend hook to be called") - } -} diff --git a/internal/cli/cmd_agent_run_tagging_test.go b/internal/cli/cmd_agent_run_tagging_test.go deleted file mode 100644 index f2efe892..00000000 --- a/internal/cli/cmd_agent_run_tagging_test.go +++ /dev/null @@ -1,102 +0,0 @@ -package cli - -import ( - "bytes" - "context" - "encoding/json" - "errors" - "os/exec" - "testing" - - "github.com/andyrewlee/amux/internal/data" - "github.com/andyrewlee/amux/internal/tmux" -) - -func TestCmdAgentRunTagFailureReturnsInternalErrorAndCleansSession(t *testing.T) { - t.Setenv("HOME", t.TempDir()) - - svc, err := NewServices("test-v1") - if err != nil { - t.Fatalf("NewServices() error = %v", err) - } - - workspaceRoot := t.TempDir() - ws := data.NewWorkspace("ws-a", "main", "origin/main", workspaceRoot, workspaceRoot) - if _, ok := svc.Config.Assistants[ws.Assistant]; !ok { - replacement := "" - for name := range svc.Config.Assistants { - replacement = name - break - } - if replacement == "" { - t.Fatalf("expected at least one assistant in config") - } - ws.Assistant = replacement - } - if err := svc.Store.Save(ws); err != nil { - t.Fatalf("Store.Save() error = %v", err) - } - - origStartSession := tmuxStartSession - origTagSetter := tmuxSetSessionTag - origKillSession := tmuxKillSession - defer func() { - tmuxStartSession = origStartSession - tmuxSetSessionTag = origTagSetter - tmuxKillSession = origKillSession - }() - - tmuxStartSession = func(_ tmux.Options, _ ...string) (*exec.Cmd, context.CancelFunc) { - return exec.Command("true"), func() {} - } - tmuxSetSessionTag = func(_, key, _ string, _ tmux.Options) error { - if key == "@amux_workspace" { - return errors.New("tag write failed") - } - return nil - } - - killCalls := 0 - tmuxKillSession = func(_ string, _ tmux.Options) error { - killCalls++ - return nil - } - - var out bytes.Buffer - var errOut bytes.Buffer - code := cmdAgentRun( - &out, - &errOut, - GlobalFlags{JSON: true}, - []string{"--workspace", string(ws.ID()), "--assistant", ws.Assistant}, - "test-v1", - ) - if code != ExitInternalError { - t.Fatalf("cmdAgentRun() code = %d, want %d", code, ExitInternalError) - } - if errOut.Len() != 0 { - t.Fatalf("expected no stderr output in JSON mode, got %q", errOut.String()) - } - if killCalls != 1 { - t.Fatalf("tmuxKillSession calls = %d, want 1", killCalls) - } - - var env Envelope - if err := json.Unmarshal(out.Bytes(), &env); err != nil { - t.Fatalf("json.Unmarshal() error = %v", err) - } - if env.OK { - t.Fatalf("expected ok=false") - } - if env.Error == nil || env.Error.Code != "session_tag_failed" { - t.Fatalf("expected session_tag_failed, got %#v", env.Error) - } - - loaded, err := svc.Store.Load(ws.ID()) - if err != nil { - t.Fatalf("Store.Load() error = %v", err) - } - if len(loaded.OpenTabs) != 0 { - t.Fatalf("expected no open tabs persisted on tag failure, got %d", len(loaded.OpenTabs)) - } -} diff --git a/internal/cli/cmd_agent_run_write.go b/internal/cli/cmd_agent_run_write.go deleted file mode 100644 index 5852d070..00000000 --- a/internal/cli/cmd_agent_run_write.go +++ /dev/null @@ -1,249 +0,0 @@ -package cli - -import ( - "errors" - "fmt" - "io" - "log/slog" - "strconv" - "strings" - "time" - - "github.com/andyrewlee/amux/internal/data" - "github.com/andyrewlee/amux/internal/tmux" - "github.com/andyrewlee/amux/internal/validation" -) - -func cmdAgentRun(w, wErr io.Writer, gf GlobalFlags, args []string, version string) int { - const usage = "Usage: amux agent run --workspace --assistant [--prompt ] [--wait] [--wait-timeout ] [--idle-threshold ] [--idempotency-key ] [--json]" - fs := newFlagSet("agent run") - wsFlag := fs.String("workspace", "", "workspace ID (required)") - assistant := fs.String("assistant", "", "assistant name (required)") - name := fs.String("name", "", "tab name") - prompt := fs.String("prompt", "", "initial prompt to send") - wait := fs.Bool("wait", false, "wait for agent to respond and go idle (requires --prompt)") - waitTimeout := fs.Duration("wait-timeout", 120*time.Second, "max time to wait for response") - idleThreshold := fs.Duration("idle-threshold", 10*time.Second, "idle time before returning response") - idempotencyKey := fs.String("idempotency-key", "", "idempotency key for safe retries") - if err := fs.Parse(args); err != nil { - return returnUsageError(w, wErr, gf, usage, version, err) - } - if fs.NArg() > 0 { - return returnUsageError( - w, wErr, gf, usage, version, - fmt.Errorf("unexpected arguments: %s", strings.Join(fs.Args(), " ")), - ) - } - assistantName := strings.ToLower(strings.TrimSpace(*assistant)) - if *wsFlag == "" || assistantName == "" { - return returnUsageError(w, wErr, gf, usage, version, nil) - } - if *wait && *prompt == "" { - return returnUsageError(w, wErr, gf, usage, version, - errors.New("--wait requires --prompt"), - ) - } - if *waitTimeout <= 0 { - return returnUsageError(w, wErr, gf, usage, version, - errors.New("--wait-timeout must be > 0"), - ) - } - if *idleThreshold <= 0 { - return returnUsageError(w, wErr, gf, usage, version, - errors.New("--idle-threshold must be > 0"), - ) - } - if err := validation.ValidateAssistant(assistantName); err != nil { - return returnUsageError( - w, - wErr, - gf, - usage, - version, - fmt.Errorf("invalid --assistant: %w", err), - ) - } - wsID, err := parseWorkspaceIDFlag(*wsFlag) - if err != nil { - return returnUsageError( - w, - wErr, - gf, - usage, - version, - err, - ) - } - ctx := &cmdCtx{ - w: w, - wErr: wErr, - gf: gf, - version: version, - cmd: "agent.run", - idemKey: *idempotencyKey, - } - - if handled, code := ctx.maybeReplay(); handled { - return code - } - - svc, err := NewServices(version) - if err != nil { - return ctx.errResult(ExitInternalError, "init_failed", err.Error(), nil, fmt.Sprintf("failed to initialize: %v", err)) - } - - ws, err := svc.Store.Load(wsID) - if err != nil { - return ctx.errResult(ExitNotFound, "not_found", fmt.Sprintf("workspace %s not found", wsID), nil) - } - - agentAssistant := assistantName - ac, ok := svc.Config.Assistants[agentAssistant] - if !ok { - return ctx.errResult(ExitUsage, "unknown_assistant", "unknown assistant: "+agentAssistant, nil) - } - - // Generate tab ID and session name. - tabID := newAgentTabID() - sessionName := tmux.SessionName("amux", string(wsID), tabID) - - // Create detached tmux session - createArgs := []string{ - "new-session", "-d", "-s", sessionName, "-c", ws.Root, ac.Command, - } - cmd, cancel := tmuxStartSession(svc.TmuxOpts, createArgs...) - defer cancel() - if err := cmd.Run(); err != nil { - return ctx.errResult(ExitInternalError, "session_failed", err.Error(), nil, fmt.Sprintf("failed to create tmux session: %v", err)) - } - - // Tag the session. - now := time.Now() - tags := []struct { - Key string - Value string - }{ - {Key: "@amux", Value: "1"}, - {Key: "@amux_workspace", Value: string(wsID)}, - {Key: "@amux_tab", Value: tabID}, - {Key: "@amux_type", Value: "agent"}, - {Key: "@amux_assistant", Value: agentAssistant}, - {Key: "@amux_created_at", Value: strconv.FormatInt(now.Unix(), 10)}, - {Key: "@amux_instance", Value: "cli"}, - {Key: tmux.TagSessionOwner, Value: "cli"}, - {Key: tmux.TagSessionLeaseAt, Value: strconv.FormatInt(now.UnixMilli(), 10)}, - } - for _, tag := range tags { - if err := tmuxSetSessionTag(sessionName, tag.Key, tag.Value, svc.TmuxOpts); err != nil { - if killErr := tmuxKillSession(sessionName, svc.TmuxOpts); killErr != nil { - slog.Debug("best-effort session kill failed", "session", sessionName, "error", killErr) - } - return ctx.errResult( - ExitInternalError, - "session_tag_failed", - err.Error(), - map[string]any{ - "session_name": sessionName, - "tag": tag.Key, - }, - fmt.Sprintf("failed to tag session %s (%s): %v", sessionName, tag.Key, err), - ) - } - } - - if code := verifyStartedAgentSession( - ctx, sessionName, svc.TmuxOpts, - ); code != ExitOK { - return code - } - - waitPreContent := "" - if code := sendAgentRunPromptIfRequested( - ctx, sessionName, agentAssistant, *prompt, svc.TmuxOpts, - func() { - if *wait && *prompt != "" { - // Capture baseline after startup readiness wait but before prompt send. - // This avoids both startup-churn false positives and fast-response misses. - waitPreContent = captureWaitBaselineWithRetry(sessionName, svc.TmuxOpts) - } - }, - ); code != ExitOK { - return code - } - - // Persist the tab append atomically to avoid lost updates when multiple - // agent runs complete concurrently for the same workspace. - tabName := agentAssistant - if *name != "" { - tabName = *name - } - tab := data.TabInfo{ - Assistant: agentAssistant, - Name: tabName, - SessionName: sessionName, - Status: "running", - CreatedAt: time.Now().Unix(), - } - if err := appendWorkspaceOpenTabMeta(svc.Store, wsID, tab); err != nil { - if killErr := tmuxKillSession(sessionName, svc.TmuxOpts); killErr != nil { - slog.Debug("best-effort session kill failed", "session", sessionName, "error", killErr) - } - return ctx.errResult( - ExitInternalError, - "metadata_save_failed", - err.Error(), - map[string]any{ - "workspace_id": string(wsID), - "session_name": sessionName, - }, - fmt.Sprintf("failed to persist workspace metadata: %v", err), - ) - } - - result := agentRunResult{ - SessionName: sessionName, - AgentID: formatAgentID(string(wsID), tabID), - WorkspaceID: string(wsID), - Assistant: agentAssistant, - TabID: tabID, - } - - if *wait && *prompt != "" { - resp := runRunWait(svc.TmuxOpts, sessionName, *waitTimeout, *idleThreshold, waitPreContent) - result.Response = &resp - } - - if gf.JSON { - return ctx.successResult(result) - } - - PrintHuman(w, func(w io.Writer) { - fmt.Fprintf(w, "Started agent %s (session: %s)\n", agentAssistant, sessionName) - if result.Response != nil { - if result.Response.NeedsInput { - if strings.TrimSpace(result.Response.InputHint) != "" { - fmt.Fprintf(w, "Agent needs input: %s\n", strings.TrimSpace(result.Response.InputHint)) - } else { - fmt.Fprintf(w, "Agent needs input\n") - } - } else if result.Response.TimedOut { - fmt.Fprintf(w, "Timed out waiting for response\n") - } else if result.Response.SessionExited { - fmt.Fprintf(w, "Session exited while waiting\n") - } else { - fmt.Fprintf(w, "Agent idle after %.1fs\n", result.Response.IdleSeconds) - } - } - }) - return ExitOK -} - -func runRunWait( - tmuxOpts tmux.Options, - sessionName string, - waitTimeout, - idleThreshold time.Duration, - preContent string, -) waitResponseResult { - return runAgentWait(tmuxOpts, sessionName, waitTimeout, idleThreshold, preContent) -} diff --git a/internal/cli/cmd_agent_run_write_test.go b/internal/cli/cmd_agent_run_write_test.go deleted file mode 100644 index 4f986395..00000000 --- a/internal/cli/cmd_agent_run_write_test.go +++ /dev/null @@ -1,185 +0,0 @@ -package cli - -import ( - "bytes" - "encoding/json" - "strings" - "testing" -) - -func TestCmdAgentRunUsageJSON(t *testing.T) { - var out, errOut bytes.Buffer - code := cmdAgentRun(&out, &errOut, GlobalFlags{JSON: true}, nil, "test-v1") - if code != ExitUsage { - t.Fatalf("cmdAgentRun() code = %d, want %d", code, ExitUsage) - } - if errOut.Len() != 0 { - t.Fatalf("expected no stderr output in JSON mode, got %q", errOut.String()) - } - - var env Envelope - if err := json.Unmarshal(out.Bytes(), &env); err != nil { - t.Fatalf("json.Unmarshal() error = %v", err) - } - if env.OK { - t.Fatalf("expected ok=false") - } - if env.Error == nil || env.Error.Code != "usage_error" { - t.Fatalf("expected usage_error, got %#v", env.Error) - } -} - -func TestCmdAgentRunRejectsInvalidWorkspaceID(t *testing.T) { - var out, errOut bytes.Buffer - code := cmdAgentRun( - &out, - &errOut, - GlobalFlags{JSON: true}, - []string{"--workspace", "../../../tmp/evil", "--assistant", "claude"}, - "test-v1", - ) - if code != ExitUsage { - t.Fatalf("cmdAgentRun() code = %d, want %d", code, ExitUsage) - } - if errOut.Len() != 0 { - t.Fatalf("expected no stderr output in JSON mode, got %q", errOut.String()) - } - - var env Envelope - if err := json.Unmarshal(out.Bytes(), &env); err != nil { - t.Fatalf("json.Unmarshal() error = %v", err) - } - if env.OK { - t.Fatalf("expected ok=false") - } - if env.Error == nil || env.Error.Code != "usage_error" { - t.Fatalf("expected usage_error, got %#v", env.Error) - } - if env.Error == nil || !strings.Contains(env.Error.Message, "invalid workspace id") { - t.Fatalf("expected invalid workspace id message, got %#v", env.Error) - } -} - -func TestCmdAgentRunRejectsUnexpectedPositionalArguments(t *testing.T) { - var out, errOut bytes.Buffer - code := cmdAgentRun( - &out, - &errOut, - GlobalFlags{JSON: true}, - []string{"--workspace", "0123456789abcdef", "--assistant", "claude", "stray-token"}, - "test-v1", - ) - if code != ExitUsage { - t.Fatalf("cmdAgentRun() code = %d, want %d", code, ExitUsage) - } - if errOut.Len() != 0 { - t.Fatalf("expected no stderr output in JSON mode, got %q", errOut.String()) - } - - var env Envelope - if err := json.Unmarshal(out.Bytes(), &env); err != nil { - t.Fatalf("json.Unmarshal() error = %v", err) - } - if env.OK { - t.Fatalf("expected ok=false") - } - if env.Error == nil || env.Error.Code != "usage_error" { - t.Fatalf("expected usage_error, got %#v", env.Error) - } - if env.Error == nil || !strings.Contains(env.Error.Message, "unexpected arguments") { - t.Fatalf("expected unexpected arguments message, got %#v", env.Error) - } -} - -func TestCmdAgentRunWaitRequiresPrompt(t *testing.T) { - var out, errOut bytes.Buffer - code := cmdAgentRun( - &out, - &errOut, - GlobalFlags{JSON: true}, - []string{"--workspace", "0123456789abcdef", "--assistant", "claude", "--wait"}, - "test-v1", - ) - if code != ExitUsage { - t.Fatalf("cmdAgentRun() code = %d, want %d", code, ExitUsage) - } - if errOut.Len() != 0 { - t.Fatalf("expected no stderr output in JSON mode, got %q", errOut.String()) - } - - var env Envelope - if err := json.Unmarshal(out.Bytes(), &env); err != nil { - t.Fatalf("json.Unmarshal() error = %v", err) - } - if env.OK { - t.Fatalf("expected ok=false") - } - if env.Error == nil || env.Error.Code != "usage_error" { - t.Fatalf("expected usage_error, got %#v", env.Error) - } - if env.Error == nil || !strings.Contains(env.Error.Message, "--wait requires --prompt") { - t.Fatalf("unexpected message: %#v", env.Error) - } -} - -func TestCmdAgentRunRejectsWaitTimeoutNonPositive(t *testing.T) { - var out, errOut bytes.Buffer - code := cmdAgentRun( - &out, - &errOut, - GlobalFlags{JSON: true}, - []string{"--workspace", "0123456789abcdef", "--assistant", "claude", "--wait-timeout", "0s"}, - "test-v1", - ) - if code != ExitUsage { - t.Fatalf("cmdAgentRun() code = %d, want %d", code, ExitUsage) - } - if errOut.Len() != 0 { - t.Fatalf("expected no stderr output in JSON mode, got %q", errOut.String()) - } - - var env Envelope - if err := json.Unmarshal(out.Bytes(), &env); err != nil { - t.Fatalf("json.Unmarshal() error = %v", err) - } - if env.OK { - t.Fatalf("expected ok=false") - } - if env.Error == nil || env.Error.Code != "usage_error" { - t.Fatalf("expected usage_error, got %#v", env.Error) - } - if env.Error == nil || !strings.Contains(env.Error.Message, "--wait-timeout must be > 0") { - t.Fatalf("unexpected message: %#v", env.Error) - } -} - -func TestCmdAgentRunRejectsIdleThresholdNonPositive(t *testing.T) { - var out, errOut bytes.Buffer - code := cmdAgentRun( - &out, - &errOut, - GlobalFlags{JSON: true}, - []string{"--workspace", "0123456789abcdef", "--assistant", "claude", "--idle-threshold", "0s"}, - "test-v1", - ) - if code != ExitUsage { - t.Fatalf("cmdAgentRun() code = %d, want %d", code, ExitUsage) - } - if errOut.Len() != 0 { - t.Fatalf("expected no stderr output in JSON mode, got %q", errOut.String()) - } - - var env Envelope - if err := json.Unmarshal(out.Bytes(), &env); err != nil { - t.Fatalf("json.Unmarshal() error = %v", err) - } - if env.OK { - t.Fatalf("expected ok=false") - } - if env.Error == nil || env.Error.Code != "usage_error" { - t.Fatalf("expected usage_error, got %#v", env.Error) - } - if env.Error == nil || !strings.Contains(env.Error.Message, "--idle-threshold must be > 0") { - t.Fatalf("unexpected message: %#v", env.Error) - } -} diff --git a/internal/cli/cmd_agent_send_agent_id_test.go b/internal/cli/cmd_agent_send_agent_id_test.go deleted file mode 100644 index 312ab855..00000000 --- a/internal/cli/cmd_agent_send_agent_id_test.go +++ /dev/null @@ -1,92 +0,0 @@ -package cli - -import ( - "bytes" - "encoding/json" - "testing" - - "github.com/andyrewlee/amux/internal/tmux" -) - -func TestCmdAgentSendInvalidAgentIDReturnsUsage(t *testing.T) { - t.Setenv("HOME", t.TempDir()) - - var out, errOut bytes.Buffer - code := cmdAgentSend( - &out, - &errOut, - GlobalFlags{JSON: true}, - []string{"--agent", "invalid-id", "--text", "hello"}, - "test-v1", - ) - if code != ExitUsage { - t.Fatalf("cmdAgentSend() code = %d, want %d", code, ExitUsage) - } - if errOut.Len() != 0 { - t.Fatalf("expected no stderr output in JSON mode, got %q", errOut.String()) - } - - var env Envelope - if err := json.Unmarshal(out.Bytes(), &env); err != nil { - t.Fatalf("json.Unmarshal() error = %v", err) - } - if env.OK { - t.Fatalf("expected ok=false") - } - if env.Error == nil || env.Error.Code != "invalid_agent_id" { - t.Fatalf("expected invalid_agent_id, got %#v", env.Error) - } -} - -func TestCmdAgentSendStaleAgentIDReturnsNotFound(t *testing.T) { - t.Setenv("HOME", t.TempDir()) - - origSessionsWithTags := tmuxSessionsWithTagsForAgentID - origStateFor := tmuxSessionStateFor - defer func() { - tmuxSessionsWithTagsForAgentID = origSessionsWithTags - tmuxSessionStateFor = origStateFor - }() - - tmuxSessionsWithTagsForAgentID = func( - _ map[string]string, - _ []string, - _ tmux.Options, - ) ([]tmux.SessionTagValues, error) { - return nil, nil - } - sessionLookupCalled := false - tmuxSessionStateFor = func(_ string, _ tmux.Options) (tmux.SessionState, error) { - sessionLookupCalled = true - return tmux.SessionState{Exists: true}, nil - } - - var out, errOut bytes.Buffer - code := cmdAgentSend( - &out, - &errOut, - GlobalFlags{JSON: true}, - []string{"--agent", "ws-a:tab-a", "--text", "hello"}, - "test-v1", - ) - if code != ExitNotFound { - t.Fatalf("cmdAgentSend() code = %d, want %d", code, ExitNotFound) - } - if sessionLookupCalled { - t.Fatalf("expected session lookup to be skipped for stale agent IDs") - } - if errOut.Len() != 0 { - t.Fatalf("expected no stderr output in JSON mode, got %q", errOut.String()) - } - - var env Envelope - if err := json.Unmarshal(out.Bytes(), &env); err != nil { - t.Fatalf("json.Unmarshal() error = %v", err) - } - if env.OK { - t.Fatalf("expected ok=false") - } - if env.Error == nil || env.Error.Code != "not_found" { - t.Fatalf("expected not_found, got %#v", env.Error) - } -} diff --git a/internal/cli/cmd_agent_send_async.go b/internal/cli/cmd_agent_send_async.go deleted file mode 100644 index ac8af5e8..00000000 --- a/internal/cli/cmd_agent_send_async.go +++ /dev/null @@ -1,40 +0,0 @@ -package cli - -import ( - "io" - "os" - "os/exec" -) - -type sendJobProcessArgs struct { - SessionName string - AgentID string - Text string - Enter bool - JobID string -} - -func launchSendJobProcessor(args sendJobProcessArgs) error { - exe, err := os.Executable() - if err != nil { - return err - } - - cmdArgs := []string{"agent", "send"} - if args.SessionName != "" { - cmdArgs = append(cmdArgs, args.SessionName) - } else if args.AgentID != "" { - cmdArgs = append(cmdArgs, "--agent", args.AgentID) - } - cmdArgs = append(cmdArgs, "--text", args.Text, "--process-job", "--job-id", args.JobID) - if args.Enter { - cmdArgs = append(cmdArgs, "--enter") - } - - cmd := exec.Command(exe, cmdArgs...) - cmd.Env = os.Environ() - cmd.Stdout = io.Discard - cmd.Stderr = io.Discard - cmd.Stdin = nil - return cmd.Start() -} diff --git a/internal/cli/cmd_agent_send_async_test.go b/internal/cli/cmd_agent_send_async_test.go deleted file mode 100644 index 64f70ed8..00000000 --- a/internal/cli/cmd_agent_send_async_test.go +++ /dev/null @@ -1,102 +0,0 @@ -package cli - -import ( - "bytes" - "encoding/json" - "testing" - - "github.com/andyrewlee/amux/internal/tmux" -) - -func TestCmdAgentSendAsyncJSONEnqueuesAndReplaysIdempotently(t *testing.T) { - t.Setenv("HOME", t.TempDir()) - - origStateFor := tmuxSessionStateFor - origSend := tmuxSendKeys - origLauncher := startSendJobProcess - defer func() { - tmuxSessionStateFor = origStateFor - tmuxSendKeys = origSend - startSendJobProcess = origLauncher - }() - - stateChecks := 0 - sendCalls := 0 - launchCalls := 0 - tmuxSessionStateFor = func(_ string, _ tmux.Options) (tmux.SessionState, error) { - stateChecks++ - return tmux.SessionState{Exists: true}, nil - } - tmuxSendKeys = func(_, _ string, _ bool, _ tmux.Options) error { - sendCalls++ - return nil - } - startSendJobProcess = func(args sendJobProcessArgs) error { - launchCalls++ - if args.JobID == "" { - t.Fatalf("expected async launcher to receive job id") - } - return nil - } - - args := []string{"session-a", "--text", "hello", "--async", "--idempotency-key", "idem-async-1"} - - var firstOut bytes.Buffer - var firstErr bytes.Buffer - firstCode := cmdAgentSend(&firstOut, &firstErr, GlobalFlags{JSON: true}, args, "test-v1") - if firstCode != ExitOK { - t.Fatalf("first code = %d, want %d", firstCode, ExitOK) - } - if firstErr.Len() != 0 { - t.Fatalf("expected no stderr output in JSON mode, got %q", firstErr.String()) - } - - var firstEnv Envelope - if err := json.Unmarshal(firstOut.Bytes(), &firstEnv); err != nil { - t.Fatalf("json.Unmarshal(first) error = %v", err) - } - if !firstEnv.OK { - t.Fatalf("expected ok=true, got error=%#v", firstEnv.Error) - } - firstData, ok := firstEnv.Data.(map[string]any) - if !ok { - t.Fatalf("expected data object, got %T", firstEnv.Data) - } - if got, _ := firstData["status"].(string); got != string(sendJobPending) { - t.Fatalf("status = %q, want %q", got, sendJobPending) - } - if got, _ := firstData["sent"].(bool); got { - t.Fatalf("sent = %v, want false", got) - } - if got, _ := firstData["delivered"].(bool); got { - t.Fatalf("delivered = %v, want false", got) - } - if got, _ := firstData["job_id"].(string); got == "" { - t.Fatalf("expected non-empty job_id") - } - if launchCalls != 1 { - t.Fatalf("launch calls = %d, want 1", launchCalls) - } - if sendCalls != 0 { - t.Fatalf("send calls = %d, want 0 for async enqueue", sendCalls) - } - - var replayOut bytes.Buffer - var replayErr bytes.Buffer - replayCode := cmdAgentSend(&replayOut, &replayErr, GlobalFlags{JSON: true}, args, "test-v1") - if replayCode != ExitOK { - t.Fatalf("replay code = %d, want %d", replayCode, ExitOK) - } - if replayErr.Len() != 0 { - t.Fatalf("expected no replay stderr output, got %q", replayErr.String()) - } - if replayOut.String() != firstOut.String() { - t.Fatalf("replay output mismatch\nfirst:\n%s\nreplay:\n%s", firstOut.String(), replayOut.String()) - } - if launchCalls != 1 { - t.Fatalf("launch calls after replay = %d, want 1", launchCalls) - } - if stateChecks != 1 { - t.Fatalf("state checks after replay = %d, want 1", stateChecks) - } -} diff --git a/internal/cli/cmd_agent_send_execute.go b/internal/cli/cmd_agent_send_execute.go deleted file mode 100644 index 06501757..00000000 --- a/internal/cli/cmd_agent_send_execute.go +++ /dev/null @@ -1,336 +0,0 @@ -package cli - -import ( - "context" - "fmt" - "io" - "strings" - "time" - - "github.com/andyrewlee/amux/internal/logging" - "github.com/andyrewlee/amux/internal/tmux" -) - -func resolveSendJobForExecution( - ctx *cmdCtx, - jobStore *sendJobStore, - requestedJobID string, - sessionName string, - agentID string, -) (sendJob, string, int) { - if requestedJobID != "" { - existing, ok, getErr := jobStore.get(requestedJobID) - if getErr != nil { - return sendJob{}, sessionName, ctx.errResult( - ExitInternalError, "job_status_failed", getErr.Error(), map[string]any{"job_id": requestedJobID}, - fmt.Sprintf("failed to load send job status: %v", getErr), - ) - } - if !ok { - return sendJob{}, sessionName, ctx.errResult( - ExitNotFound, "not_found", "send job not found", map[string]any{"job_id": requestedJobID}, - fmt.Sprintf("send job %s not found", requestedJobID), - ) - } - job := existing - // For process-job retries, job metadata is the source of truth. - sessionName = job.SessionName - if sessionName == "" { - _, _ = jobStore.setStatus(job.ID, sendJobFailed, "stored send job is missing session name") - return sendJob{}, sessionName, ctx.errResult( - ExitInternalError, "job_status_failed", "stored send job is missing session name", map[string]any{"job_id": job.ID}, - fmt.Sprintf("stored send job %s is missing session name", job.ID), - ) - } - return job, sessionName, ExitOK - } - - job, err := jobStore.create(sessionName, agentID) - if err != nil { - return sendJob{}, sessionName, ctx.errResult( - ExitInternalError, "job_create_failed", err.Error(), nil, - fmt.Sprintf("failed to create send job: %v", err), - ) - } - return job, sessionName, ExitOK -} - -func dispatchAsyncAgentSend( - ctx *cmdCtx, - jobStore *sendJobStore, - sessionName string, - agentID string, - text string, - enter bool, - job sendJob, -) int { - if err := startSendJobProcess(sendJobProcessArgs{ - SessionName: sessionName, - AgentID: agentID, - Text: text, - Enter: enter, - JobID: job.ID, - }); err != nil { - _, _ = jobStore.setStatus( - job.ID, - sendJobFailed, - "failed to start async send processor: "+err.Error(), - ) - return ctx.errResult( - ExitInternalError, "job_dispatch_failed", err.Error(), map[string]any{"job_id": job.ID}, - fmt.Sprintf("failed to start async send processor: %v", err), - ) - } - - result := agentSendResult{ - SessionName: sessionName, - AgentID: agentID, - JobID: job.ID, - Status: string(sendJobPending), - Sent: false, - Delivered: false, - } - if ctx.gf.JSON { - return ctx.successResult(result) - } - PrintHuman(ctx.w, func(w io.Writer) { - fmt.Fprintf(w, "Queued text to %s (job: %s)\n", sessionName, job.ID) - }) - return ExitOK -} - -// executeAgentSendJobCore performs the send and returns the result without -// writing output. Callers use this to optionally append --wait data before -// serializing the response. -func executeAgentSendJobCore( - ctx *cmdCtx, - jobStore *sendJobStore, - svc *Services, - sessionName string, - agentID string, - text string, - enter bool, - job sendJob, - needWaitBaseline bool, -) (agentSendResult, string, int) { - // Both direct sends and --process-job retries pass through the same - // per-session queue path to preserve FIFO delivery semantics. - queueLock, err := waitForSessionQueueTurnForJob(jobStore, sessionName, job.ID) - if err != nil { - _, _ = jobStore.setStatus(job.ID, sendJobFailed, err.Error()) - return agentSendResult{}, "", ctx.errResult( - ExitInternalError, "job_queue_failed", err.Error(), map[string]any{"job_id": job.ID}, - fmt.Sprintf("failed to join send queue: %v", err), - ) - } - defer releaseSessionQueueTurn(queueLock) - - jobID := job.ID - job, ok, err := jobStore.get(jobID) - if err != nil { - _, _ = jobStore.setStatus(jobID, sendJobFailed, err.Error()) - return agentSendResult{}, "", ctx.errResult( - ExitInternalError, "job_status_failed", err.Error(), map[string]any{"job_id": jobID}, - fmt.Sprintf("failed to load send job status: %v", err), - ) - } - if !ok { - return agentSendResult{}, "", ctx.errResult( - ExitInternalError, "job_not_found", "send job not found", map[string]any{"job_id": jobID}, - fmt.Sprintf("send job %s not found", jobID), - ) - } - - if job.Status == sendJobCanceled || job.Status == sendJobCompleted { - return agentSendResult{ - SessionName: sessionName, - AgentID: agentID, - JobID: job.ID, - Status: string(job.Status), - Sent: job.Status == sendJobCompleted, - Delivered: false, - }, "", ExitOK - } - - job, err = jobStore.setStatus(job.ID, sendJobRunning, "") - if err != nil { - return agentSendResult{}, "", ctx.errResult( - ExitInternalError, "job_status_failed", err.Error(), map[string]any{"job_id": job.ID}, - fmt.Sprintf("failed to update send job status: %v", err), - ) - } - if job.Status != sendJobRunning { - if job.Status == sendJobCanceled || job.Status == sendJobCompleted { - return agentSendResult{ - SessionName: sessionName, - AgentID: agentID, - JobID: job.ID, - Status: string(job.Status), - Sent: job.Status == sendJobCompleted, - Delivered: false, - }, "", ExitOK - } - humanMessage := fmt.Sprintf("send job %s is %s and cannot be executed", job.ID, job.Status) - if strings.TrimSpace(job.Error) != "" { - humanMessage = fmt.Sprintf("send job %s is %s: %s", job.ID, job.Status, job.Error) - } - return agentSendResult{}, "", ctx.errResult( - ExitInternalError, "job_status_conflict", "send job is not runnable", map[string]any{ - "job_id": job.ID, - "status": string(job.Status), - "error": job.Error, - }, - humanMessage, - ) - } - - preContent := "" - if needWaitBaseline { - preContent = captureWaitBaselineWithRetry(sessionName, svc.TmuxOpts) - } - - if err := tmuxSendKeys(sessionName, text, enter, svc.TmuxOpts); err != nil { - failedJob, setErr := jobStore.setStatus(job.ID, sendJobFailed, err.Error()) - if setErr != nil { - failedJob = job - failedJob.Status = sendJobFailed - } - return agentSendResult{}, "", ctx.errResult( - ExitInternalError, "send_failed", err.Error(), map[string]any{ - "job_id": failedJob.ID, - "status": string(failedJob.Status), - "agent_id": agentID, - }, - fmt.Sprintf("failed to send keys: %v", err), - ) - } - - if completedJob, setErr := jobStore.setStatus(job.ID, sendJobCompleted, ""); setErr == nil { - job = completedJob - } else { - if !ctx.gf.JSON { - Errorf(ctx.wErr, "warning: sent text but failed to persist completion for job %s: %v", job.ID, setErr) - } - job.Status = sendJobCompleted - job.Error = "" - } - - result := agentSendResult{ - SessionName: sessionName, - AgentID: agentID, - JobID: job.ID, - Status: string(job.Status), - Error: job.Error, - Sent: job.Status == sendJobCompleted, - Delivered: true, - } - return result, preContent, ExitOK -} - -// sendWaitConfig holds --wait parameters for agent send. -type sendWaitConfig struct { - Wait bool - WaitTimeout time.Duration - IdleThreshold time.Duration -} - -func executeAgentSendJob( - ctx *cmdCtx, - jobStore *sendJobStore, - svc *Services, - sessionName string, - agentID string, - text string, - enter bool, - job sendJob, - waitCfg sendWaitConfig, -) int { - result, preContent, code := executeAgentSendJobCore( - ctx, - jobStore, svc, sessionName, agentID, text, enter, job, waitCfg.Wait, - ) - if code != ExitOK { - return code - } - - if waitCfg.Wait && result.Delivered { - resp := runSendWait(svc.TmuxOpts, sessionName, waitCfg, preContent) - result.Response = &resp - } - - if ctx.gf.JSON { - return ctx.successResult(result) - } - PrintHuman(ctx.w, func(w io.Writer) { - switch { - case result.Status == string(sendJobCanceled): - fmt.Fprintf(w, "Send job %s canceled before execution\n", result.JobID) - case result.Status == string(sendJobCompleted) && !result.Delivered: - fmt.Fprintf(w, "Send job %s already completed\n", result.JobID) - case result.Delivered: - fmt.Fprintf(w, "Sent text to %s (job: %s)\n", sessionName, result.JobID) - default: - if result.Error != "" { - fmt.Fprintf(w, "Send job %s is %s: %s\n", result.JobID, result.Status, result.Error) - } else { - fmt.Fprintf(w, "Send job %s is %s and was not delivered\n", result.JobID, result.Status) - } - } - if result.Response != nil { - if result.Response.NeedsInput { - if strings.TrimSpace(result.Response.InputHint) != "" { - fmt.Fprintf(w, "Agent needs input: %s\n", strings.TrimSpace(result.Response.InputHint)) - } else { - fmt.Fprintf(w, "Agent needs input\n") - } - } else if result.Response.TimedOut { - fmt.Fprintf(w, "Timed out waiting for response\n") - } else if result.Response.SessionExited { - fmt.Fprintf(w, "Session exited while waiting\n") - } else { - fmt.Fprintf(w, "Agent idle after %.1fs\n", result.Response.IdleSeconds) - } - } - }) - return ExitOK -} - -func runSendWait(tmuxOpts tmux.Options, sessionName string, waitCfg sendWaitConfig, preContent string) waitResponseResult { - return runAgentWait(tmuxOpts, sessionName, waitCfg.WaitTimeout, waitCfg.IdleThreshold, preContent) -} - -// runAgentWait waits for an agent to finish responding after input. Shared by -// both the "agent send --wait" and "agent run --wait" code paths. -func runAgentWait(tmuxOpts tmux.Options, sessionName string, waitTimeout, idleThreshold time.Duration, preContent string) waitResponseResult { - preHash := tmux.ContentHash(preContent) - - ctx, cancel := contextWithSignal() - defer cancel() - ctx, timeoutCancel := context.WithTimeout(ctx, waitTimeout) - defer timeoutCancel() - - return waitForAgentResponse(ctx, waitResponseConfig{ - SessionName: sessionName, - CaptureLines: 100, - PollInterval: 500 * time.Millisecond, - IdleThreshold: idleThreshold, - }, tmuxOpts, tmuxCapturePaneTail, preHash, preContent) -} - -func captureWaitBaselineWithRetry(sessionName string, opts tmux.Options) string { - const ( - maxAttempts = 3 - retryDelay = 75 * time.Millisecond - ) - for attempt := 1; attempt <= maxAttempts; attempt++ { - content, ok := tmuxCapturePaneTail(sessionName, 100, opts) - if ok { - return content - } - if attempt < maxAttempts { - time.Sleep(retryDelay) - } - } - logging.Warn("wait baseline capture unavailable for session %s after %d attempts; proceeding with empty baseline", sessionName, maxAttempts) - return "" -} diff --git a/internal/cli/cmd_agent_send_helpers.go b/internal/cli/cmd_agent_send_helpers.go deleted file mode 100644 index 36ca9047..00000000 --- a/internal/cli/cmd_agent_send_helpers.go +++ /dev/null @@ -1,35 +0,0 @@ -package cli - -import ( - "crypto/rand" - "encoding/hex" - "strconv" - "strings" - "sync/atomic" - "time" -) - -var agentRunTabCounter uint64 - -func markSendJobFailedIfPresent(jobID, reason string) { - jobID = strings.TrimSpace(jobID) - reason = strings.TrimSpace(reason) - if jobID == "" { - return - } - jobStore, err := newSendJobStore() - if err != nil { - return - } - _, _ = jobStore.setStatus(jobID, sendJobFailed, reason) -} - -func newAgentTabID() string { - nowPart := strconv.FormatInt(time.Now().UnixNano(), 36) - seqPart := strconv.FormatUint(atomic.AddUint64(&agentRunTabCounter, 1), 36) - var entropy [4]byte - if _, err := rand.Read(entropy[:]); err == nil { - return "t_" + nowPart + "_" + seqPart + "_" + hex.EncodeToString(entropy[:]) - } - return "t_" + nowPart + "_" + seqPart -} diff --git a/internal/cli/cmd_agent_send_process_dispatch_test.go b/internal/cli/cmd_agent_send_process_dispatch_test.go deleted file mode 100644 index 60cd2caa..00000000 --- a/internal/cli/cmd_agent_send_process_dispatch_test.go +++ /dev/null @@ -1,122 +0,0 @@ -package cli - -import ( - "bytes" - "encoding/json" - "errors" - "strings" - "testing" - - "github.com/andyrewlee/amux/internal/tmux" -) - -func TestCmdAgentSendSessionLookupErrorReturnsInternalError(t *testing.T) { - t.Setenv("HOME", t.TempDir()) - - origStateFor := tmuxSessionStateFor - defer func() { - tmuxSessionStateFor = origStateFor - }() - - tmuxSessionStateFor = func(_ string, _ tmux.Options) (tmux.SessionState, error) { - return tmux.SessionState{}, errors.New("tmux lookup timeout") - } - - var out bytes.Buffer - var errOut bytes.Buffer - code := cmdAgentSend( - &out, - &errOut, - GlobalFlags{JSON: true}, - []string{"session-a", "--text", "hello"}, - "test-v1", - ) - if code != ExitInternalError { - t.Fatalf("cmdAgentSend() code = %d, want %d", code, ExitInternalError) - } - if errOut.Len() != 0 { - t.Fatalf("expected no stderr output in JSON mode, got %q", errOut.String()) - } - - var env Envelope - if err := json.Unmarshal(out.Bytes(), &env); err != nil { - t.Fatalf("json.Unmarshal() error = %v", err) - } - if env.OK { - t.Fatalf("expected ok=false") - } - if env.Error == nil || env.Error.Code != "session_lookup_failed" { - t.Fatalf("expected session_lookup_failed, got %#v", env.Error) - } -} - -func TestCmdAgentSendSessionNotFoundMarksNewJobFailed(t *testing.T) { - t.Setenv("HOME", t.TempDir()) - - store, err := newSendJobStore() - if err != nil { - t.Fatalf("newSendJobStore() error = %v", err) - } - - origStateFor := tmuxSessionStateFor - defer func() { - tmuxSessionStateFor = origStateFor - }() - tmuxSessionStateFor = func(_ string, _ tmux.Options) (tmux.SessionState, error) { - return tmux.SessionState{Exists: false}, nil - } - - var out bytes.Buffer - var errOut bytes.Buffer - code := cmdAgentSend( - &out, - &errOut, - GlobalFlags{JSON: true}, - []string{"session-a", "--text", "hello"}, - "test-v1", - ) - if code != ExitNotFound { - t.Fatalf("cmdAgentSend() code = %d, want %d", code, ExitNotFound) - } - if errOut.Len() != 0 { - t.Fatalf("expected no stderr output in JSON mode, got %q", errOut.String()) - } - - var env Envelope - if err := json.Unmarshal(out.Bytes(), &env); err != nil { - t.Fatalf("json.Unmarshal() error = %v", err) - } - if env.OK { - t.Fatalf("expected ok=false") - } - if env.Error == nil || env.Error.Code != "not_found" { - t.Fatalf("expected not_found, got %#v", env.Error) - } - - lockFile, err := lockIdempotencyFile(store.lockPath(), false) - if err != nil { - t.Fatalf("lockIdempotencyFile() error = %v", err) - } - state, err := store.loadState() - unlockIdempotencyFile(lockFile) - if err != nil { - t.Fatalf("store.loadState() error = %v", err) - } - if len(state.Jobs) != 1 { - t.Fatalf("jobs count = %d, want 1", len(state.Jobs)) - } - var created sendJob - for _, job := range state.Jobs { - created = job - break - } - if created.Status != sendJobFailed { - t.Fatalf("status = %q, want %q", created.Status, sendJobFailed) - } - if !strings.Contains(created.Error, "session not found") { - t.Fatalf("error = %q, want session not found message", created.Error) - } - if isQueuedSendJobStatus(created.Status) { - t.Fatalf("expected terminal failed status, got queued status %q", created.Status) - } -} diff --git a/internal/cli/cmd_agent_send_process_job_state_test.go b/internal/cli/cmd_agent_send_process_job_state_test.go deleted file mode 100644 index a5205395..00000000 --- a/internal/cli/cmd_agent_send_process_job_state_test.go +++ /dev/null @@ -1,293 +0,0 @@ -package cli - -import ( - "bytes" - "encoding/json" - "errors" - "strings" - "testing" - - "github.com/andyrewlee/amux/internal/tmux" -) - -func TestCmdAgentSendProcessJobLookupFailureMarksJobFailed(t *testing.T) { - t.Setenv("HOME", t.TempDir()) - - store, err := newSendJobStore() - if err != nil { - t.Fatalf("newSendJobStore() error = %v", err) - } - job, err := store.create("session-a", "") - if err != nil { - t.Fatalf("store.create() error = %v", err) - } - - origStateFor := tmuxSessionStateFor - defer func() { - tmuxSessionStateFor = origStateFor - }() - tmuxSessionStateFor = func(_ string, _ tmux.Options) (tmux.SessionState, error) { - return tmux.SessionState{}, errors.New("tmux lookup timeout") - } - - var out bytes.Buffer - var errOut bytes.Buffer - code := cmdAgentSend( - &out, - &errOut, - GlobalFlags{JSON: true}, - []string{ - "session-a", - "--text", "hello", - "--process-job", - "--job-id", job.ID, - }, - "test-v1", - ) - if code != ExitInternalError { - t.Fatalf("cmdAgentSend() code = %d, want %d", code, ExitInternalError) - } - - got, ok, err := store.get(job.ID) - if err != nil { - t.Fatalf("store.get() error = %v", err) - } - if !ok { - t.Fatalf("expected job to exist") - } - if got.Status != sendJobFailed { - t.Fatalf("status = %q, want %q", got.Status, sendJobFailed) - } - if !strings.Contains(got.Error, "session lookup failed") { - t.Fatalf("error = %q, want session lookup failure message", got.Error) - } -} - -func TestCmdAgentSendProcessJobCompletedDoesNotSendAgain(t *testing.T) { - t.Setenv("HOME", t.TempDir()) - - store, err := newSendJobStore() - if err != nil { - t.Fatalf("newSendJobStore() error = %v", err) - } - job, err := store.create("session-a", "") - if err != nil { - t.Fatalf("store.create() error = %v", err) - } - if _, err := store.setStatus(job.ID, sendJobCompleted, ""); err != nil { - t.Fatalf("store.setStatus() error = %v", err) - } - - origStateFor := tmuxSessionStateFor - origSend := tmuxSendKeys - defer func() { - tmuxSessionStateFor = origStateFor - tmuxSendKeys = origSend - }() - tmuxSessionStateFor = func(_ string, _ tmux.Options) (tmux.SessionState, error) { - return tmux.SessionState{Exists: true}, nil - } - - sendCalls := 0 - tmuxSendKeys = func(_, _ string, _ bool, _ tmux.Options) error { - sendCalls++ - return nil - } - - var out bytes.Buffer - var errOut bytes.Buffer - code := cmdAgentSend( - &out, - &errOut, - GlobalFlags{JSON: true}, - []string{ - "session-a", - "--text", "hello", - "--process-job", - "--job-id", job.ID, - }, - "test-v1", - ) - if code != ExitOK { - t.Fatalf("cmdAgentSend() code = %d, want %d", code, ExitOK) - } - if sendCalls != 0 { - t.Fatalf("tmuxSendKeys calls = %d, want 0", sendCalls) - } - if errOut.Len() != 0 { - t.Fatalf("expected no stderr output in JSON mode, got %q", errOut.String()) - } - - var env Envelope - if err := json.Unmarshal(out.Bytes(), &env); err != nil { - t.Fatalf("json.Unmarshal() error = %v", err) - } - if !env.OK { - t.Fatalf("expected ok=true, got error=%#v", env.Error) - } - data, ok := env.Data.(map[string]any) - if !ok { - t.Fatalf("expected response data object, got %T", env.Data) - } - if got, _ := data["status"].(string); got != string(sendJobCompleted) { - t.Fatalf("status = %q, want %q", got, sendJobCompleted) - } - if got, _ := data["sent"].(bool); !got { - t.Fatalf("sent = %v, want true", got) - } - if got, _ := data["delivered"].(bool); got { - t.Fatalf("delivered = %v, want false for already-completed job", got) - } -} - -func TestCmdAgentSendProcessJobCompletedWithWaitSkipsWaitPolling(t *testing.T) { - t.Setenv("HOME", t.TempDir()) - - store, err := newSendJobStore() - if err != nil { - t.Fatalf("newSendJobStore() error = %v", err) - } - job, err := store.create("session-a", "") - if err != nil { - t.Fatalf("store.create() error = %v", err) - } - if _, err := store.setStatus(job.ID, sendJobCompleted, ""); err != nil { - t.Fatalf("store.setStatus() error = %v", err) - } - - origStateFor := tmuxSessionStateFor - origSend := tmuxSendKeys - origCapture := tmuxCapturePaneTail - defer func() { - tmuxSessionStateFor = origStateFor - tmuxSendKeys = origSend - tmuxCapturePaneTail = origCapture - }() - tmuxSessionStateFor = func(_ string, _ tmux.Options) (tmux.SessionState, error) { - return tmux.SessionState{Exists: true}, nil - } - - sendCalls := 0 - tmuxSendKeys = func(_, _ string, _ bool, _ tmux.Options) error { - sendCalls++ - return nil - } - captureCalls := 0 - tmuxCapturePaneTail = func(_ string, _ int, _ tmux.Options) (string, bool) { - captureCalls++ - return "should not capture", true - } - - var out bytes.Buffer - var errOut bytes.Buffer - code := cmdAgentSend( - &out, - &errOut, - GlobalFlags{JSON: true}, - []string{ - "session-a", - "--text", "hello", - "--process-job", - "--job-id", job.ID, - "--wait", - "--wait-timeout", "10ms", - }, - "test-v1", - ) - if code != ExitOK { - t.Fatalf("cmdAgentSend() code = %d, want %d", code, ExitOK) - } - if sendCalls != 0 { - t.Fatalf("tmuxSendKeys calls = %d, want 0", sendCalls) - } - if captureCalls != 0 { - t.Fatalf("tmuxCapturePaneTail calls = %d, want 0", captureCalls) - } - if errOut.Len() != 0 { - t.Fatalf("expected no stderr output in JSON mode, got %q", errOut.String()) - } - - var env Envelope - if err := json.Unmarshal(out.Bytes(), &env); err != nil { - t.Fatalf("json.Unmarshal() error = %v", err) - } - if !env.OK { - t.Fatalf("expected ok=true, got error=%#v", env.Error) - } - data, ok := env.Data.(map[string]any) - if !ok { - t.Fatalf("expected response data object, got %T", env.Data) - } - if _, exists := data["response"]; exists { - t.Fatalf("expected no response payload for already-completed job, got %#v", data["response"]) - } -} - -func TestCmdAgentSendProcessJobValidatesStoredSessionName(t *testing.T) { - t.Setenv("HOME", t.TempDir()) - - store, err := newSendJobStore() - if err != nil { - t.Fatalf("newSendJobStore() error = %v", err) - } - job, err := store.create("session-missing", "") - if err != nil { - t.Fatalf("store.create() error = %v", err) - } - - origStateFor := tmuxSessionStateFor - origSend := tmuxSendKeys - defer func() { - tmuxSessionStateFor = origStateFor - tmuxSendKeys = origSend - }() - tmuxSessionStateFor = func(name string, _ tmux.Options) (tmux.SessionState, error) { - if name == "session-positional" { - return tmux.SessionState{Exists: true}, nil - } - return tmux.SessionState{Exists: false}, nil - } - sendCalls := 0 - tmuxSendKeys = func(_, _ string, _ bool, _ tmux.Options) error { - sendCalls++ - return nil - } - - var out bytes.Buffer - var errOut bytes.Buffer - code := cmdAgentSend( - &out, - &errOut, - GlobalFlags{JSON: true}, - []string{ - "session-positional", - "--text", "hello", - "--process-job", - "--job-id", job.ID, - }, - "test-v1", - ) - if code != ExitNotFound { - t.Fatalf("cmdAgentSend() code = %d, want %d", code, ExitNotFound) - } - if sendCalls != 0 { - t.Fatalf("tmuxSendKeys calls = %d, want 0", sendCalls) - } - if errOut.Len() != 0 { - t.Fatalf("expected no stderr output in JSON mode, got %q", errOut.String()) - } - - var env Envelope - if err := json.Unmarshal(out.Bytes(), &env); err != nil { - t.Fatalf("json.Unmarshal() error = %v", err) - } - if env.OK { - t.Fatalf("expected ok=false") - } - if env.Error == nil || env.Error.Code != "not_found" { - t.Fatalf("expected not_found, got %#v", env.Error) - } - if env.Error == nil || !strings.Contains(env.Error.Message, "session session-missing not found") { - t.Fatalf("unexpected error message: %#v", env.Error) - } -} diff --git a/internal/cli/cmd_agent_send_queue_order_test.go b/internal/cli/cmd_agent_send_queue_order_test.go deleted file mode 100644 index b98cd0dc..00000000 --- a/internal/cli/cmd_agent_send_queue_order_test.go +++ /dev/null @@ -1,118 +0,0 @@ -package cli - -import ( - "bytes" - "sync" - "testing" - "time" - - "github.com/andyrewlee/amux/internal/tmux" -) - -func TestCmdAgentSendProcessJobPreservesFIFOOrder(t *testing.T) { - t.Setenv("HOME", t.TempDir()) - - store, err := newSendJobStore() - if err != nil { - t.Fatalf("newSendJobStore() error = %v", err) - } - firstJob, err := store.create("session-a", "") - if err != nil { - t.Fatalf("store.create(first) error = %v", err) - } - secondJob, err := store.create("session-a", "") - if err != nil { - t.Fatalf("store.create(second) error = %v", err) - } - if err := normalizeJobCreatedAt(store, firstJob.ID, secondJob.ID); err != nil { - t.Fatalf("normalizeJobCreatedAt() error = %v", err) - } - - origStateFor := tmuxSessionStateFor - origSend := tmuxSendKeys - defer func() { - tmuxSessionStateFor = origStateFor - tmuxSendKeys = origSend - }() - tmuxSessionStateFor = func(_ string, _ tmux.Options) (tmux.SessionState, error) { - return tmux.SessionState{Exists: true}, nil - } - - var ( - mu sync.Mutex - sent []string - ) - tmuxSendKeys = func(_, text string, _ bool, _ tmux.Options) error { - mu.Lock() - sent = append(sent, text) - mu.Unlock() - return nil - } - - var codeFirst, codeSecond int - var wg sync.WaitGroup - wg.Add(2) - go func() { - defer wg.Done() - var out, errOut bytes.Buffer - codeSecond = cmdAgentSend( - &out, - &errOut, - GlobalFlags{JSON: true}, - []string{"session-a", "--text", "second", "--process-job", "--job-id", secondJob.ID}, - "test-v1", - ) - }() - go func() { - defer wg.Done() - var out, errOut bytes.Buffer - codeFirst = cmdAgentSend( - &out, - &errOut, - GlobalFlags{JSON: true}, - []string{"session-a", "--text", "first", "--process-job", "--job-id", firstJob.ID}, - "test-v1", - ) - }() - wg.Wait() - - if codeFirst != ExitOK { - t.Fatalf("first job code = %d, want %d", codeFirst, ExitOK) - } - if codeSecond != ExitOK { - t.Fatalf("second job code = %d, want %d", codeSecond, ExitOK) - } - - if len(sent) != 2 { - t.Fatalf("sent count = %d, want 2 (sent=%v)", len(sent), sent) - } - if sent[0] != "first" || sent[1] != "second" { - t.Fatalf("send order = %v, want [first second]", sent) - } -} - -func normalizeJobCreatedAt(store *sendJobStore, firstID, secondID string) error { - lockFile, err := lockIdempotencyFile(store.lockPath(), false) - if err != nil { - return err - } - defer unlockIdempotencyFile(lockFile) - - state, err := store.loadState() - if err != nil { - return err - } - first := state.Jobs[firstID] - second := state.Jobs[secondID] - now := time.Now().Unix() - first.Sequence = 1 - first.CreatedAt = now - first.UpdatedAt = now - second.Sequence = 2 - second.CreatedAt = now - second.UpdatedAt = now - state.NextSequence = 2 - state.Jobs[firstID] = first - state.Jobs[secondID] = second - return store.saveState(state) -} diff --git a/internal/cli/cmd_agent_send_resolve.go b/internal/cli/cmd_agent_send_resolve.go deleted file mode 100644 index 24b6eae7..00000000 --- a/internal/cli/cmd_agent_send_resolve.go +++ /dev/null @@ -1,26 +0,0 @@ -package cli - -import ( - "errors" - - "github.com/andyrewlee/amux/internal/tmux" -) - -func resolveSessionForAgentSend( - ctx *cmdCtx, - agentID string, - opts tmux.Options, -) (string, int, bool) { - resolved, err := resolveSessionNameForAgentID(agentID, opts) - if err == nil { - return resolved, 0, false - } - - if errors.Is(err, errInvalidAgentID) { - return "", ctx.errResult(ExitUsage, "invalid_agent_id", err.Error(), map[string]any{"agent_id": agentID}, "invalid --agent: "+err.Error()), true - } - if errors.Is(err, errAgentNotFound) { - return "", ctx.errResult(ExitNotFound, "not_found", "agent not found", map[string]any{"agent_id": agentID}, "agent "+agentID+" not found"), true - } - return "", ctx.errResult(ExitInternalError, "session_lookup_failed", err.Error(), map[string]any{"agent_id": agentID}, "failed to resolve --agent "+agentID+": "+err.Error()), true -} diff --git a/internal/cli/cmd_agent_send_validate.go b/internal/cli/cmd_agent_send_validate.go deleted file mode 100644 index ba0ae284..00000000 --- a/internal/cli/cmd_agent_send_validate.go +++ /dev/null @@ -1,31 +0,0 @@ -package cli - -import ( - "fmt" - - "github.com/andyrewlee/amux/internal/tmux" -) - -func validateAgentSendSession( - ctx *cmdCtx, - sessionName string, - requestedJobID string, - opts tmux.Options, -) int { - state, err := tmuxSessionStateFor(sessionName, opts) - if err != nil { - markSendJobFailedIfPresent(requestedJobID, "session lookup failed: "+err.Error()) - return ctx.errResult( - ExitInternalError, - "session_lookup_failed", - err.Error(), - map[string]any{"session_name": sessionName}, - fmt.Sprintf("failed to check session %s: %v", sessionName, err), - ) - } - if !state.Exists { - markSendJobFailedIfPresent(requestedJobID, "session not found") - return ctx.errResult(ExitNotFound, "not_found", fmt.Sprintf("session %s not found", sessionName), nil, fmt.Sprintf("session %s not found", sessionName)) - } - return ExitOK -} diff --git a/internal/cli/cmd_agent_send_write.go b/internal/cli/cmd_agent_send_write.go deleted file mode 100644 index e7fbbb6c..00000000 --- a/internal/cli/cmd_agent_send_write.go +++ /dev/null @@ -1,131 +0,0 @@ -package cli - -import ( - "errors" - "io" - "strings" - "time" -) - -const agentSendCommandName = "agent.send" - -func cmdAgentSend(w, wErr io.Writer, gf GlobalFlags, args []string, version string) int { - const usage = "Usage: amux agent send (|--agent ) --text [--enter] [--async] [--wait] [--wait-timeout ] [--idle-threshold ] [--idempotency-key ] [--json]" - fs := newFlagSet("agent send") - agentID := fs.String("agent", "", "agent ID (workspace_id:tab_id)") - text := fs.String("text", "", "text to send (required)") - enter := fs.Bool("enter", false, "send Enter key after text") - async := fs.Bool("async", false, "enqueue send and return immediately") - wait := fs.Bool("wait", false, "wait for agent to respond and go idle") - waitTimeout := fs.Duration("wait-timeout", 120*time.Second, "max time to wait for response") - idleThreshold := fs.Duration("idle-threshold", 10*time.Second, "idle time before returning response") - idempotencyKey := fs.String("idempotency-key", "", "idempotency key for safe retries") - processJob := fs.Bool("process-job", false, "internal: process existing send job") - jobIDFlag := fs.String("job-id", "", "internal: existing send job id") - sessionName, err := parseSinglePositionalWithFlags(fs, args) - if err != nil { - return returnUsageError(w, wErr, gf, usage, version, err) - } - if *text == "" { - return returnUsageError(w, wErr, gf, usage, version, nil) - } - if sessionName == "" && *agentID == "" { - return returnUsageError(w, wErr, gf, usage, version, nil) - } - if sessionName != "" && *agentID != "" { - return returnUsageError(w, wErr, gf, usage, version, nil) - } - if *wait && *async { - return returnUsageError(w, wErr, gf, usage, version, - errors.New("--wait and --async cannot be used together"), - ) - } - if *waitTimeout <= 0 { - return returnUsageError(w, wErr, gf, usage, version, - errors.New("--wait-timeout must be > 0"), - ) - } - if *idleThreshold <= 0 { - return returnUsageError(w, wErr, gf, usage, version, - errors.New("--idle-threshold must be > 0"), - ) - } - ctx := &cmdCtx{ - w: w, - wErr: wErr, - gf: gf, - version: version, - cmd: agentSendCommandName, - idemKey: *idempotencyKey, - } - - if handled, code := ctx.maybeReplay(); handled { - return code - } - - svc, err := NewServices(version) - if err != nil { - return ctx.errResult(ExitInternalError, "init_failed", err.Error(), nil, "failed to initialize: "+err.Error()) - } - if *agentID != "" { - resolved, code, handled := resolveSessionForAgentSend( - ctx, *agentID, svc.TmuxOpts, - ) - if handled { - return code - } - sessionName = resolved - } - - jobStore, err := newSendJobStore() - if err != nil { - return ctx.errResult(ExitInternalError, "job_store_failed", err.Error(), nil, "failed to initialize send job store: "+err.Error()) - } - - job, resolvedSessionName, code := resolveSendJobForExecution( - ctx, - jobStore, - strings.TrimSpace(*jobIDFlag), - sessionName, - *agentID, - ) - if code != ExitOK { - return code - } - sessionName = resolvedSessionName - - if code := validateAgentSendSession( - ctx, sessionName, job.ID, svc.TmuxOpts, - ); code != ExitOK { - return code - } - - // Internal process-job retries always execute inline in the child process. - if *async && !*processJob { - return dispatchAsyncAgentSend( - ctx, - jobStore, - sessionName, - *agentID, - *text, - *enter, - job, - ) - } - - return executeAgentSendJob( - ctx, - jobStore, - svc, - sessionName, - *agentID, - *text, - *enter, - job, - sendWaitConfig{ - Wait: *wait, - WaitTimeout: *waitTimeout, - IdleThreshold: *idleThreshold, - }, - ) -} diff --git a/internal/cli/cmd_agent_send_write_test.go b/internal/cli/cmd_agent_send_write_test.go deleted file mode 100644 index 433f8ba6..00000000 --- a/internal/cli/cmd_agent_send_write_test.go +++ /dev/null @@ -1,259 +0,0 @@ -package cli - -import ( - "bytes" - "encoding/json" - "fmt" - "strings" - "testing" - - "github.com/andyrewlee/amux/internal/tmux" -) - -func TestCmdAgentSendParseErrorJSON(t *testing.T) { - var out, errOut bytes.Buffer - code := cmdAgentSend(&out, &errOut, GlobalFlags{JSON: true}, []string{"session", "--bad-flag"}, "test-v1") - if code != ExitUsage { - t.Fatalf("cmdAgentSend() code = %d, want %d", code, ExitUsage) - } - if errOut.Len() != 0 { - t.Fatalf("expected no stderr output in JSON mode, got %q", errOut.String()) - } - - var env Envelope - if err := json.Unmarshal(out.Bytes(), &env); err != nil { - t.Fatalf("json.Unmarshal() error = %v", err) - } - if env.OK { - t.Fatalf("expected ok=false") - } - if env.Error == nil || env.Error.Code != "usage_error" { - t.Fatalf("expected usage_error, got %#v", env.Error) - } - if env.Error == nil || !strings.Contains(env.Error.Message, "flag provided but not defined") { - t.Fatalf("expected parse error message, got %#v", env.Error) - } -} - -func TestCmdAgentSendRejectsWaitAndAsyncTogether(t *testing.T) { - var out, errOut bytes.Buffer - code := cmdAgentSend( - &out, - &errOut, - GlobalFlags{JSON: true}, - []string{"session-a", "--text", "hello", "--wait", "--async"}, - "test-v1", - ) - if code != ExitUsage { - t.Fatalf("cmdAgentSend() code = %d, want %d", code, ExitUsage) - } - if errOut.Len() != 0 { - t.Fatalf("expected no stderr output in JSON mode, got %q", errOut.String()) - } - - var env Envelope - if err := json.Unmarshal(out.Bytes(), &env); err != nil { - t.Fatalf("json.Unmarshal() error = %v", err) - } - if env.OK { - t.Fatalf("expected ok=false") - } - if env.Error == nil || env.Error.Code != "usage_error" { - t.Fatalf("expected usage_error, got %#v", env.Error) - } - if env.Error == nil || !strings.Contains(env.Error.Message, "--wait and --async cannot be used together") { - t.Fatalf("unexpected error message: %#v", env.Error) - } -} - -func TestCmdAgentSendRejectsNonPositiveWaitTimeout(t *testing.T) { - var out, errOut bytes.Buffer - code := cmdAgentSend( - &out, - &errOut, - GlobalFlags{JSON: true}, - []string{"session-a", "--text", "hello", "--wait-timeout", "0s"}, - "test-v1", - ) - if code != ExitUsage { - t.Fatalf("cmdAgentSend() code = %d, want %d", code, ExitUsage) - } - if errOut.Len() != 0 { - t.Fatalf("expected no stderr output in JSON mode, got %q", errOut.String()) - } - - var env Envelope - if err := json.Unmarshal(out.Bytes(), &env); err != nil { - t.Fatalf("json.Unmarshal() error = %v", err) - } - if env.OK { - t.Fatalf("expected ok=false") - } - if env.Error == nil || !strings.Contains(env.Error.Message, "--wait-timeout must be > 0") { - t.Fatalf("unexpected error message: %#v", env.Error) - } -} - -func TestCmdAgentSendRejectsNonPositiveIdleThreshold(t *testing.T) { - var out, errOut bytes.Buffer - code := cmdAgentSend( - &out, - &errOut, - GlobalFlags{JSON: true}, - []string{"session-a", "--text", "hello", "--idle-threshold", "0s"}, - "test-v1", - ) - if code != ExitUsage { - t.Fatalf("cmdAgentSend() code = %d, want %d", code, ExitUsage) - } - if errOut.Len() != 0 { - t.Fatalf("expected no stderr output in JSON mode, got %q", errOut.String()) - } - - var env Envelope - if err := json.Unmarshal(out.Bytes(), &env); err != nil { - t.Fatalf("json.Unmarshal() error = %v", err) - } - if env.OK { - t.Fatalf("expected ok=false") - } - if env.Error == nil || !strings.Contains(env.Error.Message, "--idle-threshold must be > 0") { - t.Fatalf("unexpected error message: %#v", env.Error) - } -} - -func TestCmdAgentSendJSONJobResultAndIdempotentReplay(t *testing.T) { - t.Setenv("HOME", t.TempDir()) - - origStateFor := tmuxSessionStateFor - origSend := tmuxSendKeys - defer func() { - tmuxSessionStateFor = origStateFor - tmuxSendKeys = origSend - }() - - stateCalls := 0 - sendCalls := 0 - tmuxSessionStateFor = func(name string, _ tmux.Options) (tmux.SessionState, error) { - stateCalls++ - if name != "session-a" { - return tmux.SessionState{}, fmt.Errorf("unexpected session %s", name) - } - return tmux.SessionState{Exists: true}, nil - } - tmuxSendKeys = func(name, text string, enter bool, _ tmux.Options) error { - sendCalls++ - if name != "session-a" || text != "hello" || !enter { - return fmt.Errorf("unexpected send args name=%s text=%s enter=%v", name, text, enter) - } - return nil - } - - var out bytes.Buffer - var errOut bytes.Buffer - args := []string{"session-a", "--text", "hello", "--enter", "--idempotency-key", "idem-send-ok"} - code := cmdAgentSend(&out, &errOut, GlobalFlags{JSON: true}, args, "test-v1") - if code != ExitOK { - t.Fatalf("cmdAgentSend() code = %d, want %d", code, ExitOK) - } - if errOut.Len() != 0 { - t.Fatalf("expected empty stderr in JSON mode, got %q", errOut.String()) - } - - var env Envelope - if err := json.Unmarshal(out.Bytes(), &env); err != nil { - t.Fatalf("json.Unmarshal() error = %v", err) - } - if !env.OK { - t.Fatalf("expected ok=true, got error=%#v", env.Error) - } - payload, ok := env.Data.(map[string]any) - if !ok { - t.Fatalf("expected map payload, got %T", env.Data) - } - if got, _ := payload["status"].(string); got != string(sendJobCompleted) { - t.Fatalf("status = %q, want %q", got, sendJobCompleted) - } - if got, _ := payload["sent"].(bool); !got { - t.Fatalf("sent = %v, want true", got) - } - if got, _ := payload["delivered"].(bool); !got { - t.Fatalf("delivered = %v, want true", got) - } - jobID, _ := payload["job_id"].(string) - if jobID == "" { - t.Fatalf("expected non-empty job_id in response") - } - if sendCalls != 1 { - t.Fatalf("send calls = %d, want 1", sendCalls) - } - - var replay bytes.Buffer - var replayErr bytes.Buffer - replayCode := cmdAgentSend(&replay, &replayErr, GlobalFlags{JSON: true}, args, "test-v1") - if replayCode != ExitOK { - t.Fatalf("replay code = %d, want %d", replayCode, ExitOK) - } - if replayErr.Len() != 0 { - t.Fatalf("expected empty replay stderr, got %q", replayErr.String()) - } - if replay.String() != out.String() { - t.Fatalf("replayed output mismatch\nfirst:\n%s\nreplay:\n%s", out.String(), replay.String()) - } - if sendCalls != 1 { - t.Fatalf("send calls after replay = %d, want 1", sendCalls) - } - if stateCalls != 1 { - t.Fatalf("state checks after replay = %d, want 1", stateCalls) - } -} - -func TestCmdAgentSendIdempotentErrorReplay(t *testing.T) { - t.Setenv("HOME", t.TempDir()) - - origStateFor := tmuxSessionStateFor - defer func() { - tmuxSessionStateFor = origStateFor - }() - - stateCalls := 0 - tmuxSessionStateFor = func(_ string, _ tmux.Options) (tmux.SessionState, error) { - stateCalls++ - return tmux.SessionState{Exists: false}, nil - } - - args := []string{"missing-session", "--text", "hello", "--idempotency-key", "idem-send-not-found"} - var first bytes.Buffer - var firstErr bytes.Buffer - code := cmdAgentSend(&first, &firstErr, GlobalFlags{JSON: true}, args, "test-v1") - if code != ExitNotFound { - t.Fatalf("first code = %d, want %d", code, ExitNotFound) - } - if firstErr.Len() != 0 { - t.Fatalf("expected no stderr in JSON mode, got %q", firstErr.String()) - } - - var firstEnv Envelope - if err := json.Unmarshal(first.Bytes(), &firstEnv); err != nil { - t.Fatalf("json.Unmarshal(first) error = %v", err) - } - if firstEnv.OK || firstEnv.Error == nil || firstEnv.Error.Code != "not_found" { - t.Fatalf("expected not_found error, got %#v", firstEnv.Error) - } - - var replay bytes.Buffer - var replayErr bytes.Buffer - replayCode := cmdAgentSend(&replay, &replayErr, GlobalFlags{JSON: true}, args, "test-v1") - if replayCode != ExitNotFound { - t.Fatalf("replay code = %d, want %d", replayCode, ExitNotFound) - } - if replayErr.Len() != 0 { - t.Fatalf("expected empty replay stderr, got %q", replayErr.String()) - } - if replay.String() != first.String() { - t.Fatalf("replayed output mismatch\nfirst:\n%s\nreplay:\n%s", first.String(), replay.String()) - } - if stateCalls != 1 { - t.Fatalf("state checks after replay = %d, want 1", stateCalls) - } -} diff --git a/internal/cli/cmd_agent_stop.go b/internal/cli/cmd_agent_stop.go deleted file mode 100644 index 8b048ced..00000000 --- a/internal/cli/cmd_agent_stop.go +++ /dev/null @@ -1,111 +0,0 @@ -package cli - -import ( - "errors" - "fmt" - "io" - "time" -) - -type agentStopResult struct { - Stopped []string `json:"stopped"` - AgentID string `json:"agent_id,omitempty"` - StoppedAgentIDs []string `json:"stopped_agent_ids,omitempty"` -} - -func cmdAgentStop(w, wErr io.Writer, gf GlobalFlags, args []string, version string) int { - const usage = "Usage: amux agent stop (|--agent ) [--graceful] [--grace-period ] [--idempotency-key ] [--json]\n amux agent stop --all --yes [--graceful] [--grace-period ] [--idempotency-key ] [--json]" - fs := newFlagSet("agent stop") - all := fs.Bool("all", false, "stop all agents") - yes := fs.Bool("yes", false, "confirm (required for --all)") - agentID := fs.String("agent", "", "agent ID (workspace_id:tab_id)") - graceful := fs.Bool("graceful", true, "send Ctrl-C first and wait before force stop") - gracePeriod := fs.Duration("grace-period", 1200*time.Millisecond, "wait time before force stop") - idempotencyKey := fs.String("idempotency-key", "", "idempotency key for safe retries") - sessionName, err := parseSinglePositionalWithFlags(fs, args) - if err != nil { - return returnUsageError(w, wErr, gf, usage, version, err) - } - if *gracePeriod < 0 { - return returnUsageError(w, wErr, gf, usage, version, nil) - } - if *all && (sessionName != "" || *agentID != "") { - return returnUsageError(w, wErr, gf, usage, version, nil) - } - - if *all { - ctx := &cmdCtx{w: w, wErr: wErr, gf: gf, version: version, cmd: "agent.stop.all", idemKey: *idempotencyKey} - if !*yes { - if gf.JSON { - ReturnError(w, "confirmation_required", "pass --yes to confirm stopping all agents", nil, version) - return ExitUnsafeBlocked - } - Errorf(wErr, "pass --yes to confirm stopping all agents") - return ExitUnsafeBlocked - } - if handled, code := ctx.maybeReplay(); handled { - return code - } - svc, err := NewServices(version) - if err != nil { - return ctx.errResult(ExitInternalError, "init_failed", err.Error(), nil, fmt.Sprintf("failed to initialize: %v", err)) - } - return stopAllAgents( - ctx, svc, *graceful, *gracePeriod, - ) - } - if sessionName == "" && *agentID == "" { - return returnUsageError(w, wErr, gf, usage, version, nil) - } - if sessionName != "" && *agentID != "" { - return returnUsageError(w, wErr, gf, usage, version, nil) - } - - ctx := &cmdCtx{w: w, wErr: wErr, gf: gf, version: version, cmd: "agent.stop", idemKey: *idempotencyKey} - - if handled, code := ctx.maybeReplay(); handled { - return code - } - svc, err := NewServices(version) - if err != nil { - return ctx.errResult(ExitInternalError, "init_failed", err.Error(), nil, fmt.Sprintf("failed to initialize: %v", err)) - } - if *agentID != "" { - resolved, err := resolveSessionNameForAgentID(*agentID, svc.TmuxOpts) - if err != nil { - if errors.Is(err, errInvalidAgentID) { - return ctx.errResult(ExitUsage, "invalid_agent_id", err.Error(), map[string]any{"agent_id": *agentID}, fmt.Sprintf("invalid --agent: %v", err)) - } - if errors.Is(err, errAgentNotFound) { - return ctx.errResult(ExitNotFound, "not_found", "agent not found", map[string]any{"agent_id": *agentID}, fmt.Sprintf("agent %s not found", *agentID)) - } - return ctx.errResult(ExitInternalError, "stop_failed", err.Error(), map[string]any{"agent_id": *agentID}, fmt.Sprintf("failed to resolve --agent %s: %v", *agentID, err)) - } - sessionName = resolved - } - - state, err := tmuxSessionStateFor(sessionName, svc.TmuxOpts) - if err != nil { - return ctx.errResult(ExitInternalError, "stop_failed", err.Error(), nil, fmt.Sprintf("failed to check session: %v", err)) - } - if !state.Exists { - return ctx.errResult(ExitNotFound, "not_found", fmt.Sprintf("session %s not found", sessionName), nil, fmt.Sprintf("session %s not found", sessionName)) - } - - if err := stopAgentSession(sessionName, svc, *graceful, *gracePeriod); err != nil { - return ctx.errResult(ExitInternalError, "stop_failed", err.Error(), nil, fmt.Sprintf("failed to stop session: %v", err)) - } - - removeTabFromStore(svc, sessionName) - - result := agentStopResult{Stopped: []string{sessionName}, AgentID: *agentID} - - if gf.JSON { - return ctx.successResult(result) - } - - PrintHuman(w, func(w io.Writer) { - fmt.Fprintf(w, "Stopped %s\n", sessionName) - }) - return ExitOK -} diff --git a/internal/cli/cmd_agent_stop_all.go b/internal/cli/cmd_agent_stop_all.go deleted file mode 100644 index 26a1fc7c..00000000 --- a/internal/cli/cmd_agent_stop_all.go +++ /dev/null @@ -1,121 +0,0 @@ -package cli - -import ( - "fmt" - "io" - "strings" - "time" - - "github.com/andyrewlee/amux/internal/tmux" -) - -var ( - tmuxActiveAgentSessionsByActivity = tmux.ActiveAgentSessionsByActivity - tmuxSessionsWithTags = tmux.SessionsWithTags -) - -func stopAllAgents( - ctx *cmdCtx, - svc *Services, - graceful bool, - gracePeriod time.Duration, -) int { - sessions, err := listAgentSessionsForStopAll(svc.TmuxOpts) - if err != nil { - return ctx.errResult(ExitInternalError, "list_failed", err.Error(), nil, fmt.Sprintf("failed to list agents: %v", err)) - } - - stopped := []string{} - stoppedAgentIDs := []string{} - var failed []map[string]string - for _, s := range sessions { - if err := stopAgentSession(s.Name, svc, graceful, gracePeriod); err != nil { - failed = append(failed, map[string]string{ - "session": s.Name, - "error": err.Error(), - }) - continue - } - stopped = append(stopped, s.Name) - if id := formatAgentID(s.WorkspaceID, s.TabID); id != "" { - stoppedAgentIDs = append(stoppedAgentIDs, id) - } - removeTabFromStore(svc, s.Name) - } - if len(failed) > 0 { - if ctx.gf.JSON { - return ctx.errResult(ExitInternalError, "stop_partial_failed", "failed to stop one or more agents", map[string]any{ - "stopped": stopped, - "stopped_agent_ids": stoppedAgentIDs, - "failed": failed, - }) - } - for _, failure := range failed { - Errorf(ctx.wErr, "failed to stop %s: %s", failure["session"], failure["error"]) - } - PrintHuman(ctx.w, func(w io.Writer) { - fmt.Fprintf(w, "Stopped %d agent(s); %d failed\n", len(stopped), len(failed)) - }) - return ExitInternalError - } - - result := agentStopResult{Stopped: stopped, StoppedAgentIDs: stoppedAgentIDs} - - if ctx.gf.JSON { - return ctx.successResult(result) - } - - PrintHuman(ctx.w, func(w io.Writer) { - fmt.Fprintf(w, "Stopped %d agent(s)\n", len(stopped)) - }) - return ExitOK -} - -func listAgentSessionsForStopAll(opts tmux.Options) ([]tmux.SessionActivity, error) { - byName := map[string]tmux.SessionActivity{} - - activitySessions, err := tmuxActiveAgentSessionsByActivity(0, opts) - if err != nil { - return nil, err - } - for _, session := range activitySessions { - byName[session.Name] = session - } - - tagged, err := tmuxSessionsWithTags( - map[string]string{"@amux": "1"}, - []string{"@amux_workspace", "@amux_tab", "@amux_type"}, - opts, - ) - if err != nil { - return nil, err - } - for _, row := range tagged { - sessionType := strings.TrimSpace(row.Tags["@amux_type"]) - if sessionType != "agent" { - continue - } - session := byName[row.Name] - session.Name = row.Name - if session.WorkspaceID == "" { - session.WorkspaceID = strings.TrimSpace(row.Tags["@amux_workspace"]) - } - if session.TabID == "" { - session.TabID = strings.TrimSpace(row.Tags["@amux_tab"]) - } - if session.Type == "" { - session.Type = sessionType - } - session.Tagged = true - byName[row.Name] = session - } - - if len(byName) == 0 { - return nil, nil - } - sessions := make([]tmux.SessionActivity, 0, len(byName)) - for _, session := range byName { - sessions = append(sessions, session) - } - return sessions, nil -} diff --git a/internal/cli/cmd_agent_stop_all_test.go b/internal/cli/cmd_agent_stop_all_test.go deleted file mode 100644 index dd33c3a2..00000000 --- a/internal/cli/cmd_agent_stop_all_test.go +++ /dev/null @@ -1,277 +0,0 @@ -package cli - -import ( - "bytes" - "encoding/json" - "errors" - "testing" - "time" - - "github.com/andyrewlee/amux/internal/tmux" -) - -func TestCmdAgentStopAllWithPositionalTargetReturnsUsageError(t *testing.T) { - t.Setenv("HOME", t.TempDir()) - - var out, errOut bytes.Buffer - code := cmdAgentStop( - &out, - &errOut, - GlobalFlags{JSON: true}, - []string{"session-a", "--all", "--yes"}, - "test-v1", - ) - if code != ExitUsage { - t.Fatalf("cmdAgentStop() code = %d, want %d", code, ExitUsage) - } - if errOut.Len() != 0 { - t.Fatalf("expected no stderr output in JSON mode, got %q", errOut.String()) - } - - var env Envelope - if err := json.Unmarshal(out.Bytes(), &env); err != nil { - t.Fatalf("json.Unmarshal() error = %v", err) - } - if env.OK { - t.Fatalf("expected ok=false") - } - if env.Error == nil || env.Error.Code != "usage_error" { - t.Fatalf("expected usage_error, got %#v", env.Error) - } -} - -func TestCmdAgentStopAllWithAgentTargetReturnsUsageError(t *testing.T) { - t.Setenv("HOME", t.TempDir()) - - var out, errOut bytes.Buffer - code := cmdAgentStop( - &out, - &errOut, - GlobalFlags{JSON: true}, - []string{"--agent", "ws-a:tab-a", "--all", "--yes"}, - "test-v1", - ) - if code != ExitUsage { - t.Fatalf("cmdAgentStop() code = %d, want %d", code, ExitUsage) - } - if errOut.Len() != 0 { - t.Fatalf("expected no stderr output in JSON mode, got %q", errOut.String()) - } - - var env Envelope - if err := json.Unmarshal(out.Bytes(), &env); err != nil { - t.Fatalf("json.Unmarshal() error = %v", err) - } - if env.OK { - t.Fatalf("expected ok=false") - } - if env.Error == nil || env.Error.Code != "usage_error" { - t.Fatalf("expected usage_error, got %#v", env.Error) - } -} - -func TestCmdAgentStopAllPartialFailureReturnsError(t *testing.T) { - t.Setenv("HOME", t.TempDir()) - - origSessionsByActivity := tmuxActiveAgentSessionsByActivity - origSessionsWithTags := tmuxSessionsWithTags - origKillSession := tmuxKillSession - defer func() { - tmuxActiveAgentSessionsByActivity = origSessionsByActivity - tmuxSessionsWithTags = origSessionsWithTags - tmuxKillSession = origKillSession - }() - - tmuxActiveAgentSessionsByActivity = func(_ time.Duration, _ tmux.Options) ([]tmux.SessionActivity, error) { - return []tmux.SessionActivity{ - {Name: "session-ok", WorkspaceID: "ws-a", TabID: "tab-a"}, - {Name: "session-fail", WorkspaceID: "ws-a", TabID: "tab-b"}, - }, nil - } - tmuxSessionsWithTags = func(_ map[string]string, _ []string, _ tmux.Options) ([]tmux.SessionTagValues, error) { - return nil, nil - } - tmuxKillSession = func(sessionName string, _ tmux.Options) error { - if sessionName == "session-fail" { - return errors.New("kill failed") - } - return nil - } - - var out, errOut bytes.Buffer - code := cmdAgentStop( - &out, - &errOut, - GlobalFlags{JSON: true}, - []string{"--all", "--yes", "--graceful=false"}, - "test-v1", - ) - if code != ExitInternalError { - t.Fatalf("cmdAgentStop() code = %d, want %d", code, ExitInternalError) - } - if errOut.Len() != 0 { - t.Fatalf("expected no stderr output in JSON mode, got %q", errOut.String()) - } - - var env Envelope - if err := json.Unmarshal(out.Bytes(), &env); err != nil { - t.Fatalf("json.Unmarshal() error = %v", err) - } - if env.OK { - t.Fatalf("expected ok=false") - } - if env.Error == nil || env.Error.Code != "stop_partial_failed" { - t.Fatalf("expected stop_partial_failed, got %#v", env.Error) - } - details, ok := env.Error.Details.(map[string]any) - if !ok { - t.Fatalf("expected error details object, got %T", env.Error.Details) - } - failed, ok := details["failed"].([]any) - if !ok || len(failed) != 1 { - t.Fatalf("expected one failed stop entry, got %#v", details["failed"]) - } -} - -func TestCmdAgentStopAllConfirmationRequiredDoesNotCacheIdempotencyError(t *testing.T) { - t.Setenv("HOME", t.TempDir()) - - origSessionsByActivity := tmuxActiveAgentSessionsByActivity - origSessionsWithTags := tmuxSessionsWithTags - origKillSession := tmuxKillSession - defer func() { - tmuxActiveAgentSessionsByActivity = origSessionsByActivity - tmuxSessionsWithTags = origSessionsWithTags - tmuxKillSession = origKillSession - }() - - tmuxActiveAgentSessionsByActivity = func(_ time.Duration, _ tmux.Options) ([]tmux.SessionActivity, error) { - return []tmux.SessionActivity{ - {Name: "session-a", WorkspaceID: "ws-a", TabID: "tab-a"}, - }, nil - } - tmuxSessionsWithTags = func(_ map[string]string, _ []string, _ tmux.Options) ([]tmux.SessionTagValues, error) { - return nil, nil - } - - killCalls := 0 - tmuxKillSession = func(sessionName string, _ tmux.Options) error { - if sessionName != "session-a" { - t.Fatalf("tmuxKillSession() session = %q, want session-a", sessionName) - } - killCalls++ - return nil - } - - args := []string{"--all", "--idempotency-key", "idem-stop-all-confirm"} - - var firstOut, firstErr bytes.Buffer - firstCode := cmdAgentStop(&firstOut, &firstErr, GlobalFlags{JSON: true}, args, "test-v1") - if firstCode != ExitUnsafeBlocked { - t.Fatalf("first cmdAgentStop() code = %d, want %d", firstCode, ExitUnsafeBlocked) - } - if firstErr.Len() != 0 { - t.Fatalf("expected no stderr output in JSON mode, got %q", firstErr.String()) - } - if killCalls != 0 { - t.Fatalf("kill calls after unconfirmed request = %d, want 0", killCalls) - } - - var firstEnv Envelope - if err := json.Unmarshal(firstOut.Bytes(), &firstEnv); err != nil { - t.Fatalf("json.Unmarshal(first) error = %v", err) - } - if firstEnv.OK { - t.Fatalf("expected ok=false for unconfirmed stop-all") - } - if firstEnv.Error == nil || firstEnv.Error.Code != "confirmation_required" { - t.Fatalf("expected confirmation_required, got %#v", firstEnv.Error) - } - - confirmedArgs := []string{"--all", "--yes", "--graceful=false", "--idempotency-key", "idem-stop-all-confirm"} - - var secondOut, secondErr bytes.Buffer - secondCode := cmdAgentStop(&secondOut, &secondErr, GlobalFlags{JSON: true}, confirmedArgs, "test-v1") - if secondCode != ExitOK { - t.Fatalf("confirmed cmdAgentStop() code = %d, want %d", secondCode, ExitOK) - } - if secondErr.Len() != 0 { - t.Fatalf("expected no stderr output in JSON mode, got %q", secondErr.String()) - } - if killCalls != 1 { - t.Fatalf("kill calls after confirmed retry = %d, want 1", killCalls) - } - - var secondEnv Envelope - if err := json.Unmarshal(secondOut.Bytes(), &secondEnv); err != nil { - t.Fatalf("json.Unmarshal(second) error = %v", err) - } - if !secondEnv.OK { - t.Fatalf("expected ok=true for confirmed stop-all, got %#v", secondEnv.Error) - } - - var replayOut, replayErr bytes.Buffer - replayCode := cmdAgentStop(&replayOut, &replayErr, GlobalFlags{JSON: true}, confirmedArgs, "test-v1") - if replayCode != ExitOK { - t.Fatalf("replay cmdAgentStop() code = %d, want %d", replayCode, ExitOK) - } - if replayErr.Len() != 0 { - t.Fatalf("expected no replay stderr output in JSON mode, got %q", replayErr.String()) - } - if replayOut.String() != secondOut.String() { - t.Fatalf("replayed output mismatch\nfirst confirmed:\n%s\nreplay:\n%s", secondOut.String(), replayOut.String()) - } - if killCalls != 1 { - t.Fatalf("kill calls after replay = %d, want 1", killCalls) - } -} - -func TestCmdAgentStopAllExcludesPartiallyTaggedSessionsWithoutType(t *testing.T) { - t.Setenv("HOME", t.TempDir()) - - origSessionsByActivity := tmuxActiveAgentSessionsByActivity - origSessionsWithTags := tmuxSessionsWithTags - origKillSession := tmuxKillSession - defer func() { - tmuxActiveAgentSessionsByActivity = origSessionsByActivity - tmuxSessionsWithTags = origSessionsWithTags - tmuxKillSession = origKillSession - }() - - tmuxActiveAgentSessionsByActivity = func(_ time.Duration, _ tmux.Options) ([]tmux.SessionActivity, error) { - return nil, nil - } - tmuxSessionsWithTags = func(_ map[string]string, _ []string, _ tmux.Options) ([]tmux.SessionTagValues, error) { - return []tmux.SessionTagValues{ - { - Name: "session-partial", - Tags: map[string]string{ - "@amux_workspace": "ws-a", - "@amux_tab": "tab-a", - // @amux_type intentionally missing — sessions without - // explicit type "agent" are no longer included. - }, - }, - }, nil - } - killed := map[string]int{} - tmuxKillSession = func(sessionName string, _ tmux.Options) error { - killed[sessionName]++ - return nil - } - - var out, errOut bytes.Buffer - code := cmdAgentStop( - &out, - &errOut, - GlobalFlags{JSON: true}, - []string{"--all", "--yes", "--graceful=false"}, - "test-v1", - ) - if code != ExitOK { - t.Fatalf("cmdAgentStop() code = %d, want %d", code, ExitOK) - } - if got := killed["session-partial"]; got != 0 { - t.Fatalf("session-partial kill calls = %d, want 0 (should be excluded without @amux_type=agent)", got) - } -} diff --git a/internal/cli/cmd_agent_stop_helpers.go b/internal/cli/cmd_agent_stop_helpers.go deleted file mode 100644 index bfc9a2e5..00000000 --- a/internal/cli/cmd_agent_stop_helpers.go +++ /dev/null @@ -1,64 +0,0 @@ -package cli - -import ( - "errors" - "time" - - "github.com/andyrewlee/amux/internal/data" -) - -func stopAgentSession(sessionName string, svc *Services, graceful bool, gracePeriod time.Duration) error { - if !graceful { - return tmuxKillSession(sessionName, svc.TmuxOpts) - } - if err := tmuxSendInterrupt(sessionName, svc.TmuxOpts); err != nil { - return tmuxKillSession(sessionName, svc.TmuxOpts) - } - if gracePeriod <= 0 { - return tmuxKillSession(sessionName, svc.TmuxOpts) - } - - deadline := time.Now().Add(gracePeriod) - for time.Now().Before(deadline) { - state, err := tmuxSessionStateFor(sessionName, svc.TmuxOpts) - if err == nil && !state.Exists { - return nil - } - time.Sleep(100 * time.Millisecond) - } - return tmuxKillSession(sessionName, svc.TmuxOpts) -} - -func removeTabFromStore(svc *Services, sessionName string) { - ids, err := svc.Store.List() - if err != nil { - return - } - for _, id := range ids { - // Use Update to hold the workspace lock across Load+filter+Save, - // preventing lost-update races between concurrent agent stop calls. - err := svc.Store.Update(id, func(ws *data.Workspace) error { - var tabs []data.TabInfo - changed := false - for _, tab := range ws.OpenTabs { - if tab.SessionName == sessionName { - changed = true - continue - } - tabs = append(tabs, tab) - } - if !changed { - return errNoChange - } - ws.OpenTabs = tabs - return nil - }) - if err == nil { - return - } - } -} - -// errNoChange is a sentinel used by removeTabFromStore to signal that the -// workspace did not contain the target tab and should not be re-saved. -var errNoChange = errors.New("no change") diff --git a/internal/cli/cmd_agent_stop_target_test.go b/internal/cli/cmd_agent_stop_target_test.go deleted file mode 100644 index 8ee4908f..00000000 --- a/internal/cli/cmd_agent_stop_target_test.go +++ /dev/null @@ -1,105 +0,0 @@ -package cli - -import ( - "bytes" - "encoding/json" - "strings" - "testing" - - "github.com/andyrewlee/amux/internal/tmux" -) - -func TestCmdAgentStopInvalidAgentIDReturnsUsage(t *testing.T) { - t.Setenv("HOME", t.TempDir()) - - var out, errOut bytes.Buffer - code := cmdAgentStop( - &out, - &errOut, - GlobalFlags{JSON: true}, - []string{"--agent", "invalid-id"}, - "test-v1", - ) - if code != ExitUsage { - t.Fatalf("cmdAgentStop() code = %d, want %d", code, ExitUsage) - } - if errOut.Len() != 0 { - t.Fatalf("expected no stderr output in JSON mode, got %q", errOut.String()) - } - - var env Envelope - if err := json.Unmarshal(out.Bytes(), &env); err != nil { - t.Fatalf("json.Unmarshal() error = %v", err) - } - if env.OK { - t.Fatalf("expected ok=false") - } - if env.Error == nil || env.Error.Code != "invalid_agent_id" { - t.Fatalf("expected invalid_agent_id, got %#v", env.Error) - } -} - -func TestCmdAgentStopInvalidAgentIDHumanErrorKeepsContext(t *testing.T) { - t.Setenv("HOME", t.TempDir()) - - var out, errOut bytes.Buffer - code := cmdAgentStop( - &out, - &errOut, - GlobalFlags{}, - []string{"--agent", "invalid-id"}, - "test-v1", - ) - if code != ExitUsage { - t.Fatalf("cmdAgentStop() code = %d, want %d", code, ExitUsage) - } - if out.Len() != 0 { - t.Fatalf("expected no stdout output in text mode, got %q", out.String()) - } - if got := errOut.String(); !strings.HasPrefix(got, "Error: invalid --agent:") { - t.Fatalf("stderr = %q, want prefix %q", got, "Error: invalid --agent:") - } -} - -func TestCmdAgentStopStaleAgentIDReturnsNotFound(t *testing.T) { - t.Setenv("HOME", t.TempDir()) - - origSessionsWithTags := tmuxSessionsWithTagsForAgentID - defer func() { - tmuxSessionsWithTagsForAgentID = origSessionsWithTags - }() - - tmuxSessionsWithTagsForAgentID = func( - _ map[string]string, - _ []string, - _ tmux.Options, - ) ([]tmux.SessionTagValues, error) { - return nil, nil - } - - var out, errOut bytes.Buffer - code := cmdAgentStop( - &out, - &errOut, - GlobalFlags{JSON: true}, - []string{"--agent", "ws-a:tab-a"}, - "test-v1", - ) - if code != ExitNotFound { - t.Fatalf("cmdAgentStop() code = %d, want %d", code, ExitNotFound) - } - if errOut.Len() != 0 { - t.Fatalf("expected no stderr output in JSON mode, got %q", errOut.String()) - } - - var env Envelope - if err := json.Unmarshal(out.Bytes(), &env); err != nil { - t.Fatalf("json.Unmarshal() error = %v", err) - } - if env.OK { - t.Fatalf("expected ok=false") - } - if env.Error == nil || env.Error.Code != "not_found" { - t.Fatalf("expected not_found, got %#v", env.Error) - } -} diff --git a/internal/cli/cmd_agent_stop_write_test.go b/internal/cli/cmd_agent_stop_write_test.go deleted file mode 100644 index 09b54af4..00000000 --- a/internal/cli/cmd_agent_stop_write_test.go +++ /dev/null @@ -1,202 +0,0 @@ -package cli - -import ( - "bytes" - "encoding/json" - "errors" - "testing" - "time" - - "github.com/andyrewlee/amux/internal/tmux" -) - -func TestCmdAgentStopMissingSessionReturnsNotFound(t *testing.T) { - t.Setenv("HOME", t.TempDir()) - - origStateFor := tmuxSessionStateFor - origKill := tmuxKillSession - defer func() { - tmuxSessionStateFor = origStateFor - tmuxKillSession = origKill - }() - - killCalled := false - tmuxSessionStateFor = func(_ string, _ tmux.Options) (tmux.SessionState, error) { - return tmux.SessionState{Exists: false}, nil - } - tmuxKillSession = func(_ string, _ tmux.Options) error { - killCalled = true - return nil - } - - var out, errOut bytes.Buffer - code := cmdAgentStop(&out, &errOut, GlobalFlags{JSON: true}, []string{"missing-session"}, "test-v1") - if code != ExitNotFound { - t.Fatalf("cmdAgentStop() code = %d, want %d", code, ExitNotFound) - } - if killCalled { - t.Fatalf("expected tmuxKillSession to not be called when session is missing") - } - if errOut.Len() != 0 { - t.Fatalf("expected no stderr output in JSON mode, got %q", errOut.String()) - } - - var env Envelope - if err := json.Unmarshal(out.Bytes(), &env); err != nil { - t.Fatalf("json.Unmarshal() error = %v", err) - } - if env.OK { - t.Fatalf("expected ok=false for missing session") - } - if env.Error == nil || env.Error.Code != "not_found" { - t.Fatalf("expected error code not_found, got %#v", env.Error) - } -} - -func TestCmdAgentStopSessionLookupErrorReturnsInternalError(t *testing.T) { - t.Setenv("HOME", t.TempDir()) - - origStateFor := tmuxSessionStateFor - origKill := tmuxKillSession - defer func() { - tmuxSessionStateFor = origStateFor - tmuxKillSession = origKill - }() - - killCalled := false - tmuxSessionStateFor = func(_ string, _ tmux.Options) (tmux.SessionState, error) { - return tmux.SessionState{}, errors.New("tmux down") - } - tmuxKillSession = func(_ string, _ tmux.Options) error { - killCalled = true - return nil - } - - var out, errOut bytes.Buffer - code := cmdAgentStop(&out, &errOut, GlobalFlags{JSON: true}, []string{"any-session"}, "test-v1") - if code != ExitInternalError { - t.Fatalf("cmdAgentStop() code = %d, want %d", code, ExitInternalError) - } - if killCalled { - t.Fatalf("expected tmuxKillSession to not be called when session lookup fails") - } - - var env Envelope - if err := json.Unmarshal(out.Bytes(), &env); err != nil { - t.Fatalf("json.Unmarshal() error = %v", err) - } - if env.OK { - t.Fatalf("expected ok=false for lookup failure") - } - if env.Error == nil || env.Error.Code != "stop_failed" { - t.Fatalf("expected error code stop_failed, got %#v", env.Error) - } -} - -func TestCmdAgentStopGracefulFallbackKillsWhenStillRunning(t *testing.T) { - t.Setenv("HOME", t.TempDir()) - - origStateFor := tmuxSessionStateFor - origInterrupt := tmuxSendInterrupt - origKill := tmuxKillSession - defer func() { - tmuxSessionStateFor = origStateFor - tmuxSendInterrupt = origInterrupt - tmuxKillSession = origKill - }() - - stateChecks := 0 - interruptCalls := 0 - killCalls := 0 - tmuxSessionStateFor = func(_ string, _ tmux.Options) (tmux.SessionState, error) { - stateChecks++ - return tmux.SessionState{Exists: true}, nil - } - tmuxSendInterrupt = func(_ string, _ tmux.Options) error { - interruptCalls++ - return nil - } - tmuxKillSession = func(_ string, _ tmux.Options) error { - killCalls++ - return nil - } - - var out bytes.Buffer - var errOut bytes.Buffer - code := cmdAgentStop( - &out, - &errOut, - GlobalFlags{JSON: true}, - []string{"session-a", "--grace-period", "10ms"}, - "test-v1", - ) - if code != ExitOK { - t.Fatalf("cmdAgentStop() code = %d, want %d", code, ExitOK) - } - if errOut.Len() != 0 { - t.Fatalf("expected no stderr output in JSON mode, got %q", errOut.String()) - } - if interruptCalls != 1 { - t.Fatalf("interrupt calls = %d, want 1", interruptCalls) - } - if killCalls != 1 { - t.Fatalf("kill calls = %d, want 1", killCalls) - } - if stateChecks < 2 { - t.Fatalf("state checks = %d, want >= 2 (precheck + graceful polling)", stateChecks) - } -} - -func TestCmdAgentStopGracefulNoKillWhenSessionExits(t *testing.T) { - t.Setenv("HOME", t.TempDir()) - - origStateFor := tmuxSessionStateFor - origInterrupt := tmuxSendInterrupt - origKill := tmuxKillSession - defer func() { - tmuxSessionStateFor = origStateFor - tmuxSendInterrupt = origInterrupt - tmuxKillSession = origKill - }() - - stateChecks := 0 - interruptCalls := 0 - killCalls := 0 - tmuxSessionStateFor = func(_ string, _ tmux.Options) (tmux.SessionState, error) { - stateChecks++ - if stateChecks == 1 { - return tmux.SessionState{Exists: true}, nil - } - return tmux.SessionState{Exists: false}, nil - } - tmuxSendInterrupt = func(_ string, _ tmux.Options) error { - interruptCalls++ - return nil - } - tmuxKillSession = func(_ string, _ tmux.Options) error { - killCalls++ - return nil - } - - var out bytes.Buffer - var errOut bytes.Buffer - code := cmdAgentStop( - &out, - &errOut, - GlobalFlags{JSON: true}, - []string{"session-a", "--grace-period", (200 * time.Millisecond).String()}, - "test-v1", - ) - if code != ExitOK { - t.Fatalf("cmdAgentStop() code = %d, want %d", code, ExitOK) - } - if errOut.Len() != 0 { - t.Fatalf("expected no stderr output in JSON mode, got %q", errOut.String()) - } - if interruptCalls != 1 { - t.Fatalf("interrupt calls = %d, want 1", interruptCalls) - } - if killCalls != 0 { - t.Fatalf("kill calls = %d, want 0", killCalls) - } -} diff --git a/internal/cli/cmd_agent_types.go b/internal/cli/cmd_agent_types.go deleted file mode 100644 index 6cb827fc..00000000 --- a/internal/cli/cmd_agent_types.go +++ /dev/null @@ -1,21 +0,0 @@ -package cli - -type agentRunResult struct { - SessionName string `json:"session_name"` - AgentID string `json:"agent_id,omitempty"` - WorkspaceID string `json:"workspace_id"` - Assistant string `json:"assistant"` - TabID string `json:"tab_id"` - Response *waitResponseResult `json:"response,omitempty"` -} - -type agentSendResult struct { - SessionName string `json:"session_name"` - AgentID string `json:"agent_id,omitempty"` - JobID string `json:"job_id"` - Status string `json:"status"` - Error string `json:"error,omitempty"` - Sent bool `json:"sent"` - Delivered bool `json:"delivered"` - Response *waitResponseResult `json:"response,omitempty"` -} diff --git a/internal/cli/cmd_agent_wait_response.go b/internal/cli/cmd_agent_wait_response.go deleted file mode 100644 index edd189d5..00000000 --- a/internal/cli/cmd_agent_wait_response.go +++ /dev/null @@ -1,387 +0,0 @@ -package cli - -import ( - "context" - "strings" - "time" - - "github.com/andyrewlee/amux/internal/tmux" -) - -const ( - waitResponseExitAfterConsecutiveCaptureMisses = 3 - waitResponseExitAfterMissingSessionChecks = 3 -) - -// waitResponseInitialChangeTimeout bounds how long --wait blocks when pane -// content never changes after a send/run prompt. This avoids very long hangs -// when the prompt is dropped or the agent never starts responding. -// -// NOTE: This timeout fires independently of the caller's --wait-timeout flag. -// If --wait-timeout is longer than this value (e.g. 120s), the initial-change -// timeout will still expire at 90s and return a timed_out response. Both paths -// produce identical timed_out results via buildTimedOutWaitResponse, so callers -// cannot distinguish the cause from the response alone. -var waitResponseInitialChangeTimeout = 90 * time.Second - -// waitResponseConfig holds parameters for waiting on an agent response. -type waitResponseConfig struct { - SessionName string - CaptureLines int - PollInterval time.Duration - IdleThreshold time.Duration -} - -// waitResponseResult holds the outcome of waiting for an agent response. -type waitResponseResult struct { - Status string `json:"status"` - Content string `json:"content"` - Delta string `json:"delta,omitempty"` - LatestLine string `json:"latest_line,omitempty"` - Summary string `json:"summary,omitempty"` - NeedsInput bool `json:"needs_input"` - InputHint string `json:"input_hint,omitempty"` - IdleSeconds float64 `json:"idle_seconds"` - TimedOut bool `json:"timed_out"` - SessionExited bool `json:"session_exited"` - Changed bool `json:"changed"` -} - -// waitForAgentResponse polls the tmux pane until the agent produces new output -// and then goes idle (unchanged for idleThreshold). preHash is a snapshot of -// the pane content taken right after sending text — the function waits until -// content differs from preHash at least once before considering idle. -// preContent is the raw text from that same snapshot, used as fallback content -// if the session exits before any new output is captured. -func waitForAgentResponse( - ctx context.Context, - cfg waitResponseConfig, - opts tmux.Options, - capture captureFn, - preHash [16]byte, - preContent string, -) waitResponseResult { - var lastHash [16]byte - var lastContent string - var lastNonEmptyContent string - var lastDifferentFromPre string - contentChanged := false - var lastChangeTime time.Time - captureMisses := 0 - missingSessionChecks := 0 - waitStartedAt := time.Now() - preStableHash := waitResponseContentHash(preContent) - - ticker := time.NewTicker(cfg.PollInterval) - defer ticker.Stop() - - for { - select { - case <-ctx.Done(): - return buildTimedOutWaitResponse( - cfg, - opts, - capture, - preContent, - lastContent, - lastNonEmptyContent, - lastDifferentFromPre, - contentChanged, - ) - case <-ticker.C: - } - - content, ok := capture(cfg.SessionName, cfg.CaptureLines, opts) - if !ok { - captureMisses++ - if captureMisses < waitResponseExitAfterConsecutiveCaptureMisses { - continue - } - state, err := tmuxSessionStateFor(cfg.SessionName, opts) - if err != nil || state.Exists { - // Capture can miss transiently while the tmux session is still alive, - // and tmux state checks can also fail under load/timeouts. - // Reset misses and continue waiting rather than reporting a false exit. - captureMisses = 0 - missingSessionChecks = 0 - continue - } - missingSessionChecks++ - if missingSessionChecks < waitResponseExitAfterMissingSessionChecks { - continue - } - fallback := preferredWaitContent( - lastContent, - lastNonEmptyContent, - lastDifferentFromPre, - preContent, - contentChanged, - ) - delta, latestLine := buildWaitResponseView(preContent, fallback, contentChanged) - if strings.TrimSpace(latestLine) == "" { - latestLine = "(no output yet)" - } - needsInput, inputHint := detectNeedsInput(delta) - if !needsInput { - needsInput, inputHint = detectNeedsInput(fallback) - } - summary := summarizeWaitResponse( - "session_exited", - latestLine, - needsInput, - inputHint, - ) - return waitResponseResult{ - Status: "session_exited", - Content: fallback, - Delta: delta, - LatestLine: latestLine, - Summary: summary, - NeedsInput: needsInput, - InputHint: inputHint, - SessionExited: true, - Changed: contentChanged, - } - } - captureMisses = 0 - missingSessionChecks = 0 - - hash := waitResponseContentHash(content) - rawHash := tmux.ContentHash(content) - lastContent = content - if strings.TrimSpace(content) != "" { - lastNonEmptyContent = content - } - if rawHash != preHash && strings.TrimSpace(content) != "" { - lastDifferentFromPre = content - } - - // Return immediately when the agent is explicitly waiting on user input - // (approval gates, choice prompts, etc.) so chat orchestrators can ask - // the user right away instead of waiting for idle/timeout. - if rawHash != preHash { - if explicitNeedsInput, explicitHint := detectNeedsInputPrompt(content); explicitNeedsInput { - finalContent := preferredWaitContent( - content, - lastNonEmptyContent, - lastDifferentFromPre, - preContent, - true, - ) - delta, latestLine := buildWaitResponseView(preContent, finalContent, true) - needsInput, inputHint := detectNeedsInput(delta) - if !needsInput { - needsInput, inputHint = detectNeedsInput(finalContent) - } - if strings.TrimSpace(inputHint) == "" { - inputHint = explicitHint - needsInput = true - } - // In needs-input mode, surface the prompt itself as the latest line - // so chat orchestrators can notify users with a direct action hint. - if strings.TrimSpace(inputHint) != "" { - latestLine = strings.TrimSpace(inputHint) - } - summary := summarizeWaitResponse( - "needs_input", - latestLine, - needsInput, - inputHint, - ) - return waitResponseResult{ - Status: "needs_input", - Content: finalContent, - Delta: delta, - LatestLine: latestLine, - Summary: summary, - NeedsInput: needsInput, - InputHint: inputHint, - IdleSeconds: 0, - Changed: true, - } - } - } - - if !contentChanged { - if hash != preStableHash { - contentChanged = true - lastHash = hash - lastChangeTime = time.Now() - } else if waitResponseInitialChangeTimeout > 0 && - time.Since(waitStartedAt) >= waitResponseInitialChangeTimeout { - return buildTimedOutWaitResponse( - cfg, - opts, - capture, - preContent, - lastContent, - lastNonEmptyContent, - lastDifferentFromPre, - contentChanged, - ) - } - continue - } - - if hash != lastHash { - lastHash = hash - lastChangeTime = time.Now() - continue - } - - // Content unchanged — check idle threshold. - elapsed := time.Since(lastChangeTime) - if elapsed >= cfg.IdleThreshold { - finalContent := preferredWaitContent( - content, - lastNonEmptyContent, - lastDifferentFromPre, - preContent, - true, - ) - delta, latestLine := buildWaitResponseView(preContent, finalContent, true) - needsInput, inputHint := detectNeedsInput(delta) - if !needsInput { - needsInput, inputHint = detectNeedsInput(finalContent) - } - summary := summarizeWaitResponse("idle", latestLine, needsInput, inputHint) - return waitResponseResult{ - Status: "idle", - Content: finalContent, - Delta: delta, - LatestLine: latestLine, - Summary: summary, - NeedsInput: needsInput, - InputHint: inputHint, - IdleSeconds: elapsed.Seconds(), - Changed: true, - } - } - } -} - -func buildTimedOutWaitResponse( - cfg waitResponseConfig, - opts tmux.Options, - capture captureFn, - preContent string, - lastContent string, - lastNonEmptyContent string, - lastDifferentFromPre string, - contentChanged bool, -) waitResponseResult { - content := preferredWaitContent( - lastContent, - lastNonEmptyContent, - lastDifferentFromPre, - preContent, - contentChanged, - ) - if strings.TrimSpace(content) == "" { - if captured, ok := capture(cfg.SessionName, cfg.CaptureLines, opts); ok && - strings.TrimSpace(captured) != "" { - content = captured - } - } - delta, latestLine := buildWaitResponseView(preContent, content, contentChanged) - if strings.TrimSpace(latestLine) == "" { - latestLine = "(no output yet)" - } - needsInput, inputHint := detectNeedsInput(delta) - if !needsInput { - needsInput, inputHint = detectNeedsInput(content) - } - summary := summarizeWaitResponse("timed_out", latestLine, needsInput, inputHint) - return waitResponseResult{ - Status: "timed_out", - Content: content, - Delta: delta, - LatestLine: latestLine, - Summary: summary, - NeedsInput: needsInput, - InputHint: inputHint, - TimedOut: true, - IdleSeconds: 0, - Changed: contentChanged, - } -} - -func preferredWaitContent( - content, lastNonEmptyContent, lastDifferentFromPre, preContent string, - contentChanged bool, -) string { - trimmedCurrent := strings.TrimSpace(content) - trimmedPre := strings.TrimSpace(preContent) - if contentChanged { - if trimmedCurrent != "" && trimmedCurrent != trimmedPre { - return content - } - if strings.TrimSpace(lastDifferentFromPre) != "" { - return lastDifferentFromPre - } - } - if trimmedCurrent != "" { - return content - } - if strings.TrimSpace(lastNonEmptyContent) != "" { - return lastNonEmptyContent - } - return preContent -} - -// buildWaitResponseView returns a concise delta plus a single-line summary that -// orchestrators can use for compact notifications (e.g. chat UIs on mobile). -func buildWaitResponseView(preContent, content string, changed bool) (string, string) { - if !changed { - return "", lastNonEmptyLine(content) - } - preLines := strings.Split(preContent, "\n") - currentLines := strings.Split(content, "\n") - deltaLines := computeNewLines(preLines, currentLines) - delta := strings.TrimSpace(strings.Join(deltaLines, "\n")) - if delta == "" && strings.TrimSpace(content) != strings.TrimSpace(preContent) { - // Fallback when overlap heuristics cannot isolate appended lines. - delta = strings.TrimSpace(content) - } - if delta != "" { - if compact := compactAgentOutput(delta); compact != "" { - delta = compact - } - } - if delta != "" { - return delta, lastNonEmptyLine(delta) - } - return "", lastNonEmptyLine(content) -} - -func waitResponseContentHash(content string) [16]byte { - return tmux.ContentHash(waitResponseStableContent(content)) -} - -func waitResponseStableContent(content string) string { - lines := strings.Split(content, "\n") - out := make([]string, 0, len(lines)) - for _, raw := range lines { - line := strings.TrimSpace(raw) - if line == "" { - continue - } - if isVolatileWaitProgressLine(line) { - continue - } - out = append(out, line) - } - return strings.Join(out, "\n") -} - -func isVolatileWaitProgressLine(line string) bool { - if strings.TrimSpace(line) == "" { - return false - } - // Keep explicit prompt/approval gates stable so needs_input semantics remain - // accurate even when the TUI also renders interrupt hints. - if looksLikeExplicitNeedsInputLine(line) { - return false - } - return isAgentProgressNoiseLine(line) -} diff --git a/internal/cli/cmd_agent_wait_response_core_test.go b/internal/cli/cmd_agent_wait_response_core_test.go deleted file mode 100644 index b074e0fa..00000000 --- a/internal/cli/cmd_agent_wait_response_core_test.go +++ /dev/null @@ -1,434 +0,0 @@ -package cli - -import ( - "context" - "errors" - "testing" - "time" - - "github.com/andyrewlee/amux/internal/tmux" -) - -func TestWaitForAgentResponse_ContentChangesAndStabilizes(t *testing.T) { - calls := 0 - capture := func(_ string, _ int, _ tmux.Options) (string, bool) { - calls++ - switch { - case calls <= 2: - return "prompt text", true // same as preHash - case calls <= 4: - return "prompt text\nagent typing...", true // changed - default: - return "prompt text\nagent done", true // stabilized - } - } - - pre := "prompt text" - preHash := tmux.ContentHash(pre) - ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) - defer cancel() - - result := waitForAgentResponse(ctx, waitResponseConfig{ - SessionName: "test-session", - CaptureLines: 100, - PollInterval: 1 * time.Millisecond, - IdleThreshold: 5 * time.Millisecond, - }, tmux.Options{}, capture, preHash, pre) - - if result.TimedOut { - t.Error("expected no timeout") - } - if result.SessionExited { - t.Error("expected session not exited") - } - if result.Status != "idle" { - t.Errorf("status = %q, want %q", result.Status, "idle") - } - if !result.Changed { - t.Error("expected changed = true") - } - if result.Content != "prompt text\nagent done" { - t.Errorf("content = %q, want %q", result.Content, "prompt text\nagent done") - } - if result.Delta != "agent done" { - t.Errorf("delta = %q, want %q", result.Delta, "agent done") - } - if result.LatestLine != "agent done" { - t.Errorf("latest_line = %q, want %q", result.LatestLine, "agent done") - } - if result.Summary != "agent done" { - t.Errorf("summary = %q, want %q", result.Summary, "agent done") - } - if result.IdleSeconds <= 0 { - t.Errorf("idle_seconds = %f, want > 0", result.IdleSeconds) - } -} - -func TestWaitForAgentResponse_ContentNeverChanges(t *testing.T) { - capture := func(_ string, _ int, _ tmux.Options) (string, bool) { - return "unchanged content", true - } - - pre := "unchanged content" - preHash := tmux.ContentHash(pre) - ctx, cancel := context.WithTimeout(context.Background(), 50*time.Millisecond) - defer cancel() - - result := waitForAgentResponse(ctx, waitResponseConfig{ - SessionName: "test-session", - CaptureLines: 100, - PollInterval: 1 * time.Millisecond, - IdleThreshold: 10 * time.Second, // won't be reached - }, tmux.Options{}, capture, preHash, pre) - - if !result.TimedOut { - t.Error("expected timeout") - } - if result.SessionExited { - t.Error("expected session not exited") - } - if result.Status != "timed_out" { - t.Errorf("status = %q, want %q", result.Status, "timed_out") - } - if result.Changed { - t.Error("expected changed = false") - } - if result.Content != pre { - t.Errorf("content = %q, want %q", result.Content, pre) - } - if result.Delta != "" { - t.Errorf("delta = %q, want empty", result.Delta) - } - if result.LatestLine != pre { - t.Errorf("latest_line = %q, want %q", result.LatestLine, pre) - } - if result.Summary != pre { - t.Errorf("summary = %q, want %q", result.Summary, pre) - } -} - -func TestWaitForAgentResponse_InitialChangeTimeout(t *testing.T) { - origInitialTimeout := waitResponseInitialChangeTimeout - waitResponseInitialChangeTimeout = 5 * time.Millisecond - defer func() { waitResponseInitialChangeTimeout = origInitialTimeout }() - - capture := func(_ string, _ int, _ tmux.Options) (string, bool) { - return "unchanged content", true - } - - pre := "unchanged content" - preHash := tmux.ContentHash(pre) - ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) - defer cancel() - - result := waitForAgentResponse(ctx, waitResponseConfig{ - SessionName: "test-session", - CaptureLines: 100, - PollInterval: 1 * time.Millisecond, - IdleThreshold: 10 * time.Second, - }, tmux.Options{}, capture, preHash, pre) - - if !result.TimedOut { - t.Fatal("expected timed_out = true") - } - if result.Status != "timed_out" { - t.Fatalf("status = %q, want %q", result.Status, "timed_out") - } - if result.Changed { - t.Fatal("changed = true, want false") - } -} - -func TestWaitForAgentResponse_EmptyTimeoutUsesNoOutputLatestLine(t *testing.T) { - capture := func(_ string, _ int, _ tmux.Options) (string, bool) { - return "", true - } - - pre := "" - preHash := tmux.ContentHash(pre) - ctx, cancel := context.WithTimeout(context.Background(), 50*time.Millisecond) - defer cancel() - - result := waitForAgentResponse(ctx, waitResponseConfig{ - SessionName: "test-session", - CaptureLines: 100, - PollInterval: 1 * time.Millisecond, - IdleThreshold: 10 * time.Second, - }, tmux.Options{}, capture, preHash, pre) - - if !result.TimedOut { - t.Fatal("expected timed_out = true") - } - if result.LatestLine != "(no output yet)" { - t.Fatalf("latest_line = %q, want %q", result.LatestLine, "(no output yet)") - } - if result.Content != "" { - t.Fatalf("content = %q, want empty", result.Content) - } - if result.Delta != "" { - t.Fatalf("delta = %q, want empty", result.Delta) - } -} - -func TestWaitForAgentResponse_SessionExits(t *testing.T) { - calls := 0 - capture := func(_ string, _ int, _ tmux.Options) (string, bool) { - calls++ - if calls <= 2 { - return "some output", true - } - return "", false // session gone - } - - preHash := tmux.ContentHash("initial") - ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) - defer cancel() - - result := waitForAgentResponse(ctx, waitResponseConfig{ - SessionName: "test-session", - CaptureLines: 100, - PollInterval: 1 * time.Millisecond, - IdleThreshold: 10 * time.Second, - }, tmux.Options{}, capture, preHash, "initial") - - if result.TimedOut { - t.Error("expected no timeout") - } - if !result.SessionExited { - t.Error("expected session_exited = true") - } - if result.Status != "session_exited" { - t.Errorf("status = %q, want %q", result.Status, "session_exited") - } - if !result.Changed { - t.Error("expected changed = true") - } - if result.Content != "some output" { - t.Errorf("content = %q, want %q", result.Content, "some output") - } - if result.Delta != "some output" { - t.Errorf("delta = %q, want %q", result.Delta, "some output") - } - if result.LatestLine != "some output" { - t.Errorf("latest_line = %q, want %q", result.LatestLine, "some output") - } -} - -func TestWaitForAgentResponse_SessionExitsImmediately_FallsBackToPreContent(t *testing.T) { - // Session exits on the very first poll — lastContent is empty. - // Should return preContent as fallback. - capture := func(_ string, _ int, _ tmux.Options) (string, bool) { - return "", false - } - - pre := "screen content before send" - preHash := tmux.ContentHash(pre) - ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) - defer cancel() - - result := waitForAgentResponse(ctx, waitResponseConfig{ - SessionName: "test-session", - CaptureLines: 100, - PollInterval: 1 * time.Millisecond, - IdleThreshold: 10 * time.Second, - }, tmux.Options{}, capture, preHash, pre) - - if !result.SessionExited { - t.Error("expected session_exited = true") - } - if result.Status != "session_exited" { - t.Errorf("status = %q, want %q", result.Status, "session_exited") - } - if result.Changed { - t.Error("expected changed = false") - } - if result.Content != pre { - t.Errorf("content = %q, want preContent %q", result.Content, pre) - } - if result.Delta != "" { - t.Errorf("delta = %q, want empty", result.Delta) - } - if result.LatestLine != pre { - t.Errorf("latest_line = %q, want %q", result.LatestLine, pre) - } -} - -func TestWaitForAgentResponse_TransientCaptureMissDoesNotMarkSessionExited(t *testing.T) { - calls := 0 - capture := func(_ string, _ int, _ tmux.Options) (string, bool) { - calls++ - switch calls { - case 1: - return "before send", true - case 2: - return "", false // transient capture miss - default: - return "before send\nagent reply", true - } - } - - pre := "before send" - preHash := tmux.ContentHash(pre) - ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) - defer cancel() - - result := waitForAgentResponse(ctx, waitResponseConfig{ - SessionName: "test-session", - CaptureLines: 100, - PollInterval: 1 * time.Millisecond, - IdleThreshold: 5 * time.Millisecond, - }, tmux.Options{}, capture, preHash, pre) - - if result.Status != "idle" { - t.Fatalf("status = %q, want %q", result.Status, "idle") - } - if result.SessionExited { - t.Fatalf("session_exited = true, want false") - } - if result.Content != "before send\nagent reply" { - t.Fatalf("content = %q, want %q", result.Content, "before send\nagent reply") - } - if result.Delta != "agent reply" { - t.Fatalf("delta = %q, want %q", result.Delta, "agent reply") - } - if result.LatestLine != "agent reply" { - t.Fatalf("latest_line = %q, want %q", result.LatestLine, "agent reply") - } -} - -func TestWaitForAgentResponse_CaptureMissesWhileSessionAliveDoNotExit(t *testing.T) { - origStateFor := tmuxSessionStateFor - tmuxSessionStateFor = func(_ string, _ tmux.Options) (tmux.SessionState, error) { - return tmux.SessionState{Exists: true, HasLivePane: true}, nil - } - defer func() { tmuxSessionStateFor = origStateFor }() - - calls := 0 - capture := func(_ string, _ int, _ tmux.Options) (string, bool) { - calls++ - switch { - case calls == 1: - return "before send", true - case calls <= 6: - return "", false // multiple misses, but tmux session still exists - default: - return "before send\nagent reply", true - } - } - - pre := "before send" - preHash := tmux.ContentHash(pre) - ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) - defer cancel() - - result := waitForAgentResponse(ctx, waitResponseConfig{ - SessionName: "test-session", - CaptureLines: 100, - PollInterval: 1 * time.Millisecond, - IdleThreshold: 5 * time.Millisecond, - }, tmux.Options{}, capture, preHash, pre) - - if result.Status != "idle" { - t.Fatalf("status = %q, want %q", result.Status, "idle") - } - if result.SessionExited { - t.Fatalf("session_exited = true, want false") - } - if result.Content != "before send\nagent reply" { - t.Fatalf("content = %q, want %q", result.Content, "before send\nagent reply") - } - if result.Delta != "agent reply" { - t.Fatalf("delta = %q, want %q", result.Delta, "agent reply") - } -} - -func TestWaitForAgentResponse_CaptureMissesWithStateCheckErrorDoNotExit(t *testing.T) { - origStateFor := tmuxSessionStateFor - tmuxSessionStateFor = func(_ string, _ tmux.Options) (tmux.SessionState, error) { - return tmux.SessionState{}, errors.New("tmux timeout") - } - defer func() { tmuxSessionStateFor = origStateFor }() - - calls := 0 - capture := func(_ string, _ int, _ tmux.Options) (string, bool) { - calls++ - switch { - case calls == 1: - return "before send", true - case calls <= 6: - return "", false - default: - return "before send\nagent reply", true - } - } - - pre := "before send" - preHash := tmux.ContentHash(pre) - ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) - defer cancel() - - result := waitForAgentResponse(ctx, waitResponseConfig{ - SessionName: "test-session", - CaptureLines: 100, - PollInterval: 1 * time.Millisecond, - IdleThreshold: 5 * time.Millisecond, - }, tmux.Options{}, capture, preHash, pre) - - if result.Status != "idle" { - t.Fatalf("status = %q, want %q", result.Status, "idle") - } - if result.SessionExited { - t.Fatalf("session_exited = true, want false") - } - if result.Delta != "agent reply" { - t.Fatalf("delta = %q, want %q", result.Delta, "agent reply") - } -} - -func TestWaitForAgentResponse_TransientMissingSessionChecksDoNotExit(t *testing.T) { - origStateFor := tmuxSessionStateFor - stateChecks := 0 - tmuxSessionStateFor = func(_ string, _ tmux.Options) (tmux.SessionState, error) { - stateChecks++ - if stateChecks <= 2 { - return tmux.SessionState{Exists: false, HasLivePane: false}, nil - } - return tmux.SessionState{Exists: true, HasLivePane: true}, nil - } - defer func() { tmuxSessionStateFor = origStateFor }() - - calls := 0 - capture := func(_ string, _ int, _ tmux.Options) (string, bool) { - calls++ - switch { - case calls == 1: - return "before send", true - case calls <= 10: - return "", false - default: - return "before send\nagent reply", true - } - } - - pre := "before send" - preHash := tmux.ContentHash(pre) - ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) - defer cancel() - - result := waitForAgentResponse(ctx, waitResponseConfig{ - SessionName: "test-session", - CaptureLines: 100, - PollInterval: 1 * time.Millisecond, - IdleThreshold: 5 * time.Millisecond, - }, tmux.Options{}, capture, preHash, pre) - - if result.Status != "idle" { - t.Fatalf("status = %q, want %q", result.Status, "idle") - } - if result.SessionExited { - t.Fatalf("session_exited = true, want false") - } - if result.Delta != "agent reply" { - t.Fatalf("delta = %q, want %q", result.Delta, "agent reply") - } -} diff --git a/internal/cli/cmd_agent_wait_response_signals_test.go b/internal/cli/cmd_agent_wait_response_signals_test.go deleted file mode 100644 index 95a1e75c..00000000 --- a/internal/cli/cmd_agent_wait_response_signals_test.go +++ /dev/null @@ -1,399 +0,0 @@ -package cli - -import ( - "context" - "fmt" - "testing" - "time" - - "github.com/andyrewlee/amux/internal/tmux" -) - -func TestWaitForAgentResponse_PreHashPreventsEarlyIdle(t *testing.T) { - // Content stays the same as preHash for a while, then changes, then stabilizes. - calls := 0 - capture := func(_ string, _ int, _ tmux.Options) (string, bool) { - calls++ - if calls <= 5 { - return "same as before send", true // matches preHash - } - return "agent replied", true // new content - } - - pre := "same as before send" - preHash := tmux.ContentHash(pre) - ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) - defer cancel() - - result := waitForAgentResponse(ctx, waitResponseConfig{ - SessionName: "test-session", - CaptureLines: 100, - PollInterval: 1 * time.Millisecond, - IdleThreshold: 5 * time.Millisecond, - }, tmux.Options{}, capture, preHash, pre) - - if result.TimedOut { - t.Error("expected no timeout") - } - if result.Status != "idle" { - t.Errorf("status = %q, want %q", result.Status, "idle") - } - if !result.Changed { - t.Error("expected changed = true") - } - if result.Content != "agent replied" { - t.Errorf("content = %q, want %q", result.Content, "agent replied") - } - if result.Delta != "agent replied" { - t.Errorf("delta = %q, want %q", result.Delta, "agent replied") - } - if result.LatestLine != "agent replied" { - t.Errorf("latest_line = %q, want %q", result.LatestLine, "agent replied") - } - if result.IdleSeconds <= 0 { - t.Errorf("idle_seconds = %f, want > 0", result.IdleSeconds) - } -} - -func TestWaitForAgentResponse_UsesLastNonEmptyContentOnEmptyRedraw(t *testing.T) { - // Some TUIs briefly redraw to empty output before settling. - // We should keep the last non-empty snapshot in the final response. - calls := 0 - capture := func(_ string, _ int, _ tmux.Options) (string, bool) { - calls++ - switch { - case calls <= 2: - return "before send", true - case calls <= 4: - return "before send\nagent reply line", true - default: - return "", true - } - } - - pre := "before send" - preHash := tmux.ContentHash(pre) - ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) - defer cancel() - - result := waitForAgentResponse(ctx, waitResponseConfig{ - SessionName: "test-session", - CaptureLines: 100, - PollInterval: 1 * time.Millisecond, - IdleThreshold: 5 * time.Millisecond, - }, tmux.Options{}, capture, preHash, pre) - - if result.Status != "idle" { - t.Fatalf("status = %q, want %q", result.Status, "idle") - } - if result.Content != "before send\nagent reply line" { - t.Fatalf("content = %q, want last non-empty snapshot", result.Content) - } - if result.Delta != "agent reply line" { - t.Fatalf("delta = %q, want %q", result.Delta, "agent reply line") - } - if result.LatestLine != "agent reply line" { - t.Fatalf("latest_line = %q, want %q", result.LatestLine, "agent reply line") - } -} - -func TestWaitForAgentResponse_UsesLastDifferentContentWhenPaneReturnsToBaseline(t *testing.T) { - // Some TUIs render a reply briefly and then redraw to the original prompt. - // Keep the last snapshot that differed from preContent. - calls := 0 - capture := func(_ string, _ int, _ tmux.Options) (string, bool) { - calls++ - switch { - case calls <= 2: - return "before send", true - case calls <= 4: - return "before send\nshort reply", true - default: - return "before send", true - } - } - - pre := "before send" - preHash := tmux.ContentHash(pre) - ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) - defer cancel() - - result := waitForAgentResponse(ctx, waitResponseConfig{ - SessionName: "test-session", - CaptureLines: 100, - PollInterval: 1 * time.Millisecond, - IdleThreshold: 5 * time.Millisecond, - }, tmux.Options{}, capture, preHash, pre) - - if result.Status != "idle" { - t.Fatalf("status = %q, want %q", result.Status, "idle") - } - if result.Content != "before send\nshort reply" { - t.Fatalf("content = %q, want changed snapshot", result.Content) - } - if result.Delta != "short reply" { - t.Fatalf("delta = %q, want %q", result.Delta, "short reply") - } - if result.LatestLine != "short reply" { - t.Fatalf("latest_line = %q, want %q", result.LatestLine, "short reply") - } -} - -func TestWaitForAgentResponse_IgnoresVolatileProgressForIdle(t *testing.T) { - origInitialTimeout := waitResponseInitialChangeTimeout - waitResponseInitialChangeTimeout = 5 * time.Second - defer func() { waitResponseInitialChangeTimeout = origInitialTimeout }() - - calls := 0 - capture := func(_ string, _ int, _ tmux.Options) (string, bool) { - calls++ - switch calls { - case 1: - return "before send", true - case 2: - return "before send\nPlan: update parser\nWorking (0s • esc to interrupt)", true - default: - return fmt.Sprintf( - "before send\nPlan: update parser\nWorking (%ds • esc to interrupt)", - calls, - ), true - } - } - - pre := "before send" - preHash := tmux.ContentHash(pre) - ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) - defer cancel() - - result := waitForAgentResponse(ctx, waitResponseConfig{ - SessionName: "test-session", - CaptureLines: 100, - PollInterval: 1 * time.Millisecond, - IdleThreshold: 5 * time.Millisecond, - }, tmux.Options{}, capture, preHash, pre) - - if result.Status != "idle" { - t.Fatalf("status = %q, want %q", result.Status, "idle") - } - if !result.Changed { - t.Fatalf("changed = false, want true") - } - if result.Delta != "Plan: update parser" { - t.Fatalf("delta = %q, want %q", result.Delta, "Plan: update parser") - } - if result.LatestLine != "Plan: update parser" { - t.Fatalf("latest_line = %q, want %q", result.LatestLine, "Plan: update parser") - } -} - -func TestWaitForAgentResponse_IgnoresBulletedVolatileProgressForIdle(t *testing.T) { - origInitialTimeout := waitResponseInitialChangeTimeout - waitResponseInitialChangeTimeout = 5 * time.Second - defer func() { waitResponseInitialChangeTimeout = origInitialTimeout }() - - calls := 0 - capture := func(_ string, _ int, _ tmux.Options) (string, bool) { - calls++ - switch calls { - case 1: - return "before send", true - case 2: - return "before send\nPlan: update parser\n• Working (0s • esc to interrupt)", true - default: - return fmt.Sprintf( - "before send\nPlan: update parser\n• Working (%ds • esc to interrupt)", - calls, - ), true - } - } - - pre := "before send" - preHash := tmux.ContentHash(pre) - ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) - defer cancel() - - result := waitForAgentResponse(ctx, waitResponseConfig{ - SessionName: "test-session", - CaptureLines: 100, - PollInterval: 1 * time.Millisecond, - IdleThreshold: 5 * time.Millisecond, - }, tmux.Options{}, capture, preHash, pre) - - if result.Status != "idle" { - t.Fatalf("status = %q, want %q", result.Status, "idle") - } - if !result.Changed { - t.Fatalf("changed = false, want true") - } - if result.Delta != "Plan: update parser" { - t.Fatalf("delta = %q, want %q", result.Delta, "Plan: update parser") - } - if result.LatestLine != "Plan: update parser" { - t.Fatalf("latest_line = %q, want %q", result.LatestLine, "Plan: update parser") - } -} - -func TestWaitForAgentResponse_StripsTUIChromeFromDelta(t *testing.T) { - calls := 0 - capture := func(_ string, _ int, _ tmux.Options) (string, bool) { - calls++ - switch { - case calls <= 2: - return "before send", true - default: - return "before send\n╭────╮\n│ >_ OpenAI Codex │\n› user prompt\napp' or visit https://chatgpt.com/codex\n• final answer\n? for shortcuts 99% context left", true - } - } - - pre := "before send" - preHash := tmux.ContentHash(pre) - ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) - defer cancel() - - result := waitForAgentResponse(ctx, waitResponseConfig{ - SessionName: "test-session", - CaptureLines: 100, - PollInterval: 1 * time.Millisecond, - IdleThreshold: 5 * time.Millisecond, - }, tmux.Options{}, capture, preHash, pre) - - if result.Delta != "• final answer" { - t.Fatalf("delta = %q, want %q", result.Delta, "• final answer") - } - if result.LatestLine != "• final answer" { - t.Fatalf("latest_line = %q, want %q", result.LatestLine, "• final answer") - } -} - -func TestWaitForAgentResponse_DetectsNeedsInput(t *testing.T) { - calls := 0 - capture := func(_ string, _ int, _ tmux.Options) (string, bool) { - calls++ - switch { - case calls <= 2: - return "before send", true - default: - return "before send\nDo you want me to proceed? (y/N)", true - } - } - - pre := "before send" - preHash := tmux.ContentHash(pre) - ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) - defer cancel() - - result := waitForAgentResponse(ctx, waitResponseConfig{ - SessionName: "test-session", - CaptureLines: 100, - PollInterval: 1 * time.Millisecond, - IdleThreshold: 5 * time.Millisecond, - }, tmux.Options{}, capture, preHash, pre) - - if !result.NeedsInput { - t.Fatalf("needs_input = false, want true") - } - if result.InputHint != "Do you want me to proceed? (y/N)" { - t.Fatalf("input_hint = %q, want %q", result.InputHint, "Do you want me to proceed? (y/N)") - } - if result.Delta != "Do you want me to proceed? (y/N)" { - t.Fatalf("delta = %q, want %q", result.Delta, "Do you want me to proceed? (y/N)") - } - if result.LatestLine != "Do you want me to proceed? (y/N)" { - t.Fatalf("latest_line = %q, want %q", result.LatestLine, "Do you want me to proceed? (y/N)") - } -} - -func TestWaitForAgentResponse_ReturnsNeedsInputBeforeIdle(t *testing.T) { - calls := 0 - capture := func(_ string, _ int, _ tmux.Options) (string, bool) { - calls++ - switch calls { - case 1: - return "before send", true - default: - frames := []string{"⠋", "⠙", "⠹", "⠸", "⠼"} - frame := frames[(calls-2)%len(frames)] - return "before send\nThinking " + frame + "\n⏵⏵ bypass permissions on (shift+tab to cycle) · esc to interrupt", true - } - } - - pre := "before send" - preHash := tmux.ContentHash(pre) - ctx, cancel := context.WithTimeout(context.Background(), 500*time.Millisecond) - defer cancel() - - result := waitForAgentResponse(ctx, waitResponseConfig{ - SessionName: "test-session", - CaptureLines: 100, - PollInterval: 1 * time.Millisecond, - IdleThreshold: 5 * time.Second, - }, tmux.Options{}, capture, preHash, pre) - - if result.Status != "needs_input" { - t.Fatalf("status = %q, want %q", result.Status, "needs_input") - } - if result.TimedOut { - t.Fatalf("timed_out = true, want false") - } - if result.SessionExited { - t.Fatalf("session_exited = true, want false") - } - if !result.Changed { - t.Fatalf("changed = false, want true") - } - if !result.NeedsInput { - t.Fatalf("needs_input = false, want true") - } - if result.InputHint != "Assistant is waiting for local permission-mode selection." { - t.Fatalf("input_hint = %q", result.InputHint) - } - if result.Summary != "Needs input: Assistant is waiting for local permission-mode selection." { - t.Fatalf("summary = %q", result.Summary) - } - if calls > 4 { - t.Fatalf("wait loop did not return early enough, capture calls = %d", calls) - } -} - -func TestWaitForAgentResponse_DoesNotReturnNeedsInputForCodexInlinePrompt(t *testing.T) { - calls := 0 - capture := func(_ string, _ int, _ tmux.Options) (string, bool) { - calls++ - switch calls { - case 1: - return "before send", true - case 2, 3: - return "before send\nWorking (0s • esc to interrupt)\n› Improve documentation in @filename\n? for shortcuts 30% context left", true - default: - return "before send\n• READY", true - } - } - - pre := "before send" - preHash := tmux.ContentHash(pre) - ctx, cancel := context.WithTimeout(context.Background(), 500*time.Millisecond) - defer cancel() - - result := waitForAgentResponse(ctx, waitResponseConfig{ - SessionName: "test-session", - CaptureLines: 120, - PollInterval: 1 * time.Millisecond, - IdleThreshold: 5 * time.Millisecond, - }, tmux.Options{}, capture, preHash, pre) - - if result.Status != "idle" { - t.Fatalf("status = %q, want %q", result.Status, "idle") - } - if result.NeedsInput { - t.Fatalf("needs_input = true, want false") - } - if result.InputHint != "" { - t.Fatalf("input_hint = %q, want empty", result.InputHint) - } - if result.LatestLine != "• READY" { - t.Fatalf("latest_line = %q, want %q", result.LatestLine, "• READY") - } - if result.Delta != "• READY" { - t.Fatalf("delta = %q, want %q", result.Delta, "• READY") - } -} diff --git a/internal/cli/cmd_agent_watch.go b/internal/cli/cmd_agent_watch.go deleted file mode 100644 index cf7bfb02..00000000 --- a/internal/cli/cmd_agent_watch.go +++ /dev/null @@ -1,471 +0,0 @@ -package cli - -import ( - "context" - "encoding/hex" - "encoding/json" - "errors" - "io" - "os" - "os/signal" - "strings" - "syscall" - "time" - - "github.com/andyrewlee/amux/internal/tmux" -) - -// watchInitialCaptureMaxAttempts bounds the initial capture loop so it cannot -// spin indefinitely if captures alternate between failing and succeeding in -// state-reset patterns. -const watchInitialCaptureMaxAttempts = 50 - -// watchEvent is a single NDJSON line emitted by agent watch. -type watchEvent struct { - Type string `json:"type"` - Content string `json:"content,omitempty"` - NewLines []string `json:"new_lines,omitempty"` - Summary string `json:"summary,omitempty"` - LatestLine string `json:"latest_line,omitempty"` - NeedsInput bool `json:"needs_input,omitempty"` - InputHint string `json:"input_hint,omitempty"` - Hash string `json:"hash,omitempty"` - IdleSeconds float64 `json:"idle_seconds,omitempty"` - HeartbeatSeconds float64 `json:"heartbeat_seconds,omitempty"` - Timestamp string `json:"ts"` -} - -// watchConfig holds parsed flags for the watch loop. -type watchConfig struct { - SessionName string - Lines int - Interval time.Duration - IdleThreshold time.Duration - Heartbeat time.Duration -} - -const ( - watchExitAfterConsecutiveCaptureMisses = 3 - watchExitAfterMissingSessionChecks = 3 -) - -func cmdAgentWatch(w, wErr io.Writer, gf GlobalFlags, args []string, version string) int { - const usage = "Usage: amux agent watch [--lines N] [--interval ] [--idle-threshold ] [--heartbeat ]" - fs := newFlagSet("agent watch") - lines := fs.Int("lines", 100, "capture buffer depth") - interval := fs.Duration("interval", 500*time.Millisecond, "poll interval") - idleThreshold := fs.Duration("idle-threshold", 5*time.Second, "time before emitting idle event") - heartbeat := fs.Duration("heartbeat", 10*time.Second, "emit heartbeat updates while waiting (0 disables)") - - sessionName, err := parseSinglePositionalWithFlags(fs, args) - if err != nil { - return returnUsageError(w, wErr, gf, usage, version, err) - } - if sessionName == "" { - return returnUsageError(w, wErr, gf, usage, version, nil) - } - if *lines <= 0 { - return returnOperationError(w, wErr, gf, version, - ExitUsage, "invalid_lines", errors.New("--lines must be > 0"), - map[string]any{"lines": *lines}, "--lines must be > 0") - } - if *interval <= 0 { - return returnOperationError(w, wErr, gf, version, - ExitUsage, "invalid_interval", errors.New("--interval must be > 0"), nil, - "--interval must be > 0") - } - if *idleThreshold <= 0 { - return returnOperationError(w, wErr, gf, version, - ExitUsage, "invalid_idle_threshold", errors.New("--idle-threshold must be > 0"), nil, - "--idle-threshold must be > 0") - } - if *heartbeat < 0 { - return returnOperationError(w, wErr, gf, version, - ExitUsage, "invalid_heartbeat", errors.New("--heartbeat must be >= 0"), nil, - "--heartbeat must be >= 0") - } - - svc, code := initServicesOrFail(w, wErr, gf, version) - if code >= 0 { - return code - } - - cfg := watchConfig{ - SessionName: sessionName, - Lines: *lines, - Interval: *interval, - IdleThreshold: *idleThreshold, - Heartbeat: *heartbeat, - } - - ctx, cancel := contextWithSignal() - defer cancel() - return runWatchLoop(ctx, w, cfg, svc.TmuxOpts) -} - -// captureFn abstracts tmux.CapturePaneTail for testing. -type captureFn func(sessionName string, lines int, opts tmux.Options) (string, bool) - -// runWatchLoop is the core watch loop, separated for testability. -func runWatchLoop(ctx context.Context, w io.Writer, cfg watchConfig, opts tmux.Options) int { - return runWatchLoopWith(ctx, w, cfg, opts, tmux.CapturePaneTail) -} - -// runWatchLoopWith runs the watch loop with an injectable capture function. -func runWatchLoopWith(ctx context.Context, w io.Writer, cfg watchConfig, opts tmux.Options, capture captureFn) int { - enc := json.NewEncoder(w) - enc.SetEscapeHTML(false) - - var lastHash [16]byte - var lastLines []string - lastChangeTime := time.Now() - lastHeartbeatTime := time.Now() - emittedIdle := false - captureMisses := 0 - missingSessionChecks := 0 - - // Initial capture → snapshot. A transient capture miss should not be - // treated as exit if the tmux session still exists. - var content string - initialAttempts := 0 - for { - select { - case <-ctx.Done(): - return ExitOK - default: - } - initialAttempts++ - if initialAttempts > watchInitialCaptureMaxAttempts { - if !emitEvent(enc, watchEvent{ - Type: "exited", - Timestamp: now(), - }) { - return ExitOK - } - return ExitOK - } - captured, ok := capture(cfg.SessionName, cfg.Lines, opts) - if ok { - content = captured - resetWatchCaptureMissState(&captureMisses, &missingSessionChecks) - break - } - if watchShouldEmitExited(cfg.SessionName, opts, &captureMisses, &missingSessionChecks) { - if !emitEvent(enc, watchEvent{ - Type: "exited", - Timestamp: now(), - }) { - return ExitOK - } - return ExitOK - } - select { - case <-ctx.Done(): - return ExitOK - case <-time.After(cfg.Interval): - } - } - - lastHash = tmux.ContentHash(content) - lastLines = strings.Split(content, "\n") - snapshotLatest := latestLineForContent(content) - snapshotNeedsInput, snapshotInputHint := detectNeedsInput(content) - if !emitEvent(enc, watchEvent{ - Type: "snapshot", - Content: content, - Hash: hashStr(lastHash), - LatestLine: snapshotLatest, - NeedsInput: snapshotNeedsInput, - InputHint: snapshotInputHint, - Summary: summarizeWatchEvent( - "snapshot", - snapshotLatest, - snapshotNeedsInput, - snapshotInputHint, - 0, - ), - Timestamp: now(), - }) { - return ExitOK - } - - ticker := time.NewTicker(cfg.Interval) - defer ticker.Stop() - - for { - select { - case <-ctx.Done(): - return ExitOK - case <-ticker.C: - } - - content, ok := capture(cfg.SessionName, cfg.Lines, opts) - if !ok { - if !watchShouldEmitExited(cfg.SessionName, opts, &captureMisses, &missingSessionChecks) { - continue - } - if !emitEvent(enc, watchEvent{ - Type: "exited", - Timestamp: now(), - }) { - return ExitOK - } - return ExitOK - } - resetWatchCaptureMissState(&captureMisses, &missingSessionChecks) - - hash := tmux.ContentHash(content) - if hash == lastHash { - // No change — check idle threshold - elapsed := time.Since(lastChangeTime) - if elapsed >= cfg.IdleThreshold && !emittedIdle { - latestLine := latestLineForContent(content) - needsInput, inputHint := detectNeedsInput(content) - if !emitEvent(enc, watchEvent{ - Type: "idle", - IdleSeconds: elapsed.Seconds(), - Hash: hashStr(hash), - LatestLine: latestLine, - NeedsInput: needsInput, - InputHint: inputHint, - Summary: summarizeWatchEvent( - "idle", - latestLine, - needsInput, - inputHint, - elapsed.Seconds(), - ), - Timestamp: now(), - }) { - return ExitOK - } - emittedIdle = true - } - if cfg.Heartbeat > 0 && time.Since(lastHeartbeatTime) >= cfg.Heartbeat { - latestLine := latestLineForContent(content) - needsInput, inputHint := detectNeedsInput(content) - if !emitEvent(enc, watchEvent{ - Type: "heartbeat", - HeartbeatSeconds: elapsed.Seconds(), - Hash: hashStr(hash), - LatestLine: latestLine, - NeedsInput: needsInput, - InputHint: inputHint, - Summary: summarizeWatchEvent( - "heartbeat", - latestLine, - needsInput, - inputHint, - elapsed.Seconds(), - ), - Timestamp: now(), - }) { - return ExitOK - } - lastHeartbeatTime = time.Now() - } - continue - } - - // Content changed — compute delta - currentLines := strings.Split(content, "\n") - newLines := computeNewLines(lastLines, currentLines) - if len(newLines) == 0 { - lastHash = hash - lastLines = currentLines - lastChangeTime = time.Now() - lastHeartbeatTime = time.Now() - emittedIdle = false - continue - } - - deltaText := strings.TrimSpace(strings.Join(newLines, "\n")) - compactDelta := compactAgentOutput(deltaText) - latestLine := lastNonEmptyLine(compactDelta) - if latestLine == "" { - latestLine = lastNonEmptyLine(deltaText) - } - if latestLine == "" { - latestLine = latestLineForContent(content) - } - needsInput, inputHint := detectNeedsInput(compactDelta) - if !needsInput { - needsInput, inputHint = detectNeedsInput(deltaText) - } - if !needsInput { - needsInput, inputHint = detectNeedsInput(content) - } - - if !emitEvent(enc, watchEvent{ - Type: "delta", - NewLines: newLines, - Hash: hashStr(hash), - LatestLine: latestLine, - NeedsInput: needsInput, - InputHint: inputHint, - Summary: summarizeWatchEvent( - "delta", - latestLine, - needsInput, - inputHint, - 0, - ), - Timestamp: now(), - }) { - return ExitOK - } - - lastHash = hash - lastLines = currentLines - lastChangeTime = time.Now() - lastHeartbeatTime = time.Now() - emittedIdle = false - } -} - -func watchShouldEmitExited( - sessionName string, - opts tmux.Options, - captureMisses, missingSessionChecks *int, -) bool { - *captureMisses = *captureMisses + 1 - if *captureMisses < watchExitAfterConsecutiveCaptureMisses { - return false - } - state, err := tmuxSessionStateFor(sessionName, opts) - if err != nil || state.Exists { - // Capture can miss transiently while the tmux session is still alive, - // and tmux state checks can also fail under load/timeouts. - resetWatchCaptureMissState(captureMisses, missingSessionChecks) - return false - } - *missingSessionChecks = *missingSessionChecks + 1 - return *missingSessionChecks >= watchExitAfterMissingSessionChecks -} - -func resetWatchCaptureMissState(captureMisses, missingSessionChecks *int) { - *captureMisses = 0 - *missingSessionChecks = 0 -} - -func latestLineForContent(content string) string { - compact := compactAgentOutput(content) - if line := lastNonEmptyLine(compact); line != "" { - return line - } - return lastNonEmptyLine(content) -} - -// computeNewLines returns lines in current that are new compared to previous. -// It finds the longest suffix of current that doesn't overlap with previous. -// -// Limitation: this heuristic matches the last line of previous in current and -// assumes sequential appending. Interleaved or rewritten output may cause -// missed or duplicated lines. For the terminal-capture use case this is an -// acceptable tradeoff; verifyOverlap provides additional correctness. -// trimTrailingEmpty removes a single trailing empty string produced by -// strings.Split when content ends with "\n". Keep additional empty lines -// so blank-line deltas can be observed. -func trimTrailingEmpty(lines []string) []string { - if len(lines) > 0 && lines[len(lines)-1] == "" { - lines = lines[:len(lines)-1] - } - return lines -} - -func computeNewLines(previous, current []string) []string { - // strings.Split produces a trailing "" when content ends with "\n"; - // strip one trailing empty element to avoid anchoring overlap on the - // synthetic terminator while preserving intentional blank-line output. - previous = trimTrailingEmpty(previous) - current = trimTrailingEmpty(current) - - if len(previous) == 0 { - return current - } - - // Find the last line of previous in current, searching backwards. - // This handles the common case where new lines are appended. - lastPrev := previous[len(previous)-1] - matchIdx := -1 - for i := len(current) - 1; i >= 0; i-- { - if current[i] == lastPrev { - // Verify the match extends backwards - if verifyOverlap(previous, current, i) { - matchIdx = i - break - } - } - } - - if matchIdx < 0 || matchIdx+1 >= len(current) { - // No overlap found or no new lines after overlap — no new lines. - if matchIdx < 0 { - if isPrefix(previous, current) { - return nil - } - return current - } - return nil - } - - return current[matchIdx+1:] -} - -// verifyOverlap checks that previous lines match ending at current[endIdx]. -func verifyOverlap(previous, current []string, endIdx int) bool { - pLen := len(previous) - // Check as many lines as we can - checkCount := pLen - if endIdx+1 < checkCount { - checkCount = endIdx + 1 - } - for i := 0; i < checkCount; i++ { - if previous[pLen-1-i] != current[endIdx-i] { - return false - } - } - return true -} - -func isPrefix(previous, current []string) bool { - if len(current) > len(previous) { - return false - } - for i := range current { - if previous[i] != current[i] { - return false - } - } - return true -} - -func emitEvent(enc *json.Encoder, event watchEvent) bool { - return enc.Encode(event) == nil -} - -func hashStr(h [16]byte) string { - return hex.EncodeToString(h[:]) -} - -func now() string { - return time.Now().UTC().Format(time.RFC3339) -} - -// contextWithSignal returns a context canceled on SIGINT or SIGTERM. -// The caller must invoke the returned cancel function to avoid leaking the -// signal-forwarding goroutine. -func contextWithSignal() (context.Context, context.CancelFunc) { - ctx, cancel := context.WithCancel(context.Background()) - ch := make(chan os.Signal, 1) - signal.Notify(ch, syscall.SIGINT, syscall.SIGTERM) - go func() { - select { - case <-ch: - cancel() - case <-ctx.Done(): - } - signal.Stop(ch) - }() - return ctx, cancel -} diff --git a/internal/cli/cmd_agent_watch_compute_test.go b/internal/cli/cmd_agent_watch_compute_test.go deleted file mode 100644 index 476f9d7b..00000000 --- a/internal/cli/cmd_agent_watch_compute_test.go +++ /dev/null @@ -1,101 +0,0 @@ -package cli - -import ( - "bytes" - "encoding/json" - "strings" - "testing" -) - -func TestCmdAgentWatchRejectsNonPositiveIdleThreshold(t *testing.T) { - var w, wErr bytes.Buffer - code := cmdAgentWatch(&w, &wErr, GlobalFlags{}, []string{"session-a", "--idle-threshold", "0s"}, "test-v1") - if code != ExitUsage { - t.Fatalf("cmdAgentWatch() code = %d, want %d", code, ExitUsage) - } - if !strings.Contains(wErr.String(), "--idle-threshold must be > 0") { - t.Fatalf("expected validation message, got %q", wErr.String()) - } -} - -func TestCmdAgentWatchRejectsNonPositiveIdleThresholdJSON(t *testing.T) { - var w, wErr bytes.Buffer - code := cmdAgentWatch(&w, &wErr, GlobalFlags{JSON: true}, []string{"session-a", "--idle-threshold", "-1s"}, "test-v1") - if code != ExitUsage { - t.Fatalf("cmdAgentWatch() code = %d, want %d", code, ExitUsage) - } - if wErr.Len() != 0 { - t.Fatalf("expected no stderr output in JSON mode, got %q", wErr.String()) - } - - var env Envelope - if err := json.Unmarshal(w.Bytes(), &env); err != nil { - t.Fatalf("json.Unmarshal() error = %v\nraw: %s", err, w.String()) - } - if env.OK { - t.Fatalf("expected ok=false") - } - if env.Error == nil || env.Error.Code != "invalid_idle_threshold" { - t.Fatalf("expected invalid_idle_threshold, got %#v", env.Error) - } -} - -func TestComputeNewLinesAppended(t *testing.T) { - prev := []string{"a", "b", "c"} - curr := []string{"a", "b", "c", "d", "e"} - got := computeNewLines(prev, curr) - want := []string{"d", "e"} - if len(got) != len(want) { - t.Fatalf("got %v, want %v", got, want) - } - for i := range got { - if got[i] != want[i] { - t.Errorf("got[%d] = %q, want %q", i, got[i], want[i]) - } - } -} - -func TestComputeNewLinesNoOverlap(t *testing.T) { - prev := []string{"a", "b"} - curr := []string{"x", "y", "z"} - got := computeNewLines(prev, curr) - // No overlap → return all current - if len(got) != 3 { - t.Fatalf("got %v, want all current lines", got) - } -} - -func TestComputeNewLinesShrunkNoAdditions(t *testing.T) { - prev := []string{"a", "b"} - curr := []string{"b"} - got := computeNewLines(prev, curr) - if len(got) != 0 { - t.Fatalf("got %v, want 0 new lines", got) - } -} - -func TestComputeNewLinesShrunkTrailingBlankLine(t *testing.T) { - prev := strings.Split("line1\n\n", "\n") - curr := strings.Split("line1\n", "\n") - got := computeNewLines(prev, curr) - if len(got) != 0 { - t.Fatalf("got %v, want 0 new lines", got) - } -} - -func TestComputeNewLinesEmptyPrevious(t *testing.T) { - curr := []string{"a", "b"} - got := computeNewLines(nil, curr) - if len(got) != 2 { - t.Fatalf("got %v, want %v", got, curr) - } -} - -func TestComputeNewLinesTrailingBlankLineAdded(t *testing.T) { - prev := strings.Split("line1\n", "\n") - curr := strings.Split("line1\n\n", "\n") - got := computeNewLines(prev, curr) - if len(got) != 1 || got[0] != "" { - t.Fatalf("got %v, want [\"\"]", got) - } -} diff --git a/internal/cli/cmd_agent_watch_core_test.go b/internal/cli/cmd_agent_watch_core_test.go deleted file mode 100644 index d571bda2..00000000 --- a/internal/cli/cmd_agent_watch_core_test.go +++ /dev/null @@ -1,474 +0,0 @@ -package cli - -import ( - "bytes" - "context" - "encoding/json" - "strings" - "testing" - "time" - - "github.com/andyrewlee/amux/internal/tmux" -) - -func fakeCapture(contents ...string) captureFn { - idx := 0 - return func(_ string, _ int, _ tmux.Options) (string, bool) { - if idx >= len(contents) { - return "", false - } - c := contents[idx] - idx++ - return c, true - } -} - -// fakeCaptureFunc returns a capture function backed by a custom func. -func fakeCaptureFunc(fn func(call int) (string, bool)) captureFn { - call := 0 - return func(_ string, _ int, _ tmux.Options) (string, bool) { - c, ok := fn(call) - call++ - return c, ok - } -} - -func parseEvents(t *testing.T, output string) []watchEvent { - t.Helper() - var events []watchEvent - for _, line := range strings.Split(strings.TrimSpace(output), "\n") { - if line == "" { - continue - } - var ev watchEvent - if err := json.Unmarshal([]byte(line), &ev); err != nil { - t.Fatalf("failed to parse event JSON %q: %v", line, err) - } - events = append(events, ev) - } - return events -} - -func TestWatchEmitsSnapshotThenExited(t *testing.T) { - var buf bytes.Buffer - capture := fakeCapture("hello world") - - cfg := watchConfig{ - SessionName: "test-session", - Lines: 100, - Interval: 1 * time.Millisecond, - IdleThreshold: 1 * time.Hour, // won't trigger - } - - code := runWatchLoopWith(context.Background(), &buf, cfg, tmux.Options{}, capture) - if code != ExitOK { - t.Fatalf("exit code = %d, want %d", code, ExitOK) - } - - events := parseEvents(t, buf.String()) - if len(events) != 2 { - t.Fatalf("got %d events, want 2: %v", len(events), events) - } - if events[0].Type != "snapshot" { - t.Errorf("events[0].Type = %q, want snapshot", events[0].Type) - } - if events[0].Content != "hello world" { - t.Errorf("events[0].Content = %q, want %q", events[0].Content, "hello world") - } - if events[1].Type != "exited" { - t.Errorf("events[1].Type = %q, want exited", events[1].Type) - } -} - -func TestWatchEmitsDeltaOnChange(t *testing.T) { - var buf bytes.Buffer - capture := fakeCapture("line1\nline2", "line1\nline2\nline3\nline4") - - cfg := watchConfig{ - SessionName: "test-session", - Lines: 100, - Interval: 1 * time.Millisecond, - IdleThreshold: 1 * time.Hour, - } - - code := runWatchLoopWith(context.Background(), &buf, cfg, tmux.Options{}, capture) - if code != ExitOK { - t.Fatalf("exit code = %d, want %d", code, ExitOK) - } - - events := parseEvents(t, buf.String()) - if len(events) != 3 { - t.Fatalf("got %d events, want 3: %v", len(events), events) - } - if events[0].Type != "snapshot" { - t.Errorf("events[0].Type = %q, want snapshot", events[0].Type) - } - if events[1].Type != "delta" { - t.Errorf("events[1].Type = %q, want delta", events[1].Type) - } - if len(events[1].NewLines) != 2 || events[1].NewLines[0] != "line3" || events[1].NewLines[1] != "line4" { - t.Errorf("events[1].NewLines = %v, want [line3, line4]", events[1].NewLines) - } - if events[2].Type != "exited" { - t.Errorf("events[2].Type = %q, want exited", events[2].Type) - } -} - -func TestWatchDeltaMarksNeedsInput(t *testing.T) { - var buf bytes.Buffer - capture := fakeCapture("ready", "ready\nDo you want me to continue? (y/N)") - - cfg := watchConfig{ - SessionName: "test-session", - Lines: 100, - Interval: 1 * time.Millisecond, - IdleThreshold: 1 * time.Hour, - } - - code := runWatchLoopWith(context.Background(), &buf, cfg, tmux.Options{}, capture) - if code != ExitOK { - t.Fatalf("exit code = %d, want %d", code, ExitOK) - } - - events := parseEvents(t, buf.String()) - if len(events) != 3 { - t.Fatalf("got %d events, want 3: %v", len(events), events) - } - if events[1].Type != "delta" { - t.Fatalf("events[1].Type = %q, want delta", events[1].Type) - } - if !events[1].NeedsInput { - t.Fatalf("delta needs_input = false, want true") - } - if events[1].InputHint != "Do you want me to continue? (y/N)" { - t.Fatalf("delta input_hint = %q", events[1].InputHint) - } - if events[1].LatestLine != "Do you want me to continue? (y/N)" { - t.Fatalf("delta latest_line = %q", events[1].LatestLine) - } -} - -func TestWatchEmitsIdleAfterThreshold(t *testing.T) { - var buf bytes.Buffer - // Same content twice → triggers idle, then session exits - capture := fakeCapture("hello", "hello") - - cfg := watchConfig{ - SessionName: "test-session", - Lines: 100, - Interval: 1 * time.Millisecond, - IdleThreshold: 1 * time.Nanosecond, // immediate idle - } - - code := runWatchLoopWith(context.Background(), &buf, cfg, tmux.Options{}, capture) - if code != ExitOK { - t.Fatalf("exit code = %d, want %d", code, ExitOK) - } - - events := parseEvents(t, buf.String()) - if len(events) != 3 { - t.Fatalf("got %d events, want 3: %v", len(events), events) - } - if events[0].Type != "snapshot" { - t.Errorf("events[0].Type = %q, want snapshot", events[0].Type) - } - if events[1].Type != "idle" { - t.Errorf("events[1].Type = %q, want idle", events[1].Type) - } - if events[1].IdleSeconds <= 0 { - t.Errorf("events[1].IdleSeconds = %f, want > 0", events[1].IdleSeconds) - } - if events[2].Type != "exited" { - t.Errorf("events[2].Type = %q, want exited", events[2].Type) - } -} - -func TestWatchIdleEmittedOnlyOnce(t *testing.T) { - var buf bytes.Buffer - // Same content three times → idle emitted once, then exit - capture := fakeCapture("hello", "hello", "hello") - - cfg := watchConfig{ - SessionName: "test-session", - Lines: 100, - Interval: 1 * time.Millisecond, - IdleThreshold: 1 * time.Nanosecond, - } - - code := runWatchLoopWith(context.Background(), &buf, cfg, tmux.Options{}, capture) - if code != ExitOK { - t.Fatalf("exit code = %d, want %d", code, ExitOK) - } - - events := parseEvents(t, buf.String()) - idleCount := 0 - for _, ev := range events { - if ev.Type == "idle" { - idleCount++ - } - } - if idleCount != 1 { - t.Errorf("idle events = %d, want 1", idleCount) - } -} - -func TestWatchEmitsHeartbeatWhenConfigured(t *testing.T) { - var buf bytes.Buffer - capture := fakeCaptureFunc(func(call int) (string, bool) { - if call < 8 { - return "still working", true - } - return "", false - }) - - cfg := watchConfig{ - SessionName: "test-session", - Lines: 100, - Interval: 1 * time.Millisecond, - IdleThreshold: 1 * time.Hour, - Heartbeat: 2 * time.Millisecond, - } - - code := runWatchLoopWith(context.Background(), &buf, cfg, tmux.Options{}, capture) - if code != ExitOK { - t.Fatalf("exit code = %d, want %d", code, ExitOK) - } - - events := parseEvents(t, buf.String()) - heartbeatCount := 0 - for _, ev := range events { - if ev.Type == "heartbeat" { - heartbeatCount++ - if ev.HeartbeatSeconds <= 0 { - t.Fatalf("heartbeat_seconds = %f, want > 0", ev.HeartbeatSeconds) - } - if ev.Summary == "" { - t.Fatalf("heartbeat summary is empty") - } - } - } - if heartbeatCount == 0 { - t.Fatalf("expected at least one heartbeat event, got events=%v", events) - } -} - -func TestWatchIdleResetsAfterDelta(t *testing.T) { - var buf bytes.Buffer - // Same → idle, change → delta (resets idle), same → idle again - capture := fakeCapture("hello", "hello", "hello world", "hello world") - - cfg := watchConfig{ - SessionName: "test-session", - Lines: 100, - Interval: 1 * time.Millisecond, - IdleThreshold: 1 * time.Nanosecond, - } - - code := runWatchLoopWith(context.Background(), &buf, cfg, tmux.Options{}, capture) - if code != ExitOK { - t.Fatalf("exit code = %d, want %d", code, ExitOK) - } - - events := parseEvents(t, buf.String()) - var types []string - for _, ev := range events { - types = append(types, ev.Type) - } - // snapshot, idle, delta, idle, exited - expected := []string{"snapshot", "idle", "delta", "idle", "exited"} - if len(types) != len(expected) { - t.Fatalf("event types = %v, want %v", types, expected) - } - for i, tp := range types { - if tp != expected[i] { - t.Errorf("types[%d] = %q, want %q", i, tp, expected[i]) - } - } -} - -func TestWatchNoDeltaWhenContentShrinks(t *testing.T) { - var buf bytes.Buffer - capture := fakeCapture("a\nb", "b") - - cfg := watchConfig{ - SessionName: "test-session", - Lines: 100, - Interval: 1 * time.Millisecond, - IdleThreshold: 1 * time.Hour, - } - - code := runWatchLoopWith(context.Background(), &buf, cfg, tmux.Options{}, capture) - if code != ExitOK { - t.Fatalf("exit code = %d, want %d", code, ExitOK) - } - - events := parseEvents(t, buf.String()) - if len(events) != 2 { - t.Fatalf("got %d events, want 2: %v", len(events), events) - } - if events[0].Type != "snapshot" { - t.Errorf("events[0].Type = %q, want snapshot", events[0].Type) - } - if events[1].Type != "exited" { - t.Errorf("events[1].Type = %q, want exited", events[1].Type) - } -} - -func TestWatchExitsOnSessionGoneImmediately(t *testing.T) { - var buf bytes.Buffer - // Session gone from the start - capture := fakeCapture() - - cfg := watchConfig{ - SessionName: "test-session", - Lines: 100, - Interval: 1 * time.Millisecond, - IdleThreshold: 5 * time.Second, - } - - code := runWatchLoopWith(context.Background(), &buf, cfg, tmux.Options{}, capture) - if code != ExitOK { - t.Fatalf("exit code = %d, want %d", code, ExitOK) - } - - events := parseEvents(t, buf.String()) - if len(events) != 1 { - t.Fatalf("got %d events, want 1", len(events)) - } - if events[0].Type != "exited" { - t.Errorf("events[0].Type = %q, want exited", events[0].Type) - } -} - -func TestWatchInitialTransientCaptureMissDoesNotExit(t *testing.T) { - origStateFor := tmuxSessionStateFor - tmuxSessionStateFor = func(_ string, _ tmux.Options) (tmux.SessionState, error) { - return tmux.SessionState{Exists: true, HasLivePane: true}, nil - } - defer func() { tmuxSessionStateFor = origStateFor }() - - var buf bytes.Buffer - capture := fakeCaptureFunc(func(call int) (string, bool) { - if call < 2 { - return "", false - } - return "hello", true - }) - - ctx, cancel := context.WithTimeout(context.Background(), 10*time.Millisecond) - defer cancel() - - cfg := watchConfig{ - SessionName: "test-session", - Lines: 100, - Interval: 1 * time.Millisecond, - IdleThreshold: 1 * time.Hour, - } - - code := runWatchLoopWith(ctx, &buf, cfg, tmux.Options{}, capture) - if code != ExitOK { - t.Fatalf("exit code = %d, want %d", code, ExitOK) - } - - events := parseEvents(t, buf.String()) - if len(events) == 0 { - t.Fatalf("expected at least one event") - } - if events[0].Type != "snapshot" { - t.Fatalf("events[0].Type = %q, want snapshot", events[0].Type) - } -} - -func TestWatchTransientCaptureMissDuringLoopDoesNotEmitExited(t *testing.T) { - origStateFor := tmuxSessionStateFor - tmuxSessionStateFor = func(_ string, _ tmux.Options) (tmux.SessionState, error) { - return tmux.SessionState{Exists: true, HasLivePane: true}, nil - } - defer func() { tmuxSessionStateFor = origStateFor }() - - var buf bytes.Buffer - capture := fakeCaptureFunc(func(call int) (string, bool) { - switch call { - case 0: - return "a", true - case 1: - return "", false - default: - return "a\nb", true - } - }) - - ctx, cancel := context.WithTimeout(context.Background(), 10*time.Millisecond) - defer cancel() - - cfg := watchConfig{ - SessionName: "test-session", - Lines: 100, - Interval: 1 * time.Millisecond, - IdleThreshold: 1 * time.Hour, - } - - code := runWatchLoopWith(ctx, &buf, cfg, tmux.Options{}, capture) - if code != ExitOK { - t.Fatalf("exit code = %d, want %d", code, ExitOK) - } - - events := parseEvents(t, buf.String()) - foundDelta := false - for _, ev := range events { - if ev.Type == "exited" { - t.Fatalf("unexpected exited event after transient miss: %v", events) - } - if ev.Type == "delta" { - foundDelta = true - } - } - if !foundDelta { - t.Fatalf("expected delta event after transient miss, got %v", events) - } -} - -func TestWatchExitsOnContextCancel(t *testing.T) { - var buf bytes.Buffer - - // Infinite content — always returns the same thing - capture := fakeCaptureFunc(func(_ int) (string, bool) { - return "hello", true - }) - - ctx, cancel := context.WithCancel(context.Background()) - - cfg := watchConfig{ - SessionName: "test-session", - Lines: 100, - Interval: 1 * time.Millisecond, - IdleThreshold: 1 * time.Hour, - } - - done := make(chan int, 1) - go func() { - done <- runWatchLoopWith(ctx, &buf, cfg, tmux.Options{}, capture) - }() - - // Let a few ticks happen, then cancel - time.Sleep(20 * time.Millisecond) - cancel() - - select { - case code := <-done: - if code != ExitOK { - t.Fatalf("exit code = %d, want %d", code, ExitOK) - } - case <-time.After(2 * time.Second): - t.Fatal("watch loop did not exit after context cancel") - } - - // Should have at least the initial snapshot - events := parseEvents(t, buf.String()) - if len(events) == 0 { - t.Fatal("expected at least one event") - } - if events[0].Type != "snapshot" { - t.Errorf("events[0].Type = %q, want snapshot", events[0].Type) - } -} diff --git a/internal/cli/cmd_agent_watch_events_test.go b/internal/cli/cmd_agent_watch_events_test.go deleted file mode 100644 index a454e259..00000000 --- a/internal/cli/cmd_agent_watch_events_test.go +++ /dev/null @@ -1,91 +0,0 @@ -package cli - -import ( - "bytes" - "context" - "errors" - "testing" - "time" - - "github.com/andyrewlee/amux/internal/tmux" -) - -type failingWriter struct{} - -func (failingWriter) Write(_ []byte) (int, error) { - return 0, errors.New("broken pipe") -} - -func TestWatchExitsWhenOutputWriterFails(t *testing.T) { - capture := fakeCaptureFunc(func(_ int) (string, bool) { - return "hello", true - }) - - cfg := watchConfig{ - SessionName: "test-session", - Lines: 100, - Interval: 1 * time.Millisecond, - IdleThreshold: 1 * time.Nanosecond, - } - - done := make(chan int, 1) - go func() { - done <- runWatchLoopWith(context.Background(), failingWriter{}, cfg, tmux.Options{}, capture) - }() - - select { - case code := <-done: - if code != ExitOK { - t.Fatalf("exit code = %d, want %d", code, ExitOK) - } - case <-time.After(200 * time.Millisecond): - t.Fatal("watch loop did not exit after writer failure") - } -} - -func TestWatchEventHasTimestamp(t *testing.T) { - var buf bytes.Buffer - capture := fakeCapture("hello") - - cfg := watchConfig{ - SessionName: "test-session", - Lines: 100, - Interval: 1 * time.Millisecond, - IdleThreshold: 1 * time.Hour, - } - - runWatchLoopWith(context.Background(), &buf, cfg, tmux.Options{}, capture) - - events := parseEvents(t, buf.String()) - for i, ev := range events { - if ev.Timestamp == "" { - t.Errorf("events[%d].Timestamp is empty", i) - } - if _, err := time.Parse(time.RFC3339, ev.Timestamp); err != nil { - t.Errorf("events[%d].Timestamp %q is not valid RFC3339: %v", i, ev.Timestamp, err) - } - } -} - -func TestWatchEventHasHash(t *testing.T) { - var buf bytes.Buffer - capture := fakeCapture("hello") - - cfg := watchConfig{ - SessionName: "test-session", - Lines: 100, - Interval: 1 * time.Millisecond, - IdleThreshold: 1 * time.Hour, - } - - runWatchLoopWith(context.Background(), &buf, cfg, tmux.Options{}, capture) - - events := parseEvents(t, buf.String()) - if events[0].Hash == "" { - t.Error("snapshot event hash is empty") - } - // MD5 hex is 32 characters - if len(events[0].Hash) != 32 { - t.Errorf("snapshot event hash length = %d, want 32", len(events[0].Hash)) - } -} diff --git a/internal/cli/cmd_capabilities.go b/internal/cli/cmd_capabilities.go deleted file mode 100644 index 8f6ae3ad..00000000 --- a/internal/cli/cmd_capabilities.go +++ /dev/null @@ -1,126 +0,0 @@ -package cli - -import ( - "fmt" - "io" -) - -type capabilityFeatures struct { - JSONEnvelope bool `json:"json_envelope"` - RequestID bool `json:"request_id"` - AgentID bool `json:"agent_id"` - IdempotencyKey bool `json:"idempotency_key"` - SendJobs bool `json:"send_jobs"` - AsyncSend bool `json:"async_send"` - JobWait bool `json:"job_wait"` - AgentWait bool `json:"agent_wait"` - WaitResponseStatus bool `json:"wait_response_status"` - WaitResponseDelta bool `json:"wait_response_delta"` - WaitResponseEarlyInput bool `json:"wait_response_early_input"` - WaitResponseNeedsInput bool `json:"wait_response_needs_input"` - WaitResponseSummary bool `json:"wait_response_summary"` - WatchHeartbeat bool `json:"watch_heartbeat"` - WatchNeedsInput bool `json:"watch_needs_input"` -} - -type capabilitiesResult struct { - SchemaVersion string `json:"schema_version"` - Commands []string `json:"commands"` - Mutating []string `json:"mutating_commands"` - GlobalFlags []string `json:"global_flags"` - Features capabilityFeatures `json:"features"` -} - -func cmdCapabilities(w, wErr io.Writer, gf GlobalFlags, args []string, version string) int { - const usage = "Usage: amux capabilities [--json]" - if len(args) > 0 { - return returnUsageError(w, wErr, gf, usage, version, nil) - } - - result := capabilitiesResult{ - SchemaVersion: EnvelopeSchemaVersion, - Commands: []string{ - "status", - "doctor", - "capabilities", - "logs tail", - "workspace list", - "workspace create", - "workspace remove", - "agent list", - "agent capture", - "agent run", - "agent send", - "agent stop", - "agent watch", - "agent job status", - "agent job cancel", - "agent job wait", - "terminal list", - "terminal run", - "terminal logs", - "project list", - "project add", - "project remove", - "session list", - "session prune", - "version", - "help", - }, - Mutating: []string{ - "workspace create", - "workspace remove", - "agent run", - "agent send", - "agent stop", - "agent job cancel", - "terminal run", - "project add", - "project remove", - "session prune", - }, - GlobalFlags: []string{ - "--json", - "--request-id", - "--cwd", - "--timeout", - "--quiet", - "--no-color", - }, - Features: capabilityFeatures{ - JSONEnvelope: true, - RequestID: true, - AgentID: true, - IdempotencyKey: true, - SendJobs: true, - AsyncSend: true, - JobWait: true, - AgentWait: true, - WaitResponseStatus: true, - WaitResponseDelta: true, - WaitResponseEarlyInput: true, - WaitResponseNeedsInput: true, - WaitResponseSummary: true, - WatchHeartbeat: true, - WatchNeedsInput: true, - }, - } - - if gf.JSON { - PrintJSON(w, result, version) - return ExitOK - } - - PrintHuman(w, func(w io.Writer) { - fmt.Fprintf(w, "schema: %s\n", result.SchemaVersion) - fmt.Fprintln(w, "commands:") - for _, cmd := range result.Commands { - fmt.Fprintf(w, " - %s\n", cmd) - } - fmt.Fprintln(w, "mutating:") - for _, cmd := range result.Mutating { - fmt.Fprintf(w, " - %s\n", cmd) - } - }) - return ExitOK -} diff --git a/internal/cli/cmd_capabilities_test.go b/internal/cli/cmd_capabilities_test.go deleted file mode 100644 index d53e727d..00000000 --- a/internal/cli/cmd_capabilities_test.go +++ /dev/null @@ -1,101 +0,0 @@ -package cli - -import ( - "bytes" - "encoding/json" - "testing" -) - -func TestCmdCapabilitiesJSON(t *testing.T) { - var out bytes.Buffer - var errOut bytes.Buffer - - code := cmdCapabilities(&out, &errOut, GlobalFlags{JSON: true}, nil, "test-v1") - if code != ExitOK { - t.Fatalf("cmdCapabilities() code = %d, want %d", code, ExitOK) - } - if errOut.Len() != 0 { - t.Fatalf("expected no stderr output, got %q", errOut.String()) - } - - var env Envelope - if err := json.Unmarshal(out.Bytes(), &env); err != nil { - t.Fatalf("failed to decode envelope: %v", err) - } - if !env.OK { - t.Fatalf("expected ok=true, got error=%#v", env.Error) - } - - payload, ok := env.Data.(map[string]any) - if !ok { - t.Fatalf("expected map payload, got %T", env.Data) - } - if got, _ := payload["schema_version"].(string); got != EnvelopeSchemaVersion { - t.Fatalf("schema_version = %q, want %q", got, EnvelopeSchemaVersion) - } - features, ok := payload["features"].(map[string]any) - if !ok { - t.Fatalf("expected features object") - } - if got, _ := features["idempotency_key"].(bool); !got { - t.Fatalf("expected idempotency_key capability") - } - if got, _ := features["send_jobs"].(bool); !got { - t.Fatalf("expected send_jobs capability") - } - if got, _ := features["async_send"].(bool); !got { - t.Fatalf("expected async_send capability") - } - if got, _ := features["job_wait"].(bool); !got { - t.Fatalf("expected job_wait capability") - } - if got, _ := features["agent_wait"].(bool); !got { - t.Fatalf("expected agent_wait capability") - } - if got, _ := features["wait_response_status"].(bool); !got { - t.Fatalf("expected wait_response_status capability") - } - if got, _ := features["wait_response_delta"].(bool); !got { - t.Fatalf("expected wait_response_delta capability") - } - if got, _ := features["wait_response_early_input"].(bool); !got { - t.Fatalf("expected wait_response_early_input capability") - } - if got, _ := features["wait_response_needs_input"].(bool); !got { - t.Fatalf("expected wait_response_needs_input capability") - } - if got, _ := features["wait_response_summary"].(bool); !got { - t.Fatalf("expected wait_response_summary capability") - } - if got, _ := features["watch_heartbeat"].(bool); !got { - t.Fatalf("expected watch_heartbeat capability") - } - if got, _ := features["watch_needs_input"].(bool); !got { - t.Fatalf("expected watch_needs_input capability") - } - - commands, ok := payload["commands"].([]any) - if !ok { - t.Fatalf("expected commands list") - } - foundAgentJobStatus := false - for _, raw := range commands { - if cmd, _ := raw.(string); cmd == "agent job status" { - foundAgentJobStatus = true - break - } - } - if !foundAgentJobStatus { - t.Fatalf("expected agent job status command in capabilities") - } - foundAgentJobWait := false - for _, raw := range commands { - if cmd, _ := raw.(string); cmd == "agent job wait" { - foundAgentJobWait = true - break - } - } - if !foundAgentJobWait { - t.Fatalf("expected agent job wait command in capabilities") - } -} diff --git a/internal/cli/cmd_context.go b/internal/cli/cmd_context.go deleted file mode 100644 index 7a06c9c7..00000000 --- a/internal/cli/cmd_context.go +++ /dev/null @@ -1,43 +0,0 @@ -package cli - -import "io" - -// cmdCtx bundles the common parameters threaded through CLI command handlers. -type cmdCtx struct { - w io.Writer - wErr io.Writer - gf GlobalFlags - version string - cmd string - idemKey string -} - -// errResult returns a JSON error (when --json is set) or a human-readable -// error message, and stores an idempotent response when an idempotency key -// is present. humanMessage optionally overrides the non-JSON stderr text. -func (c *cmdCtx) errResult(exitCode int, errorCode, message string, details any, humanMessage ...string) int { - if c.gf.JSON { - return returnJSONErrorMaybeIdempotent( - c.w, c.wErr, c.gf, c.version, c.cmd, c.idemKey, - exitCode, errorCode, message, details, - ) - } - text := message - if len(humanMessage) > 0 && humanMessage[0] != "" { - text = humanMessage[0] - } - Errorf(c.wErr, "%s", text) - return exitCode -} - -// successResult returns a JSON success envelope with idempotency support. -func (c *cmdCtx) successResult(data any) int { - return returnJSONSuccessWithIdempotency( - c.w, c.wErr, c.gf, c.version, c.cmd, c.idemKey, data, - ) -} - -// maybeReplay checks whether a previous idempotent response can be replayed. -func (c *cmdCtx) maybeReplay() (bool, int) { - return maybeReplayIdempotentResponse(c.w, c.wErr, c.gf, c.version, c.cmd, c.idemKey) -} diff --git a/internal/cli/cmd_context_test.go b/internal/cli/cmd_context_test.go deleted file mode 100644 index 3b7cfcbb..00000000 --- a/internal/cli/cmd_context_test.go +++ /dev/null @@ -1,59 +0,0 @@ -package cli - -import ( - "bytes" - "encoding/json" - "testing" -) - -func TestCmdCtxErrResultUsesHumanOverrideInTextMode(t *testing.T) { - var out, errOut bytes.Buffer - ctx := &cmdCtx{ - w: &out, - wErr: &errOut, - gf: GlobalFlags{}, - version: "test-v1", - cmd: "test.command", - } - - code := ctx.errResult(ExitInternalError, "boom", "json message", nil, "human message") - if code != ExitInternalError { - t.Fatalf("errResult() code = %d, want %d", code, ExitInternalError) - } - if out.Len() != 0 { - t.Fatalf("expected no stdout output in text mode, got %q", out.String()) - } - if got := errOut.String(); got != "Error: human message\n" { - t.Fatalf("stderr = %q, want %q", got, "Error: human message\n") - } -} - -func TestCmdCtxErrResultKeepsJSONMessageWhenHumanOverrideProvided(t *testing.T) { - var out, errOut bytes.Buffer - ctx := &cmdCtx{ - w: &out, - wErr: &errOut, - gf: GlobalFlags{JSON: true}, - version: "test-v1", - cmd: "test.command", - } - - code := ctx.errResult(ExitInternalError, "boom", "json message", nil, "human message") - if code != ExitInternalError { - t.Fatalf("errResult() code = %d, want %d", code, ExitInternalError) - } - if errOut.Len() != 0 { - t.Fatalf("expected no stderr output in JSON mode, got %q", errOut.String()) - } - - var env Envelope - if err := json.Unmarshal(out.Bytes(), &env); err != nil { - t.Fatalf("json.Unmarshal() error = %v", err) - } - if env.OK { - t.Fatalf("expected ok=false") - } - if env.Error == nil || env.Error.Message != "json message" { - t.Fatalf("expected JSON message to be preserved, got %#v", env.Error) - } -} diff --git a/internal/cli/cmd_doctor.go b/internal/cli/cmd_doctor.go deleted file mode 100644 index 117846bc..00000000 --- a/internal/cli/cmd_doctor.go +++ /dev/null @@ -1,138 +0,0 @@ -package cli - -import ( - "fmt" - "io" - "os" - "os/exec" - "strings" - - "github.com/andyrewlee/amux/internal/tmux" -) - -type checkResult struct { - Name string `json:"name"` - Status string `json:"status"` // ok, warn, fail - Message string `json:"message"` -} - -type doctorResult struct { - Checks []checkResult `json:"checks"` -} - -func cmdDoctor(w, wErr io.Writer, gf GlobalFlags, args []string, version string) int { - const usage = "Usage: amux doctor [--json]" - if len(args) > 0 { - return returnUsageError( - w, - wErr, - gf, - usage, - version, - fmt.Errorf("unexpected arguments: %s", strings.Join(args, " ")), - ) - } - - svc, code := initServicesOrFail(w, wErr, gf, version) - if code >= 0 { - return code - } - - var checks []checkResult - - // 1. tmux installed - checks = append(checks, checkTmuxInstalled()) - - // 2. tmux version - checks = append(checks, checkTmuxVersion()) - - // 3. amux home exists and writable - checks = append(checks, checkHomeDir(svc.Config.Paths.Home)) - - // 4. metadata parseable - checks = append(checks, checkMetadata(svc)) - - // 5. registry parseable - checks = append(checks, checkRegistry(svc)) - - // 6. tmux server reachable - checks = append(checks, checkTmuxServer(svc.TmuxOpts)) - - result := doctorResult{Checks: checks} - - if gf.JSON { - PrintJSON(w, result, version) - return ExitOK - } - - PrintHuman(w, func(w io.Writer) { - for _, c := range result.Checks { - icon := "+" - if c.Status == "warn" { - icon = "!" - } else if c.Status == "fail" { - icon = "x" - } - fmt.Fprintf(w, " [%s] %-25s %s\n", icon, c.Name, c.Message) - } - }) - return ExitOK -} - -func checkTmuxInstalled() checkResult { - if tmux.EnsureAvailable() == nil { - return checkResult{Name: "tmux_installed", Status: "ok", Message: "tmux found on PATH"} - } - return checkResult{Name: "tmux_installed", Status: "fail", Message: "tmux not found; " + tmux.InstallHint()} -} - -func checkTmuxVersion() checkResult { - out, err := exec.Command("tmux", "-V").Output() - if err != nil { - return checkResult{Name: "tmux_version", Status: "fail", Message: "could not determine tmux version"} - } - ver := strings.TrimSpace(string(out)) - return checkResult{Name: "tmux_version", Status: "ok", Message: ver} -} - -func checkHomeDir(home string) checkResult { - info, err := os.Stat(home) - if err != nil { - return checkResult{Name: "amux_home", Status: "fail", Message: home + " not found"} - } - if !info.IsDir() { - return checkResult{Name: "amux_home", Status: "fail", Message: home + " is not a directory"} - } - // Check writable by creating a temp file - tmp, err := os.CreateTemp(home, ".doctor-check-*") - if err != nil { - return checkResult{Name: "amux_home", Status: "warn", Message: home + " is not writable"} - } - tmp.Close() - os.Remove(tmp.Name()) - return checkResult{Name: "amux_home", Status: "ok", Message: home} -} - -func checkMetadata(svc *Services) checkResult { - ids, err := svc.Store.List() - if err != nil { - return checkResult{Name: "metadata", Status: "fail", Message: err.Error()} - } - return checkResult{Name: "metadata", Status: "ok", Message: fmt.Sprintf("%d workspace(s)", len(ids))} -} - -func checkRegistry(svc *Services) checkResult { - projects, err := svc.Registry.Projects() - if err != nil { - return checkResult{Name: "registry", Status: "fail", Message: err.Error()} - } - return checkResult{Name: "registry", Status: "ok", Message: fmt.Sprintf("%d project(s)", len(projects))} -} - -func checkTmuxServer(opts tmux.Options) checkResult { - sessions, err := tmux.ListSessions(opts) - if err != nil { - return checkResult{Name: "tmux_server", Status: "warn", Message: "server not reachable (no sessions)"} - } - return checkResult{Name: "tmux_server", Status: "ok", Message: fmt.Sprintf("%d session(s)", len(sessions))} -} diff --git a/internal/cli/cmd_doctor_test.go b/internal/cli/cmd_doctor_test.go deleted file mode 100644 index 61599875..00000000 --- a/internal/cli/cmd_doctor_test.go +++ /dev/null @@ -1,34 +0,0 @@ -package cli - -import ( - "bytes" - "encoding/json" - "strings" - "testing" -) - -func TestCmdDoctorUnexpectedArgsReturnsUsageError(t *testing.T) { - var w bytes.Buffer - var wErr bytes.Buffer - code := cmdDoctor(&w, &wErr, GlobalFlags{JSON: true}, []string{"garbage"}, "test-v1") - if code != ExitUsage { - t.Fatalf("cmdDoctor() code = %d, want %d", code, ExitUsage) - } - if wErr.Len() != 0 { - t.Fatalf("expected no stderr output in JSON mode, got %q", wErr.String()) - } - - var env Envelope - if err := json.Unmarshal(w.Bytes(), &env); err != nil { - t.Fatalf("json.Unmarshal() error = %v\nraw: %s", err, w.String()) - } - if env.OK { - t.Fatalf("expected ok=false") - } - if env.Error == nil || env.Error.Code != "usage_error" { - t.Fatalf("expected usage_error, got %#v", env.Error) - } - if env.Error == nil || !strings.Contains(env.Error.Message, "unexpected arguments") { - t.Fatalf("unexpected usage_error message: %#v", env.Error) - } -} diff --git a/internal/cli/cmd_logs.go b/internal/cli/cmd_logs.go deleted file mode 100644 index b73f0f82..00000000 --- a/internal/cli/cmd_logs.go +++ /dev/null @@ -1,109 +0,0 @@ -package cli - -import ( - "errors" - "fmt" - "io" - "os" - "path/filepath" - "sort" - "strings" - - "github.com/andyrewlee/amux/internal/logging" -) - -type logsResult struct { - Path string `json:"path"` - Lines []string `json:"lines"` - Count int `json:"count"` -} - -func cmdLogs(w, wErr io.Writer, gf GlobalFlags, args []string, version string) int { - const usage = "Usage: amux logs tail [--lines N] [--json]" - if len(args) == 0 || args[0] != "tail" { - return returnUsageError(w, wErr, gf, usage, version, nil) - } - tailArgs := args[1:] - - fs := newFlagSet("logs tail") - lines := fs.Int("lines", 50, "number of lines to tail") - if err := fs.Parse(tailArgs); err != nil { - return returnUsageError(w, wErr, gf, usage, version, err) - } - if *lines < 0 { - return returnOperationError(w, wErr, gf, version, - ExitUsage, "invalid_lines", errors.New("--lines must be >= 0"), - map[string]any{"lines": *lines}, - "--lines must be >= 0") - } - - logPath := logging.GetLogPath() - if logPath == "" { - // Logging not initialized in headless mode; find the latest log file. - logPath = findLatestLogFile() - } - - content, err := os.ReadFile(logPath) - if err != nil { - return returnOperationError(w, wErr, gf, version, - ExitNotFound, "log_not_found", fmt.Errorf("cannot read log: %w", err), nil, - "cannot read log file: %v", err) - } - - var allLines []string - if len(content) > 0 { - allLines = strings.Split(strings.TrimRight(string(content), "\n"), "\n") - } - start := 0 - if len(allLines) > *lines { - start = len(allLines) - *lines - } - tail := allLines[start:] - - result := logsResult{ - Path: logPath, - Lines: tail, - Count: len(tail), - } - - if gf.JSON { - PrintJSON(w, result, version) - return ExitOK - } - - PrintHuman(w, func(w io.Writer) { - for _, line := range tail { - fmt.Fprintln(w, line) - } - }) - return ExitOK -} - -// findLatestLogFile locates the most recent amux-*.log in ~/.amux/logs. -func findLatestLogFile() string { - home, err := os.UserHomeDir() - if err != nil { - return "" - } - logDir := filepath.Join(home, ".amux", "logs") - entries, err := os.ReadDir(logDir) - if err != nil { - return "" - } - var logs []string - for _, e := range entries { - if e.IsDir() { - continue - } - name := e.Name() - // Match only date-stamped logs: amux-YYYY-MM-DD.log (len == 19) - if strings.HasPrefix(name, "amux-") && strings.HasSuffix(name, ".log") && len(name) == 19 { - logs = append(logs, name) - } - } - if len(logs) == 0 { - return "" - } - sort.Strings(logs) // date-stamped names sort chronologically - return filepath.Join(logDir, logs[len(logs)-1]) -} diff --git a/internal/cli/cmd_logs_test.go b/internal/cli/cmd_logs_test.go deleted file mode 100644 index 2404a1e6..00000000 --- a/internal/cli/cmd_logs_test.go +++ /dev/null @@ -1,104 +0,0 @@ -package cli - -import ( - "bytes" - "encoding/json" - "os" - "path/filepath" - "strings" - "testing" -) - -func TestCmdLogsTailRejectsNegativeLines(t *testing.T) { - var w, wErr bytes.Buffer - code := cmdLogs(&w, &wErr, GlobalFlags{}, []string{"tail", "--lines", "-1"}, "test-v1") - - if code != ExitUsage { - t.Fatalf("expected ExitUsage for negative --lines, got %d", code) - } - if !strings.Contains(wErr.String(), "--lines must be >= 0") { - t.Fatalf("expected validation error, got stderr: %q", wErr.String()) - } -} - -func TestCmdLogsUsageJSON(t *testing.T) { - var w, wErr bytes.Buffer - code := cmdLogs(&w, &wErr, GlobalFlags{JSON: true}, []string{"unknown"}, "test-v1") - if code != ExitUsage { - t.Fatalf("expected ExitUsage, got %d", code) - } - if wErr.Len() != 0 { - t.Fatalf("expected no stderr in JSON mode, got %q", wErr.String()) - } - - var env Envelope - if err := json.Unmarshal(w.Bytes(), &env); err != nil { - t.Fatalf("json.Unmarshal() error = %v\nraw: %s", err, w.String()) - } - if env.OK { - t.Fatalf("expected ok=false") - } - if env.Error == nil || env.Error.Code != "usage_error" { - t.Fatalf("expected usage_error, got %#v", env.Error) - } -} - -func TestCmdLogsTailEmptyFileReportsZeroLines(t *testing.T) { - home := t.TempDir() - t.Setenv("HOME", home) - - logDir := filepath.Join(home, ".amux", "logs") - if err := os.MkdirAll(logDir, 0o755); err != nil { - t.Fatalf("MkdirAll() error = %v", err) - } - logFile := filepath.Join(logDir, "amux-2025-01-01.log") - if err := os.WriteFile(logFile, []byte{}, 0o644); err != nil { - t.Fatalf("WriteFile() error = %v", err) - } - - var w, wErr bytes.Buffer - code := cmdLogs(&w, &wErr, GlobalFlags{JSON: true}, []string{"tail"}, "test-v1") - if code != ExitOK { - t.Fatalf("expected ExitOK, got %d; stderr: %s", code, wErr.String()) - } - - var env Envelope - if err := json.Unmarshal(w.Bytes(), &env); err != nil { - t.Fatalf("json.Unmarshal() error = %v\nraw: %s", err, w.String()) - } - data, ok := env.Data.(map[string]any) - if !ok { - t.Fatalf("expected data object, got %T", env.Data) - } - count, _ := data["count"].(float64) - if count != 0 { - t.Fatalf("expected count=0 for empty file, got %v", count) - } - lines, _ := data["lines"].([]any) - if len(lines) != 0 { - t.Fatalf("expected empty lines array, got %v", lines) - } -} - -func TestCmdLogsTailEmptyFileHumanNoOutput(t *testing.T) { - home := t.TempDir() - t.Setenv("HOME", home) - - logDir := filepath.Join(home, ".amux", "logs") - if err := os.MkdirAll(logDir, 0o755); err != nil { - t.Fatalf("MkdirAll() error = %v", err) - } - logFile := filepath.Join(logDir, "amux-2025-01-01.log") - if err := os.WriteFile(logFile, []byte{}, 0o644); err != nil { - t.Fatalf("WriteFile() error = %v", err) - } - - var w, wErr bytes.Buffer - code := cmdLogs(&w, &wErr, GlobalFlags{JSON: false}, []string{"tail"}, "test-v1") - if code != ExitOK { - t.Fatalf("expected ExitOK, got %d; stderr: %s", code, wErr.String()) - } - if w.Len() != 0 { - t.Fatalf("expected no stdout for empty log, got %q", w.String()) - } -} diff --git a/internal/cli/cmd_project.go b/internal/cli/cmd_project.go deleted file mode 100644 index 378445ae..00000000 --- a/internal/cli/cmd_project.go +++ /dev/null @@ -1,218 +0,0 @@ -package cli - -import ( - "errors" - "fmt" - "io" - "path/filepath" - "strings" - - "github.com/andyrewlee/amux/internal/git" -) - -func canonicalizeProjectPathNoSymlinks(path string) string { - path = strings.TrimSpace(path) - if path == "" { - return "" - } - if abs, err := filepath.Abs(path); err == nil { - return filepath.Clean(abs) - } - return filepath.Clean(path) -} - -// lenientCanonicalizePath resolves a path to an absolute, cleaned form without -// requiring the path to exist on disk. It tries EvalSymlinks on the full path -// first. If that fails (e.g. the directory was deleted), it resolves the -// parent directory's symlinks and appends the base name, so that platform -// symlinks like /tmp → /private/tmp are still resolved correctly. -func lenientCanonicalizePath(path string) string { - if canon, err := canonicalizeProjectPath(path); err == nil { - return canon - } - abs, err := filepath.Abs(path) - if err != nil { - return filepath.Clean(path) - } - abs = filepath.Clean(abs) - // Try resolving the parent; the leaf may not exist but the parent might. - dir := filepath.Dir(abs) - base := filepath.Base(abs) - if resolvedDir, err := filepath.EvalSymlinks(dir); err == nil { - return filepath.Join(resolvedDir, base) - } - return abs -} - -// --- project routing --- - -func routeProject(w, wErr io.Writer, gf GlobalFlags, args []string, version string) int { - return routeSubcommand(w, wErr, gf, args, version, "project", []subcommand{ - {names: []string{"list", "ls"}, handler: cmdProjectList}, - {names: []string{"add"}, handler: cmdProjectAdd}, - {names: []string{"remove", "rm"}, handler: cmdProjectRemove}, - }) -} - -// --- project list --- - -type projectEntry struct { - Name string `json:"name"` - Path string `json:"path"` -} - -func cmdProjectList(w, wErr io.Writer, gf GlobalFlags, args []string, version string) int { - const usage = "Usage: amux project list [--json]" - if len(args) > 0 { - return returnUsageError(w, wErr, gf, usage, version, errors.New("unexpected arguments")) - } - - svc, code := initServicesOrFail(w, wErr, gf, version) - if code >= 0 { - return code - } - - paths, err := svc.Registry.Projects() - if err != nil { - return returnOperationError(w, wErr, gf, version, - ExitInternalError, "list_failed", err, nil, - "failed to list projects: %v", err) - } - - entries := make([]projectEntry, len(paths)) - for i, p := range paths { - entries[i] = projectEntry{ - Name: filepath.Base(p), - Path: p, - } - } - - if gf.JSON { - PrintJSON(w, entries, version) - return ExitOK - } - - PrintHuman(w, func(w io.Writer) { - if len(entries) == 0 { - fmt.Fprintln(w, "No projects registered.") - return - } - for _, e := range entries { - fmt.Fprintf(w, " %s\t%s\n", e.Name, e.Path) - } - }) - return ExitOK -} - -// --- project add --- - -func cmdProjectAdd(w, wErr io.Writer, gf GlobalFlags, args []string, version string) int { - const usage = "Usage: amux project add [--json]" - fs := newFlagSet("project add") - path, err := parseSinglePositionalWithFlags(fs, args) - if err != nil { - return returnUsageError(w, wErr, gf, usage, version, err) - } - if path == "" { - return returnUsageError(w, wErr, gf, usage, version, nil) - } - - projectPath, err := canonicalizeProjectPath(path) - if err != nil { - return returnOperationError(w, wErr, gf, version, - ExitUsage, "invalid_project_path", err, map[string]any{"path": path}, - "invalid path: %v", err) - } - - if !git.IsGitRepository(projectPath) { - return returnOperationError(w, wErr, gf, version, - ExitUsage, "not_git_repo", fmt.Errorf("%s is not a git repository", projectPath), - map[string]any{"path": projectPath}, - "%s is not a git repository", projectPath) - } - - svc, code := initServicesOrFail(w, wErr, gf, version) - if code >= 0 { - return code - } - - if err := svc.Registry.AddProject(projectPath); err != nil { - return returnOperationError(w, wErr, gf, version, - ExitInternalError, "add_failed", err, map[string]any{"path": projectPath}, - "failed to add project: %v", err) - } - - entry := projectEntry{ - Name: filepath.Base(projectPath), - Path: projectPath, - } - - if gf.JSON { - PrintJSON(w, entry, version) - return ExitOK - } - - PrintHuman(w, func(w io.Writer) { - fmt.Fprintf(w, "Added project %s (%s)\n", entry.Name, entry.Path) - }) - return ExitOK -} - -// --- project remove --- - -func cmdProjectRemove(w, wErr io.Writer, gf GlobalFlags, args []string, version string) int { - const usage = "Usage: amux project remove [--json]" - fs := newFlagSet("project remove") - path, err := parseSinglePositionalWithFlags(fs, args) - if err != nil { - return returnUsageError(w, wErr, gf, usage, version, err) - } - if path == "" { - return returnUsageError(w, wErr, gf, usage, version, nil) - } - - // Use registry-compatible canonicalization so removal works for paths stored - // without symlink resolution, and also try lenient canonicalization for - // deleted/moved paths that still need cleanup. - projectPath := lenientCanonicalizePath(path) - projectPathNoResolve := canonicalizeProjectPathNoSymlinks(path) - - svc, code := initServicesOrFail(w, wErr, gf, version) - if code >= 0 { - return code - } - - for candidate := range map[string]struct{}{ - projectPath: {}, - projectPathNoResolve: {}, - } { - if candidate == "" { - continue - } - if err := svc.Registry.RemoveProject(candidate); err != nil { - return returnOperationError(w, wErr, gf, version, - ExitInternalError, "remove_failed", err, map[string]any{"path": candidate}, - "failed to remove project: %v", err) - } - } - - displayPath := projectPathNoResolve - if displayPath == "" { - displayPath = projectPath - } - - entry := projectEntry{ - Name: filepath.Base(displayPath), - Path: displayPath, - } - - if gf.JSON { - PrintJSON(w, entry, version) - return ExitOK - } - - PrintHuman(w, func(w io.Writer) { - fmt.Fprintf(w, "Removed project %s (%s)\n", entry.Name, entry.Path) - }) - return ExitOK -} diff --git a/internal/cli/cmd_project_test.go b/internal/cli/cmd_project_test.go deleted file mode 100644 index daa0bf7e..00000000 --- a/internal/cli/cmd_project_test.go +++ /dev/null @@ -1,427 +0,0 @@ -package cli - -import ( - "bytes" - "encoding/json" - "os" - "os/exec" - "path/filepath" - "runtime" - "strings" - "testing" - - "github.com/andyrewlee/amux/internal/data" -) - -func TestRouteProjectNoSubcommand(t *testing.T) { - var out, errOut bytes.Buffer - code := routeProject(&out, &errOut, GlobalFlags{JSON: true}, nil, "test-v1") - if code != ExitUsage { - t.Fatalf("routeProject() code = %d, want %d", code, ExitUsage) - } - var env Envelope - if err := json.Unmarshal(out.Bytes(), &env); err != nil { - t.Fatalf("json.Unmarshal() error = %v\nraw: %s", err, out.String()) - } - if env.OK { - t.Fatalf("expected ok=false") - } - if env.Error == nil || env.Error.Code != "usage_error" { - t.Fatalf("expected usage_error, got %#v", env.Error) - } -} - -func TestRouteProjectUnknownSubcommand(t *testing.T) { - var out, errOut bytes.Buffer - code := routeProject(&out, &errOut, GlobalFlags{JSON: true}, []string{"bogus"}, "test-v1") - if code != ExitUsage { - t.Fatalf("routeProject() code = %d, want %d", code, ExitUsage) - } - var env Envelope - if err := json.Unmarshal(out.Bytes(), &env); err != nil { - t.Fatalf("json.Unmarshal() error = %v\nraw: %s", err, out.String()) - } - if env.OK { - t.Fatalf("expected ok=false") - } - if env.Error == nil || !strings.Contains(env.Error.Message, "bogus") { - t.Fatalf("expected unknown subcommand message mentioning 'bogus', got %#v", env.Error) - } -} - -func TestCmdProjectListEmpty(t *testing.T) { - home := t.TempDir() - t.Setenv("HOME", home) - - var out, errOut bytes.Buffer - code := cmdProjectList(&out, &errOut, GlobalFlags{JSON: true}, nil, "test-v1") - if code != ExitOK { - t.Fatalf("cmdProjectList() code = %d; stderr: %s", code, errOut.String()) - } - - var env Envelope - if err := json.Unmarshal(out.Bytes(), &env); err != nil { - t.Fatalf("json.Unmarshal() error = %v\nraw: %s", err, out.String()) - } - if !env.OK { - t.Fatalf("expected ok=true; raw=%s", out.String()) - } - - entries, ok := env.Data.([]any) - if !ok { - t.Fatalf("expected data to be array, got %T", env.Data) - } - if len(entries) != 0 { - t.Fatalf("expected empty project list, got %d entries", len(entries)) - } -} - -func TestCmdProjectListWithProjects(t *testing.T) { - if _, err := exec.LookPath("git"); err != nil { - t.Skip("git not available") - } - - home := t.TempDir() - t.Setenv("HOME", home) - - repoRoot := filepath.Join(t.TempDir(), "myrepo") - if err := os.MkdirAll(repoRoot, 0o755); err != nil { - t.Fatalf("MkdirAll() error = %v", err) - } - runGit(t, repoRoot, "init") - - // Register via project add - var addOut, addErr bytes.Buffer - addCode := cmdProjectAdd(&addOut, &addErr, GlobalFlags{JSON: true}, []string{repoRoot}, "test-v1") - if addCode != ExitOK { - t.Fatalf("cmdProjectAdd() code = %d; stderr: %s", addCode, addErr.String()) - } - - var out, errOut bytes.Buffer - code := cmdProjectList(&out, &errOut, GlobalFlags{JSON: true}, nil, "test-v1") - if code != ExitOK { - t.Fatalf("cmdProjectList() code = %d; stderr: %s", code, errOut.String()) - } - - var env Envelope - if err := json.Unmarshal(out.Bytes(), &env); err != nil { - t.Fatalf("json.Unmarshal() error = %v\nraw: %s", err, out.String()) - } - if !env.OK { - t.Fatalf("expected ok=true; raw=%s", out.String()) - } - - entries, ok := env.Data.([]any) - if !ok { - t.Fatalf("expected data to be array, got %T", env.Data) - } - if len(entries) != 1 { - t.Fatalf("expected 1 project, got %d", len(entries)) - } - entry, ok := entries[0].(map[string]any) - if !ok { - t.Fatalf("expected entry to be object, got %T", entries[0]) - } - if entry["name"] != "myrepo" { - t.Fatalf("name = %q, want %q", entry["name"], "myrepo") - } -} - -func TestCmdProjectAddRegistersProject(t *testing.T) { - if _, err := exec.LookPath("git"); err != nil { - t.Skip("git not available") - } - - home := t.TempDir() - t.Setenv("HOME", home) - - repoRoot := filepath.Join(t.TempDir(), "addme") - if err := os.MkdirAll(repoRoot, 0o755); err != nil { - t.Fatalf("MkdirAll() error = %v", err) - } - runGit(t, repoRoot, "init") - - var out, errOut bytes.Buffer - code := cmdProjectAdd(&out, &errOut, GlobalFlags{JSON: true}, []string{repoRoot}, "test-v1") - if code != ExitOK { - t.Fatalf("cmdProjectAdd() code = %d; stderr: %s; stdout: %s", code, errOut.String(), out.String()) - } - - var env Envelope - if err := json.Unmarshal(out.Bytes(), &env); err != nil { - t.Fatalf("json.Unmarshal() error = %v\nraw: %s", err, out.String()) - } - if !env.OK { - t.Fatalf("expected ok=true; raw=%s", out.String()) - } - - data, ok := env.Data.(map[string]any) - if !ok { - t.Fatalf("expected data object, got %T", env.Data) - } - if data["name"] != "addme" { - t.Fatalf("name = %q, want %q", data["name"], "addme") - } - - // Verify it appears in list - var listOut, listErr bytes.Buffer - listCode := cmdProjectList(&listOut, &listErr, GlobalFlags{JSON: true}, nil, "test-v1") - if listCode != ExitOK { - t.Fatalf("cmdProjectList() code = %d", listCode) - } - var listEnv Envelope - if err := json.Unmarshal(listOut.Bytes(), &listEnv); err != nil { - t.Fatalf("json.Unmarshal(list) error = %v", err) - } - entries, _ := listEnv.Data.([]any) - if len(entries) != 1 { - t.Fatalf("expected 1 project in list, got %d", len(entries)) - } -} - -func TestCmdProjectAddNotGitRepo(t *testing.T) { - home := t.TempDir() - t.Setenv("HOME", home) - - notRepo := t.TempDir() - - var out, errOut bytes.Buffer - code := cmdProjectAdd(&out, &errOut, GlobalFlags{JSON: true}, []string{notRepo}, "test-v1") - if code != ExitUsage { - t.Fatalf("cmdProjectAdd() code = %d, want %d", code, ExitUsage) - } - - var env Envelope - if err := json.Unmarshal(out.Bytes(), &env); err != nil { - t.Fatalf("json.Unmarshal() error = %v\nraw: %s", err, out.String()) - } - if env.OK { - t.Fatalf("expected ok=false") - } - if env.Error == nil || env.Error.Code != "not_git_repo" { - t.Fatalf("expected not_git_repo error, got %#v", env.Error) - } -} - -func TestCmdProjectAddDuplicate(t *testing.T) { - if _, err := exec.LookPath("git"); err != nil { - t.Skip("git not available") - } - - home := t.TempDir() - t.Setenv("HOME", home) - - repoRoot := filepath.Join(t.TempDir(), "duprepo") - if err := os.MkdirAll(repoRoot, 0o755); err != nil { - t.Fatalf("MkdirAll() error = %v", err) - } - runGit(t, repoRoot, "init") - - // Add twice — should be idempotent. - for i := 0; i < 2; i++ { - var out, errOut bytes.Buffer - code := cmdProjectAdd(&out, &errOut, GlobalFlags{JSON: true}, []string{repoRoot}, "test-v1") - if code != ExitOK { - t.Fatalf("cmdProjectAdd() attempt %d code = %d; stderr: %s", i+1, code, errOut.String()) - } - } - - // Verify only one entry in list - var listOut, listErr bytes.Buffer - listCode := cmdProjectList(&listOut, &listErr, GlobalFlags{JSON: true}, nil, "test-v1") - if listCode != ExitOK { - t.Fatalf("cmdProjectList() code = %d", listCode) - } - var env Envelope - if err := json.Unmarshal(listOut.Bytes(), &env); err != nil { - t.Fatalf("json.Unmarshal(list) error = %v", err) - } - entries, _ := env.Data.([]any) - if len(entries) != 1 { - t.Fatalf("expected 1 project after duplicate add, got %d", len(entries)) - } -} - -func TestCmdProjectRemoveSuccess(t *testing.T) { - if _, err := exec.LookPath("git"); err != nil { - t.Skip("git not available") - } - - home := t.TempDir() - t.Setenv("HOME", home) - - repoRoot := filepath.Join(t.TempDir(), "removeme") - if err := os.MkdirAll(repoRoot, 0o755); err != nil { - t.Fatalf("MkdirAll() error = %v", err) - } - runGit(t, repoRoot, "init") - - // Add first - var addOut, addErr bytes.Buffer - addCode := cmdProjectAdd(&addOut, &addErr, GlobalFlags{JSON: true}, []string{repoRoot}, "test-v1") - if addCode != ExitOK { - t.Fatalf("cmdProjectAdd() code = %d", addCode) - } - - // Remove - var out, errOut bytes.Buffer - code := cmdProjectRemove(&out, &errOut, GlobalFlags{JSON: true}, []string{repoRoot}, "test-v1") - if code != ExitOK { - t.Fatalf("cmdProjectRemove() code = %d; stderr: %s", code, errOut.String()) - } - - var env Envelope - if err := json.Unmarshal(out.Bytes(), &env); err != nil { - t.Fatalf("json.Unmarshal() error = %v\nraw: %s", err, out.String()) - } - if !env.OK { - t.Fatalf("expected ok=true; raw=%s", out.String()) - } - - // Verify list is empty - var listOut, listErr bytes.Buffer - listCode := cmdProjectList(&listOut, &listErr, GlobalFlags{JSON: true}, nil, "test-v1") - if listCode != ExitOK { - t.Fatalf("cmdProjectList() code = %d", listCode) - } - var listEnv Envelope - if err := json.Unmarshal(listOut.Bytes(), &listEnv); err != nil { - t.Fatalf("json.Unmarshal(list) error = %v", err) - } - entries, _ := listEnv.Data.([]any) - if len(entries) != 0 { - t.Fatalf("expected 0 projects after remove, got %d", len(entries)) - } -} - -func TestCmdProjectRemoveNotRegistered(t *testing.T) { - home := t.TempDir() - t.Setenv("HOME", home) - - notRegistered := t.TempDir() - - var out, errOut bytes.Buffer - code := cmdProjectRemove(&out, &errOut, GlobalFlags{JSON: true}, []string{notRegistered}, "test-v1") - if code != ExitOK { - t.Fatalf("cmdProjectRemove() code = %d, want %d; stderr: %s", code, ExitOK, errOut.String()) - } - - var env Envelope - if err := json.Unmarshal(out.Bytes(), &env); err != nil { - t.Fatalf("json.Unmarshal() error = %v\nraw: %s", err, out.String()) - } - if !env.OK { - t.Fatalf("expected ok=true (remove is idempotent); raw=%s", out.String()) - } -} - -func TestCmdProjectRemoveMatchesStoredPathWithoutSymlinks(t *testing.T) { - if runtime.GOOS == "windows" { - t.Skip("symlink path canonicalization path is unstable on windows in test environment") - } - - home := t.TempDir() - t.Setenv("HOME", home) - - realRepo := filepath.Join(t.TempDir(), "real-repo") - if err := os.MkdirAll(realRepo, 0o755); err != nil { - t.Fatalf("MkdirAll(realRepo) error = %v", err) - } - storedLink := filepath.Join(t.TempDir(), "linked-repo") - if err := os.Symlink(realRepo, storedLink); err != nil { - t.Fatalf("Symlink() error = %v", err) - } - - registry := data.NewRegistry(filepath.Join(home, ".amux", "projects.json")) - if err := registry.AddProject(storedLink); err != nil { - t.Fatalf("registry.AddProject(%q) error = %v", storedLink, err) - } - - // Remove using the symlink path so lenient canonicalization resolves it away, - // while no-resolve canonicalization keeps the stored entry matchable. - var out, errOut bytes.Buffer - code := cmdProjectRemove(&out, &errOut, GlobalFlags{JSON: true}, []string{storedLink}, "test-v1") - if code != ExitOK { - t.Fatalf("cmdProjectRemove() code = %d; stderr: %s; stdout: %s", code, errOut.String(), out.String()) - } - - var env Envelope - if err := json.Unmarshal(out.Bytes(), &env); err != nil { - t.Fatalf("json.Unmarshal() error = %v\nraw: %s", err, out.String()) - } - if !env.OK { - t.Fatalf("expected ok=true; raw=%s", out.String()) - } - - // Verify stale non-resolved entry is gone. - var listOut, listErr bytes.Buffer - listCode := cmdProjectList(&listOut, &listErr, GlobalFlags{JSON: true}, nil, "test-v1") - if listCode != ExitOK { - t.Fatalf("cmdProjectList() code = %d", listCode) - } - var listEnv Envelope - if err := json.Unmarshal(listOut.Bytes(), &listEnv); err != nil { - t.Fatalf("json.Unmarshal(list) error = %v", err) - } - entries, _ := listEnv.Data.([]any) - if len(entries) != 0 { - t.Fatalf("expected 0 projects after remove, got %d", len(entries)) - } -} - -func TestCmdProjectRemoveDeletedPath(t *testing.T) { - if _, err := exec.LookPath("git"); err != nil { - t.Skip("git not available") - } - - home := t.TempDir() - t.Setenv("HOME", home) - - repoRoot := filepath.Join(t.TempDir(), "ephemeral") - if err := os.MkdirAll(repoRoot, 0o755); err != nil { - t.Fatalf("MkdirAll() error = %v", err) - } - runGit(t, repoRoot, "init") - - // Register while it still exists. - var addOut, addErr bytes.Buffer - addCode := cmdProjectAdd(&addOut, &addErr, GlobalFlags{JSON: true}, []string{repoRoot}, "test-v1") - if addCode != ExitOK { - t.Fatalf("cmdProjectAdd() code = %d", addCode) - } - - // Delete the directory. - if err := os.RemoveAll(repoRoot); err != nil { - t.Fatalf("RemoveAll() error = %v", err) - } - - // Remove should still succeed even though the path no longer exists. - var out, errOut bytes.Buffer - code := cmdProjectRemove(&out, &errOut, GlobalFlags{JSON: true}, []string{repoRoot}, "test-v1") - if code != ExitOK { - t.Fatalf("cmdProjectRemove() code = %d, want %d; stderr: %s; stdout: %s", code, ExitOK, errOut.String(), out.String()) - } - - var env Envelope - if err := json.Unmarshal(out.Bytes(), &env); err != nil { - t.Fatalf("json.Unmarshal() error = %v\nraw: %s", err, out.String()) - } - if !env.OK { - t.Fatalf("expected ok=true; raw=%s", out.String()) - } - - // Verify list is now empty. - var listOut, listErr bytes.Buffer - listCode := cmdProjectList(&listOut, &listErr, GlobalFlags{JSON: true}, nil, "test-v1") - if listCode != ExitOK { - t.Fatalf("cmdProjectList() code = %d", listCode) - } - var listEnv Envelope - if err := json.Unmarshal(listOut.Bytes(), &listEnv); err != nil { - t.Fatalf("json.Unmarshal(list) error = %v", err) - } - entries, _ := listEnv.Data.([]any) - if len(entries) != 0 { - t.Fatalf("expected 0 projects after removing deleted path, got %d", len(entries)) - } -} diff --git a/internal/cli/cmd_session.go b/internal/cli/cmd_session.go deleted file mode 100644 index 1a11ddb8..00000000 --- a/internal/cli/cmd_session.go +++ /dev/null @@ -1,229 +0,0 @@ -package cli - -import ( - "fmt" - "io" - "strings" - "time" -) - -// --- session list --- - -type sessionListEntry struct { - SessionName string `json:"session_name"` - WorkspaceID string `json:"workspace_id"` - Type string `json:"type"` - Attached bool `json:"attached"` - CreatedAt int64 `json:"created_at"` - AgeSeconds int64 `json:"age_seconds"` -} - -func cmdSessionList(w, wErr io.Writer, gf GlobalFlags, args []string, version string) int { - return cmdSessionListWith(w, wErr, gf, args, version, nil) -} - -func cmdSessionListWith(w, wErr io.Writer, gf GlobalFlags, args []string, version string, svc *Services) int { - const usage = "Usage: amux session list [--json]" - if len(args) > 0 { - return returnUsageError(w, wErr, gf, usage, version, fmt.Errorf("unexpected arguments: %s", strings.Join(args, " "))) - } - - if svc == nil { - var code int - svc, code = initServicesOrFail(w, wErr, gf, version) - if code >= 0 { - return code - } - } - - rows, err := svc.QuerySessionRows(svc.TmuxOpts) - if err != nil { - return returnOperationError(w, wErr, gf, version, - ExitInternalError, "list_failed", err, nil, - "failed to list sessions: %v", err) - } - - entries := buildSessionList(rows, time.Now()) - - if gf.JSON { - PrintJSON(w, entries, version) - return ExitOK - } - - PrintHuman(w, func(w io.Writer) { - if len(entries) == 0 { - fmt.Fprintln(w, "No sessions.") - return - } - for _, e := range entries { - attached := "" - if e.Attached { - attached = " (attached)" - } - fmt.Fprintf(w, " %-45s ws=%-16s type=%-12s age=%s%s\n", - e.SessionName, e.WorkspaceID, e.Type, formatAge(e.AgeSeconds), attached) - } - }) - return ExitOK -} - -// --- session prune --- - -type pruneEntry struct { - Session string `json:"session"` - WorkspaceID string `json:"workspace_id"` - Reason string `json:"reason"` - AgeSeconds int64 `json:"age_seconds"` -} - -type pruneResult struct { - DryRun bool `json:"dry_run"` - Pruned []pruneEntry `json:"pruned"` - Total int `json:"total"` - Errors []string `json:"errors"` -} - -func cmdSessionPrune(w, wErr io.Writer, gf GlobalFlags, args []string, version string) int { - return cmdSessionPruneWith(w, wErr, gf, args, version, nil) -} - -func cmdSessionPruneWith(w, wErr io.Writer, gf GlobalFlags, args []string, version string, svc *Services) int { - const usage = "Usage: amux session prune [--yes] [--older-than ] [--json]" - fs := newFlagSet("session prune") - yes := fs.Bool("yes", false, "confirm prune (required)") - olderThan := fs.String("older-than", "", "only prune sessions older than duration (e.g. 1h, 30m)") - if err := fs.Parse(args); err != nil { - return returnUsageError(w, wErr, gf, usage, version, err) - } - if fs.NArg() > 0 { - return returnUsageError(w, wErr, gf, usage, version, - fmt.Errorf("unexpected arguments: %s", strings.Join(fs.Args(), " "))) - } - - var minAge time.Duration - if *olderThan != "" { - d, err := time.ParseDuration(*olderThan) - if err != nil || d <= 0 { - msg := "--older-than must be a positive duration" - if err != nil { - msg = fmt.Sprintf("invalid --older-than: %v", err) - } - return returnOperationError(w, wErr, gf, version, - ExitUsage, "invalid_older_than", fmt.Errorf("%s", msg), - map[string]any{"older_than": *olderThan}, - "%s", msg) - } - minAge = d - } - - if svc == nil { - var code int - svc, code = initServicesOrFail(w, wErr, gf, version) - if code >= 0 { - return code - } - } - - rows, err := svc.QuerySessionRows(svc.TmuxOpts) - if err != nil { - return returnOperationError(w, wErr, gf, version, - ExitInternalError, "prune_failed", err, nil, - "failed to scan sessions: %v", err) - } - - wsIDs, err := svc.Store.List() - if err != nil { - return returnOperationError(w, wErr, gf, version, - ExitInternalError, "prune_failed", err, nil, - "failed to list workspaces: %v", err) - } - - candidates := findPruneCandidates(rows, wsIDs, minAge, time.Now()) - - if !*yes { - result := pruneResult{ - DryRun: true, - Pruned: candidates, - Total: len(candidates), - Errors: []string{}, - } - if gf.JSON { - PrintJSON(w, result, version) - return ExitOK - } - PrintHuman(w, func(w io.Writer) { - if len(candidates) == 0 { - fmt.Fprintln(w, "Nothing to prune.") - return - } - fmt.Fprintf(w, "Would prune %d session(s) (pass --yes to confirm):\n", len(candidates)) - for _, c := range candidates { - fmt.Fprintf(w, " %-45s (%s, %s old)\n", c.Session, humanReason(c.Reason), formatAge(c.AgeSeconds)) - } - }) - return ExitOK - } - - // Actually prune. - var pruned []pruneEntry - var errs []string - for _, c := range candidates { - if err := tmuxKillSession(c.Session, svc.TmuxOpts); err != nil { - errs = append(errs, fmt.Sprintf("%s: %v", c.Session, err)) - continue - } - pruned = append(pruned, c) - } - - result := pruneResult{ - DryRun: false, - Pruned: pruned, - Total: len(pruned), - Errors: errs, - } - if result.Errors == nil { - result.Errors = []string{} - } - - exitCode := ExitOK - if len(errs) > 0 { - exitCode = ExitInternalError - } - - if gf.JSON { - if len(errs) > 0 { - ReturnError(w, "prune_partial_failed", - fmt.Sprintf("pruned %d session(s) but %d failed", len(pruned), len(errs)), - map[string]any{"pruned": pruned, "errors": errs}, version) - } else { - PrintJSON(w, result, version) - } - return exitCode - } - - PrintHuman(w, func(w io.Writer) { - if len(pruned) == 0 && len(errs) == 0 { - fmt.Fprintln(w, "Nothing to prune.") - return - } - if len(pruned) > 0 { - fmt.Fprintf(w, "Pruned %d session(s):\n", len(pruned)) - for _, p := range pruned { - fmt.Fprintf(w, " %-45s (%s, %s old)\n", p.Session, humanReason(p.Reason), formatAge(p.AgeSeconds)) - } - } - for _, e := range errs { - fmt.Fprintf(w, "Error: %s\n", e) - } - }) - return exitCode -} - -// --- routing --- - -func routeSession(w, wErr io.Writer, gf GlobalFlags, args []string, version string) int { - return routeSubcommand(w, wErr, gf, args, version, "session", []subcommand{ - {names: []string{"list", "ls"}, handler: cmdSessionList}, - {names: []string{"prune"}, handler: cmdSessionPrune}, - }) -} diff --git a/internal/cli/cmd_session_helpers.go b/internal/cli/cmd_session_helpers.go deleted file mode 100644 index 21bf7aea..00000000 --- a/internal/cli/cmd_session_helpers.go +++ /dev/null @@ -1,250 +0,0 @@ -package cli - -import ( - "fmt" - "strconv" - "strings" - "time" - - "github.com/andyrewlee/amux/internal/data" - "github.com/andyrewlee/amux/internal/tmux" -) - -type sessionRow struct { - name string - tags map[string]string - attached bool - createdAt int64 -} - -// buildSessionList converts raw session rows into list entries. -func buildSessionList(rows []sessionRow, now time.Time) []sessionListEntry { - var entries []sessionListEntry - for _, row := range rows { - wsID := row.tags["@amux_workspace"] - if wsID == "" { - wsID = inferWorkspaceID(row.name) - } - sessionType := row.tags["@amux_type"] - if sessionType == "" { - sessionType = inferSessionType(row.name) - } - var age int64 - if row.createdAt > 0 { - age = int64(now.Sub(time.Unix(row.createdAt, 0)).Seconds()) - if age < 0 { - age = 0 - } - } - entries = append(entries, sessionListEntry{ - SessionName: row.name, - WorkspaceID: wsID, - Type: sessionType, - Attached: row.attached, - CreatedAt: row.createdAt, - AgeSeconds: age, - }) - } - return entries -} - -// findPruneCandidates determines which sessions should be pruned. -func findPruneCandidates(rows []sessionRow, wsIDs []data.WorkspaceID, minAge time.Duration, now time.Time) []pruneEntry { - validWS := make(map[string]bool, len(wsIDs)) - for _, id := range wsIDs { - validWS[string(id)] = true - } - - var candidates []pruneEntry - for _, row := range rows { - // Only consider amux-owned sessions for pruning. - if !isAmuxSession(row) { - continue - } - - wsID := row.tags["@amux_workspace"] - if wsID == "" { - wsID = inferWorkspaceID(row.name) - } - sessionType := row.tags["@amux_type"] - if sessionType == "" { - sessionType = inferSessionType(row.name) - } - - var age int64 - if row.createdAt > 0 { - age = int64(now.Sub(time.Unix(row.createdAt, 0)).Seconds()) - if age < 0 { - age = 0 - } - } - - // Apply age filter. When --older-than is set, skip sessions whose age - // is unknown — we can't prove they meet the threshold, so err on the - // safe side and leave them alone. - if minAge > 0 { - if row.createdAt == 0 { - continue - } - if now.Sub(time.Unix(row.createdAt, 0)) < minAge { - continue - } - } - - reason := classifyForPrune(wsID, sessionType, row.attached, validWS) - if reason == "" { - continue - } - - candidates = append(candidates, pruneEntry{ - Session: row.name, - WorkspaceID: wsID, - Reason: reason, - AgeSeconds: age, - }) - } - return candidates -} - -// classifyForPrune returns the prune reason, or "" if the session should not be pruned. -func classifyForPrune(wsID, sessionType string, attached bool, validWS map[string]bool) string { - // Never prune attached sessions. - if attached { - return "" - } - // Orphaned: workspace not in metadata store. - if wsID != "" && !validWS[wsID] { - return "orphaned_workspace" - } - // Detached term-tab sessions for existing workspaces. - if isTermTabType(sessionType) { - return "detached_terminal" - } - return "" -} - -// isAmuxSession returns true if the session is owned by amux (tagged or name-prefixed). -func isAmuxSession(row sessionRow) bool { - if row.tags["@amux_workspace"] != "" { - return true - } - return strings.HasPrefix(row.name, "amux-") -} - -// defaultQuerySessionRows queries tmux list-sessions with tags, attached state, and creation time. -func defaultQuerySessionRows(opts tmux.Options) ([]sessionRow, error) { - if err := tmux.EnsureAvailable(); err != nil { - return nil, err - } - // Query amux tags. - rows, err := tmux.SessionsWithTags( - nil, - []string{"@amux_workspace", "@amux_type", "@amux_created_at"}, - opts, - ) - if err != nil { - return nil, err - } - // Query attached state and tmux creation time. - metaRows, err := tmux.SessionsWithTags( - nil, - []string{"session_attached", "session_created"}, - opts, - ) - if err != nil { - return nil, err - } - attachedMap := make(map[string]bool, len(metaRows)) - createdMap := make(map[string]int64, len(metaRows)) - for _, r := range metaRows { - if v := r.Tags["session_attached"]; v != "" && v != "0" { - attachedMap[r.Name] = true - } - if v := r.Tags["session_created"]; v != "" { - if ts, parseErr := strconv.ParseInt(v, 10, 64); parseErr == nil { - createdMap[r.Name] = ts - } - } - } - - var result []sessionRow - for _, r := range rows { - createdAt := parseTagCreatedAt(r.Tags["@amux_created_at"]) - if createdAt == 0 { - createdAt = createdMap[r.Name] - } - result = append(result, sessionRow{ - name: r.Name, - tags: r.Tags, - attached: attachedMap[r.Name], - createdAt: createdAt, - }) - } - return result, nil -} - -func parseTagCreatedAt(raw string) int64 { - raw = strings.TrimSpace(raw) - if raw == "" { - return 0 - } - v, err := strconv.ParseInt(raw, 10, 64) - if err != nil { - return 0 - } - return v -} - -// inferWorkspaceID extracts workspace ID from a session name like "amux--tab-1". -func inferWorkspaceID(name string) string { - if !strings.HasPrefix(name, "amux-") { - return "" - } - rest := name[len("amux-"):] - if idx := strings.Index(rest, "-term-tab-"); idx > 0 { - return rest[:idx] - } - if idx := strings.Index(rest, "-tab-"); idx > 0 { - return rest[:idx] - } - return rest -} - -// inferSessionType guesses session type from the session name. -func inferSessionType(name string) string { - if strings.Contains(name, "-term-tab-") { - return "term-tab" - } - if strings.Contains(name, "-tab-") { - return "agent" - } - return "unknown" -} - -func isTermTabType(sessionType string) bool { - return sessionType == "term-tab" || sessionType == "terminal" -} - -func formatAge(seconds int64) string { - if seconds < 60 { - return fmt.Sprintf("%ds", seconds) - } - if seconds < 3600 { - return fmt.Sprintf("%dm", seconds/60) - } - if seconds < 86400 { - return fmt.Sprintf("%dh", seconds/3600) - } - return fmt.Sprintf("%dd", seconds/86400) -} - -func humanReason(reason string) string { - switch reason { - case "orphaned_workspace": - return "orphaned workspace" - case "detached_terminal": - return "detached terminal" - default: - return reason - } -} diff --git a/internal/cli/cmd_session_test.go b/internal/cli/cmd_session_test.go deleted file mode 100644 index 6dbf1e7e..00000000 --- a/internal/cli/cmd_session_test.go +++ /dev/null @@ -1,319 +0,0 @@ -package cli - -import ( - "testing" - "time" - - "github.com/andyrewlee/amux/internal/data" -) - -// --- classifyForPrune tests --- - -func TestClassifyForPruneAttachedNeverPruned(t *testing.T) { - valid := map[string]bool{"ws-a": true} - reason := classifyForPrune("ws-a", "term-tab", true, valid) - if reason != "" { - t.Fatalf("expected no prune for attached session, got %q", reason) - } -} - -func TestClassifyForPruneOrphanedWorkspace(t *testing.T) { - valid := map[string]bool{"ws-a": true} - reason := classifyForPrune("ws-gone", "agent", false, valid) - if reason != "orphaned_workspace" { - t.Fatalf("expected orphaned_workspace, got %q", reason) - } -} - -func TestClassifyForPruneDetachedTermTab(t *testing.T) { - valid := map[string]bool{"ws-a": true} - reason := classifyForPrune("ws-a", "term-tab", false, valid) - if reason != "detached_terminal" { - t.Fatalf("expected detached_terminal, got %q", reason) - } -} - -func TestClassifyForPruneDetachedTerminal(t *testing.T) { - valid := map[string]bool{"ws-a": true} - reason := classifyForPrune("ws-a", "terminal", false, valid) - if reason != "detached_terminal" { - t.Fatalf("expected detached_terminal, got %q", reason) - } -} - -func TestClassifyForPruneDetachedAgentNotPruned(t *testing.T) { - valid := map[string]bool{"ws-a": true} - reason := classifyForPrune("ws-a", "agent", false, valid) - if reason != "" { - t.Fatalf("expected no prune for detached agent in valid workspace, got %q", reason) - } -} - -func TestClassifyForPruneEmptyWorkspaceNotPruned(t *testing.T) { - valid := map[string]bool{"ws-a": true} - reason := classifyForPrune("", "agent", false, valid) - if reason != "" { - t.Fatalf("expected no prune for empty workspace ID, got %q", reason) - } -} - -// --- inferWorkspaceID tests --- - -func TestInferWorkspaceIDTermTab(t *testing.T) { - got := inferWorkspaceID("amux-abc123-term-tab-3") - if got != "abc123" { - t.Fatalf("inferWorkspaceID() = %q, want %q", got, "abc123") - } -} - -func TestInferWorkspaceIDTab(t *testing.T) { - got := inferWorkspaceID("amux-abc123-tab-1") - if got != "abc123" { - t.Fatalf("inferWorkspaceID() = %q, want %q", got, "abc123") - } -} - -func TestInferWorkspaceIDNoPrefix(t *testing.T) { - got := inferWorkspaceID("other-session") - if got != "" { - t.Fatalf("inferWorkspaceID() = %q, want empty", got) - } -} - -func TestInferWorkspaceIDNoSuffix(t *testing.T) { - got := inferWorkspaceID("amux-abc123") - if got != "abc123" { - t.Fatalf("inferWorkspaceID() = %q, want %q", got, "abc123") - } -} - -// --- inferSessionType tests --- - -func TestInferSessionTypeTermTab(t *testing.T) { - got := inferSessionType("amux-abc123-term-tab-3") - if got != "term-tab" { - t.Fatalf("inferSessionType() = %q, want %q", got, "term-tab") - } -} - -func TestInferSessionTypeAgent(t *testing.T) { - got := inferSessionType("amux-abc123-tab-1") - if got != "agent" { - t.Fatalf("inferSessionType() = %q, want %q", got, "agent") - } -} - -func TestInferSessionTypeUnknown(t *testing.T) { - got := inferSessionType("amux-abc123") - if got != "unknown" { - t.Fatalf("inferSessionType() = %q, want %q", got, "unknown") - } -} - -// --- formatAge tests --- - -func TestFormatAgeSeconds(t *testing.T) { - if got := formatAge(30); got != "30s" { - t.Fatalf("formatAge(30) = %q, want %q", got, "30s") - } -} - -func TestFormatAgeMinutes(t *testing.T) { - if got := formatAge(300); got != "5m" { - t.Fatalf("formatAge(300) = %q, want %q", got, "5m") - } -} - -func TestFormatAgeHours(t *testing.T) { - if got := formatAge(7200); got != "2h" { - t.Fatalf("formatAge(7200) = %q, want %q", got, "2h") - } -} - -func TestFormatAgeDays(t *testing.T) { - if got := formatAge(172800); got != "2d" { - t.Fatalf("formatAge(172800) = %q, want %q", got, "2d") - } -} - -// --- buildSessionList tests --- - -func TestBuildSessionListUsesTagsOverInference(t *testing.T) { - now := time.Unix(1000, 0) - rows := []sessionRow{ - { - name: "amux-ws1-tab-1", - tags: map[string]string{ - "@amux_workspace": "ws-tagged", - "@amux_type": "agent", - }, - createdAt: 900, - }, - } - entries := buildSessionList(rows, now) - if len(entries) != 1 { - t.Fatalf("got %d entries, want 1", len(entries)) - } - if entries[0].WorkspaceID != "ws-tagged" { - t.Errorf("WorkspaceID = %q, want %q", entries[0].WorkspaceID, "ws-tagged") - } - if entries[0].Type != "agent" { - t.Errorf("Type = %q, want %q", entries[0].Type, "agent") - } - if entries[0].AgeSeconds != 100 { - t.Errorf("AgeSeconds = %d, want 100", entries[0].AgeSeconds) - } -} - -func TestBuildSessionListFallsBackToInference(t *testing.T) { - now := time.Unix(1000, 0) - rows := []sessionRow{ - { - name: "amux-abc123-term-tab-3", - tags: map[string]string{}, - createdAt: 500, - }, - } - entries := buildSessionList(rows, now) - if len(entries) != 1 { - t.Fatalf("got %d entries, want 1", len(entries)) - } - if entries[0].WorkspaceID != "abc123" { - t.Errorf("WorkspaceID = %q, want %q", entries[0].WorkspaceID, "abc123") - } - if entries[0].Type != "term-tab" { - t.Errorf("Type = %q, want %q", entries[0].Type, "term-tab") - } -} - -// --- findPruneCandidates tests --- - -func TestFindPruneCandidatesOrphanedSession(t *testing.T) { - now := time.Unix(1000, 0) - rows := []sessionRow{ - {name: "amux-gone-tab-1", tags: map[string]string{"@amux_workspace": "gone"}, createdAt: 500}, - } - candidates := findPruneCandidates(rows, []data.WorkspaceID{"ws-a"}, 0, now) - if len(candidates) != 1 { - t.Fatalf("got %d candidates, want 1", len(candidates)) - } - if candidates[0].Reason != "orphaned_workspace" { - t.Errorf("reason = %q, want %q", candidates[0].Reason, "orphaned_workspace") - } -} - -func TestFindPruneCandidatesDetachedTermTab(t *testing.T) { - now := time.Unix(1000, 0) - rows := []sessionRow{ - {name: "amux-ws-a-term-tab-1", tags: map[string]string{"@amux_workspace": "ws-a", "@amux_type": "term-tab"}, createdAt: 500}, - } - candidates := findPruneCandidates(rows, []data.WorkspaceID{"ws-a"}, 0, now) - if len(candidates) != 1 { - t.Fatalf("got %d candidates, want 1", len(candidates)) - } - if candidates[0].Reason != "detached_terminal" { - t.Errorf("reason = %q, want %q", candidates[0].Reason, "detached_terminal") - } -} - -func TestFindPruneCandidatesAttachedNotPruned(t *testing.T) { - now := time.Unix(1000, 0) - rows := []sessionRow{ - {name: "amux-ws-a-term-tab-1", tags: map[string]string{"@amux_workspace": "ws-a", "@amux_type": "term-tab"}, attached: true, createdAt: 500}, - } - candidates := findPruneCandidates(rows, []data.WorkspaceID{"ws-a"}, 0, now) - if len(candidates) != 0 { - t.Fatalf("got %d candidates, want 0 (attached sessions should not be pruned)", len(candidates)) - } -} - -func TestFindPruneCandidatesAgentNotPruned(t *testing.T) { - now := time.Unix(1000, 0) - rows := []sessionRow{ - {name: "amux-ws-a-tab-1", tags: map[string]string{"@amux_workspace": "ws-a", "@amux_type": "agent"}, createdAt: 500}, - } - candidates := findPruneCandidates(rows, []data.WorkspaceID{"ws-a"}, 0, now) - if len(candidates) != 0 { - t.Fatalf("got %d candidates, want 0 (detached agents in valid workspace should not be pruned)", len(candidates)) - } -} - -func TestFindPruneCandidatesOlderThanSkipsUnknownAge(t *testing.T) { - now := time.Unix(1000, 0) - rows := []sessionRow{ - {name: "amux-ws-a-term-tab-1", tags: map[string]string{"@amux_workspace": "ws-a", "@amux_type": "term-tab"}, createdAt: 0}, - } - candidates := findPruneCandidates(rows, []data.WorkspaceID{"ws-a"}, 10*time.Minute, now) - if len(candidates) != 0 { - t.Fatalf("got %d candidates, want 0 (unknown age should be skipped when --older-than is set)", len(candidates)) - } -} - -func TestFindPruneCandidatesOlderThanFilter(t *testing.T) { - now := time.Unix(1000, 0) - rows := []sessionRow{ - {name: "amux-ws-a-term-tab-1", tags: map[string]string{"@amux_workspace": "ws-a", "@amux_type": "term-tab"}, createdAt: 999}, - {name: "amux-ws-a-term-tab-2", tags: map[string]string{"@amux_workspace": "ws-a", "@amux_type": "term-tab"}, createdAt: 100}, - } - candidates := findPruneCandidates(rows, []data.WorkspaceID{"ws-a"}, 10*time.Minute, now) - if len(candidates) != 1 { - t.Fatalf("got %d candidates, want 1", len(candidates)) - } - if candidates[0].Session != "amux-ws-a-term-tab-2" { - t.Errorf("session = %q, want %q", candidates[0].Session, "amux-ws-a-term-tab-2") - } -} - -func TestFindPruneCandidatesSkipsNonAmuxSessions(t *testing.T) { - now := time.Unix(1000, 0) - rows := []sessionRow{ - {name: "my-term-tab-1", tags: map[string]string{}, createdAt: 100}, - } - candidates := findPruneCandidates(rows, []data.WorkspaceID{"ws-a"}, 0, now) - if len(candidates) != 0 { - t.Fatalf("got %d candidates, want 0 (non-amux sessions should not be pruned)", len(candidates)) - } -} - -// --- isAmuxSession tests --- - -func TestIsAmuxSessionTagged(t *testing.T) { - row := sessionRow{name: "whatever", tags: map[string]string{"@amux_workspace": "ws-a"}} - if !isAmuxSession(row) { - t.Fatal("expected true for tagged session") - } -} - -func TestIsAmuxSessionPrefixed(t *testing.T) { - row := sessionRow{name: "amux-ws-a-tab-1", tags: map[string]string{}} - if !isAmuxSession(row) { - t.Fatal("expected true for amux-prefixed session") - } -} - -func TestIsAmuxSessionForeignNotMatched(t *testing.T) { - row := sessionRow{name: "my-term-tab-1", tags: map[string]string{}} - if isAmuxSession(row) { - t.Fatal("expected false for non-amux session") - } -} - -// --- humanReason tests --- - -func TestHumanReasonOrphaned(t *testing.T) { - if got := humanReason("orphaned_workspace"); got != "orphaned workspace" { - t.Fatalf("humanReason() = %q, want %q", got, "orphaned workspace") - } -} - -func TestHumanReasonDetached(t *testing.T) { - if got := humanReason("detached_terminal"); got != "detached terminal" { - t.Fatalf("humanReason() = %q, want %q", got, "detached terminal") - } -} - -func TestHumanReasonUnknown(t *testing.T) { - if got := humanReason("custom"); got != "custom" { - t.Fatalf("humanReason() = %q, want %q", got, "custom") - } -} diff --git a/internal/cli/cmd_session_write_test.go b/internal/cli/cmd_session_write_test.go deleted file mode 100644 index 78bb054c..00000000 --- a/internal/cli/cmd_session_write_test.go +++ /dev/null @@ -1,341 +0,0 @@ -package cli - -import ( - "bytes" - "encoding/json" - "errors" - "testing" - - "github.com/andyrewlee/amux/internal/data" - "github.com/andyrewlee/amux/internal/tmux" -) - -func testSessionServices(t *testing.T, queryFn func(tmux.Options) ([]sessionRow, error)) *Services { - t.Helper() - return &Services{ - Store: data.NewWorkspaceStore(t.TempDir()), - TmuxOpts: tmux.Options{}, - Version: "test-v1", - QuerySessionRows: queryFn, - } -} - -// --- routeSession tests --- - -func TestRouteSessionNoSubcommand(t *testing.T) { - var out, errOut bytes.Buffer - code := routeSession(&out, &errOut, GlobalFlags{}, nil, "test-v1") - if code != ExitUsage { - t.Fatalf("code = %d, want %d", code, ExitUsage) - } -} - -func TestRouteSessionNoSubcommandJSON(t *testing.T) { - var out, errOut bytes.Buffer - code := routeSession(&out, &errOut, GlobalFlags{JSON: true}, nil, "test-v1") - if code != ExitUsage { - t.Fatalf("code = %d, want %d", code, ExitUsage) - } - var env Envelope - if err := json.Unmarshal(out.Bytes(), &env); err != nil { - t.Fatalf("json.Unmarshal() error = %v", err) - } - if env.OK { - t.Fatalf("expected ok=false") - } -} - -func TestRouteSessionUnknownSubcommand(t *testing.T) { - var out, errOut bytes.Buffer - code := routeSession(&out, &errOut, GlobalFlags{}, []string{"bogus"}, "test-v1") - if code != ExitUsage { - t.Fatalf("code = %d, want %d", code, ExitUsage) - } -} - -func TestRouteSessionUnknownSubcommandJSON(t *testing.T) { - var out, errOut bytes.Buffer - code := routeSession(&out, &errOut, GlobalFlags{JSON: true}, []string{"bogus"}, "test-v1") - if code != ExitUsage { - t.Fatalf("code = %d, want %d", code, ExitUsage) - } - var env Envelope - if err := json.Unmarshal(out.Bytes(), &env); err != nil { - t.Fatalf("json.Unmarshal() error = %v", err) - } - if env.OK { - t.Fatalf("expected ok=false") - } -} - -// --- cmdSessionList tests --- - -func TestCmdSessionListJSON(t *testing.T) { - svc := testSessionServices(t, func(_ tmux.Options) ([]sessionRow, error) { - return []sessionRow{ - { - name: "amux-ws1-tab-1", - tags: map[string]string{"@amux_workspace": "ws1", "@amux_type": "agent"}, - attached: true, - createdAt: 1000, - }, - }, nil - }) - - var out, errOut bytes.Buffer - code := cmdSessionListWith(&out, &errOut, GlobalFlags{JSON: true}, nil, "test-v1", svc) - if code != ExitOK { - t.Fatalf("code = %d, want %d; stderr: %s", code, ExitOK, errOut.String()) - } - - var env Envelope - if err := json.Unmarshal(out.Bytes(), &env); err != nil { - t.Fatalf("json.Unmarshal() error = %v", err) - } - if !env.OK { - t.Fatalf("expected ok=true") - } -} - -func TestCmdSessionListRejectsArgs(t *testing.T) { - var out, errOut bytes.Buffer - code := cmdSessionListWith(&out, &errOut, GlobalFlags{JSON: true}, []string{"extra"}, "test-v1", nil) - if code != ExitUsage { - t.Fatalf("code = %d, want %d", code, ExitUsage) - } -} - -// --- cmdSessionPrune tests --- - -func TestCmdSessionPruneDryRunJSON(t *testing.T) { - svc := testSessionServices(t, func(_ tmux.Options) ([]sessionRow, error) { - return []sessionRow{ - { - name: "amux-gone-tab-1", - tags: map[string]string{"@amux_workspace": "gone", "@amux_type": "agent"}, - createdAt: 100, - }, - }, nil - }) - - var out, errOut bytes.Buffer - code := cmdSessionPruneWith(&out, &errOut, GlobalFlags{JSON: true}, nil, "test-v1", svc) - if code != ExitOK { - t.Fatalf("code = %d, want %d; stderr: %s", code, ExitOK, errOut.String()) - } - - var env Envelope - if err := json.Unmarshal(out.Bytes(), &env); err != nil { - t.Fatalf("json.Unmarshal() error = %v", err) - } - if !env.OK { - t.Fatalf("expected ok=true for dry run") - } - - raw, _ := json.Marshal(env.Data) - var result pruneResult - if err := json.Unmarshal(raw, &result); err != nil { - t.Fatalf("unmarshal pruneResult: %v", err) - } - if !result.DryRun { - t.Fatalf("expected dry_run=true") - } - if result.Total != 1 { - t.Fatalf("total = %d, want 1", result.Total) - } -} - -func TestCmdSessionPruneYesJSON(t *testing.T) { - svc := testSessionServices(t, func(_ tmux.Options) ([]sessionRow, error) { - return []sessionRow{ - { - name: "amux-gone-tab-1", - tags: map[string]string{"@amux_workspace": "gone", "@amux_type": "agent"}, - createdAt: 100, - }, - }, nil - }) - - killed := []string{} - origKill := tmuxKillSession - defer func() { tmuxKillSession = origKill }() - tmuxKillSession = func(name string, _ tmux.Options) error { - killed = append(killed, name) - return nil - } - - var out, errOut bytes.Buffer - code := cmdSessionPruneWith(&out, &errOut, GlobalFlags{JSON: true}, []string{"--yes"}, "test-v1", svc) - if code != ExitOK { - t.Fatalf("code = %d, want %d; stderr: %s", code, ExitOK, errOut.String()) - } - - if len(killed) != 1 || killed[0] != "amux-gone-tab-1" { - t.Fatalf("killed = %v, want [amux-gone-tab-1]", killed) - } - - var env Envelope - if err := json.Unmarshal(out.Bytes(), &env); err != nil { - t.Fatalf("json.Unmarshal() error = %v", err) - } - if !env.OK { - t.Fatalf("expected ok=true") - } -} - -func TestCmdSessionPruneOlderThanInvalid(t *testing.T) { - var out, errOut bytes.Buffer - code := cmdSessionPruneWith(&out, &errOut, GlobalFlags{JSON: true}, []string{"--older-than", "abc"}, "test-v1", nil) - if code != ExitUsage { - t.Fatalf("code = %d, want %d", code, ExitUsage) - } -} - -func TestCmdSessionPruneOlderThanNonPositive(t *testing.T) { - var out, errOut bytes.Buffer - code := cmdSessionPruneWith(&out, &errOut, GlobalFlags{JSON: true}, []string{"--older-than", "-5m"}, "test-v1", nil) - if code != ExitUsage { - t.Fatalf("code = %d, want %d", code, ExitUsage) - } -} - -func TestCmdSessionPruneRejectsExtraArgs(t *testing.T) { - var out, errOut bytes.Buffer - code := cmdSessionPruneWith(&out, &errOut, GlobalFlags{JSON: true}, []string{"extra"}, "test-v1", nil) - if code != ExitUsage { - t.Fatalf("code = %d, want %d", code, ExitUsage) - } -} - -func TestCmdSessionPruneDryRunHuman(t *testing.T) { - svc := testSessionServices(t, func(_ tmux.Options) ([]sessionRow, error) { - return []sessionRow{ - { - name: "amux-ws-a-term-tab-1", - tags: map[string]string{"@amux_workspace": "ws-a", "@amux_type": "term-tab"}, - createdAt: 100, - }, - }, nil - }) - - var out, errOut bytes.Buffer - code := cmdSessionPruneWith(&out, &errOut, GlobalFlags{}, nil, "test-v1", svc) - if code != ExitOK { - t.Fatalf("code = %d, want %d; stderr: %s", code, ExitOK, errOut.String()) - } - - output := out.String() - if !bytes.Contains(out.Bytes(), []byte("Would prune")) { - t.Fatalf("expected dry-run message, got %q", output) - } -} - -func TestCmdSessionPruneNothingToPrune(t *testing.T) { - svc := testSessionServices(t, func(_ tmux.Options) ([]sessionRow, error) { - return nil, nil - }) - - var out, errOut bytes.Buffer - code := cmdSessionPruneWith(&out, &errOut, GlobalFlags{JSON: true}, []string{"--yes"}, "test-v1", svc) - if code != ExitOK { - t.Fatalf("code = %d, want %d", code, ExitOK) - } - - var env Envelope - if err := json.Unmarshal(out.Bytes(), &env); err != nil { - t.Fatalf("json.Unmarshal() error = %v", err) - } - if !env.OK { - t.Fatalf("expected ok=true") - } -} - -func TestCmdSessionPrunePartialFailureReturnsError(t *testing.T) { - svc := testSessionServices(t, func(_ tmux.Options) ([]sessionRow, error) { - return []sessionRow{ - { - name: "amux-gone-tab-1", - tags: map[string]string{"@amux_workspace": "gone", "@amux_type": "agent"}, - createdAt: 100, - }, - { - name: "amux-gone-tab-2", - tags: map[string]string{"@amux_workspace": "gone", "@amux_type": "agent"}, - createdAt: 100, - }, - }, nil - }) - - origKill := tmuxKillSession - defer func() { tmuxKillSession = origKill }() - tmuxKillSession = func(name string, _ tmux.Options) error { - if name == "amux-gone-tab-2" { - return errors.New("kill failed") - } - return nil - } - - var out, errOut bytes.Buffer - code := cmdSessionPruneWith(&out, &errOut, GlobalFlags{JSON: true}, []string{"--yes"}, "test-v1", svc) - if code != ExitInternalError { - t.Fatalf("code = %d, want %d", code, ExitInternalError) - } - - var env Envelope - if err := json.Unmarshal(out.Bytes(), &env); err != nil { - t.Fatalf("json.Unmarshal() error = %v", err) - } - if env.OK { - t.Fatalf("expected ok=false for partial failure") - } - if env.Error == nil || env.Error.Code != "prune_partial_failed" { - t.Fatalf("expected error code prune_partial_failed, got %#v", env.Error) - } -} - -func TestCmdSessionPruneFullSuccessReturnsOK(t *testing.T) { - svc := testSessionServices(t, func(_ tmux.Options) ([]sessionRow, error) { - return []sessionRow{ - { - name: "amux-gone-tab-1", - tags: map[string]string{"@amux_workspace": "gone", "@amux_type": "agent"}, - createdAt: 100, - }, - }, nil - }) - - origKill := tmuxKillSession - defer func() { tmuxKillSession = origKill }() - tmuxKillSession = func(_ string, _ tmux.Options) error { - return nil - } - - var out, errOut bytes.Buffer - code := cmdSessionPruneWith(&out, &errOut, GlobalFlags{JSON: true}, []string{"--yes"}, "test-v1", svc) - if code != ExitOK { - t.Fatalf("code = %d, want %d", code, ExitOK) - } - - var env Envelope - if err := json.Unmarshal(out.Bytes(), &env); err != nil { - t.Fatalf("json.Unmarshal() error = %v", err) - } - if !env.OK { - t.Fatalf("expected ok=true for full success") - } -} - -func TestCmdSessionListHumanEmpty(t *testing.T) { - svc := testSessionServices(t, func(_ tmux.Options) ([]sessionRow, error) { - return nil, nil - }) - - var out, errOut bytes.Buffer - code := cmdSessionListWith(&out, &errOut, GlobalFlags{}, nil, "test-v1", svc) - if code != ExitOK { - t.Fatalf("code = %d, want %d", code, ExitOK) - } - if !bytes.Contains(out.Bytes(), []byte("No sessions")) { - t.Fatalf("expected 'No sessions' message, got %q", out.String()) - } -} diff --git a/internal/cli/cmd_status.go b/internal/cli/cmd_status.go deleted file mode 100644 index 120f5e26..00000000 --- a/internal/cli/cmd_status.go +++ /dev/null @@ -1,87 +0,0 @@ -package cli - -import ( - "fmt" - "io" - "strings" - - "github.com/andyrewlee/amux/internal/tmux" -) - -type statusResult struct { - Version string `json:"version"` - TmuxAvailable bool `json:"tmux_available"` - HomeReadable bool `json:"home_readable"` - ProjectCount int `json:"project_count"` - WorkspaceCount int `json:"workspace_count"` - SessionCount int `json:"session_count"` -} - -func cmdStatus(w, wErr io.Writer, gf GlobalFlags, args []string, version string) int { - const usage = "Usage: amux status [--json]" - if len(args) > 0 { - return returnUsageError( - w, - wErr, - gf, - usage, - version, - fmt.Errorf("unexpected arguments: %s", strings.Join(args, " ")), - ) - } - - svc, code := initServicesOrFail(w, wErr, gf, version) - if code >= 0 { - return code - } - - result := statusResult{Version: svc.Version} - - // Tmux - result.TmuxAvailable = tmux.EnsureAvailable() == nil - - // Home dir - result.HomeReadable = isReadable(svc.Config.Paths.Home) - - // Projects - projects, err := svc.Registry.Projects() - if err == nil { - result.ProjectCount = len(projects) - } - - // Workspaces - wsIDs, err := svc.Store.List() - if err == nil { - result.WorkspaceCount = len(wsIDs) - } - - // Sessions - if result.TmuxAvailable { - sessions, err := tmux.ListSessions(svc.TmuxOpts) - if err == nil { - result.SessionCount = len(sessions) - } - } - - if gf.JSON { - PrintJSON(w, result, version) - return ExitOK - } - - PrintHuman(w, func(w io.Writer) { - fmt.Fprintf(w, "amux %s\n", result.Version) - fmt.Fprintf(w, " tmux: %s\n", boolStatus(result.TmuxAvailable)) - fmt.Fprintf(w, " home: %s\n", boolStatus(result.HomeReadable)) - fmt.Fprintf(w, " projects: %d\n", result.ProjectCount) - fmt.Fprintf(w, " workspaces: %d\n", result.WorkspaceCount) - fmt.Fprintf(w, " sessions: %d\n", result.SessionCount) - }) - return ExitOK -} - -func boolStatus(ok bool) string { - if ok { - return "ok" - } - return "unavailable" -} diff --git a/internal/cli/cmd_status_test.go b/internal/cli/cmd_status_test.go deleted file mode 100644 index be8675ee..00000000 --- a/internal/cli/cmd_status_test.go +++ /dev/null @@ -1,78 +0,0 @@ -package cli - -import ( - "bytes" - "encoding/json" - "strings" - "testing" -) - -func TestCmdStatusJSON(t *testing.T) { - var w, wErr bytes.Buffer - gf := GlobalFlags{JSON: true} - code := cmdStatus(&w, &wErr, gf, nil, "test-v1") - - if code != ExitOK { - t.Fatalf("expected exit 0, got %d; stderr: %s", code, wErr.String()) - } - - var env Envelope - if err := json.Unmarshal(w.Bytes(), &env); err != nil { - t.Fatalf("failed to decode JSON: %v\nraw: %s", err, w.String()) - } - if !env.OK { - t.Error("expected ok=true") - } - - data, ok := env.Data.(map[string]any) - if !ok { - t.Fatalf("expected data to be an object, got %T", env.Data) - } - if _, exists := data["version"]; !exists { - t.Error("expected 'version' in data") - } - if _, exists := data["tmux_available"]; !exists { - t.Error("expected 'tmux_available' in data") - } -} - -func TestCmdStatusHuman(t *testing.T) { - var w, wErr bytes.Buffer - gf := GlobalFlags{JSON: false} - code := cmdStatus(&w, &wErr, gf, nil, "test-v1") - - if code != ExitOK { - t.Fatalf("expected exit 0, got %d; stderr: %s", code, wErr.String()) - } - - output := w.String() - if output == "" { - t.Error("expected non-empty human output") - } -} - -func TestCmdStatusUnexpectedArgsReturnsUsageError(t *testing.T) { - var w bytes.Buffer - var wErr bytes.Buffer - code := cmdStatus(&w, &wErr, GlobalFlags{JSON: true}, []string{"garbage"}, "test-v1") - if code != ExitUsage { - t.Fatalf("cmdStatus() code = %d, want %d", code, ExitUsage) - } - if wErr.Len() != 0 { - t.Fatalf("expected no stderr output in JSON mode, got %q", wErr.String()) - } - - var env Envelope - if err := json.Unmarshal(w.Bytes(), &env); err != nil { - t.Fatalf("json.Unmarshal() error = %v\nraw: %s", err, w.String()) - } - if env.OK { - t.Fatalf("expected ok=false") - } - if env.Error == nil || env.Error.Code != "usage_error" { - t.Fatalf("expected usage_error, got %#v", env.Error) - } - if env.Error == nil || !strings.Contains(env.Error.Message, "unexpected arguments") { - t.Fatalf("unexpected usage_error message: %#v", env.Error) - } -} diff --git a/internal/cli/cmd_terminal.go b/internal/cli/cmd_terminal.go deleted file mode 100644 index 51a4be18..00000000 --- a/internal/cli/cmd_terminal.go +++ /dev/null @@ -1,122 +0,0 @@ -package cli - -import ( - "fmt" - "io" - "strings" - "time" -) - -type terminalInfo struct { - SessionName string `json:"session_name"` - WorkspaceID string `json:"workspace_id"` - Attached bool `json:"attached"` - CreatedAt int64 `json:"created_at"` - AgeSeconds int64 `json:"age_seconds"` -} - -type terminalRunResult struct { - SessionName string `json:"session_name"` - WorkspaceID string `json:"workspace_id"` - Created bool `json:"created"` - Command string `json:"command"` -} - -type terminalLogsResult struct { - SessionName string `json:"session_name"` - WorkspaceID string `json:"workspace_id"` - Lines int `json:"lines"` - Content string `json:"content"` -} - -func routeTerminal(w, wErr io.Writer, gf GlobalFlags, args []string, version string) int { - return routeSubcommand(w, wErr, gf, args, version, "terminal", []subcommand{ - {names: []string{"list", "ls"}, handler: cmdTerminalList}, - {names: []string{"run"}, handler: cmdTerminalRun}, - {names: []string{"logs"}, handler: cmdTerminalLogs}, - }) -} - -func cmdTerminalList(w, wErr io.Writer, gf GlobalFlags, args []string, version string) int { - const usage = "Usage: amux terminal list [--workspace ] [--json]" - fs := newFlagSet("terminal list") - workspace := fs.String("workspace", "", "filter by workspace ID") - if err := fs.Parse(args); err != nil { - return returnUsageError(w, wErr, gf, usage, version, err) - } - - filterWS := "" - if strings.TrimSpace(*workspace) != "" { - wsID, err := parseWorkspaceIDFlag(*workspace) - if err != nil { - return returnUsageError(w, wErr, gf, usage, version, err) - } - filterWS = string(wsID) - } - - svc, code := initServicesOrFail(w, wErr, gf, version) - if code >= 0 { - return code - } - - rows, err := svc.QuerySessionRows(svc.TmuxOpts) - if err != nil { - return returnOperationError(w, wErr, gf, version, - ExitInternalError, "list_failed", err, nil, - "failed to list terminal sessions: %v", err) - } - - now := time.Now() - var terminals []terminalInfo - for _, row := range rows { - sessionType := strings.TrimSpace(row.tags["@amux_type"]) - if sessionType == "" { - sessionType = inferSessionType(row.name) - } - if !isTermTabType(sessionType) { - continue - } - wsID := strings.TrimSpace(row.tags["@amux_workspace"]) - if wsID == "" { - wsID = inferWorkspaceID(row.name) - } - if filterWS != "" && wsID != filterWS { - continue - } - ageSeconds := int64(0) - if row.createdAt > 0 { - ageSeconds = int64(now.Sub(time.Unix(row.createdAt, 0)).Seconds()) - if ageSeconds < 0 { - ageSeconds = 0 - } - } - terminals = append(terminals, terminalInfo{ - SessionName: row.name, - WorkspaceID: wsID, - Attached: row.attached, - CreatedAt: row.createdAt, - AgeSeconds: ageSeconds, - }) - } - - if gf.JSON { - PrintJSON(w, terminals, version) - return ExitOK - } - - PrintHuman(w, func(w io.Writer) { - if len(terminals) == 0 { - fmt.Fprintln(w, "No terminal sessions.") - return - } - for _, t := range terminals { - attached := "" - if t.Attached { - attached = " (attached)" - } - fmt.Fprintf(w, " %-45s ws=%-16s age=%s%s\n", - t.SessionName, t.WorkspaceID, formatAge(t.AgeSeconds), attached) - } - }) - return ExitOK -} diff --git a/internal/cli/cmd_terminal_logs.go b/internal/cli/cmd_terminal_logs.go deleted file mode 100644 index aaa0704c..00000000 --- a/internal/cli/cmd_terminal_logs.go +++ /dev/null @@ -1,99 +0,0 @@ -package cli - -import ( - "errors" - "fmt" - "io" - "strings" - "time" - - "github.com/andyrewlee/amux/internal/tmux" -) - -func cmdTerminalLogs(w, wErr io.Writer, gf GlobalFlags, args []string, version string) int { - const usage = "Usage: amux terminal logs --workspace [--lines N] [--follow] [--interval ] [--idle-threshold ] [--json]" - fs := newFlagSet("terminal logs") - workspace := fs.String("workspace", "", "workspace ID (required)") - lines := fs.Int("lines", 200, "number of lines to capture") - follow := fs.Bool("follow", false, "stream terminal output as NDJSON") - interval := fs.Duration("interval", 500*time.Millisecond, "poll interval when --follow") - idleThreshold := fs.Duration("idle-threshold", 5*time.Second, "idle event threshold when --follow") - if err := fs.Parse(args); err != nil { - return returnUsageError(w, wErr, gf, usage, version, err) - } - if strings.TrimSpace(*workspace) == "" { - return returnUsageError(w, wErr, gf, usage, version, nil) - } - if *lines <= 0 { - return returnUsageError(w, wErr, gf, usage, version, errors.New("--lines must be > 0")) - } - if *follow { - if *interval <= 0 { - return returnUsageError(w, wErr, gf, usage, version, errors.New("--interval must be > 0")) - } - if *idleThreshold <= 0 { - return returnUsageError(w, wErr, gf, usage, version, errors.New("--idle-threshold must be > 0")) - } - } - - wsID, err := parseWorkspaceIDFlag(*workspace) - if err != nil { - return returnUsageError(w, wErr, gf, usage, version, err) - } - - svc, code := initServicesOrFail(w, wErr, gf, version) - if code >= 0 { - return code - } - - sessionName, found, err := resolveTerminalSessionForWorkspace(wsID, svc.TmuxOpts, svc.QuerySessionRows) - if err != nil { - return returnOperationError(w, wErr, gf, version, - ExitInternalError, "session_lookup_failed", err, map[string]any{"workspace_id": string(wsID)}, - "failed to lookup terminal session for %s: %v", wsID, err) - } - if !found { - return returnOperationError(w, wErr, gf, version, - ExitNotFound, "not_found", errors.New("no terminal session found for workspace"), - map[string]any{"workspace_id": string(wsID)}, - "no terminal session found for workspace %s", wsID) - } - - if *follow { - cfg := watchConfig{ - SessionName: sessionName, - Lines: *lines, - Interval: *interval, - IdleThreshold: *idleThreshold, - } - ctx, cancel := contextWithSignal() - defer cancel() - return runWatchLoop(ctx, w, cfg, svc.TmuxOpts) - } - - content, ok := tmux.CapturePaneTail(sessionName, *lines, svc.TmuxOpts) - if !ok { - return returnOperationError(w, wErr, gf, version, - ExitNotFound, "capture_failed", errors.New("could not capture pane output"), - map[string]any{"session_name": sessionName}, - "could not capture pane output for session %s", sessionName) - } - - result := terminalLogsResult{ - SessionName: sessionName, - WorkspaceID: string(wsID), - Lines: *lines, - Content: content, - } - if gf.JSON { - PrintJSON(w, result, version) - return ExitOK - } - PrintHuman(w, func(w io.Writer) { - fmt.Fprint(w, content) - if content != "" && content[len(content)-1] != '\n' { - fmt.Fprintln(w) - } - }) - return ExitOK -} diff --git a/internal/cli/cmd_terminal_run.go b/internal/cli/cmd_terminal_run.go deleted file mode 100644 index 4b94c52d..00000000 --- a/internal/cli/cmd_terminal_run.go +++ /dev/null @@ -1,208 +0,0 @@ -package cli - -import ( - "errors" - "fmt" - "io" - "log/slog" - "os" - "strconv" - "strings" - "time" - - "github.com/andyrewlee/amux/internal/data" - "github.com/andyrewlee/amux/internal/tmux" -) - -func cmdTerminalRun(w, wErr io.Writer, gf GlobalFlags, args []string, version string) int { - const usage = "Usage: amux terminal run --workspace --text [--enter=true] [--create=true] [--json]" - fs := newFlagSet("terminal run") - workspace := fs.String("workspace", "", "workspace ID (required)") - text := fs.String("text", "", "command text to send (required)") - enter := fs.Bool("enter", true, "send Enter key after text") - create := fs.Bool("create", true, "create terminal session when missing") - if err := fs.Parse(args); err != nil { - return returnUsageError(w, wErr, gf, usage, version, err) - } - if fs.NArg() > 0 { - return returnUsageError( - w, - wErr, - gf, - usage, - version, - fmt.Errorf("unexpected arguments: %s", strings.Join(fs.Args(), " ")), - ) - } - if strings.TrimSpace(*workspace) == "" || strings.TrimSpace(*text) == "" { - return returnUsageError(w, wErr, gf, usage, version, nil) - } - - wsID, err := parseWorkspaceIDFlag(*workspace) - if err != nil { - return returnUsageError(w, wErr, gf, usage, version, err) - } - - svc, code := initServicesOrFail(w, wErr, gf, version) - if code >= 0 { - return code - } - - sessionName, found, err := resolveTerminalSessionForWorkspace(wsID, svc.TmuxOpts, svc.QuerySessionRows) - if err != nil { - return returnOperationError(w, wErr, gf, version, - ExitInternalError, "session_lookup_failed", err, map[string]any{"workspace_id": string(wsID)}, - "failed to lookup terminal session for %s: %v", wsID, err) - } - - created := false - if !found { - if !*create { - return returnOperationError(w, wErr, gf, version, - ExitNotFound, "not_found", errors.New("no terminal session found for workspace"), - map[string]any{"workspace_id": string(wsID)}, - "no terminal session found for workspace %s", wsID) - } - ws, err := svc.Store.Load(wsID) - if err != nil { - return returnOperationError(w, wErr, gf, version, - ExitNotFound, "not_found", fmt.Errorf("workspace %s not found", wsID), nil, - "workspace %s not found", wsID) - } - sessionName, err = createWorkspaceTerminalSession(ws, wsID, svc.TmuxOpts) - if err != nil { - return returnOperationError(w, wErr, gf, version, - ExitInternalError, "session_create_failed", err, map[string]any{"workspace_id": string(wsID)}, - "failed to create terminal session for %s: %v", wsID, err) - } - created = true - } - - command := *text - if err := tmuxSendKeys(sessionName, command, *enter, svc.TmuxOpts); err != nil { - return returnOperationError(w, wErr, gf, version, - ExitInternalError, "send_failed", err, map[string]any{ - "workspace_id": string(wsID), - "session_name": sessionName, - }, - "failed to send command to %s: %v", sessionName, err) - } - - result := terminalRunResult{ - SessionName: sessionName, - WorkspaceID: string(wsID), - Created: created, - Command: command, - } - if gf.JSON { - PrintJSON(w, result, version) - return ExitOK - } - PrintHuman(w, func(w io.Writer) { - createdSuffix := "" - if created { - createdSuffix = " (created)" - } - fmt.Fprintf(w, "Sent to terminal %s%s\n", sessionName, createdSuffix) - }) - return ExitOK -} - -func resolveTerminalSessionForWorkspace(wsID data.WorkspaceID, opts tmux.Options, queryRows ...func(tmux.Options) ([]sessionRow, error)) (string, bool, error) { - queryFn := defaultQuerySessionRows - if len(queryRows) > 0 && queryRows[0] != nil { - queryFn = queryRows[0] - } - rows, err := queryFn(opts) - if err != nil { - return "", false, err - } - target := string(wsID) - - bestName := "" - bestAttached := false - bestCreated := int64(-1) - for _, row := range rows { - sessionType := strings.TrimSpace(row.tags["@amux_type"]) - if sessionType == "" { - sessionType = inferSessionType(row.name) - } - if !isTermTabType(sessionType) { - continue - } - rowWSID := strings.TrimSpace(row.tags["@amux_workspace"]) - if rowWSID == "" { - rowWSID = inferWorkspaceID(row.name) - } - if rowWSID != target { - continue - } - if bestName == "" || - (row.attached && !bestAttached) || - (row.attached == bestAttached && row.createdAt > bestCreated) { - bestName = row.name - bestAttached = row.attached - bestCreated = row.createdAt - } - } - if bestName == "" { - return "", false, nil - } - return bestName, true, nil -} - -func createWorkspaceTerminalSession(ws *data.Workspace, wsID data.WorkspaceID, opts tmux.Options) (string, error) { - if ws == nil { - return "", errors.New("workspace is required") - } - root := strings.TrimSpace(ws.Root) - if root == "" { - return "", errors.New("workspace root is empty") - } - - tabID := "term-tab-" + strconv.FormatInt(time.Now().UnixNano(), 36) - sessionName := tmux.SessionName("amux", string(wsID), tabID) - createArgs := []string{ - "new-session", "-d", "-s", sessionName, "-c", root, terminalShellCommand(), - } - cmd, cancel := tmuxStartSession(opts, createArgs...) - defer cancel() - if err := cmd.Run(); err != nil { - return "", err - } - - now := time.Now() - nowUnix := strconv.FormatInt(now.Unix(), 10) - nowMS := strconv.FormatInt(now.UnixMilli(), 10) - tags := []struct { - Key string - Value string - }{ - {Key: "@amux", Value: "1"}, - {Key: "@amux_workspace", Value: string(wsID)}, - {Key: "@amux_tab", Value: tabID}, - {Key: "@amux_type", Value: "terminal"}, - {Key: "@amux_assistant", Value: "terminal"}, - {Key: "@amux_created_at", Value: nowUnix}, - {Key: "@amux_instance", Value: "cli"}, - {Key: tmux.TagSessionOwner, Value: "cli"}, - {Key: tmux.TagSessionLeaseAt, Value: nowMS}, - } - for _, tag := range tags { - if err := tmuxSetSessionTag(sessionName, tag.Key, tag.Value, opts); err != nil { - if killErr := tmuxKillSession(sessionName, opts); killErr != nil { - slog.Debug("best-effort session kill failed", "session", sessionName, "error", killErr) - } - return "", fmt.Errorf("failed to set %s: %w", tag.Key, err) - } - } - return sessionName, nil -} - -func terminalShellCommand() string { - shell := strings.TrimSpace(os.Getenv("SHELL")) - if shell == "" { - return "sh" - } - return shell -} diff --git a/internal/cli/cmd_terminal_test.go b/internal/cli/cmd_terminal_test.go deleted file mode 100644 index 29269b81..00000000 --- a/internal/cli/cmd_terminal_test.go +++ /dev/null @@ -1,141 +0,0 @@ -package cli - -import ( - "bytes" - "encoding/json" - "errors" - "testing" - - "github.com/andyrewlee/amux/internal/data" - "github.com/andyrewlee/amux/internal/tmux" -) - -func TestResolveTerminalSessionForWorkspacePrefersAttachedThenNewest(t *testing.T) { - queryFn := func(_ tmux.Options) ([]sessionRow, error) { - return []sessionRow{ - {name: "amux-ws-a-term-tab-1", tags: map[string]string{"@amux_workspace": "ws-a", "@amux_type": "terminal"}, attached: false, createdAt: 100}, - {name: "amux-ws-a-term-tab-2", tags: map[string]string{"@amux_workspace": "ws-a", "@amux_type": "terminal"}, attached: false, createdAt: 200}, - {name: "amux-ws-a-term-tab-3", tags: map[string]string{"@amux_workspace": "ws-a", "@amux_type": "terminal"}, attached: true, createdAt: 50}, - {name: "amux-ws-b-term-tab-1", tags: map[string]string{"@amux_workspace": "ws-b", "@amux_type": "terminal"}, attached: true, createdAt: 999}, - }, nil - } - - got, ok, err := resolveTerminalSessionForWorkspace(data.WorkspaceID("ws-a"), tmux.Options{}, queryFn) - if err != nil { - t.Fatalf("resolveTerminalSessionForWorkspace() error = %v", err) - } - if !ok { - t.Fatal("expected session to be found") - } - if got != "amux-ws-a-term-tab-3" { - t.Fatalf("session = %q, want %q", got, "amux-ws-a-term-tab-3") - } -} - -func TestResolveTerminalSessionForWorkspaceReturnsQueryError(t *testing.T) { - wantErr := errors.New("query failed") - queryFn := func(_ tmux.Options) ([]sessionRow, error) { - return nil, wantErr - } - - _, ok, err := resolveTerminalSessionForWorkspace(data.WorkspaceID("ws-a"), tmux.Options{}, queryFn) - if !errors.Is(err, wantErr) { - t.Fatalf("error = %v, want %v", err, wantErr) - } - if ok { - t.Fatal("expected ok=false on query error") - } -} - -func TestCmdTerminalRunRejectsUnexpectedPositionalArgs(t *testing.T) { - var out bytes.Buffer - var errOut bytes.Buffer - code := cmdTerminalRun( - &out, - &errOut, - GlobalFlags{JSON: true}, - []string{"--workspace", "0123456789abcdef", "--text", "npm", "run", "dev"}, - "test-v1", - ) - if code != ExitUsage { - t.Fatalf("cmdTerminalRun() code = %d, want %d", code, ExitUsage) - } - if errOut.Len() != 0 { - t.Fatalf("expected no stderr output in JSON mode, got %q", errOut.String()) - } - - var env Envelope - if err := json.Unmarshal(out.Bytes(), &env); err != nil { - t.Fatalf("json.Unmarshal() error = %v", err) - } - if env.OK { - t.Fatalf("expected ok=false") - } - if env.Error == nil || env.Error.Code != "usage_error" { - t.Fatalf("expected usage_error, got %#v", env.Error) - } -} - -func TestCmdTerminalRunPreservesWhitespaceInTextPayload(t *testing.T) { - t.Setenv("HOME", t.TempDir()) - - origSend := tmuxSendKeys - t.Cleanup(func() { - tmuxSendKeys = origSend - }) - - const workspaceID = "0123456789abcdef" - - // Override the service's QuerySessionRows via setCLITmuxTimeoutOverride pattern - // isn't needed here — we override via NewServices by setting HOME to a temp dir. - // The test uses cmdTerminalRun which creates its own Services. - // We need to ensure the service's QuerySessionRows returns our mock data. - // Since NewServices sets QuerySessionRows to defaultQuerySessionRows, and we - // can't easily override that, we skip this integration-level test when tmux - // is not available. - if err := tmux.EnsureAvailable(); err != nil { - t.Skip("tmux not available, skipping integration test") - } - - var gotSession string - var gotText string - var gotEnter bool - tmuxSendKeys = func(name, text string, enter bool, _ tmux.Options) error { - gotSession = name - gotText = text - gotEnter = enter - return nil - } - - raw := " npm run dev " - var out bytes.Buffer - var errOut bytes.Buffer - code := cmdTerminalRun( - &out, - &errOut, - GlobalFlags{JSON: true}, - []string{"--workspace", workspaceID, "--text", raw, "--enter=false"}, - "test-v1", - ) - // This test requires a live tmux server with a matching session. - // Without the old sessionQueryRows mock, we can only verify the - // argument-parsing path (ExitUsage) or skip if tmux returns an error. - if code == ExitInternalError || code == ExitNotFound { - t.Skip("tmux session not available, skipping integration test") - } - if code != ExitOK { - t.Fatalf("cmdTerminalRun() code = %d, want %d", code, ExitOK) - } - if errOut.Len() != 0 { - t.Fatalf("expected no stderr output in JSON mode, got %q", errOut.String()) - } - if gotSession != "amux-test-term-tab-1" { - t.Fatalf("session = %q, want %q", gotSession, "amux-test-term-tab-1") - } - if gotText != raw { - t.Fatalf("text = %q, want %q", gotText, raw) - } - if gotEnter { - t.Fatalf("enter = true, want false") - } -} diff --git a/internal/cli/cmd_workspace.go b/internal/cli/cmd_workspace.go deleted file mode 100644 index 8849a82a..00000000 --- a/internal/cli/cmd_workspace.go +++ /dev/null @@ -1,150 +0,0 @@ -package cli - -import ( - "errors" - "fmt" - "io" - "path/filepath" - "strings" - - "github.com/andyrewlee/amux/internal/data" -) - -// WorkspaceInfo is the JSON-serializable workspace representation. -type WorkspaceInfo struct { - ID string `json:"id"` - Name string `json:"name"` - Branch string `json:"branch"` - Base string `json:"base"` - Repo string `json:"repo"` - Root string `json:"root"` - Runtime string `json:"runtime"` - Assistant string `json:"assistant"` - Archived bool `json:"archived"` - Created string `json:"created"` - TabCount int `json:"tab_count"` -} - -func workspaceToInfo(ws *data.Workspace) WorkspaceInfo { - created := "" - if !ws.Created.IsZero() { - created = ws.Created.UTC().Format("2006-01-02T15:04:05Z") - } - return WorkspaceInfo{ - ID: string(ws.ID()), - Name: ws.Name, - Branch: ws.Branch, - Base: ws.Base, - Repo: ws.Repo, - Root: ws.Root, - Runtime: ws.Runtime, - Assistant: ws.Assistant, - Archived: ws.Archived, - Created: created, - TabCount: len(ws.OpenTabs), - } -} - -func cmdWorkspaceList(w, wErr io.Writer, gf GlobalFlags, args []string, version string) int { - const usage = "Usage: amux workspace list [--repo |--project ] [--archived] [--json]" - fs := newFlagSet("workspace list") - repo := fs.String("repo", "", "filter by repo path") - project := fs.String("project", "", "alias for --repo") - archived := fs.Bool("archived", false, "include archived workspaces") - if err := fs.Parse(args); err != nil { - return returnUsageError(w, wErr, gf, usage, version, err) - } - if strings.TrimSpace(*repo) != "" && strings.TrimSpace(*project) != "" { - return returnUsageError( - w, wErr, gf, usage, version, - errors.New("use either --repo or --project, not both"), - ) - } - - svc, code := initServicesOrFail(w, wErr, gf, version) - if code >= 0 { - return code - } - - var infos []WorkspaceInfo - var err error - - repoFilter := strings.TrimSpace(*repo) - if repoFilter == "" { - repoFilter = strings.TrimSpace(*project) - } - if repoFilter != "" { - repoPath := repoFilter - if canonical, cErr := canonicalizeProjectPath(repoPath); cErr == nil { - repoPath = canonical - } else if abs, aErr := filepath.Abs(repoPath); aErr == nil { - repoPath = abs - } - infos, err = listByRepo(svc, repoPath, *archived) - } else { - infos, err = listAll(svc, *archived) - } - if err != nil { - return returnOperationError(w, wErr, gf, version, - ExitInternalError, "list_failed", err, nil, - "%v", err) - } - - if gf.JSON { - PrintJSON(w, infos, version) - return ExitOK - } - - PrintHuman(w, func(w io.Writer) { - if len(infos) == 0 { - fmt.Fprintln(w, "No workspaces found.") - return - } - for _, info := range infos { - status := "" - if info.Archived { - status = " (archived)" - } - fmt.Fprintf(w, " %-16s %-20s %-20s %s%s\n", - info.ID, info.Name, info.Branch, info.Repo, status) - } - }) - return ExitOK -} - -func listByRepo(svc *Services, repoPath string, includeArchived bool) ([]WorkspaceInfo, error) { - var workspaces []*data.Workspace - var err error - if includeArchived { - workspaces, err = svc.Store.ListByRepoIncludingArchived(repoPath) - } else { - workspaces, err = svc.Store.ListByRepo(repoPath) - } - if err != nil { - return nil, err - } - infos := make([]WorkspaceInfo, 0, len(workspaces)) - for _, ws := range workspaces { - infos = append(infos, workspaceToInfo(ws)) - } - return infos, nil -} - -func listAll(svc *Services, includeArchived bool) ([]WorkspaceInfo, error) { - ids, err := svc.Store.List() - if err != nil { - return nil, err - } - infos := make([]WorkspaceInfo, 0, len(ids)) - for _, id := range ids { - ws, err := svc.Store.Load(id) - if err != nil { - return nil, fmt.Errorf("failed to load workspace metadata %s: %w", id, err) - } - if !includeArchived && ws.Archived { - continue - } - infos = append(infos, workspaceToInfo(ws)) - } - return infos, nil -} diff --git a/internal/cli/cmd_workspace_create.go b/internal/cli/cmd_workspace_create.go deleted file mode 100644 index 4748a1ff..00000000 --- a/internal/cli/cmd_workspace_create.go +++ /dev/null @@ -1,398 +0,0 @@ -package cli - -import ( - "context" - "errors" - "fmt" - "io" - "os" - "path/filepath" - "strings" - "time" - - "github.com/andyrewlee/amux/internal/data" - "github.com/andyrewlee/amux/internal/git" - "github.com/andyrewlee/amux/internal/validation" -) - -const ( - workspaceCreateReadyAttempts = 100 - workspaceCreateReadyDelay = 50 * time.Millisecond -) - -func cmdWorkspaceCreate(w, wErr io.Writer, gf GlobalFlags, args []string, version string) int { - const usage = "Usage: amux workspace create --project [--assistant ] [--base ] [--idempotency-key ] [--json]" - fs := newFlagSet("workspace create") - project := fs.String("project", "", "project repo path (required)") - assistant := fs.String("assistant", "", "assistant name (defaults to configured default assistant)") - base := fs.String("base", "", "base branch (auto-detected if omitted)") - idempotencyKey := fs.String("idempotency-key", "", "idempotency key for safe retries") - name, err := parseSinglePositionalWithFlags(fs, args) - if err != nil { - return returnUsageError(w, wErr, gf, usage, version, err) - } - assistantName := strings.ToLower(strings.TrimSpace(*assistant)) - if name == "" || *project == "" { - return returnUsageError(w, wErr, gf, usage, version, nil) - } - if assistantName != "" { - if err := validation.ValidateAssistant(assistantName); err != nil { - return returnUsageError( - w, - wErr, - gf, - usage, - version, - fmt.Errorf("invalid --assistant: %w", err), - ) - } - } - if err := validation.ValidateWorkspaceName(name); err != nil { - return returnUsageError( - w, - wErr, - gf, - usage, - version, - fmt.Errorf("invalid workspace name: %w", err), - ) - } - if *base != "" { - if err := validation.ValidateBaseRef(*base); err != nil { - return returnUsageError( - w, - wErr, - gf, - usage, - version, - fmt.Errorf("invalid --base: %w", err), - ) - } - } - ctx := &cmdCtx{w: w, wErr: wErr, gf: gf, version: version, cmd: "workspace.create", idemKey: *idempotencyKey} - - if handled, code := ctx.maybeReplay(); handled { - return code - } - - projectPath, err := canonicalizeProjectPath(*project) - if err != nil { - return ctx.errResult(ExitUsage, "invalid_project_path", err.Error(), map[string]any{"project": *project}, fmt.Sprintf("invalid --project path: %v", err)) - } - - if !git.IsGitRepository(projectPath) { - return ctx.errResult(ExitUsage, "not_git_repo", projectPath+" is not a git repository", nil) - } - - svc, err := NewServices(version) - if err != nil { - return ctx.errResult(ExitInternalError, "init_failed", err.Error(), nil, fmt.Sprintf("failed to initialize: %v", err)) - } - assistantExplicit := assistantName != "" - if assistantName == "" { - assistantName = svc.Config.ResolvedDefaultAssistant() - } - if !svc.Config.IsAssistantKnown(assistantName) { - return ctx.errResult(ExitUsage, "unknown_assistant", "unknown assistant: "+assistantName, nil) - } - - // Require project to be registered before creating a workspace. - registered, err := svc.Registry.Projects() - if err != nil { - return ctx.errResult(ExitInternalError, "registry_read_failed", err.Error(), nil, fmt.Sprintf("failed to read project registry: %v", err)) - } - if !isProjectRegistered(registered, projectPath) { - msg := fmt.Sprintf("project %s is not registered; run `amux project add %s` first", projectPath, projectPath) - return ctx.errResult(ExitUsage, "project_not_registered", msg, map[string]any{"project": projectPath}) - } - - // Determine base branch - baseBranch := *base - if baseBranch == "" { - baseBranch, err = git.GetBaseBranch(projectPath) - baseBranch = resolveWorkspaceBaseFallback(projectPath, baseBranch, err) - } - - // Compute workspace path - projectName := filepath.Base(projectPath) - wsPath := filepath.Join(svc.Config.Paths.WorkspacesRoot, projectName, name) - branchExistedBefore := gitLocalBranchExists(projectPath, name) - - // Idempotent path: if the target worktree already exists for this repo, reuse it. - existingWS, found, err := loadExistingWorkspaceAtPath(svc, projectPath, wsPath, name, baseBranch, assistantName, assistantExplicit) - if err != nil { - return ctx.errResult(ExitInternalError, "existing_workspace_check_failed", err.Error(), nil, fmt.Sprintf("failed to check existing workspace: %v", err)) - } - if found { - info := workspaceToInfo(existingWS) - if gf.JSON { - return ctx.successResult(info) - } - PrintHuman(w, func(w io.Writer) { - fmt.Fprintf(w, "Using existing workspace %s (%s) at %s\n", info.Name, info.ID, info.Root) - }) - return ExitOK - } - - // Create the worktree - if err := git.CreateWorkspace(projectPath, wsPath, name, baseBranch); err != nil { - return ctx.errResult(ExitInternalError, "create_failed", err.Error(), nil, fmt.Sprintf("failed to create workspace: %v", err)) - } - - // Wait for .git file to appear (same pattern as workspace_service.go) - gitFile := filepath.Join(wsPath, ".git") - if err := waitForPath(gitFile, workspaceCreateReadyAttempts, workspaceCreateReadyDelay); err != nil { - cleanupErr := rollbackWorkspaceCreate(projectPath, wsPath, name, !branchExistedBefore) - msg := fmt.Sprintf("workspace setup incomplete: %v", err) - if cleanupErr != nil { - msg = fmt.Sprintf("%s (cleanup failed: %v)", msg, cleanupErr) - } - details := map[string]any{ - "workspace_root": wsPath, - "workspace_id": name, - "git_file": gitFile, - } - if cleanupErr != nil { - details["cleanup_error"] = cleanupErr.Error() - } - return ctx.errResult(ExitInternalError, "workspace_not_ready", msg, details) - } - - // Save metadata - ws := data.NewWorkspace(name, name, baseBranch, projectPath, wsPath) - ws.Assistant = assistantName - if err := svc.Store.Save(ws); err != nil { - cleanupErr := rollbackWorkspaceCreate(projectPath, wsPath, name, !branchExistedBefore) - msg := err.Error() - if cleanupErr != nil { - msg = fmt.Sprintf("%s (cleanup failed: %v)", msg, cleanupErr) - } - details := map[string]any{ - "workspace_root": wsPath, - "workspace_id": name, - } - if cleanupErr != nil { - details["cleanup_error"] = cleanupErr.Error() - } - return ctx.errResult( - ExitInternalError, - "save_failed", - msg, - details, - workspaceCreateSaveFailedHumanMessage(err, cleanupErr), - ) - } - - info := workspaceToInfo(ws) - - if gf.JSON { - return ctx.successResult(info) - } - - PrintHuman(w, func(w io.Writer) { - fmt.Fprintf(w, "Created workspace %s (%s) at %s\n", info.Name, info.ID, info.Root) - }) - return ExitOK -} - -func workspaceCreateSaveFailedHumanMessage(saveErr, cleanupErr error) string { - if cleanupErr != nil { - return fmt.Sprintf("%v (cleanup failed: %v)", saveErr, cleanupErr) - } - return fmt.Sprintf("failed to save workspace metadata: %v", saveErr) -} - -func rollbackWorkspaceCreate(repoPath, workspacePath, branch string, deleteBranch bool) error { - var errs []string - - if err := git.RemoveWorkspace(repoPath, workspacePath); err != nil { - errs = append(errs, fmt.Sprintf("remove worktree: %v", err)) - } - if deleteBranch { - if err := git.DeleteBranch(repoPath, branch); err != nil { - errs = append(errs, fmt.Sprintf("delete branch: %v", err)) - } - } - - if len(errs) == 0 { - return nil - } - return errors.New(strings.Join(errs, "; ")) -} - -func loadExistingWorkspaceAtPath( - svc *Services, - projectPath, wsPath, name, baseBranch, assistantName string, - assistantExplicit bool, -) (*data.Workspace, bool, error) { - gitFile := filepath.Join(wsPath, ".git") - if _, err := os.Stat(gitFile); err != nil { - if os.IsNotExist(err) { - return nil, false, nil - } - return nil, false, err - } - - workspaceCommonDir, err := canonicalizeGitCommonDir(wsPath) - if err != nil { - return nil, false, err - } - projectCommonDir, err := canonicalizeGitCommonDir(projectPath) - if err != nil { - return nil, false, err - } - if workspaceCommonDir != projectCommonDir { - return nil, false, nil - } - - branch, err := git.GetCurrentBranch(wsPath) - if err != nil || strings.TrimSpace(branch) == "" { - branch = strings.TrimSpace(baseBranch) - if branch == "" { - branch = "HEAD" - } - } - - id := data.Workspace{Repo: projectPath, Root: wsPath}.ID() - stored, err := svc.Store.Load(id) - if err != nil && !os.IsNotExist(err) { - return nil, false, err - } - - if os.IsNotExist(err) { - ws := data.NewWorkspace(name, branch, baseBranch, projectPath, wsPath) - ws.Assistant = assistantName - if err := svc.Store.Save(ws); err != nil { - return nil, false, err - } - return ws, true, nil - } - - if strings.TrimSpace(stored.Name) == "" { - stored.Name = name - } - if strings.TrimSpace(stored.Branch) == "" { - stored.Branch = branch - } - if strings.TrimSpace(stored.Repo) == "" { - stored.Repo = projectPath - } - if strings.TrimSpace(stored.Root) == "" { - stored.Root = wsPath - } - if strings.TrimSpace(stored.Base) == "" { - stored.Base = baseBranch - } - if strings.TrimSpace(stored.Assistant) == "" { - stored.Assistant = assistantName - } else if assistantExplicit && !strings.EqualFold(stored.Assistant, assistantName) { - return nil, false, fmt.Errorf( - "existing workspace %q uses assistant %q, but %q was requested; "+ - "use a different workspace name or omit --assistant", - name, stored.Assistant, assistantName, - ) - } - if err := svc.Store.Save(stored); err != nil { - return nil, false, err - } - return stored, true, nil -} - -func canonicalizeGitCommonDir(repoPath string) (string, error) { - raw, err := git.RunGitCtx(context.Background(), repoPath, "rev-parse", "--git-common-dir") - if err != nil { - return "", err - } - commonDir := strings.TrimSpace(raw) - if commonDir == "" { - return "", fmt.Errorf("empty git common dir for %s", repoPath) - } - if !filepath.IsAbs(commonDir) { - commonDir = filepath.Join(repoPath, commonDir) - } - resolved, err := filepath.EvalSymlinks(commonDir) - if err != nil { - return "", err - } - return resolved, nil -} - -func canonicalizeProjectPath(projectPath string) (string, error) { - absPath, err := filepath.Abs(projectPath) - if err != nil { - return "", err - } - canonicalPath, err := filepath.EvalSymlinks(absPath) - if err != nil { - return "", err - } - return canonicalPath, nil -} - -func waitForPath(path string, attempts int, delay time.Duration) error { - if attempts <= 0 { - return fmt.Errorf("%s did not appear in time", path) - } - for i := 0; i < attempts; i++ { - _, err := os.Stat(path) - if err == nil { - return nil - } - if !os.IsNotExist(err) { - return err - } - time.Sleep(delay) - } - return fmt.Errorf("%s did not appear in time", path) -} - -func resolveWorkspaceBaseFallback(projectPath, detected string, detectedErr error) string { - if detectedErr != nil { - return "HEAD" - } - - base := strings.TrimSpace(detected) - if base == "" { - return "HEAD" - } - if gitRefExists(projectPath, base) { - return base - } - - remoteBase := "origin/" + base - if gitRefExists(projectPath, remoteBase) { - return remoteBase - } - return "HEAD" -} - -func isProjectRegistered(registered []string, projectPath string) bool { - for _, p := range registered { - canon, err := canonicalizeProjectPath(p) - if err != nil { - continue - } - if canon == projectPath { - return true - } - } - return false -} - -func gitRefExists(repoPath, ref string) bool { - ref = strings.TrimSpace(ref) - if ref == "" { - return false - } - _, err := git.RunGitCtx(context.Background(), repoPath, "rev-parse", "--verify", ref) - return err == nil -} - -func gitLocalBranchExists(repoPath, branchName string) bool { - branchName = strings.TrimSpace(branchName) - if branchName == "" { - return false - } - _, err := git.RunGitCtx(context.Background(), repoPath, "rev-parse", "--verify", "refs/heads/"+branchName) - return err == nil -} diff --git a/internal/cli/cmd_workspace_create_core_test.go b/internal/cli/cmd_workspace_create_core_test.go deleted file mode 100644 index 6ae0aa1e..00000000 --- a/internal/cli/cmd_workspace_create_core_test.go +++ /dev/null @@ -1,451 +0,0 @@ -package cli - -import ( - "bytes" - "encoding/json" - "os" - "os/exec" - "path/filepath" - "strings" - "testing" - "time" - - "github.com/andyrewlee/amux/internal/data" -) - -func TestCanonicalizeProjectPathRelative(t *testing.T) { - base := t.TempDir() - project := filepath.Join(base, "repo") - if err := os.MkdirAll(project, 0o755); err != nil { - t.Fatalf("MkdirAll() error = %v", err) - } - - originalWD, err := os.Getwd() - if err != nil { - t.Fatalf("Getwd() error = %v", err) - } - if err := os.Chdir(base); err != nil { - t.Fatalf("Chdir(%q) error = %v", base, err) - } - defer func() { _ = os.Chdir(originalWD) }() - - got, err := canonicalizeProjectPath("./repo") - if err != nil { - t.Fatalf("canonicalizeProjectPath() error = %v", err) - } - want, err := filepath.EvalSymlinks(project) - if err != nil { - t.Fatalf("EvalSymlinks() error = %v", err) - } - if got != want { - t.Fatalf("canonicalized project path = %q, want %q", got, want) - } -} - -func TestWaitForPath(t *testing.T) { - existing := filepath.Join(t.TempDir(), "exists") - if err := os.WriteFile(existing, []byte("x"), 0o644); err != nil { - t.Fatalf("WriteFile() error = %v", err) - } - if err := waitForPath(existing, 1, time.Millisecond); err != nil { - t.Fatalf("waitForPath(existing) error = %v", err) - } - - missing := filepath.Join(t.TempDir(), "missing") - if err := waitForPath(missing, 2, time.Millisecond); err == nil { - t.Fatalf("expected waitForPath(missing) to fail") - } -} - -func TestCmdWorkspaceCreateRelativeProjectRemoveFromDifferentDir(t *testing.T) { - if _, err := exec.LookPath("git"); err != nil { - t.Skip("git not available") - } - - home := t.TempDir() - t.Setenv("HOME", home) - - repoRoot := filepath.Join(t.TempDir(), "repo") - if err := os.MkdirAll(repoRoot, 0o755); err != nil { - t.Fatalf("MkdirAll(repoRoot) error = %v", err) - } - runGit(t, repoRoot, "init") - runGit(t, repoRoot, "config", "user.email", "test@example.com") - runGit(t, repoRoot, "config", "user.name", "amux-test") - if err := os.WriteFile(filepath.Join(repoRoot, "README.md"), []byte("hello\n"), 0o644); err != nil { - t.Fatalf("WriteFile() error = %v", err) - } - runGit(t, repoRoot, "add", "README.md") - runGit(t, repoRoot, "commit", "-m", "init") - - registerProject(t, home, repoRoot) - - originalWD, err := os.Getwd() - if err != nil { - t.Fatalf("Getwd() error = %v", err) - } - if err := os.Chdir(repoRoot); err != nil { - t.Fatalf("Chdir(repoRoot) error = %v", err) - } - defer func() { _ = os.Chdir(originalWD) }() - - var out, errOut bytes.Buffer - createCode := cmdWorkspaceCreate( - &out, - &errOut, - GlobalFlags{JSON: true}, - []string{"rel-ws", "--project", ".", "--assistant", "claude"}, - "test-v1", - ) - if createCode != ExitOK { - t.Fatalf("cmdWorkspaceCreate() code = %d; stderr: %s", createCode, errOut.String()) - } - - var env Envelope - if err := json.Unmarshal(out.Bytes(), &env); err != nil { - t.Fatalf("json.Unmarshal(create output) error = %v", err) - } - if !env.OK { - t.Fatalf("create output expected ok=true; raw=%s", out.String()) - } - data, ok := env.Data.(map[string]any) - if !ok { - t.Fatalf("expected create data object, got %T", env.Data) - } - workspaceID, _ := data["id"].(string) - if workspaceID == "" { - t.Fatalf("expected workspace id in create response") - } - gotRepo, _ := data["repo"].(string) - wantRepo, err := filepath.EvalSymlinks(repoRoot) - if err != nil { - t.Fatalf("EvalSymlinks(repoRoot) error = %v", err) - } - if gotRepo != wantRepo { - t.Fatalf("stored repo path = %q, want %q", gotRepo, wantRepo) - } - - if err := os.Chdir(home); err != nil { - t.Fatalf("Chdir(home) error = %v", err) - } - - out.Reset() - errOut.Reset() - removeCode := cmdWorkspaceRemove( - &out, - &errOut, - GlobalFlags{JSON: true}, - []string{workspaceID, "--yes"}, - "test-v1", - ) - if removeCode != ExitOK { - t.Fatalf("cmdWorkspaceRemove() code = %d; stderr: %s; stdout: %s", removeCode, errOut.String(), out.String()) - } -} - -func TestCmdWorkspaceCreateWithoutBaseFallsBackToHead(t *testing.T) { - if _, err := exec.LookPath("git"); err != nil { - t.Skip("git not available") - } - - home := t.TempDir() - t.Setenv("HOME", home) - - repoRoot := filepath.Join(t.TempDir(), "repo") - if err := os.MkdirAll(repoRoot, 0o755); err != nil { - t.Fatalf("MkdirAll(repoRoot) error = %v", err) - } - runGit(t, repoRoot, "init") - runGit(t, repoRoot, "config", "user.email", "test@example.com") - runGit(t, repoRoot, "config", "user.name", "amux-test") - if err := os.WriteFile(filepath.Join(repoRoot, "README.md"), []byte("hello\n"), 0o644); err != nil { - t.Fatalf("WriteFile() error = %v", err) - } - runGit(t, repoRoot, "add", "README.md") - runGit(t, repoRoot, "commit", "-m", "init") - - currentBranch := runGitOutput(t, repoRoot, "rev-parse", "--abbrev-ref", "HEAD") - if currentBranch != "trunk" { - runGit(t, repoRoot, "branch", "-m", "trunk") - } - - registerProject(t, home, repoRoot) - - var out, errOut bytes.Buffer - createCode := cmdWorkspaceCreate( - &out, - &errOut, - GlobalFlags{JSON: true}, - []string{"head-fallback-ws", "--project", repoRoot, "--assistant", "claude"}, - "test-v1", - ) - if createCode != ExitOK { - t.Fatalf("cmdWorkspaceCreate() code = %d; stderr: %s; stdout: %s", createCode, errOut.String(), out.String()) - } - - var env Envelope - if err := json.Unmarshal(out.Bytes(), &env); err != nil { - t.Fatalf("json.Unmarshal(create output) error = %v", err) - } - if !env.OK { - t.Fatalf("create output expected ok=true; raw=%s", out.String()) - } - - data, ok := env.Data.(map[string]any) - if !ok { - t.Fatalf("expected create data object, got %T", env.Data) - } - if gotBase, _ := data["base"].(string); gotBase != "HEAD" { - t.Fatalf("base = %q, want %q", gotBase, "HEAD") - } -} - -func TestCmdWorkspaceCreateRejectsInvalidWorkspaceName(t *testing.T) { - var out, errOut bytes.Buffer - code := cmdWorkspaceCreate( - &out, - &errOut, - GlobalFlags{JSON: true}, - []string{"feature branch", "--project", t.TempDir(), "--assistant", "claude"}, - "test-v1", - ) - if code != ExitUsage { - t.Fatalf("cmdWorkspaceCreate() code = %d, want %d", code, ExitUsage) - } - if errOut.Len() != 0 { - t.Fatalf("expected no stderr output in JSON mode, got %q", errOut.String()) - } - - var env Envelope - if err := json.Unmarshal(out.Bytes(), &env); err != nil { - t.Fatalf("json.Unmarshal() error = %v\nraw: %s", err, out.String()) - } - if env.OK { - t.Fatalf("expected ok=false") - } - if env.Error == nil || env.Error.Code != "usage_error" { - t.Fatalf("expected usage_error, got %#v", env.Error) - } - if env.Error == nil || !strings.Contains(env.Error.Message, "invalid workspace name") { - t.Fatalf("expected invalid workspace name message, got %#v", env.Error) - } -} - -func TestCmdWorkspaceCreateDefaultsAssistantWhenOmitted(t *testing.T) { - if _, err := exec.LookPath("git"); err != nil { - t.Skip("git not available") - } - - home := t.TempDir() - t.Setenv("HOME", home) - - repoRoot := filepath.Join(t.TempDir(), "repo") - if err := os.MkdirAll(repoRoot, 0o755); err != nil { - t.Fatalf("MkdirAll(repoRoot) error = %v", err) - } - runGit(t, repoRoot, "init") - runGit(t, repoRoot, "config", "user.email", "test@example.com") - runGit(t, repoRoot, "config", "user.name", "amux-test") - if err := os.WriteFile(filepath.Join(repoRoot, "README.md"), []byte("hello\n"), 0o644); err != nil { - t.Fatalf("WriteFile() error = %v", err) - } - runGit(t, repoRoot, "add", "README.md") - runGit(t, repoRoot, "commit", "-m", "init") - - registerProject(t, home, repoRoot) - - var out, errOut bytes.Buffer - code := cmdWorkspaceCreate( - &out, - &errOut, - GlobalFlags{JSON: true}, - []string{"default-assistant-ws", "--project", repoRoot}, - "test-v1", - ) - if code != ExitOK { - t.Fatalf("cmdWorkspaceCreate() code = %d; stderr: %s; stdout: %s", code, errOut.String(), out.String()) - } - - var env Envelope - if err := json.Unmarshal(out.Bytes(), &env); err != nil { - t.Fatalf("json.Unmarshal(create output) error = %v", err) - } - if !env.OK { - t.Fatalf("create output expected ok=true; raw=%s", out.String()) - } - dataMap, ok := env.Data.(map[string]any) - if !ok { - t.Fatalf("expected create data object, got %T", env.Data) - } - gotAssistant, _ := dataMap["assistant"].(string) - if gotAssistant != data.DefaultAssistant { - t.Fatalf("assistant = %q, want %q", gotAssistant, data.DefaultAssistant) - } -} - -func TestWorkspaceCreateReadinessWaitConfig(t *testing.T) { - if workspaceCreateReadyAttempts != 100 { - t.Fatalf("workspaceCreateReadyAttempts = %d, want %d", workspaceCreateReadyAttempts, 100) - } - if workspaceCreateReadyDelay != 50*time.Millisecond { - t.Fatalf("workspaceCreateReadyDelay = %v, want %v", workspaceCreateReadyDelay, 50*time.Millisecond) - } -} - -func TestCmdWorkspaceCreateRejectsUnregisteredProject(t *testing.T) { - if _, err := exec.LookPath("git"); err != nil { - t.Skip("git not available") - } - - home := t.TempDir() - t.Setenv("HOME", home) - - repoRoot := filepath.Join(t.TempDir(), "unregistered") - if err := os.MkdirAll(repoRoot, 0o755); err != nil { - t.Fatalf("MkdirAll(repoRoot) error = %v", err) - } - runGit(t, repoRoot, "init") - runGit(t, repoRoot, "config", "user.email", "test@example.com") - runGit(t, repoRoot, "config", "user.name", "amux-test") - if err := os.WriteFile(filepath.Join(repoRoot, "README.md"), []byte("hello\n"), 0o644); err != nil { - t.Fatalf("WriteFile() error = %v", err) - } - runGit(t, repoRoot, "add", "README.md") - runGit(t, repoRoot, "commit", "-m", "init") - - // Do NOT register the project — workspace create should fail. - var out, errOut bytes.Buffer - code := cmdWorkspaceCreate( - &out, - &errOut, - GlobalFlags{JSON: true}, - []string{"feat-ws", "--project", repoRoot, "--assistant", "claude"}, - "test-v1", - ) - if code != ExitUsage { - t.Fatalf("cmdWorkspaceCreate() code = %d, want %d; stderr: %s; stdout: %s", code, ExitUsage, errOut.String(), out.String()) - } - - var env Envelope - if err := json.Unmarshal(out.Bytes(), &env); err != nil { - t.Fatalf("json.Unmarshal() error = %v\nraw: %s", err, out.String()) - } - if env.OK { - t.Fatalf("expected ok=false") - } - if env.Error == nil || env.Error.Code != "project_not_registered" { - t.Fatalf("expected project_not_registered error, got %#v", env.Error) - } - if !strings.Contains(env.Error.Message, "amux project add") { - t.Fatalf("expected error message to mention 'amux project add', got %q", env.Error.Message) - } -} - -func TestCmdWorkspaceCreateReusesExistingWorkspacePath(t *testing.T) { - if _, err := exec.LookPath("git"); err != nil { - t.Skip("git not available") - } - - home := t.TempDir() - t.Setenv("HOME", home) - - repoRoot := filepath.Join(t.TempDir(), "repo") - if err := os.MkdirAll(repoRoot, 0o755); err != nil { - t.Fatalf("MkdirAll(repoRoot) error = %v", err) - } - runGit(t, repoRoot, "init") - runGit(t, repoRoot, "config", "user.email", "test@example.com") - runGit(t, repoRoot, "config", "user.name", "amux-test") - if err := os.WriteFile(filepath.Join(repoRoot, "README.md"), []byte("hello\n"), 0o644); err != nil { - t.Fatalf("WriteFile() error = %v", err) - } - runGit(t, repoRoot, "add", "README.md") - runGit(t, repoRoot, "commit", "-m", "init") - - registerProject(t, home, repoRoot) - - var firstOut, firstErr bytes.Buffer - firstCode := cmdWorkspaceCreate( - &firstOut, - &firstErr, - GlobalFlags{JSON: true}, - []string{"refactor", "--project", repoRoot, "--assistant", "claude"}, - "test-v1", - ) - if firstCode != ExitOK { - t.Fatalf("first create failed: code=%d stderr=%s stdout=%s", firstCode, firstErr.String(), firstOut.String()) - } - - var firstEnv Envelope - if err := json.Unmarshal(firstOut.Bytes(), &firstEnv); err != nil { - t.Fatalf("json.Unmarshal(first) error = %v", err) - } - firstData, ok := firstEnv.Data.(map[string]any) - if !ok { - t.Fatalf("first create data type = %T, want object", firstEnv.Data) - } - firstID, _ := firstData["id"].(string) - firstRoot, _ := firstData["root"].(string) - if firstID == "" || firstRoot == "" { - t.Fatalf("first create missing id/root: %v", firstData) - } - - var secondOut, secondErr bytes.Buffer - secondCode := cmdWorkspaceCreate( - &secondOut, - &secondErr, - GlobalFlags{JSON: true}, - []string{"refactor", "--project", repoRoot, "--assistant", "claude"}, - "test-v1", - ) - if secondCode != ExitOK { - t.Fatalf("second create failed: code=%d stderr=%s stdout=%s", secondCode, secondErr.String(), secondOut.String()) - } - - var secondEnv Envelope - if err := json.Unmarshal(secondOut.Bytes(), &secondEnv); err != nil { - t.Fatalf("json.Unmarshal(second) error = %v", err) - } - secondData, ok := secondEnv.Data.(map[string]any) - if !ok { - t.Fatalf("second create data type = %T, want object", secondEnv.Data) - } - secondID, _ := secondData["id"].(string) - secondRoot, _ := secondData["root"].(string) - if secondID != firstID { - t.Fatalf("second id = %q, want %q", secondID, firstID) - } - if secondRoot != firstRoot { - t.Fatalf("second root = %q, want %q", secondRoot, firstRoot) - } -} - -func registerProject(t *testing.T, home, repoRoot string) { - t.Helper() - registryPath := filepath.Join(home, ".amux", "projects.json") - reg := data.NewRegistry(registryPath) - if err := reg.AddProject(repoRoot); err != nil { - t.Fatalf("AddProject(%q) error = %v", repoRoot, err) - } -} - -func runGit(t *testing.T, dir string, args ...string) { - t.Helper() - cmd := exec.Command("git", args...) - cmd.Dir = dir - output, err := cmd.CombinedOutput() - if err != nil { - t.Fatalf("git %v failed: %v\n%s", args, err, string(output)) - } -} - -func runGitOutput(t *testing.T, dir string, args ...string) string { - t.Helper() - cmd := exec.Command("git", args...) - cmd.Dir = dir - output, err := cmd.CombinedOutput() - if err != nil { - t.Fatalf("git %v failed: %v\n%s", args, err, string(output)) - } - return strings.TrimSpace(string(output)) -} diff --git a/internal/cli/cmd_workspace_create_human_test.go b/internal/cli/cmd_workspace_create_human_test.go deleted file mode 100644 index fb8f7970..00000000 --- a/internal/cli/cmd_workspace_create_human_test.go +++ /dev/null @@ -1,35 +0,0 @@ -package cli - -import ( - "errors" - "testing" -) - -func TestWorkspaceCreateSaveFailedHumanMessage(t *testing.T) { - tests := []struct { - name string - saveErr error - cleanupErr error - want string - }{ - { - name: "save only", - saveErr: errors.New("metadata write failed"), - want: "failed to save workspace metadata: metadata write failed", - }, - { - name: "save and cleanup", - saveErr: errors.New("metadata write failed"), - cleanupErr: errors.New("remove worktree: permission denied"), - want: "metadata write failed (cleanup failed: remove worktree: permission denied)", - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - if got := workspaceCreateSaveFailedHumanMessage(tt.saveErr, tt.cleanupErr); got != tt.want { - t.Fatalf("workspaceCreateSaveFailedHumanMessage() = %q, want %q", got, tt.want) - } - }) - } -} diff --git a/internal/cli/cmd_workspace_create_rollback_test.go b/internal/cli/cmd_workspace_create_rollback_test.go deleted file mode 100644 index 2eee03c6..00000000 --- a/internal/cli/cmd_workspace_create_rollback_test.go +++ /dev/null @@ -1,79 +0,0 @@ -package cli - -import ( - "os" - "os/exec" - "path/filepath" - "strings" - "testing" - - gitpkg "github.com/andyrewlee/amux/internal/git" -) - -func TestRollbackWorkspaceCreatePreservesExistingBranch(t *testing.T) { - if _, err := exec.LookPath("git"); err != nil { - t.Skip("git not available") - } - - repoRoot := filepath.Join(t.TempDir(), "repo") - if err := os.MkdirAll(repoRoot, 0o755); err != nil { - t.Fatalf("MkdirAll(repoRoot) error = %v", err) - } - runGit(t, repoRoot, "init") - runGit(t, repoRoot, "config", "user.email", "test@example.com") - runGit(t, repoRoot, "config", "user.name", "amux-test") - if err := os.WriteFile(filepath.Join(repoRoot, "README.md"), []byte("hello\n"), 0o644); err != nil { - t.Fatalf("WriteFile() error = %v", err) - } - runGit(t, repoRoot, "add", "README.md") - runGit(t, repoRoot, "commit", "-m", "init") - - branchName := "existing-feature" - runGit(t, repoRoot, "branch", branchName) - - workspacePath := filepath.Join(t.TempDir(), "existing-feature") - if err := gitpkg.CreateWorkspace(repoRoot, workspacePath, branchName, "HEAD"); err != nil { - t.Fatalf("CreateWorkspace() error = %v", err) - } - - if err := rollbackWorkspaceCreate(repoRoot, workspacePath, branchName, false); err != nil { - t.Fatalf("rollbackWorkspaceCreate() error = %v", err) - } - - if got := strings.TrimSpace(runGitOutput(t, repoRoot, "branch", "--list", branchName)); got == "" { - t.Fatalf("expected branch %q to remain after rollback", branchName) - } -} - -func TestRollbackWorkspaceCreateDeletesNewBranch(t *testing.T) { - if _, err := exec.LookPath("git"); err != nil { - t.Skip("git not available") - } - - repoRoot := filepath.Join(t.TempDir(), "repo") - if err := os.MkdirAll(repoRoot, 0o755); err != nil { - t.Fatalf("MkdirAll(repoRoot) error = %v", err) - } - runGit(t, repoRoot, "init") - runGit(t, repoRoot, "config", "user.email", "test@example.com") - runGit(t, repoRoot, "config", "user.name", "amux-test") - if err := os.WriteFile(filepath.Join(repoRoot, "README.md"), []byte("hello\n"), 0o644); err != nil { - t.Fatalf("WriteFile() error = %v", err) - } - runGit(t, repoRoot, "add", "README.md") - runGit(t, repoRoot, "commit", "-m", "init") - - branchName := "new-feature" - workspacePath := filepath.Join(t.TempDir(), "new-feature") - if err := gitpkg.CreateWorkspace(repoRoot, workspacePath, branchName, "HEAD"); err != nil { - t.Fatalf("CreateWorkspace() error = %v", err) - } - - if err := rollbackWorkspaceCreate(repoRoot, workspacePath, branchName, true); err != nil { - t.Fatalf("rollbackWorkspaceCreate() error = %v", err) - } - - if got := strings.TrimSpace(runGitOutput(t, repoRoot, "branch", "--list", branchName)); got != "" { - t.Fatalf("expected branch %q to be deleted after rollback, got %q", branchName, got) - } -} diff --git a/internal/cli/cmd_workspace_remove.go b/internal/cli/cmd_workspace_remove.go deleted file mode 100644 index e678b291..00000000 --- a/internal/cli/cmd_workspace_remove.go +++ /dev/null @@ -1,99 +0,0 @@ -package cli - -import ( - "fmt" - "io" - "log/slog" - "os" - "strings" - - "github.com/andyrewlee/amux/internal/data" - "github.com/andyrewlee/amux/internal/git" - "github.com/andyrewlee/amux/internal/tmux" -) - -func cmdWorkspaceRemove(w, wErr io.Writer, gf GlobalFlags, args []string, version string) int { - const usage = "Usage: amux workspace remove --yes [--idempotency-key ] [--json]" - fs := newFlagSet("workspace remove") - yes := fs.Bool("yes", false, "confirm removal (required)") - idempotencyKey := fs.String("idempotency-key", "", "idempotency key for safe retries") - wsIDArg, err := parseSinglePositionalWithFlags(fs, args) - if err != nil { - return returnUsageError(w, wErr, gf, usage, version, err) - } - if wsIDArg == "" { - return returnUsageError(w, wErr, gf, usage, version, nil) - } - if !*yes { - if gf.JSON { - ReturnError(w, "confirmation_required", "pass --yes to confirm removal", nil, version) - return ExitUnsafeBlocked - } - Errorf(wErr, "pass --yes to confirm removal") - return ExitUnsafeBlocked - } - wsID := data.WorkspaceID(strings.TrimSpace(wsIDArg)) - if !data.IsValidWorkspaceID(wsID) { - return returnUsageError( - w, - wErr, - gf, - usage, - version, - fmt.Errorf("invalid workspace id: %s", wsIDArg), - ) - } - ctx := &cmdCtx{w: w, wErr: wErr, gf: gf, version: version, cmd: "workspace.remove", idemKey: *idempotencyKey} - - if handled, code := ctx.maybeReplay(); handled { - return code - } - - svc, err := NewServices(version) - if err != nil { - return ctx.errResult(ExitInternalError, "init_failed", err.Error(), nil, fmt.Sprintf("failed to initialize: %v", err)) - } - - ws, err := svc.Store.Load(wsID) - if err != nil { - if os.IsNotExist(err) { - return ctx.errResult(ExitNotFound, "not_found", fmt.Sprintf("workspace %s not found", wsID), nil) - } - return ctx.errResult(ExitInternalError, "metadata_load_failed", err.Error(), map[string]any{"workspace_id": string(wsID)}, fmt.Sprintf("failed to load workspace metadata %s: %v", wsID, err)) - } - - if ws.IsPrimaryCheckout() { - return ctx.errResult(ExitUnsafeBlocked, "primary_checkout", "cannot remove primary checkout", nil) - } - - // Kill tmux sessions for this workspace - if err := tmux.KillWorkspaceSessions(string(wsID), svc.TmuxOpts); err != nil { - slog.Debug("best-effort workspace session kill failed", "workspace", string(wsID), "error", err) - } - - // Remove worktree - if err := git.RemoveWorkspace(ws.Repo, ws.Root); err != nil { - return ctx.errResult(ExitInternalError, "remove_failed", err.Error(), nil, fmt.Sprintf("failed to remove worktree: %v", err)) - } - - // Delete branch (best-effort) - if err := git.DeleteBranch(ws.Repo, ws.Branch); err != nil { - slog.Debug("best-effort branch delete failed", "repo", ws.Repo, "branch", ws.Branch, "error", err) - } - - // Delete metadata - if err := svc.Store.Delete(wsID); err != nil { - return ctx.errResult(ExitInternalError, "metadata_delete_failed", err.Error(), nil, fmt.Sprintf("failed to delete metadata: %v", err)) - } - - info := workspaceToInfo(ws) - - if gf.JSON { - return ctx.successResult(map[string]any{"removed": info}) - } - - PrintHuman(w, func(w io.Writer) { - fmt.Fprintf(w, "Removed workspace %s (%s)\n", info.Name, info.ID) - }) - return ExitOK -} diff --git a/internal/cli/cmd_workspace_remove_test.go b/internal/cli/cmd_workspace_remove_test.go deleted file mode 100644 index 045175af..00000000 --- a/internal/cli/cmd_workspace_remove_test.go +++ /dev/null @@ -1,133 +0,0 @@ -package cli - -import ( - "bytes" - "encoding/json" - "os" - "path/filepath" - "strings" - "testing" -) - -func TestCmdWorkspaceRemoveUsageJSON(t *testing.T) { - var out, errOut bytes.Buffer - code := cmdWorkspaceRemove(&out, &errOut, GlobalFlags{JSON: true}, nil, "test-v1") - if code != ExitUsage { - t.Fatalf("cmdWorkspaceRemove() code = %d, want %d", code, ExitUsage) - } - if errOut.Len() != 0 { - t.Fatalf("expected no stderr output in JSON mode, got %q", errOut.String()) - } - - var env Envelope - if err := json.Unmarshal(out.Bytes(), &env); err != nil { - t.Fatalf("json.Unmarshal() error = %v\nraw: %s", err, out.String()) - } - if env.OK { - t.Fatalf("expected ok=false") - } - if env.Error == nil || env.Error.Code != "usage_error" { - t.Fatalf("expected usage_error, got %#v", env.Error) - } -} - -func TestCmdWorkspaceRemoveRejectsInvalidWorkspaceID(t *testing.T) { - var out, errOut bytes.Buffer - code := cmdWorkspaceRemove( - &out, - &errOut, - GlobalFlags{JSON: true}, - []string{"../../../tmp", "--yes"}, - "test-v1", - ) - if code != ExitUsage { - t.Fatalf("cmdWorkspaceRemove() code = %d, want %d", code, ExitUsage) - } - if errOut.Len() != 0 { - t.Fatalf("expected no stderr output in JSON mode, got %q", errOut.String()) - } - - var env Envelope - if err := json.Unmarshal(out.Bytes(), &env); err != nil { - t.Fatalf("json.Unmarshal() error = %v\nraw: %s", err, out.String()) - } - if env.OK { - t.Fatalf("expected ok=false") - } - if env.Error == nil || env.Error.Code != "usage_error" { - t.Fatalf("expected usage_error, got %#v", env.Error) - } - if env.Error == nil || !strings.Contains(env.Error.Message, "invalid workspace id") { - t.Fatalf("expected invalid workspace id message, got %#v", env.Error) - } -} - -func TestCmdWorkspaceRemoveNotFoundReturnsNotFound(t *testing.T) { - t.Setenv("HOME", t.TempDir()) - - var out, errOut bytes.Buffer - code := cmdWorkspaceRemove( - &out, - &errOut, - GlobalFlags{JSON: true}, - []string{"0000000000000000", "--yes"}, - "test-v1", - ) - if code != ExitNotFound { - t.Fatalf("cmdWorkspaceRemove() code = %d, want %d", code, ExitNotFound) - } - if errOut.Len() != 0 { - t.Fatalf("expected no stderr output in JSON mode, got %q", errOut.String()) - } - - var env Envelope - if err := json.Unmarshal(out.Bytes(), &env); err != nil { - t.Fatalf("json.Unmarshal() error = %v\nraw: %s", err, out.String()) - } - if env.OK { - t.Fatalf("expected ok=false") - } - if env.Error == nil || env.Error.Code != "not_found" { - t.Fatalf("expected not_found, got %#v", env.Error) - } -} - -func TestCmdWorkspaceRemoveCorruptedMetadataReturnsInternalError(t *testing.T) { - home := t.TempDir() - t.Setenv("HOME", home) - - wsID := "0123456789abcdef" - metaDir := filepath.Join(home, ".amux", "workspaces-metadata", wsID) - if err := os.MkdirAll(metaDir, 0o755); err != nil { - t.Fatalf("MkdirAll() error = %v", err) - } - if err := os.WriteFile(filepath.Join(metaDir, "workspace.json"), []byte("{invalid"), 0o644); err != nil { - t.Fatalf("WriteFile() error = %v", err) - } - - var out, errOut bytes.Buffer - code := cmdWorkspaceRemove( - &out, - &errOut, - GlobalFlags{JSON: true}, - []string{wsID, "--yes"}, - "test-v1", - ) - if code != ExitInternalError { - t.Fatalf("cmdWorkspaceRemove() code = %d, want %d", code, ExitInternalError) - } - if errOut.Len() != 0 { - t.Fatalf("expected no stderr output in JSON mode, got %q", errOut.String()) - } - - var env Envelope - if err := json.Unmarshal(out.Bytes(), &env); err != nil { - t.Fatalf("json.Unmarshal() error = %v\nraw: %s", err, out.String()) - } - if env.OK { - t.Fatalf("expected ok=false") - } - if env.Error == nil || env.Error.Code != "metadata_load_failed" { - t.Fatalf("expected metadata_load_failed, got %#v", env.Error) - } -} diff --git a/internal/cli/cmd_workspace_test.go b/internal/cli/cmd_workspace_test.go deleted file mode 100644 index 29405890..00000000 --- a/internal/cli/cmd_workspace_test.go +++ /dev/null @@ -1,163 +0,0 @@ -package cli - -import ( - "bytes" - "encoding/json" - "os" - "path/filepath" - "testing" -) - -func TestCmdWorkspaceListByRelativeRepo(t *testing.T) { - home := t.TempDir() - t.Setenv("HOME", home) - - originalWD, err := os.Getwd() - if err != nil { - t.Fatalf("Getwd() error = %v", err) - } - defer func() { _ = os.Chdir(originalWD) }() - - // Use the current repo directory so "." resolves to a real path. - if err := os.Chdir(originalWD); err != nil { - t.Fatalf("Chdir() error = %v", err) - } - - var w, wErr bytes.Buffer - gf := GlobalFlags{JSON: true} - code := cmdWorkspaceList(&w, &wErr, gf, []string{"--repo", "."}, "test-v1") - if code != ExitOK { - t.Fatalf("expected ExitOK, got %d; stderr: %s", code, wErr.String()) - } - - var env Envelope - if err := json.Unmarshal(w.Bytes(), &env); err != nil { - t.Fatalf("json.Unmarshal() error = %v\nraw: %s", err, w.String()) - } - if !env.OK { - t.Fatalf("expected ok=true") - } -} - -func TestCmdWorkspaceListByRelativeProjectAlias(t *testing.T) { - home := t.TempDir() - t.Setenv("HOME", home) - - originalWD, err := os.Getwd() - if err != nil { - t.Fatalf("Getwd() error = %v", err) - } - defer func() { _ = os.Chdir(originalWD) }() - - if err := os.Chdir(originalWD); err != nil { - t.Fatalf("Chdir() error = %v", err) - } - - var w, wErr bytes.Buffer - gf := GlobalFlags{JSON: true} - code := cmdWorkspaceList(&w, &wErr, gf, []string{"--project", "."}, "test-v1") - if code != ExitOK { - t.Fatalf("expected ExitOK, got %d; stderr: %s", code, wErr.String()) - } - - var env Envelope - if err := json.Unmarshal(w.Bytes(), &env); err != nil { - t.Fatalf("json.Unmarshal() error = %v\nraw: %s", err, w.String()) - } - if !env.OK { - t.Fatalf("expected ok=true") - } -} - -func TestCmdWorkspaceListRejectsRepoAndProjectTogether(t *testing.T) { - var w, wErr bytes.Buffer - gf := GlobalFlags{JSON: true} - code := cmdWorkspaceList( - &w, - &wErr, - gf, - []string{"--repo", "/tmp/repo-a", "--project", "/tmp/repo-b"}, - "test-v1", - ) - if code != ExitUsage { - t.Fatalf("expected ExitUsage, got %d; stderr: %s", code, wErr.String()) - } - - var env Envelope - if err := json.Unmarshal(w.Bytes(), &env); err != nil { - t.Fatalf("json.Unmarshal() error = %v\nraw: %s", err, w.String()) - } - if env.OK { - t.Fatalf("expected ok=false") - } - if env.Error == nil || env.Error.Code != "usage_error" { - t.Fatalf("expected usage_error, got %#v", env.Error) - } -} - -func TestCmdWorkspaceListJSON(t *testing.T) { - var w, wErr bytes.Buffer - gf := GlobalFlags{JSON: true} - code := cmdWorkspaceList(&w, &wErr, gf, nil, "test-v1") - - if code != ExitOK { - t.Fatalf("expected exit 0, got %d; stderr: %s", code, wErr.String()) - } - - var env Envelope - if err := json.Unmarshal(w.Bytes(), &env); err != nil { - t.Fatalf("failed to decode JSON: %v\nraw: %s", err, w.String()) - } - if !env.OK { - t.Error("expected ok=true") - } - - // Data should be an array (possibly empty) - if env.Data == nil { - t.Fatal("expected data to be set") - } -} - -func TestCmdWorkspaceListHuman(t *testing.T) { - var w, wErr bytes.Buffer - gf := GlobalFlags{JSON: false} - code := cmdWorkspaceList(&w, &wErr, gf, nil, "test-v1") - - if code != ExitOK { - t.Fatalf("expected exit 0, got %d; stderr: %s", code, wErr.String()) - } -} - -func TestCmdWorkspaceListJSONReturnsInternalErrorOnCorruptMetadata(t *testing.T) { - home := t.TempDir() - t.Setenv("HOME", home) - - wsID := "0123456789abcdef" - metaDir := filepath.Join(home, ".amux", "workspaces-metadata", wsID) - if err := os.MkdirAll(metaDir, 0o755); err != nil { - t.Fatalf("MkdirAll() error = %v", err) - } - if err := os.WriteFile(filepath.Join(metaDir, "workspace.json"), []byte("{invalid"), 0o644); err != nil { - t.Fatalf("WriteFile() error = %v", err) - } - - var w, wErr bytes.Buffer - code := cmdWorkspaceList(&w, &wErr, GlobalFlags{JSON: true}, nil, "test-v1") - if code != ExitInternalError { - t.Fatalf("expected exit %d, got %d", ExitInternalError, code) - } - if wErr.Len() != 0 { - t.Fatalf("expected empty stderr in JSON mode, got %q", wErr.String()) - } - - var env Envelope - if err := json.Unmarshal(w.Bytes(), &env); err != nil { - t.Fatalf("failed to decode JSON: %v\nraw: %s", err, w.String()) - } - if env.OK { - t.Fatal("expected ok=false") - } - if env.Error == nil || env.Error.Code != "list_failed" { - t.Fatalf("expected list_failed error, got %#v", env.Error) - } -} diff --git a/internal/cli/cmd_write_flags_test.go b/internal/cli/cmd_write_flags_test.go deleted file mode 100644 index 4012ba7e..00000000 --- a/internal/cli/cmd_write_flags_test.go +++ /dev/null @@ -1,59 +0,0 @@ -package cli - -import ( - "bytes" - "strings" - "testing" -) - -func TestCmdWorkspaceCreateParsesTrailingProjectFlag(t *testing.T) { - nonRepo := t.TempDir() - var w, wErr bytes.Buffer - code := cmdWorkspaceCreate( - &w, - &wErr, - GlobalFlags{}, - []string{"feature-x", "--project", nonRepo, "--assistant", "claude"}, - "test-v1", - ) - - if code != ExitUsage { - t.Fatalf("expected ExitUsage for non-git repo, got %d", code) - } - if !strings.Contains(wErr.String(), "is not a git repository") { - t.Fatalf("expected parsed --project validation error, got stderr: %q", wErr.String()) - } -} - -func TestCmdWorkspaceRemoveParsesTrailingYesFlag(t *testing.T) { - var w, wErr bytes.Buffer - code := cmdWorkspaceRemove( - &w, - &wErr, - GlobalFlags{}, - []string{"missing-workspace", "--yes"}, - "test-v1", - ) - - if code == ExitUnsafeBlocked { - t.Fatalf("expected --yes to be parsed, got confirmation block; stderr: %q", wErr.String()) - } - if strings.Contains(wErr.String(), "pass --yes") { - t.Fatalf("expected confirmation check to be bypassed, got stderr: %q", wErr.String()) - } -} - -func TestCmdAgentSendParsesTrailingTextFlag(t *testing.T) { - var w, wErr bytes.Buffer - code := cmdAgentSend( - &w, - &wErr, - GlobalFlags{}, - []string{"missing-session", "--text", "hello"}, - "test-v1", - ) - - if code == ExitUsage { - t.Fatalf("expected --text to be parsed, got usage; stderr: %q", wErr.String()) - } -} diff --git a/internal/cli/flags.go b/internal/cli/flags.go deleted file mode 100644 index 88b0b300..00000000 --- a/internal/cli/flags.go +++ /dev/null @@ -1,14 +0,0 @@ -package cli - -import ( - "flag" - "io" -) - -// newFlagSet creates a flag set that never writes parse errors to stderr. -// Commands decide how to surface parse failures in human/JSON modes. -func newFlagSet(name string) *flag.FlagSet { - fs := flag.NewFlagSet(name, flag.ContinueOnError) - fs.SetOutput(io.Discard) - return fs -} diff --git a/internal/cli/helpers.go b/internal/cli/helpers.go deleted file mode 100644 index 11e1db98..00000000 --- a/internal/cli/helpers.go +++ /dev/null @@ -1,36 +0,0 @@ -package cli - -import ( - "context" - "os" - "os/exec" - "time" - - "github.com/andyrewlee/amux/internal/tmux" -) - -func isReadable(path string) bool { - _, err := os.Stat(path) - return err == nil -} - -// tmuxNewSession builds a tmux new-session command using the same server/config -// options as other tmux helpers. This avoids importing unexported functions. -func tmuxNewSession(opts tmux.Options, extraArgs ...string) (*exec.Cmd, context.CancelFunc) { - args := []string{} - if opts.ServerName != "" { - args = append(args, "-L", opts.ServerName) - } - if opts.ConfigPath != "" { - args = append(args, "-f", opts.ConfigPath) - } - args = append(args, extraArgs...) - - timeout := 5 * time.Second - if opts.CommandTimeout > 0 { - timeout = opts.CommandTimeout - } - ctx, cancel := context.WithTimeout(context.Background(), timeout) - cmd := exec.CommandContext(ctx, "tmux", args...) - return cmd, cancel -} diff --git a/internal/cli/idempotency.go b/internal/cli/idempotency.go deleted file mode 100644 index 90498a4f..00000000 --- a/internal/cli/idempotency.go +++ /dev/null @@ -1,227 +0,0 @@ -package cli - -import ( - "encoding/json" - "errors" - "io" - "log/slog" - "os" - "path/filepath" - "strings" - "time" - - "github.com/andyrewlee/amux/internal/config" -) - -const ( - idempotencyStateFilename = "cli-idempotency.json" - idempotencyStateVersion = 1 - idempotencyRetention = 7 * 24 * time.Hour -) - -type idempotencyEntry struct { - Command string `json:"command"` - Key string `json:"key"` - ExitCode int `json:"exit_code"` - Envelope []byte `json:"envelope"` - CreatedAt int64 `json:"created_at"` -} - -type idempotencyState struct { - Version int `json:"version"` - Entries map[string]idempotencyEntry `json:"entries"` -} - -type idempotencyStore struct { - path string -} - -func newIdempotencyStore() (*idempotencyStore, error) { - paths, err := config.DefaultPaths() - if err != nil { - return nil, err - } - return &idempotencyStore{path: filepath.Join(paths.Home, idempotencyStateFilename)}, nil -} - -func (s *idempotencyStore) replay(command, key string) ([]byte, int, bool, error) { - command = strings.TrimSpace(command) - key = strings.TrimSpace(key) - if command == "" || key == "" { - return nil, 0, false, nil - } - - lockFile, err := lockIdempotencyFile(s.lockPath(), true) - if err != nil { - return nil, 0, false, err - } - defer unlockIdempotencyFile(lockFile) - - state, err := s.loadState() - if err != nil { - return nil, 0, false, err - } - if state == nil || len(state.Entries) == 0 { - return nil, 0, false, nil - } - - entry, ok := state.Entries[s.entryKey(command, key)] - if !ok { - return nil, 0, false, nil - } - if entry.CreatedAt <= time.Now().Add(-idempotencyRetention).Unix() { - return nil, 0, false, nil - } - if len(entry.Envelope) == 0 { - return nil, 0, false, nil - } - return entry.Envelope, entry.ExitCode, true, nil -} - -func (s *idempotencyStore) store(command, key string, exitCode int, envelope []byte) error { - command = strings.TrimSpace(command) - key = strings.TrimSpace(key) - if command == "" || key == "" { - return nil - } - if len(envelope) == 0 { - return errors.New("idempotency envelope cannot be empty") - } - - lockFile, err := lockIdempotencyFile(s.lockPath(), false) - if err != nil { - return err - } - defer unlockIdempotencyFile(lockFile) - - state, err := s.loadState() - if err != nil { - return err - } - if state == nil { - state = &idempotencyState{ - Version: idempotencyStateVersion, - Entries: map[string]idempotencyEntry{}, - } - } - if state.Entries == nil { - state.Entries = map[string]idempotencyEntry{} - } - - s.prune(state) - state.Entries[s.entryKey(command, key)] = idempotencyEntry{ - Command: command, - Key: key, - ExitCode: exitCode, - Envelope: append([]byte(nil), envelope...), - CreatedAt: time.Now().Unix(), - } - - return s.saveState(state) -} - -func (s *idempotencyStore) prune(state *idempotencyState) { - if state == nil || len(state.Entries) == 0 { - return - } - cutoff := time.Now().Add(-idempotencyRetention).Unix() - for key, entry := range state.Entries { - if entry.CreatedAt <= cutoff { - delete(state.Entries, key) - } - } -} - -func (s *idempotencyStore) entryKey(command, key string) string { - return command + "|" + key -} - -func (s *idempotencyStore) lockPath() string { - return s.path + ".lock" -} - -func (s *idempotencyStore) loadState() (*idempotencyState, error) { - data, err := os.ReadFile(s.path) - if os.IsNotExist(err) { - return &idempotencyState{ - Version: idempotencyStateVersion, - Entries: map[string]idempotencyEntry{}, - }, nil - } - if err != nil { - return nil, err - } - - var state idempotencyState - if err := json.Unmarshal(data, &state); err != nil { - // Treat malformed state as empty instead of breaking mutating flows. - return &idempotencyState{ - Version: idempotencyStateVersion, - Entries: map[string]idempotencyEntry{}, - }, nil - } - if state.Version != idempotencyStateVersion { - return &idempotencyState{ - Version: idempotencyStateVersion, - Entries: map[string]idempotencyEntry{}, - }, nil - } - if state.Entries == nil { - state.Entries = map[string]idempotencyEntry{} - } - return &state, nil -} - -func (s *idempotencyStore) saveState(state *idempotencyState) error { - if err := os.MkdirAll(filepath.Dir(s.path), 0o755); err != nil { - return err - } - payload, err := json.MarshalIndent(state, "", " ") - if err != nil { - return err - } - tmpPath := s.path + ".tmp" - if err := os.WriteFile(tmpPath, payload, 0o644); err != nil { - return err - } - if err := os.Rename(tmpPath, s.path); err != nil { - if removeErr := os.Remove(tmpPath); removeErr != nil { - slog.Debug("failed to remove temp file after rename failure", "path", tmpPath, "error", removeErr) - } - return err - } - return nil -} - -func maybeReplayIdempotentResponse( - w io.Writer, - wErr io.Writer, - gf GlobalFlags, - version string, - command string, - key string, -) (bool, int) { - key = strings.TrimSpace(key) - if key == "" { - return false, 0 - } - if !gf.JSON { - Errorf(wErr, "--idempotency-key requires --json") - return true, ExitUsage - } - store, err := newIdempotencyStore() - if err != nil { - ReturnError(w, "idempotency_failed", err.Error(), nil, version) - return true, ExitInternalError - } - envelope, exitCode, ok, err := store.replay(command, key) - if err != nil { - ReturnError(w, "idempotency_failed", err.Error(), nil, version) - return true, ExitInternalError - } - if !ok { - return false, 0 - } - _, _ = w.Write(envelope) - return true, exitCode -} diff --git a/internal/cli/idempotency_lock_unix.go b/internal/cli/idempotency_lock_unix.go deleted file mode 100644 index b97fa5b5..00000000 --- a/internal/cli/idempotency_lock_unix.go +++ /dev/null @@ -1,36 +0,0 @@ -//go:build !windows - -package cli - -import ( - "os" - "path/filepath" - "syscall" -) - -func lockIdempotencyFile(lockPath string, shared bool) (*os.File, error) { - if err := os.MkdirAll(filepath.Dir(lockPath), 0o755); err != nil { - return nil, err - } - file, err := os.OpenFile(lockPath, os.O_CREATE|os.O_RDWR, 0o644) - if err != nil { - return nil, err - } - flag := syscall.LOCK_EX - if shared { - flag = syscall.LOCK_SH - } - if err := syscall.Flock(int(file.Fd()), flag); err != nil { - _ = file.Close() - return nil, err - } - return file, nil -} - -func unlockIdempotencyFile(file *os.File) { - if file == nil { - return - } - _ = syscall.Flock(int(file.Fd()), syscall.LOCK_UN) - _ = file.Close() -} diff --git a/internal/cli/idempotency_lock_windows.go b/internal/cli/idempotency_lock_windows.go deleted file mode 100644 index d0d24f97..00000000 --- a/internal/cli/idempotency_lock_windows.go +++ /dev/null @@ -1,52 +0,0 @@ -//go:build windows - -package cli - -import ( - "os" - "path/filepath" - "unsafe" - - "golang.org/x/sys/windows" -) - -func lockIdempotencyFile(lockPath string, shared bool) (*os.File, error) { - if err := os.MkdirAll(filepath.Dir(lockPath), 0o755); err != nil { - return nil, err - } - file, err := os.OpenFile(lockPath, os.O_CREATE|os.O_RDWR, 0o644) - if err != nil { - return nil, err - } - var flags uint32 = windows.LOCKFILE_FAIL_IMMEDIATELY - if !shared { - flags |= windows.LOCKFILE_EXCLUSIVE_LOCK - } - ol := new(windows.Overlapped) - // Lock the first byte range (entire file). Using maxLen ensures the lock - // covers any file size, matching the Unix flock() semantics. - const maxLen = ^uint32(0) - err = windows.LockFileEx(windows.Handle(file.Fd()), flags, 0, maxLen, maxLen, ol) - if err != nil { - // FAIL_IMMEDIATELY may return ERROR_LOCK_VIOLATION; fall back to blocking. - flags &^= windows.LOCKFILE_FAIL_IMMEDIATELY - err = windows.LockFileEx(windows.Handle(file.Fd()), flags, 0, maxLen, maxLen, ol) - } - if err != nil { - _ = file.Close() - return nil, &os.PathError{Op: "lock", Path: lockPath, Err: err} - } - // Prevent ol from being GC'd before LockFileEx returns. - _ = unsafe.Pointer(ol) - return file, nil -} - -func unlockIdempotencyFile(file *os.File) { - if file == nil { - return - } - ol := new(windows.Overlapped) - const maxLen = ^uint32(0) - _ = windows.UnlockFileEx(windows.Handle(file.Fd()), 0, maxLen, maxLen, ol) - _ = file.Close() -} diff --git a/internal/cli/idempotency_output.go b/internal/cli/idempotency_output.go deleted file mode 100644 index c963610a..00000000 --- a/internal/cli/idempotency_output.go +++ /dev/null @@ -1,125 +0,0 @@ -package cli - -import ( - "io" - "strings" -) - -func writeJSONEnvelopeWithIdempotency( - w io.Writer, - wErr io.Writer, - gf GlobalFlags, - version string, - command string, - key string, - exitCode int, - env Envelope, -) int { - _ = wErr - encoded, err := encodeEnvelope(env) - if err != nil { - ReturnError(w, "encode_failed", "failed to encode response", nil, version) - return ExitInternalError - } - encoded = append(encoded, '\n') - - key = strings.TrimSpace(key) - if key != "" { - if !gf.JSON { - _, _ = w.Write(encoded) - return exitCode - } - // Persist first so mutating JSON calls fail closed when idempotency state - // cannot be recorded. - store, storeErr := newIdempotencyStore() - if storeErr != nil { - ReturnError(w, "idempotency_failed", storeErr.Error(), nil, version) - return ExitInternalError - } - if storeErr := store.store(command, key, exitCode, encoded); storeErr != nil { - ReturnError(w, "idempotency_failed", storeErr.Error(), nil, version) - return ExitInternalError - } - } - _, _ = w.Write(encoded) - return exitCode -} - -func returnJSONSuccessWithIdempotency( - w io.Writer, - wErr io.Writer, - gf GlobalFlags, - version string, - command string, - key string, - data any, -) int { - return writeJSONEnvelopeWithIdempotency( - w, - wErr, - gf, - version, - command, - key, - ExitOK, - successEnvelope(data, version), - ) -} - -func returnJSONErrorWithIdempotency( - w io.Writer, - wErr io.Writer, - gf GlobalFlags, - version string, - command string, - key string, - exitCode int, - errorCode string, - message string, - details any, -) int { - return writeJSONEnvelopeWithIdempotency( - w, - wErr, - gf, - version, - command, - key, - exitCode, - errorEnvelope(errorCode, message, details, version), - ) -} - -func returnJSONErrorMaybeIdempotent( - w io.Writer, - wErr io.Writer, - gf GlobalFlags, - version string, - command string, - key string, - exitCode int, - errorCode string, - message string, - details any, -) int { - if !gf.JSON { - _ = wErr - return exitCode - } - if strings.TrimSpace(key) == "" { - ReturnError(w, errorCode, message, details, version) - return exitCode - } - return returnJSONErrorWithIdempotency( - w, - wErr, - gf, - version, - command, - key, - exitCode, - errorCode, - message, - details, - ) -} diff --git a/internal/cli/idempotency_test.go b/internal/cli/idempotency_test.go deleted file mode 100644 index d5c47909..00000000 --- a/internal/cli/idempotency_test.go +++ /dev/null @@ -1,233 +0,0 @@ -package cli - -import ( - "bytes" - "encoding/json" - "os" - "path/filepath" - "testing" - "time" -) - -func TestIdempotencyReplaySuccessEnvelope(t *testing.T) { - t.Setenv("HOME", t.TempDir()) - setResponseContext("req-1", "agent run") - defer clearResponseContext() - - var first bytes.Buffer - var firstErr bytes.Buffer - code := returnJSONSuccessWithIdempotency( - &first, - &firstErr, - GlobalFlags{JSON: true}, - "test-v1", - "agent.run", - "idem-1", - map[string]any{"session_name": "amux-ws-tab"}, - ) - if code != ExitOK { - t.Fatalf("first write code = %d, want %d", code, ExitOK) - } - if firstErr.Len() != 0 { - t.Fatalf("expected no stderr warnings, got %q", firstErr.String()) - } - - var replay bytes.Buffer - var replayErr bytes.Buffer - handled, replayCode := maybeReplayIdempotentResponse( - &replay, - &replayErr, - GlobalFlags{JSON: true}, - "test-v1", - "agent.run", - "idem-1", - ) - if !handled { - t.Fatalf("expected replay hit") - } - if replayCode != ExitOK { - t.Fatalf("replay code = %d, want %d", replayCode, ExitOK) - } - if got, want := replay.String(), first.String(); got != want { - t.Fatalf("replayed envelope mismatch:\n got: %s\nwant: %s", got, want) - } - if replayErr.Len() != 0 { - t.Fatalf("expected no stderr replay warnings, got %q", replayErr.String()) - } -} - -func TestIdempotencyReplayMissByCommand(t *testing.T) { - t.Setenv("HOME", t.TempDir()) - setResponseContext("req-1", "agent run") - defer clearResponseContext() - - var out bytes.Buffer - var errOut bytes.Buffer - _ = returnJSONSuccessWithIdempotency( - &out, - &errOut, - GlobalFlags{JSON: true}, - "test-v1", - "agent.run", - "idem-2", - map[string]any{"session_name": "amux-ws-tab"}, - ) - - var replay bytes.Buffer - handled, _ := maybeReplayIdempotentResponse( - &replay, - &errOut, - GlobalFlags{JSON: true}, - "test-v1", - "workspace.create", - "idem-2", - ) - if handled { - t.Fatalf("expected replay miss for different command") - } - if replay.Len() != 0 { - t.Fatalf("expected no replay output, got %q", replay.String()) - } -} - -func TestIdempotencyRequiresJSON(t *testing.T) { - var out bytes.Buffer - var errOut bytes.Buffer - handled, code := maybeReplayIdempotentResponse( - &out, - &errOut, - GlobalFlags{JSON: false}, - "test-v1", - "agent.run", - "idem-3", - ) - if !handled { - t.Fatalf("expected non-json guard to handle request") - } - if code != ExitUsage { - t.Fatalf("code = %d, want %d", code, ExitUsage) - } - if errOut.Len() == 0 { - t.Fatalf("expected human-readable guard message") - } -} - -func TestIdempotencyReplaySkipsExpiredEntries(t *testing.T) { - t.Setenv("HOME", t.TempDir()) - - store, err := newIdempotencyStore() - if err != nil { - t.Fatalf("newIdempotencyStore() error = %v", err) - } - if err := store.store("agent.run", "expired-key", ExitOK, []byte("{\"ok\":true}\n")); err != nil { - t.Fatalf("store.store() error = %v", err) - } - - lockFile, err := lockIdempotencyFile(store.lockPath(), false) - if err != nil { - t.Fatalf("lockIdempotencyFile() error = %v", err) - } - state, err := store.loadState() - if err != nil { - unlockIdempotencyFile(lockFile) - t.Fatalf("store.loadState() error = %v", err) - } - entryKey := store.entryKey("agent.run", "expired-key") - entry := state.Entries[entryKey] - entry.CreatedAt = time.Now().Add(-idempotencyRetention - time.Hour).Unix() - state.Entries[entryKey] = entry - if err := store.saveState(state); err != nil { - unlockIdempotencyFile(lockFile) - t.Fatalf("store.saveState() error = %v", err) - } - unlockIdempotencyFile(lockFile) - - var out bytes.Buffer - var errOut bytes.Buffer - handled, code := maybeReplayIdempotentResponse( - &out, - &errOut, - GlobalFlags{JSON: true}, - "test-v1", - "agent.run", - "expired-key", - ) - if handled { - t.Fatalf("expected expired idempotency entry to miss replay") - } - if code != 0 { - t.Fatalf("code = %d, want %d", code, 0) - } - if out.Len() != 0 { - t.Fatalf("expected no replay output, got %q", out.String()) - } - if errOut.Len() != 0 { - t.Fatalf("expected no stderr output, got %q", errOut.String()) - } -} - -func TestWriteJSONEnvelopeWithIdempotencyNoKeyPreservesExitCode(t *testing.T) { - t.Setenv("HOME", t.TempDir()) - - var out bytes.Buffer - var errOut bytes.Buffer - code := writeJSONEnvelopeWithIdempotency( - &out, - &errOut, - GlobalFlags{JSON: true}, - "test-v1", - "agent.send", - "", - ExitInternalError, - errorEnvelope("send_failed", "send failed", nil, "test-v1"), - ) - if code != ExitInternalError { - t.Fatalf("code = %d, want %d", code, ExitInternalError) - } - if out.Len() == 0 { - t.Fatalf("expected envelope output") - } - if errOut.Len() != 0 { - t.Fatalf("expected no stderr output, got %q", errOut.String()) - } -} - -func TestWriteJSONEnvelopeWithIdempotencyReportsStoreFailureInJSONMode(t *testing.T) { - home := t.TempDir() - t.Setenv("HOME", home) - - // Force idempotency store writes to fail by blocking ~/.amux with a file. - if err := os.WriteFile(filepath.Join(home, ".amux"), []byte("not-a-dir"), 0o644); err != nil { - t.Fatalf("WriteFile() error = %v", err) - } - - var out bytes.Buffer - var errOut bytes.Buffer - code := writeJSONEnvelopeWithIdempotency( - &out, - &errOut, - GlobalFlags{JSON: true}, - "test-v1", - "agent.run", - "idem-store-fail", - ExitOK, - successEnvelope(map[string]any{"session_name": "s1"}, "test-v1"), - ) - if code != ExitInternalError { - t.Fatalf("code = %d, want %d", code, ExitInternalError) - } - if errOut.Len() != 0 { - t.Fatalf("expected no stderr output in JSON mode, got %q", errOut.String()) - } - - var env Envelope - if err := json.Unmarshal(out.Bytes(), &env); err != nil { - t.Fatalf("json.Unmarshal() error = %v\nraw: %s", err, out.String()) - } - if env.OK { - t.Fatalf("expected ok=false") - } - if env.Error == nil || env.Error.Code != "idempotency_failed" { - t.Fatalf("expected idempotency_failed, got %#v", env.Error) - } -} diff --git a/internal/cli/openclaw_dx_script_assistant_validation_test.go b/internal/cli/openclaw_dx_script_assistant_validation_test.go deleted file mode 100644 index 2aab1bcd..00000000 --- a/internal/cli/openclaw_dx_script_assistant_validation_test.go +++ /dev/null @@ -1,226 +0,0 @@ -package cli - -import ( - "os" - "path/filepath" - "strings" - "testing" -) - -func TestOpenClawDXStart_InvalidAssistantReturnsCommandError(t *testing.T) { - requireBinary(t, "jq") - requireBinary(t, "bash") - - scriptPath := filepath.Join("..", "..", "skills", "amux", "scripts", "openclaw-dx.sh") - fakeBinDir := t.TempDir() - fakeAmuxPath := filepath.Join(fakeBinDir, "amux") - - writeExecutable(t, fakeAmuxPath, `#!/usr/bin/env bash -set -euo pipefail -if [[ "${1:-}" == "--json" ]]; then - shift -fi -case "${1:-} ${2:-}" in - "workspace list") - printf '%s' '{"ok":true,"data":[{"id":"ws-1","name":"demo","repo":"/tmp/demo"}],"error":null}' - ;; - *) - printf '%s' '{"ok":true,"data":{},"error":null}' - ;; -esac -`) - - env := os.Environ() - env = withEnv(env, "PATH", fakeBinDir+":"+os.Getenv("PATH")) - - payload := runScriptJSON(t, scriptPath, env, - "start", - "--workspace", "ws-1", - "--assistant", "not/a-real-assistant", - "--prompt", "hello", - ) - - if got, _ := payload["command"].(string); got != "start" { - t.Fatalf("command = %q, want %q", got, "start") - } - if got, _ := payload["status"].(string); got != "command_error" { - t.Fatalf("status = %q, want %q", got, "command_error") - } - summary, _ := payload["summary"].(string) - if !strings.Contains(summary, "invalid assistant") { - t.Fatalf("summary = %q, want invalid assistant", summary) - } -} - -func TestOpenClawDXReview_InvalidAssistantReturnsCommandError(t *testing.T) { - requireBinary(t, "jq") - requireBinary(t, "bash") - - scriptPath := filepath.Join("..", "..", "skills", "amux", "scripts", "openclaw-dx.sh") - fakeBinDir := t.TempDir() - fakeAmuxPath := filepath.Join(fakeBinDir, "amux") - - writeExecutable(t, fakeAmuxPath, `#!/usr/bin/env bash -set -euo pipefail -if [[ "${1:-}" == "--json" ]]; then - shift -fi -case "${1:-} ${2:-}" in - "workspace list") - printf '%s' '{"ok":true,"data":[{"id":"ws-1","name":"demo","repo":"/tmp/demo"}],"error":null}' - ;; - *) - printf '%s' '{"ok":true,"data":{},"error":null}' - ;; -esac -`) - - env := os.Environ() - env = withEnv(env, "PATH", fakeBinDir+":"+os.Getenv("PATH")) - - payload := runScriptJSON(t, scriptPath, env, - "review", - "--workspace", "ws-1", - "--assistant", "not/a-real-assistant", - ) - - if got, _ := payload["command"].(string); got != "review" { - t.Fatalf("command = %q, want %q", got, "review") - } - if got, _ := payload["status"].(string); got != "command_error" { - t.Fatalf("status = %q, want %q", got, "command_error") - } - summary, _ := payload["summary"].(string) - if !strings.Contains(summary, "invalid assistant") { - t.Fatalf("summary = %q, want invalid assistant", summary) - } -} - -func TestOpenClawDXWorkflowKickoff_InvalidAssistantReturnsCommandError(t *testing.T) { - requireBinary(t, "jq") - requireBinary(t, "bash") - - scriptPath := filepath.Join("..", "..", "skills", "amux", "scripts", "openclaw-dx.sh") - fakeBinDir := t.TempDir() - fakeAmuxPath := filepath.Join(fakeBinDir, "amux") - - writeExecutable(t, fakeAmuxPath, `#!/usr/bin/env bash -set -euo pipefail -printf '%s' '{"ok":true,"data":{},"error":null}' -`) - - env := os.Environ() - env = withEnv(env, "PATH", fakeBinDir+":"+os.Getenv("PATH")) - - payload := runScriptJSON(t, scriptPath, env, - "workflow", "kickoff", - "--name", "ws-new", - "--project", "/tmp/example", - "--assistant", "not/a-real-assistant", - "--prompt", "hello", - ) - - if got, _ := payload["command"].(string); got != "workflow.kickoff" { - t.Fatalf("command = %q, want %q", got, "workflow.kickoff") - } - if got, _ := payload["status"].(string); got != "command_error" { - t.Fatalf("status = %q, want %q", got, "command_error") - } - summary, _ := payload["summary"].(string) - if !strings.Contains(summary, "invalid assistant") { - t.Fatalf("summary = %q, want invalid assistant", summary) - } -} - -func TestOpenClawDXWorkflowDual_InvalidAssistantReturnsCommandError(t *testing.T) { - requireBinary(t, "jq") - requireBinary(t, "bash") - - scriptPath := filepath.Join("..", "..", "skills", "amux", "scripts", "openclaw-dx.sh") - fakeBinDir := t.TempDir() - fakeAmuxPath := filepath.Join(fakeBinDir, "amux") - - writeExecutable(t, fakeAmuxPath, `#!/usr/bin/env bash -set -euo pipefail -if [[ "${1:-}" == "--json" ]]; then - shift -fi -case "${1:-} ${2:-}" in - "workspace list") - printf '%s' '{"ok":true,"data":[{"id":"ws-1","name":"demo","repo":"/tmp/demo"}],"error":null}' - ;; - *) - printf '%s' '{"ok":true,"data":{},"error":null}' - ;; -esac -`) - - env := os.Environ() - env = withEnv(env, "PATH", fakeBinDir+":"+os.Getenv("PATH")) - - payload := runScriptJSON(t, scriptPath, env, - "workflow", "dual", - "--workspace", "ws-1", - "--implement-assistant", "fake/impl", - "--review-assistant", "codex", - ) - - if got, _ := payload["command"].(string); got != "workflow.dual" { - t.Fatalf("command = %q, want %q", got, "workflow.dual") - } - if got, _ := payload["status"].(string); got != "command_error" { - t.Fatalf("status = %q, want %q", got, "command_error") - } - summary, _ := payload["summary"].(string) - if !strings.Contains(summary, "invalid assistant") { - t.Fatalf("summary = %q, want invalid assistant", summary) - } -} - -func TestOpenClawDXStart_UnlistedAssistantPassesThrough(t *testing.T) { - requireBinary(t, "jq") - requireBinary(t, "bash") - - scriptPath := filepath.Join("..", "..", "skills", "amux", "scripts", "openclaw-dx.sh") - fakeBinDir := t.TempDir() - fakeAmuxPath := filepath.Join(fakeBinDir, "amux") - fakeTurnPath := filepath.Join(fakeBinDir, "openclaw-turn.sh") - - writeExecutable(t, fakeAmuxPath, `#!/usr/bin/env bash -set -euo pipefail -if [[ "${1:-}" == "--json" ]]; then - shift -fi -case "${1:-} ${2:-}" in - "workspace list") - printf '%s' '{"ok":true,"data":[{"id":"ws-1","name":"demo","repo":"/tmp/demo"}],"error":null}' - ;; - *) - printf '%s' '{"ok":true,"data":{},"error":null}' - ;; -esac -`) - - writeExecutable(t, fakeTurnPath, `#!/usr/bin/env bash -set -euo pipefail -printf '%s' '{"ok":true,"mode":"run","status":"idle","overall_status":"completed","summary":"turn ok","agent_id":"agent-1","workspace_id":"ws-1","assistant":"aider","quick_actions":[],"channel":{"message":"done","chunks":["done"],"chunks_meta":[{"index":1,"total":1,"text":"done"}],"inline_buttons":[]}}' -`) - - env := os.Environ() - env = withEnv(env, "PATH", fakeBinDir+":"+os.Getenv("PATH")) - env = withEnv(env, "OPENCLAW_DX_TURN_SCRIPT", fakeTurnPath) - - payload := runScriptJSON(t, scriptPath, env, - "start", - "--workspace", "ws-1", - "--assistant", "aider", - "--prompt", "hello", - ) - - if got, _ := payload["command"].(string); got != "start" { - t.Fatalf("command = %q, want %q", got, "start") - } - if got, _ := payload["status"].(string); got == "command_error" { - t.Fatalf("status = %q, expected non-command_error for unlisted assistant pass-through", got) - } -} diff --git a/internal/cli/openclaw_dx_script_assistants_git_test.go b/internal/cli/openclaw_dx_script_assistants_git_test.go deleted file mode 100644 index 495265b5..00000000 --- a/internal/cli/openclaw_dx_script_assistants_git_test.go +++ /dev/null @@ -1,410 +0,0 @@ -package cli - -import ( - "os" - "os/exec" - "path/filepath" - "strings" - "testing" -) - -func TestOpenClawDXAssistants_ReportsMissingFromConfig(t *testing.T) { - requireBinary(t, "jq") - requireBinary(t, "bash") - - scriptPath := filepath.Join("..", "..", "skills", "amux", "scripts", "openclaw-dx.sh") - fakeBinDir := t.TempDir() - fakeAmuxPath := filepath.Join(fakeBinDir, "amux") - readyBotPath := filepath.Join(fakeBinDir, "readybot") - homeDir := t.TempDir() - amuxHome := filepath.Join(homeDir, ".amux") - if err := os.MkdirAll(amuxHome, 0o755); err != nil { - t.Fatalf("mkdir amux home: %v", err) - } - configPath := filepath.Join(amuxHome, "config.json") - - writeExecutable(t, fakeAmuxPath, `#!/usr/bin/env bash -set -euo pipefail -if [[ "${1:-}" == "--json" ]]; then - shift -fi -case "${1:-} ${2:-}" in - "workspace list") - printf '%s' '{"ok":true,"data":[{"id":"ws-1","name":"demo","repo":"/tmp/demo","assistant":"codex"}],"error":null}' - ;; - *) - printf '%s' '{"ok":true,"data":{},"error":null}' - ;; -esac -`) - writeExecutable(t, readyBotPath, `#!/usr/bin/env bash -set -euo pipefail -echo ready -`) - if err := os.WriteFile(configPath, []byte(`{ - "assistants": { - "ready": {"command": "readybot"}, - "missing": {"command": "missing-bot"} - } -} -`), 0o644); err != nil { - t.Fatalf("write config: %v", err) - } - - env := os.Environ() - env = withEnv(env, "PATH", fakeBinDir+":"+os.Getenv("PATH")) - env = withEnv(env, "HOME", homeDir) - - payload := runScriptJSON(t, scriptPath, env, "assistants") - - if got, _ := payload["command"].(string); got != "assistants" { - t.Fatalf("command = %q, want %q", got, "assistants") - } - if got, _ := payload["status"].(string); got != "attention" { - t.Fatalf("status = %q, want %q", got, "attention") - } - data, ok := payload["data"].(map[string]any) - if !ok { - t.Fatalf("data missing or wrong type: %T", payload["data"]) - } - if got, _ := data["missing_count"].(float64); got < 1 { - t.Fatalf("missing_count = %v, want >=1", got) - } - assistants, ok := data["assistants"].([]any) - if !ok { - t.Fatalf("assistants missing or wrong type: %T", data["assistants"]) - } - var sawReady bool - var sawMissing bool - for _, raw := range assistants { - item, ok := raw.(map[string]any) - if !ok { - continue - } - name, _ := item["name"].(string) - status, _ := item["status"].(string) - if name == "ready" && status == "ready" { - sawReady = true - } - if name == "missing" && status == "missing" { - sawMissing = true - } - } - if !sawReady || !sawMissing { - t.Fatalf("assistant statuses missing expected ready/missing entries: %#v", assistants) - } -} - -func TestOpenClawDXAssistants_ProbeAggregatesReadiness(t *testing.T) { - requireBinary(t, "jq") - requireBinary(t, "bash") - - scriptPath := filepath.Join("..", "..", "skills", "amux", "scripts", "openclaw-dx.sh") - fakeBinDir := t.TempDir() - fakeAmuxPath := filepath.Join(fakeBinDir, "amux") - fakeTurnPath := filepath.Join(fakeBinDir, "fake-turn.sh") - passBotPath := filepath.Join(fakeBinDir, "aa-pass-bot") - needsBotPath := filepath.Join(fakeBinDir, "ab-needs-bot") - homeDir := t.TempDir() - amuxHome := filepath.Join(homeDir, ".amux") - if err := os.MkdirAll(amuxHome, 0o755); err != nil { - t.Fatalf("mkdir amux home: %v", err) - } - configPath := filepath.Join(amuxHome, "config.json") - - writeExecutable(t, fakeAmuxPath, `#!/usr/bin/env bash -set -euo pipefail -if [[ "${1:-}" == "--json" ]]; then - shift -fi -case "${1:-} ${2:-}" in - "workspace list") - printf '%s' '{"ok":true,"data":[{"id":"ws-1","name":"demo","repo":"/tmp/demo","assistant":"codex"}],"error":null}' - ;; - *) - printf '%s' '{"ok":true,"data":{},"error":null}' - ;; -esac -`) - writeExecutable(t, passBotPath, `#!/usr/bin/env bash -set -euo pipefail -echo pass-ready -`) - writeExecutable(t, needsBotPath, `#!/usr/bin/env bash -set -euo pipefail -echo needs-ready -`) - if err := os.WriteFile(configPath, []byte(`{ - "assistants": { - "aa-pass": {"command": "aa-pass-bot"}, - "ab-needs": {"command": "ab-needs-bot"} - } -} -`), 0o644); err != nil { - t.Fatalf("write config: %v", err) - } - - writeExecutable(t, fakeTurnPath, `#!/usr/bin/env bash -set -euo pipefail -assistant="" -for ((i=1; i<=$#; i++)); do - if [[ "${!i}" == "--assistant" ]]; then - next=$((i+1)) - assistant="${!next}" - fi -done -if [[ "$assistant" == "aa-pass" ]]; then - printf '%s' '{"ok":true,"status":"idle","overall_status":"completed","summary":"READY: codex objective identified."}' - exit 0 -fi -printf '%s' '{"ok":true,"status":"needs_input","overall_status":"needs_input","summary":"Needs local permission confirmation."}' -`) - - env := os.Environ() - env = withEnv(env, "PATH", fakeBinDir+":"+os.Getenv("PATH")) - env = withEnv(env, "HOME", homeDir) - env = withEnv(env, "OPENCLAW_DX_TURN_SCRIPT", fakeTurnPath) - - payload := runScriptJSON(t, scriptPath, env, - "assistants", - "--workspace", "ws-1", - "--probe", - "--limit", "2", - ) - - if got, _ := payload["command"].(string); got != "assistants" { - t.Fatalf("command = %q, want %q", got, "assistants") - } - if got, _ := payload["status"].(string); got != "needs_input" { - t.Fatalf("status = %q, want %q", got, "needs_input") - } - data, ok := payload["data"].(map[string]any) - if !ok { - t.Fatalf("data missing or wrong type: %T", payload["data"]) - } - if got, _ := data["probe_count"].(float64); got != 2 { - t.Fatalf("probe_count = %v, want 2", got) - } - if got, _ := data["probe_passed"].(float64); got != 1 { - t.Fatalf("probe_passed = %v, want 1", got) - } - if got, _ := data["probe_needs_input"].(float64); got != 1 { - t.Fatalf("probe_needs_input = %v, want 1", got) - } - probes, ok := data["probes"].([]any) - if !ok || len(probes) != 2 { - t.Fatalf("probes = %#v, want len=2", data["probes"]) - } - var sawPassed bool - var sawNeedsInput bool - for _, raw := range probes { - probe, ok := raw.(map[string]any) - if !ok { - continue - } - assistant, _ := probe["assistant"].(string) - result, _ := probe["result"].(string) - if assistant == "aa-pass" && result == "passed" { - sawPassed = true - } - if assistant == "ab-needs" && result == "needs_input" { - sawNeedsInput = true - } - } - if !sawPassed || !sawNeedsInput { - t.Fatalf("probe results missing expected entries: %#v", probes) - } -} - -func TestOpenClawDXGitShip_CommitsWorkspaceChanges(t *testing.T) { - requireBinary(t, "jq") - requireBinary(t, "bash") - requireBinary(t, "git") - - scriptPath := filepath.Join("..", "..", "skills", "amux", "scripts", "openclaw-dx.sh") - fakeBinDir := t.TempDir() - fakeAmuxPath := filepath.Join(fakeBinDir, "amux") - - repoDir := t.TempDir() - if out, err := exec.Command("git", "-C", repoDir, "init", "-b", "main").CombinedOutput(); err != nil { - t.Fatalf("git init: %v\n%s", err, string(out)) - } - if out, err := exec.Command("git", "-C", repoDir, "config", "user.email", "dx@example.com").CombinedOutput(); err != nil { - t.Fatalf("git config email: %v\n%s", err, string(out)) - } - if out, err := exec.Command("git", "-C", repoDir, "config", "user.name", "DX Bot").CombinedOutput(); err != nil { - t.Fatalf("git config name: %v\n%s", err, string(out)) - } - if err := os.WriteFile(filepath.Join(repoDir, "README.md"), []byte("hello\n"), 0o644); err != nil { - t.Fatalf("write README: %v", err) - } - if out, err := exec.Command("git", "-C", repoDir, "add", "README.md").CombinedOutput(); err != nil { - t.Fatalf("git add: %v\n%s", err, string(out)) - } - if out, err := exec.Command("git", "-C", repoDir, "commit", "-m", "initial").CombinedOutput(); err != nil { - t.Fatalf("git commit initial: %v\n%s", err, string(out)) - } - if err := os.WriteFile(filepath.Join(repoDir, "README.md"), []byte("hello\nworld\n"), 0o644); err != nil { - t.Fatalf("modify README: %v", err) - } - - writeExecutable(t, fakeAmuxPath, `#!/usr/bin/env bash -set -euo pipefail -if [[ "${1:-}" == "--json" ]]; then - shift -fi -case "${1:-} ${2:-}" in - "workspace list") - printf '%s' "${FAKE_WORKSPACE_LIST_JSON:?missing FAKE_WORKSPACE_LIST_JSON}" - ;; - *) - printf '{"ok":false,"error":{"code":"unexpected","message":"unexpected args: %s"}}' "$*" - ;; -esac -`) - - workspaceListJSON := `{"ok":true,"data":[{"id":"ws-1","name":"demo","repo":"` + repoDir + `","root":"` + repoDir + `"}],"error":null}` - env := os.Environ() - env = withEnv(env, "PATH", fakeBinDir+":"+os.Getenv("PATH")) - env = withEnv(env, "FAKE_WORKSPACE_LIST_JSON", workspaceListJSON) - - payload := runScriptJSON(t, scriptPath, env, - "git", "ship", - "--workspace", "ws-1", - "--message", "feat: update readme", - ) - - if got, _ := payload["command"].(string); got != "git.ship" { - t.Fatalf("command = %q, want %q", got, "git.ship") - } - if got, _ := payload["status"].(string); got != "ok" { - t.Fatalf("status = %q, want %q", got, "ok") - } - data, ok := payload["data"].(map[string]any) - if !ok { - t.Fatalf("data missing or wrong type: %T", payload["data"]) - } - commitHash, _ := data["commit_hash"].(string) - if strings.TrimSpace(commitHash) == "" { - t.Fatalf("commit_hash is empty: %#v", data) - } - if pushed, _ := data["pushed"].(bool); pushed { - t.Fatalf("pushed = true, expected false in local-only test") - } - - logOut, err := exec.Command("git", "-C", repoDir, "log", "-1", "--pretty=%s").CombinedOutput() - if err != nil { - t.Fatalf("git log: %v\n%s", err, string(logOut)) - } - if got := strings.TrimSpace(string(logOut)); got != "feat: update readme" { - t.Fatalf("last commit message = %q, want %q", got, "feat: update readme") - } -} - -func TestOpenClawDXGitShip_NoChangesButAheadSuggestsPush(t *testing.T) { - requireBinary(t, "jq") - requireBinary(t, "bash") - requireBinary(t, "git") - - scriptPath := filepath.Join("..", "..", "skills", "amux", "scripts", "openclaw-dx.sh") - fakeBinDir := t.TempDir() - fakeAmuxPath := filepath.Join(fakeBinDir, "amux") - - repoDir := t.TempDir() - if out, err := exec.Command("git", "-C", repoDir, "init", "-b", "main").CombinedOutput(); err != nil { - t.Fatalf("git init: %v\n%s", err, string(out)) - } - if out, err := exec.Command("git", "-C", repoDir, "config", "user.email", "dx@example.com").CombinedOutput(); err != nil { - t.Fatalf("git config email: %v\n%s", err, string(out)) - } - if out, err := exec.Command("git", "-C", repoDir, "config", "user.name", "DX Bot").CombinedOutput(); err != nil { - t.Fatalf("git config name: %v\n%s", err, string(out)) - } - - remoteDir := filepath.Join(t.TempDir(), "remote.git") - if out, err := exec.Command("git", "init", "--bare", remoteDir).CombinedOutput(); err != nil { - t.Fatalf("git init bare: %v\n%s", err, string(out)) - } - if out, err := exec.Command("git", "-C", repoDir, "remote", "add", "origin", remoteDir).CombinedOutput(); err != nil { - t.Fatalf("git remote add: %v\n%s", err, string(out)) - } - - if err := os.WriteFile(filepath.Join(repoDir, "README.md"), []byte("hello\n"), 0o644); err != nil { - t.Fatalf("write README: %v", err) - } - if out, err := exec.Command("git", "-C", repoDir, "add", "README.md").CombinedOutput(); err != nil { - t.Fatalf("git add: %v\n%s", err, string(out)) - } - if out, err := exec.Command("git", "-C", repoDir, "commit", "-m", "initial").CombinedOutput(); err != nil { - t.Fatalf("git commit initial: %v\n%s", err, string(out)) - } - if out, err := exec.Command("git", "-C", repoDir, "push", "-u", "origin", "HEAD").CombinedOutput(); err != nil { - t.Fatalf("git push initial: %v\n%s", err, string(out)) - } - - if err := os.WriteFile(filepath.Join(repoDir, "README.md"), []byte("hello\nworld\n"), 0o644); err != nil { - t.Fatalf("modify README: %v", err) - } - if out, err := exec.Command("git", "-C", repoDir, "add", "README.md").CombinedOutput(); err != nil { - t.Fatalf("git add second: %v\n%s", err, string(out)) - } - if out, err := exec.Command("git", "-C", repoDir, "commit", "-m", "second").CombinedOutput(); err != nil { - t.Fatalf("git commit second: %v\n%s", err, string(out)) - } - - writeExecutable(t, fakeAmuxPath, `#!/usr/bin/env bash -set -euo pipefail -if [[ "${1:-}" == "--json" ]]; then - shift -fi -case "${1:-} ${2:-}" in - "workspace list") - printf '%s' "${FAKE_WORKSPACE_LIST_JSON:?missing FAKE_WORKSPACE_LIST_JSON}" - ;; - *) - printf '{"ok":false,"error":{"code":"unexpected","message":"unexpected args: %s"}}' "$*" - ;; -esac -`) - - workspaceListJSON := `{"ok":true,"data":[{"id":"ws-1","name":"demo","repo":"` + repoDir + `","root":"` + repoDir + `"}],"error":null}` - env := os.Environ() - env = withEnv(env, "PATH", fakeBinDir+":"+os.Getenv("PATH")) - env = withEnv(env, "FAKE_WORKSPACE_LIST_JSON", workspaceListJSON) - - payload := runScriptJSON(t, scriptPath, env, - "git", "ship", - "--workspace", "ws-1", - ) - - if got, _ := payload["status"].(string); got != "attention" { - t.Fatalf("status = %q, want %q", got, "attention") - } - summary, _ := payload["summary"].(string) - if !strings.Contains(summary, "ready to push") { - t.Fatalf("summary = %q, want push-ready guidance", summary) - } - suggested, _ := payload["suggested_command"].(string) - if !strings.Contains(suggested, "git ship --workspace ws-1 --push") { - t.Fatalf("suggested_command = %q, want push command", suggested) - } - quickActions, ok := payload["quick_actions"].([]any) - if !ok || len(quickActions) == 0 { - t.Fatalf("quick_actions missing or empty: %#v", payload["quick_actions"]) - } - var sawPush bool - for _, raw := range quickActions { - action, ok := raw.(map[string]any) - if !ok { - continue - } - id, _ := action["id"].(string) - if id == "push" { - sawPush = true - break - } - } - if !sawPush { - t.Fatalf("expected push quick action in %#v", quickActions) - } -} diff --git a/internal/cli/openclaw_dx_script_assistants_probe_test.go b/internal/cli/openclaw_dx_script_assistants_probe_test.go deleted file mode 100644 index 92f34b16..00000000 --- a/internal/cli/openclaw_dx_script_assistants_probe_test.go +++ /dev/null @@ -1,175 +0,0 @@ -package cli - -import ( - "os" - "path/filepath" - "strings" - "testing" -) - -func TestOpenClawDXAssistants_ProbeWorkspaceNotFoundReturnsCommandError(t *testing.T) { - requireBinary(t, "jq") - requireBinary(t, "bash") - - scriptPath := filepath.Join("..", "..", "skills", "amux", "scripts", "openclaw-dx.sh") - fakeBinDir := t.TempDir() - fakeAmuxPath := filepath.Join(fakeBinDir, "amux") - homeDir := t.TempDir() - amuxHome := filepath.Join(homeDir, ".amux") - if err := os.MkdirAll(amuxHome, 0o755); err != nil { - t.Fatalf("mkdir amux home: %v", err) - } - - writeExecutable(t, fakeAmuxPath, `#!/usr/bin/env bash -set -euo pipefail -if [[ "${1:-}" == "--json" ]]; then - shift -fi -case "${1:-} ${2:-}" in - "workspace list") - printf '%s' '{"ok":true,"data":[],"error":null}' - ;; - *) - printf '%s' '{"ok":true,"data":{},"error":null}' - ;; -esac -`) - - env := os.Environ() - env = withEnv(env, "PATH", fakeBinDir+":"+os.Getenv("PATH")) - env = withEnv(env, "HOME", homeDir) - - payload := runScriptJSON(t, scriptPath, env, - "assistants", - "--workspace", "ws-missing", - "--probe", - ) - - if got, _ := payload["command"].(string); got != "assistants" { - t.Fatalf("command = %q, want %q", got, "assistants") - } - if got, _ := payload["status"].(string); got != "command_error" { - t.Fatalf("status = %q, want %q", got, "command_error") - } - summary, _ := payload["summary"].(string) - if !strings.Contains(summary, "workspace not found") { - t.Fatalf("summary = %q, want workspace not found", summary) - } -} - -func TestOpenClawDXAssistants_ProbePrefersProbePassedAssistant(t *testing.T) { - requireBinary(t, "jq") - requireBinary(t, "bash") - - scriptPath := filepath.Join("..", "..", "skills", "amux", "scripts", "openclaw-dx.sh") - fakeBinDir := t.TempDir() - fakeAmuxPath := filepath.Join(fakeBinDir, "amux") - fakeTurnPath := filepath.Join(fakeBinDir, "fake-turn.sh") - claudeBotPath := filepath.Join(fakeBinDir, "claude-ready-bot") - codexBotPath := filepath.Join(fakeBinDir, "codex-ready-bot") - homeDir := t.TempDir() - amuxHome := filepath.Join(homeDir, ".amux") - if err := os.MkdirAll(amuxHome, 0o755); err != nil { - t.Fatalf("mkdir amux home: %v", err) - } - configPath := filepath.Join(amuxHome, "config.json") - - writeExecutable(t, fakeAmuxPath, `#!/usr/bin/env bash -set -euo pipefail -if [[ "${1:-}" == "--json" ]]; then - shift -fi -case "${1:-} ${2:-}" in - "workspace list") - printf '%s' '{"ok":true,"data":[{"id":"ws-1","name":"demo","repo":"/tmp/demo","assistant":"codex"}],"error":null}' - ;; - *) - printf '%s' '{"ok":true,"data":{},"error":null}' - ;; -esac -`) - writeExecutable(t, claudeBotPath, `#!/usr/bin/env bash -set -euo pipefail -echo ready -`) - writeExecutable(t, codexBotPath, `#!/usr/bin/env bash -set -euo pipefail -echo ready -`) - if err := os.WriteFile(configPath, []byte(`{ - "assistants": { - "claude": {"command": "claude-ready-bot"}, - "codex": {"command": "codex-ready-bot"} - } -} -`), 0o644); err != nil { - t.Fatalf("write config: %v", err) - } - - writeExecutable(t, fakeTurnPath, `#!/usr/bin/env bash -set -euo pipefail -assistant="" -for ((i=1; i<=$#; i++)); do - if [[ "${!i}" == "--assistant" ]]; then - next=$((i+1)) - assistant="${!next}" - fi -done -if [[ "$assistant" == "claude" ]]; then - printf '%s' '{"ok":true,"status":"needs_input","overall_status":"needs_input","summary":"Needs local permission choice."}' - exit 0 -fi -printf '%s' '{"ok":true,"status":"idle","overall_status":"completed","summary":"READY: codex can proceed."}' -`) - - env := os.Environ() - env = withEnv(env, "PATH", fakeBinDir+":"+os.Getenv("PATH")) - env = withEnv(env, "HOME", homeDir) - env = withEnv(env, "OPENCLAW_DX_TURN_SCRIPT", fakeTurnPath) - - payload := runScriptJSON(t, scriptPath, env, - "assistants", - "--workspace", "ws-1", - "--probe", - "--limit", "9", - ) - - if got, _ := payload["command"].(string); got != "assistants" { - t.Fatalf("command = %q, want %q", got, "assistants") - } - if got, _ := payload["status"].(string); got != "needs_input" { - t.Fatalf("status = %q, want %q", got, "needs_input") - } - suggested, _ := payload["suggested_command"].(string) - if !strings.Contains(suggested, "--assistant codex") { - t.Fatalf("suggested_command = %q, want codex start recommendation", suggested) - } - if strings.Contains(suggested, "workflow dual") { - t.Fatalf("suggested_command = %q, expected no dual workflow when claude probe needs input", suggested) - } - quickActions, ok := payload["quick_actions"].([]any) - if !ok || len(quickActions) == 0 { - t.Fatalf("quick_actions missing or empty: %#v", payload["quick_actions"]) - } - var sawStartReady bool - var sawDual bool - for _, raw := range quickActions { - action, ok := raw.(map[string]any) - if !ok { - continue - } - id, _ := action["id"].(string) - if id == "start_ready" { - sawStartReady = true - } - if id == "dual" { - sawDual = true - } - } - if !sawStartReady { - t.Fatalf("expected start_ready quick action in %#v", quickActions) - } - if sawDual { - t.Fatalf("did not expect dual quick action when claude probe needs input: %#v", quickActions) - } -} diff --git a/internal/cli/openclaw_dx_script_context_test.go b/internal/cli/openclaw_dx_script_context_test.go deleted file mode 100644 index 3b213e56..00000000 --- a/internal/cli/openclaw_dx_script_context_test.go +++ /dev/null @@ -1,293 +0,0 @@ -package cli - -import ( - "os" - "path/filepath" - "strings" - "testing" -) - -func TestOpenClawDXProjectPick_NameSupportsDisambiguation(t *testing.T) { - requireBinary(t, "jq") - requireBinary(t, "bash") - - scriptPath := filepath.Join("..", "..", "skills", "amux", "scripts", "openclaw-dx.sh") - fakeBinDir := t.TempDir() - fakeAmuxPath := filepath.Join(fakeBinDir, "amux") - - writeExecutable(t, fakeAmuxPath, `#!/usr/bin/env bash -set -euo pipefail -if [[ "${1:-}" == "--json" ]]; then - shift -fi -case "${1:-} ${2:-}" in - "project list") - printf '%s' '{"ok":true,"data":[{"name":"api-core","path":"/tmp/api-core"},{"name":"api-gateway","path":"/tmp/api-gateway"},{"name":"mobile","path":"/tmp/mobile"}],"error":null}' - ;; - *) - printf '{"ok":false,"error":{"code":"unexpected","message":"unexpected args: %s"}}' "$*" - ;; -esac -`) - - env := os.Environ() - env = withEnv(env, "PATH", fakeBinDir+":"+os.Getenv("PATH")) - - payload := runScriptJSON(t, scriptPath, env, - "project", "pick", - "--name", "api", - ) - - if got, _ := payload["command"].(string); got != "project.pick" { - t.Fatalf("command = %q, want %q", got, "project.pick") - } - if got, _ := payload["status"].(string); got != "attention" { - t.Fatalf("status = %q, want %q", got, "attention") - } - if got, _ := payload["ok"].(bool); got { - t.Fatalf("ok = true, want false when disambiguation is required") - } - data, ok := payload["data"].(map[string]any) - if !ok { - t.Fatalf("data missing or wrong type: %T", payload["data"]) - } - matches, ok := data["matches"].([]any) - if !ok || len(matches) != 2 { - t.Fatalf("matches = %#v, want len=2", data["matches"]) - } - quickActions, ok := payload["quick_actions"].([]any) - if !ok || len(quickActions) == 0 { - t.Fatalf("quick_actions missing or empty: %#v", payload["quick_actions"]) - } - var sawPick1 bool - for _, raw := range quickActions { - action, ok := raw.(map[string]any) - if !ok { - continue - } - id, _ := action["id"].(string) - if id == "pick_1" { - sawPick1 = true - break - } - } - if !sawPick1 { - t.Fatalf("expected pick_1 quick action in %#v", quickActions) - } -} - -func TestOpenClawDXGuide_RecommendsReplyWhenAgentNeedsInput(t *testing.T) { - requireBinary(t, "jq") - requireBinary(t, "bash") - - scriptPath := filepath.Join("..", "..", "skills", "amux", "scripts", "openclaw-dx.sh") - fakeBinDir := t.TempDir() - fakeAmuxPath := filepath.Join(fakeBinDir, "amux") - - writeExecutable(t, fakeAmuxPath, `#!/usr/bin/env bash -set -euo pipefail -if [[ "${1:-}" == "--json" ]]; then - shift -fi -case "${1:-} ${2:-}" in - "project list") - printf '%s' '{"ok":true,"data":[{"name":"demo","path":"/tmp/demo"}],"error":null}' - ;; - "workspace list") - printf '%s' '{"ok":true,"data":[{"id":"ws-1","name":"mobile","repo":"/tmp/demo","root":"/tmp/ws-1","assistant":"codex"}],"error":null}' - ;; - "agent list") - printf '%s' '{"ok":true,"data":[{"agent_id":"agent-1","session_name":"sess-1","workspace_id":"ws-1"}],"error":null}' - ;; - "terminal list") - printf '%s' '{"ok":true,"data":[{"workspace_id":"ws-1","session_name":"term-1"}],"error":null}' - ;; - "agent capture") - printf '%s' '{"ok":true,"data":{"status":"captured","summary":"Need user choice before proceeding.","needs_input":true,"input_hint":"Choose migration path A or B."},"error":null}' - ;; - *) - printf '{"ok":false,"error":{"code":"unexpected","message":"unexpected args: %s"}}' "$*" - ;; -esac -`) - - env := os.Environ() - env = withEnv(env, "PATH", fakeBinDir+":"+os.Getenv("PATH")) - - payload := runScriptJSON(t, scriptPath, env, - "guide", - "--workspace", "ws-1", - ) - - if got, _ := payload["command"].(string); got != "guide" { - t.Fatalf("command = %q, want %q", got, "guide") - } - if got, _ := payload["status"].(string); got != "needs_input" { - t.Fatalf("status = %q, want %q", got, "needs_input") - } - data, ok := payload["data"].(map[string]any) - if !ok { - t.Fatalf("data missing or wrong type: %T", payload["data"]) - } - if got, _ := data["stage"].(string); got != "reply_agent" { - t.Fatalf("stage = %q, want %q", got, "reply_agent") - } - suggested, _ := payload["suggested_command"].(string) - if !strings.Contains(suggested, "continue --agent agent-1") { - t.Fatalf("suggested_command = %q, want continue command", suggested) - } - quickActions, ok := payload["quick_actions"].([]any) - if !ok || len(quickActions) == 0 { - t.Fatalf("quick_actions missing or empty: %#v", payload["quick_actions"]) - } - var sawReply bool - for _, raw := range quickActions { - action, ok := raw.(map[string]any) - if !ok { - continue - } - id, _ := action["id"].(string) - if id == "reply" { - sawReply = true - break - } - } - if !sawReply { - t.Fatalf("expected reply quick action in %#v", quickActions) - } -} - -func TestOpenClawDXStart_UsesSiblingTurnScriptWhenInvokedOutsideRepoRoot(t *testing.T) { - requireBinary(t, "jq") - requireBinary(t, "bash") - - sourceScriptPath := filepath.Join("..", "..", "skills", "amux", "scripts", "openclaw-dx.sh") - src, err := os.ReadFile(sourceScriptPath) - if err != nil { - t.Fatalf("read source script: %v", err) - } - - scriptDir := t.TempDir() - runDir := t.TempDir() - copiedScriptPath := filepath.Join(scriptDir, "openclaw-dx.sh") - if err := os.WriteFile(copiedScriptPath, src, 0o755); err != nil { - t.Fatalf("write copied script: %v", err) - } - - argsLog := filepath.Join(scriptDir, "turn-args.log") - fakeTurnPath := filepath.Join(scriptDir, "openclaw-turn.sh") - writeExecutable(t, fakeTurnPath, `#!/usr/bin/env bash -set -euo pipefail -printf '%s\n' "$*" > "${TURN_ARGS_LOG:?missing TURN_ARGS_LOG}" -printf '%s' '{"ok":true,"mode":"run","status":"idle","overall_status":"completed","summary":"sibling turn script used","agent_id":"agent-1","workspace_id":"ws-1","quick_actions":[],"channel":{"message":"done","chunks":["done"],"chunks_meta":[{"index":1,"total":1,"text":"done"}]}}' -`) - - fakeBinDir := t.TempDir() - fakeAmuxPath := filepath.Join(fakeBinDir, "amux") - writeExecutable(t, fakeAmuxPath, `#!/usr/bin/env bash -set -euo pipefail -if [[ "${1:-}" == "--json" ]]; then - shift -fi -case "${1:-} ${2:-}" in - "workspace list") - printf '%s' '{"ok":true,"data":[{"id":"ws-1","name":"demo","repo":"/tmp/demo"}],"error":null}' - ;; - *) - printf '%s' '{"ok":true,"data":{},"error":null}' - ;; -esac -`) - - env := os.Environ() - env = withEnv(env, "PATH", fakeBinDir+":"+os.Getenv("PATH")) - env = withEnv(env, "TURN_ARGS_LOG", argsLog) - - payload := runScriptJSONInDir(t, copiedScriptPath, runDir, env, - "start", - "--workspace", "ws-1", - "--assistant", "codex", - "--prompt", "hello", - ) - - if got, _ := payload["command"].(string); got != "start" { - t.Fatalf("command = %q, want %q", got, "start") - } - if got, _ := payload["status"].(string); got != "idle" { - t.Fatalf("status = %q, want %q", got, "idle") - } - argsRaw, err := os.ReadFile(argsLog) - if err != nil { - t.Fatalf("read args log: %v", err) - } - args := strings.TrimSpace(string(argsRaw)) - if !strings.Contains(args, "run") || !strings.Contains(args, "--workspace ws-1") { - t.Fatalf("turn args = %q, expected run/workspace", args) - } -} - -func TestOpenClawDXStart_UsesContextWorkspaceAndAssistant(t *testing.T) { - requireBinary(t, "jq") - requireBinary(t, "bash") - - scriptPath := filepath.Join("..", "..", "skills", "amux", "scripts", "openclaw-dx.sh") - fakeBinDir := t.TempDir() - fakeAmuxPath := filepath.Join(fakeBinDir, "amux") - fakeTurnPath := filepath.Join(fakeBinDir, "fake-turn.sh") - turnArgsPath := filepath.Join(fakeBinDir, "turn-args.log") - contextPath := filepath.Join(t.TempDir(), "context.json") - if err := os.WriteFile(contextPath, []byte(`{"workspace":{"id":"ws-context","assistant":"claude"}}`), 0o644); err != nil { - t.Fatalf("write context file: %v", err) - } - - writeExecutable(t, fakeAmuxPath, `#!/usr/bin/env bash -set -euo pipefail -if [[ "${1:-}" == "--json" ]]; then - shift -fi -case "${1:-} ${2:-}" in - "workspace list") - printf '%s' '{"ok":true,"data":[{"id":"ws-context","name":"demo","repo":"/tmp/demo"}],"error":null}' - ;; - *) - printf '%s' '{"ok":true,"data":{},"error":null}' - ;; -esac -`) - - writeExecutable(t, fakeTurnPath, `#!/usr/bin/env bash -set -euo pipefail -printf '%s\n' "$*" > "${TURN_ARGS_PATH:?missing TURN_ARGS_PATH}" -printf '%s' '{"ok":true,"mode":"run","status":"idle","overall_status":"completed","summary":"turn complete","agent_id":"agent-ctx","workspace_id":"ws-context","assistant":"claude","quick_actions":[],"channel":{"message":"done","chunks":["done"],"chunks_meta":[{"index":1,"total":1,"text":"done"}],"inline_buttons":[]}}' -`) - - env := os.Environ() - env = withEnv(env, "PATH", fakeBinDir+":"+os.Getenv("PATH")) - env = withEnv(env, "TURN_ARGS_PATH", turnArgsPath) - env = withEnv(env, "OPENCLAW_DX_TURN_SCRIPT", fakeTurnPath) - env = withEnv(env, "OPENCLAW_DX_CONTEXT_FILE", contextPath) - - payload := runScriptJSON(t, scriptPath, env, - "start", - "--prompt", "Continue work", - ) - - if got, _ := payload["command"].(string); got != "start" { - t.Fatalf("command = %q, want %q", got, "start") - } - if got, _ := payload["assistant"].(string); got != "claude" { - t.Fatalf("assistant = %q, want claude", got) - } - - turnArgsRaw, err := os.ReadFile(turnArgsPath) - if err != nil { - t.Fatalf("read turn args: %v", err) - } - turnArgs := string(turnArgsRaw) - if !strings.Contains(turnArgs, "--workspace ws-context") { - t.Fatalf("turn args missing context workspace: %s", turnArgs) - } - if !strings.Contains(turnArgs, "--assistant claude") { - t.Fatalf("turn args missing context assistant: %s", turnArgs) - } -} diff --git a/internal/cli/openclaw_dx_script_continue_test.go b/internal/cli/openclaw_dx_script_continue_test.go deleted file mode 100644 index d2e895ed..00000000 --- a/internal/cli/openclaw_dx_script_continue_test.go +++ /dev/null @@ -1,471 +0,0 @@ -package cli - -import ( - "os" - "path/filepath" - "strings" - "testing" -) - -func TestOpenClawDXStatus_SurfacesNeedsInputAndStaleSessions(t *testing.T) { - requireBinary(t, "jq") - requireBinary(t, "bash") - - scriptPath := filepath.Join("..", "..", "skills", "amux", "scripts", "openclaw-dx.sh") - fakeBinDir := t.TempDir() - fakeAmuxPath := filepath.Join(fakeBinDir, "amux") - - writeExecutable(t, fakeAmuxPath, `#!/usr/bin/env bash -set -euo pipefail -if [[ "${1:-}" == "--json" ]]; then - shift -fi -case "${1:-} ${2:-}" in - "project list") - printf '%s' '{"ok":true,"data":[{"name":"demo","path":"/tmp/demo"}],"error":null}' - ;; - "workspace list") - printf '%s' '{"ok":true,"data":[{"id":"ws-1","name":"demo","repo":"/tmp/demo","created":"2026-01-01T00:00:00Z"}],"error":null}' - ;; - "agent list") - printf '%s' '{"ok":true,"data":[{"session_name":"sess-1","agent_id":"agent-1","workspace_id":"ws-1","tab_id":"tab-1","type":"agent"}],"error":null}' - ;; - "terminal list") - printf '%s' '{"ok":true,"data":[],"error":null}' - ;; - "session list") - printf '%s' '{"ok":true,"data":[{"session_name":"sess-1","workspace_id":"ws-1","type":"agent","attached":false,"age_seconds":100}],"error":null}' - ;; - "session prune") - printf '%s' '{"ok":true,"data":{"dry_run":true,"pruned":[],"total":2,"errors":[]},"error":null}' - ;; - "agent capture") - printf '%s' '{"ok":true,"data":{"session_name":"sess-1","status":"captured","summary":"Need approval","needs_input":true,"input_hint":"Confirm strategy"},"error":null}' - ;; - *) - printf '{"ok":false,"error":{"code":"unexpected","message":"unexpected args: %s"}}' "$*" - ;; -esac -`) - - env := os.Environ() - env = withEnv(env, "PATH", fakeBinDir+":"+os.Getenv("PATH")) - - payload := runScriptJSON(t, scriptPath, env, "status", "--limit", "3", "--include-stale") - - if got, _ := payload["command"].(string); got != "status" { - t.Fatalf("command = %q, want %q", got, "status") - } - if got, _ := payload["status"].(string); got != "needs_input" { - t.Fatalf("status = %q, want %q", got, "needs_input") - } - data, ok := payload["data"].(map[string]any) - if !ok { - t.Fatalf("data missing or wrong type: %T", payload["data"]) - } - alerts, ok := data["alerts"].([]any) - if !ok || len(alerts) < 2 { - t.Fatalf("alerts = %#v, want >=2", data["alerts"]) - } - var sawNeedsInput bool - var sawStale bool - for _, raw := range alerts { - alert, ok := raw.(map[string]any) - if !ok { - continue - } - switch alert["type"] { - case "needs_input": - sawNeedsInput = true - case "stale_sessions": - sawStale = true - } - } - if !sawNeedsInput { - t.Fatalf("expected needs_input alert in %#v", alerts) - } - if !sawStale { - t.Fatalf("expected stale_sessions alert in %#v", alerts) - } -} - -func TestOpenClawDXStatus_DefaultOmitsStaleSessionAlerts(t *testing.T) { - requireBinary(t, "jq") - requireBinary(t, "bash") - - scriptPath := filepath.Join("..", "..", "skills", "amux", "scripts", "openclaw-dx.sh") - fakeBinDir := t.TempDir() - fakeAmuxPath := filepath.Join(fakeBinDir, "amux") - - writeExecutable(t, fakeAmuxPath, `#!/usr/bin/env bash -set -euo pipefail -if [[ "${1:-}" == "--json" ]]; then - shift -fi -case "${1:-} ${2:-}" in - "project list") - printf '%s' '{"ok":true,"data":[{"name":"demo","path":"/tmp/demo"}],"error":null}' - ;; - "workspace list") - printf '%s' '{"ok":true,"data":[{"id":"ws-1","name":"demo","repo":"/tmp/demo","created":"2026-01-01T00:00:00Z"}],"error":null}' - ;; - "agent list") - printf '%s' '{"ok":true,"data":[],"error":null}' - ;; - "terminal list") - printf '%s' '{"ok":true,"data":[],"error":null}' - ;; - "session list") - printf '%s' '{"ok":true,"data":[],"error":null}' - ;; - "session prune") - printf '%s' '{"ok":true,"data":{"dry_run":true,"pruned":[],"total":4,"errors":[]},"error":null}' - ;; - *) - printf '{"ok":false,"error":{"code":"unexpected","message":"unexpected args: %s"}}' "$*" - ;; -esac -`) - - env := os.Environ() - env = withEnv(env, "PATH", fakeBinDir+":"+os.Getenv("PATH")) - - payload := runScriptJSON(t, scriptPath, env, "status") - - if got, _ := payload["command"].(string); got != "status" { - t.Fatalf("command = %q, want %q", got, "status") - } - if got, _ := payload["status"].(string); got != "ok" { - t.Fatalf("status = %q, want %q", got, "ok") - } - data, ok := payload["data"].(map[string]any) - if !ok { - t.Fatalf("data missing or wrong type: %T", payload["data"]) - } - alerts, ok := data["alerts"].([]any) - if !ok { - t.Fatalf("alerts missing or wrong type: %T", data["alerts"]) - } - if len(alerts) != 0 { - t.Fatalf("expected no alerts by default, got %#v", alerts) - } -} - -func TestOpenClawDXContinue_ResolvesWorkspaceAgentAndCallsTurnScript(t *testing.T) { - requireBinary(t, "jq") - requireBinary(t, "bash") - - scriptPath := filepath.Join("..", "..", "skills", "amux", "scripts", "openclaw-dx.sh") - fakeBinDir := t.TempDir() - fakeAmuxPath := filepath.Join(fakeBinDir, "amux") - fakeTurnPath := filepath.Join(fakeBinDir, "fake-turn.sh") - argsLog := filepath.Join(fakeBinDir, "turn-args.log") - - writeExecutable(t, fakeAmuxPath, `#!/usr/bin/env bash -set -euo pipefail -if [[ "${1:-}" == "--json" ]]; then - shift -fi -case "${1:-} ${2:-}" in - "agent list") - printf '%s' '{"ok":true,"data":[{"session_name":"sess-1","agent_id":"agent-1","workspace_id":"ws-1","tab_id":"tab-1","type":"agent"}],"error":null}' - ;; - "workspace list") - printf '%s' '{"ok":true,"data":[{"id":"ws-1","name":"mobile","repo":"/tmp/demo","assistant":"codex"}],"error":null}' - ;; - *) - printf '{"ok":false,"error":{"code":"unexpected","message":"unexpected args: %s"}}' "$*" - ;; -esac -`) - - writeExecutable(t, fakeTurnPath, `#!/usr/bin/env bash -set -euo pipefail -printf '%s\n' "$*" > "${TURN_ARGS_LOG:?missing TURN_ARGS_LOG}" -printf '%s' '{"ok":true,"mode":"send","status":"idle","overall_status":"completed","summary":"continued","agent_id":"agent-1","workspace_id":"ws-1","channel":{"message":"done","chunks":["done"],"chunks_meta":[{"index":1,"total":1,"text":"done"}]},"quick_actions":[]}' -`) - - env := os.Environ() - env = withEnv(env, "PATH", fakeBinDir+":"+os.Getenv("PATH")) - env = withEnv(env, "OPENCLAW_DX_TURN_SCRIPT", fakeTurnPath) - env = withEnv(env, "TURN_ARGS_LOG", argsLog) - - payload := runScriptJSON(t, scriptPath, env, - "continue", - "--workspace", "ws-1", - "--text", "ping", - "--enter", - ) - - if got, _ := payload["command"].(string); got != "continue" { - t.Fatalf("command = %q, want %q", got, "continue") - } - if got, _ := payload["workflow"].(string); got != "followup_turn" { - t.Fatalf("workflow = %q, want %q", got, "followup_turn") - } - argsRaw, err := os.ReadFile(argsLog) - if err != nil { - t.Fatalf("read turn args: %v", err) - } - args := string(argsRaw) - if !strings.Contains(args, "send") || !strings.Contains(args, "--agent agent-1") || !strings.Contains(args, "--text ping") || !strings.Contains(args, "--enter") { - t.Fatalf("turn args = %q, expected send/agent/text/enter", args) - } -} - -func TestOpenClawDXContinue_PassthroughSanitizesNeedsInputAndAddsFallbackCommand(t *testing.T) { - requireBinary(t, "jq") - requireBinary(t, "bash") - - scriptPath := filepath.Join("..", "..", "skills", "amux", "scripts", "openclaw-dx.sh") - fakeBinDir := t.TempDir() - fakeAmuxPath := filepath.Join(fakeBinDir, "amux") - fakeTurnPath := filepath.Join(fakeBinDir, "fake-turn.sh") - - writeExecutable(t, fakeAmuxPath, `#!/usr/bin/env bash -set -euo pipefail -printf '%s' '{"ok":true,"data":{},"error":null}' -`) - - writeExecutable(t, fakeTurnPath, `#!/usr/bin/env bash -set -euo pipefail -printf '%s' '{"ok":true,"mode":"send","status":"needs_input","overall_status":"needs_input","summary":"Need guidance █","agent_id":"agent-1","next_action":"","suggested_command":"","quick_actions":[],"channel":{"message":"Need guidance █","chunks":["Need guidance █"],"chunks_meta":[{"index":1,"total":1,"text":"Need guidance █"}]}}' -`) - - env := os.Environ() - env = withEnv(env, "PATH", fakeBinDir+":"+os.Getenv("PATH")) - env = withEnv(env, "OPENCLAW_DX_TURN_SCRIPT", fakeTurnPath) - - payload := runScriptJSON(t, scriptPath, env, - "continue", - "--agent", "agent-1", - "--text", "continue", - "--enter", - ) - - if got, _ := payload["status"].(string); got != "needs_input" { - t.Fatalf("status = %q, want %q", got, "needs_input") - } - summary, _ := payload["summary"].(string) - if strings.Contains(summary, "█") { - t.Fatalf("summary still contains cursor artifact: %q", summary) - } - nextAction, _ := payload["next_action"].(string) - if strings.TrimSpace(nextAction) == "" { - t.Fatalf("next_action should be auto-filled for needs_input") - } - suggested, _ := payload["suggested_command"].(string) - if !strings.Contains(suggested, "openclaw-step.sh send --agent agent-1") { - t.Fatalf("suggested_command = %q, want fallback step command", suggested) - } -} - -func TestOpenClawDXContinue_AutoStartsWhenNoActiveAgent(t *testing.T) { - requireBinary(t, "jq") - requireBinary(t, "bash") - - scriptPath := filepath.Join("..", "..", "skills", "amux", "scripts", "openclaw-dx.sh") - fakeBinDir := t.TempDir() - fakeAmuxPath := filepath.Join(fakeBinDir, "amux") - fakeTurnPath := filepath.Join(fakeBinDir, "fake-turn.sh") - argsLog := filepath.Join(fakeBinDir, "turn-args.log") - - writeExecutable(t, fakeAmuxPath, `#!/usr/bin/env bash -set -euo pipefail -if [[ "${1:-}" == "--json" ]]; then - shift -fi -case "${1:-} ${2:-}" in - "agent list") - printf '%s' '{"ok":true,"data":[],"error":null}' - ;; - "workspace list") - printf '%s' '{"ok":true,"data":[{"id":"ws-1","name":"mobile","assistant":"codex","repo":"/tmp/demo"}],"error":null}' - ;; - *) - printf '{"ok":false,"error":{"code":"unexpected","message":"unexpected args: %s"}}' "$*" - ;; -esac -`) - - writeExecutable(t, fakeTurnPath, `#!/usr/bin/env bash -set -euo pipefail -printf '%s\n' "$*" > "${TURN_ARGS_LOG:?missing TURN_ARGS_LOG}" -printf '%s' '{"ok":true,"mode":"run","status":"idle","overall_status":"completed","summary":"auto-started","agent_id":"agent-1","workspace_id":"ws-1","channel":{"message":"done","chunks":["done"],"chunks_meta":[{"index":1,"total":1,"text":"done"}]},"quick_actions":[]}' -`) - - env := os.Environ() - env = withEnv(env, "PATH", fakeBinDir+":"+os.Getenv("PATH")) - env = withEnv(env, "OPENCLAW_DX_TURN_SCRIPT", fakeTurnPath) - env = withEnv(env, "OPENCLAW_DX_SELF_SCRIPT", scriptPath) - env = withEnv(env, "TURN_ARGS_LOG", argsLog) - - payload := runScriptJSON(t, scriptPath, env, - "continue", - "--workspace", "ws-1", - "--auto-start", - "--assistant", "codex", - "--text", "Resume and report status.", - ) - - if got, _ := payload["command"].(string); got != "continue" { - t.Fatalf("command = %q, want %q", got, "continue") - } - if got, _ := payload["workflow"].(string); got != "auto_start_turn" { - t.Fatalf("workflow = %q, want %q", got, "auto_start_turn") - } - if got, _ := payload["auto_started"].(bool); !got { - t.Fatalf("auto_started = %v, want true", got) - } - - argsRaw, err := os.ReadFile(argsLog) - if err != nil { - t.Fatalf("read turn args: %v", err) - } - args := strings.TrimSpace(string(argsRaw)) - if !strings.Contains(args, "run") || - !strings.Contains(args, "--workspace ws-1") || - !strings.Contains(args, "--assistant codex") || - !strings.Contains(args, "--prompt Resume and report status.") { - t.Fatalf("turn args = %q, expected run/workspace/assistant/prompt", args) - } -} - -func TestOpenClawDXContinue_NoTargetUsesSingleActiveAgent(t *testing.T) { - requireBinary(t, "jq") - requireBinary(t, "bash") - - scriptPath := filepath.Join("..", "..", "skills", "amux", "scripts", "openclaw-dx.sh") - fakeBinDir := t.TempDir() - fakeAmuxPath := filepath.Join(fakeBinDir, "amux") - fakeTurnPath := filepath.Join(fakeBinDir, "fake-turn.sh") - argsLog := filepath.Join(fakeBinDir, "turn-args.log") - contextPath := filepath.Join(t.TempDir(), "context.json") - - writeExecutable(t, fakeAmuxPath, `#!/usr/bin/env bash -set -euo pipefail -if [[ "${1:-}" == "--json" ]]; then - shift -fi -case "${1:-} ${2:-}" in - "agent list") - printf '%s' '{"ok":true,"data":[{"session_name":"sess-1","agent_id":"agent-1","workspace_id":"ws-1","tab_id":"tab-1","type":"agent"}],"error":null}' - ;; - "workspace list") - printf '%s' '{"ok":true,"data":[{"id":"ws-1","name":"mobile","repo":"/tmp/demo","assistant":"codex"}],"error":null}' - ;; - *) - printf '{"ok":false,"error":{"code":"unexpected","message":"unexpected args: %s"}}' "$*" - ;; -esac -`) - - writeExecutable(t, fakeTurnPath, `#!/usr/bin/env bash -set -euo pipefail -printf '%s\n' "$*" > "${TURN_ARGS_LOG:?missing TURN_ARGS_LOG}" -printf '%s' '{"ok":true,"mode":"send","status":"idle","overall_status":"completed","summary":"continued","agent_id":"agent-1","workspace_id":"ws-1","channel":{"message":"done","chunks":["done"],"chunks_meta":[{"index":1,"total":1,"text":"done"}]},"quick_actions":[]}' -`) - - env := os.Environ() - env = withEnv(env, "PATH", fakeBinDir+":"+os.Getenv("PATH")) - env = withEnv(env, "OPENCLAW_DX_TURN_SCRIPT", fakeTurnPath) - env = withEnv(env, "TURN_ARGS_LOG", argsLog) - env = withEnv(env, "OPENCLAW_DX_CONTEXT_FILE", contextPath) - - payload := runScriptJSON(t, scriptPath, env, - "continue", - "--text", "Resume now.", - "--enter", - ) - - if got, _ := payload["command"].(string); got != "continue" { - t.Fatalf("command = %q, want %q", got, "continue") - } - if got, _ := payload["workflow"].(string); got != "followup_turn" { - t.Fatalf("workflow = %q, want %q", got, "followup_turn") - } - - argsRaw, err := os.ReadFile(argsLog) - if err != nil { - t.Fatalf("read turn args: %v", err) - } - args := string(argsRaw) - if !strings.Contains(args, "send") || !strings.Contains(args, "--agent agent-1") { - t.Fatalf("turn args = %q, expected send with resolved single agent", args) - } -} - -func TestOpenClawDXContinue_NoTargetWithMultipleAgentsPromptsSelection(t *testing.T) { - requireBinary(t, "jq") - requireBinary(t, "bash") - - scriptPath := filepath.Join("..", "..", "skills", "amux", "scripts", "openclaw-dx.sh") - fakeBinDir := t.TempDir() - fakeAmuxPath := filepath.Join(fakeBinDir, "amux") - contextPath := filepath.Join(t.TempDir(), "context.json") - - writeExecutable(t, fakeAmuxPath, `#!/usr/bin/env bash -set -euo pipefail -if [[ "${1:-}" == "--json" ]]; then - shift -fi -case "${1:-} ${2:-}" in - "agent list") - printf '%s' '{"ok":true,"data":[{"session_name":"sess-a","agent_id":"agent-a","workspace_id":"ws-a"},{"session_name":"sess-b","agent_id":"agent-b","workspace_id":"ws-b"}],"error":null}' - ;; - *) - printf '{"ok":false,"error":{"code":"unexpected","message":"unexpected args: %s"}}' "$*" - ;; -esac -`) - - env := os.Environ() - env = withEnv(env, "PATH", fakeBinDir+":"+os.Getenv("PATH")) - env = withEnv(env, "OPENCLAW_DX_CONTEXT_FILE", contextPath) - - payload := runScriptJSON(t, scriptPath, env, - "continue", - "--text", "Resume now.", - "--enter", - ) - - if got, _ := payload["status"].(string); got != "attention" { - t.Fatalf("status = %q, want %q", got, "attention") - } - suggested, _ := payload["suggested_command"].(string) - if !strings.Contains(suggested, "--agent agent-a") { - t.Fatalf("suggested_command = %q, want agent selection command", suggested) - } - - data, ok := payload["data"].(map[string]any) - if !ok { - t.Fatalf("data missing or wrong type: %T", payload["data"]) - } - if got, _ := data["reason"].(string); got != "multiple_active_agents" { - t.Fatalf("reason = %q, want %q", got, "multiple_active_agents") - } - - quickActions, ok := payload["quick_actions"].([]any) - if !ok || len(quickActions) == 0 { - t.Fatalf("quick_actions missing or empty: %#v", payload["quick_actions"]) - } - var sawFirst bool - var sawSecond bool - for _, raw := range quickActions { - action, ok := raw.(map[string]any) - if !ok { - continue - } - id, _ := action["id"].(string) - cmd, _ := action["command"].(string) - if id == "continue_1" && strings.Contains(cmd, "--agent agent-a") { - sawFirst = true - } - if id == "continue_2" && strings.Contains(cmd, "--agent agent-b") { - sawSecond = true - } - } - if !sawFirst || !sawSecond { - t.Fatalf("expected continue actions for both agents: %#v", quickActions) - } -} diff --git a/internal/cli/openclaw_dx_script_continue_validation_test.go b/internal/cli/openclaw_dx_script_continue_validation_test.go deleted file mode 100644 index 4611487e..00000000 --- a/internal/cli/openclaw_dx_script_continue_validation_test.go +++ /dev/null @@ -1,183 +0,0 @@ -package cli - -import ( - "os" - "path/filepath" - "strings" - "testing" -) - -func TestOpenClawDXContinue_WorkspaceNotFoundReturnsCommandError(t *testing.T) { - requireBinary(t, "jq") - requireBinary(t, "bash") - - scriptPath := filepath.Join("..", "..", "skills", "amux", "scripts", "openclaw-dx.sh") - fakeBinDir := t.TempDir() - fakeAmuxPath := filepath.Join(fakeBinDir, "amux") - - writeExecutable(t, fakeAmuxPath, `#!/usr/bin/env bash -set -euo pipefail -if [[ "${1:-}" == "--json" ]]; then - shift -fi -case "${1:-} ${2:-}" in - "workspace list") - printf '%s' '{"ok":true,"data":[],"error":null}' - ;; - *) - printf '%s' '{"ok":true,"data":[],"error":null}' - ;; -esac -`) - - env := os.Environ() - env = withEnv(env, "PATH", fakeBinDir+":"+os.Getenv("PATH")) - - payload := runScriptJSON(t, scriptPath, env, - "continue", - "--workspace", "ws-missing", - "--text", "resume", - "--enter", - ) - - if got, _ := payload["command"].(string); got != "continue" { - t.Fatalf("command = %q, want %q", got, "continue") - } - if got, _ := payload["status"].(string); got != "command_error" { - t.Fatalf("status = %q, want %q", got, "command_error") - } - summary, _ := payload["summary"].(string) - if !strings.Contains(summary, "workspace not found") { - t.Fatalf("summary = %q, want workspace not found", summary) - } -} - -func TestOpenClawDXContinue_InvalidAssistantReturnsCommandError(t *testing.T) { - requireBinary(t, "jq") - requireBinary(t, "bash") - - scriptPath := filepath.Join("..", "..", "skills", "amux", "scripts", "openclaw-dx.sh") - fakeBinDir := t.TempDir() - fakeAmuxPath := filepath.Join(fakeBinDir, "amux") - - writeExecutable(t, fakeAmuxPath, `#!/usr/bin/env bash -set -euo pipefail -if [[ "${1:-}" == "--json" ]]; then - shift -fi -case "${1:-} ${2:-}" in - "workspace list") - printf '%s' '{"ok":true,"data":[{"id":"ws-1","name":"demo","repo":"/tmp/demo"}],"error":null}' - ;; - "agent list") - printf '%s' '{"ok":true,"data":[],"error":null}' - ;; - *) - printf '%s' '{"ok":true,"data":[],"error":null}' - ;; -esac -`) - - env := os.Environ() - env = withEnv(env, "PATH", fakeBinDir+":"+os.Getenv("PATH")) - - payload := runScriptJSON(t, scriptPath, env, - "continue", - "--workspace", "ws-1", - "--auto-start", - "--assistant", "not/real-assistant", - "--text", "resume", - ) - - if got, _ := payload["command"].(string); got != "continue" { - t.Fatalf("command = %q, want %q", got, "continue") - } - if got, _ := payload["status"].(string); got != "command_error" { - t.Fatalf("status = %q, want %q", got, "command_error") - } - summary, _ := payload["summary"].(string) - if !strings.Contains(summary, "invalid assistant") { - t.Fatalf("summary = %q, want invalid assistant", summary) - } -} - -func TestOpenClawDXContinue_WorkspaceValidationPrecedesAssistantValidation(t *testing.T) { - requireBinary(t, "jq") - requireBinary(t, "bash") - - scriptPath := filepath.Join("..", "..", "skills", "amux", "scripts", "openclaw-dx.sh") - fakeBinDir := t.TempDir() - fakeAmuxPath := filepath.Join(fakeBinDir, "amux") - - writeExecutable(t, fakeAmuxPath, `#!/usr/bin/env bash -set -euo pipefail -if [[ "${1:-}" == "--json" ]]; then - shift -fi -case "${1:-} ${2:-}" in - "workspace list") - printf '%s' '{"ok":true,"data":[],"error":null}' - ;; - *) - printf '%s' '{"ok":true,"data":[],"error":null}' - ;; -esac -`) - - env := os.Environ() - env = withEnv(env, "PATH", fakeBinDir+":"+os.Getenv("PATH")) - - payload := runScriptJSON(t, scriptPath, env, - "continue", - "--workspace", "ws-missing", - "--auto-start", - "--assistant", "not/real-assistant", - "--text", "resume", - ) - - if got, _ := payload["command"].(string); got != "continue" { - t.Fatalf("command = %q, want %q", got, "continue") - } - if got, _ := payload["status"].(string); got != "command_error" { - t.Fatalf("status = %q, want %q", got, "command_error") - } - summary, _ := payload["summary"].(string) - if !strings.Contains(summary, "workspace not found") { - t.Fatalf("summary = %q, want workspace not found", summary) - } -} - -func TestOpenClawDXContinue_AssistantWithoutAutoStartReturnsCommandError(t *testing.T) { - requireBinary(t, "jq") - requireBinary(t, "bash") - - scriptPath := filepath.Join("..", "..", "skills", "amux", "scripts", "openclaw-dx.sh") - fakeBinDir := t.TempDir() - fakeAmuxPath := filepath.Join(fakeBinDir, "amux") - - writeExecutable(t, fakeAmuxPath, `#!/usr/bin/env bash -set -euo pipefail -printf '%s' '{"ok":true,"data":[],"error":null}' -`) - - env := os.Environ() - env = withEnv(env, "PATH", fakeBinDir+":"+os.Getenv("PATH")) - - payload := runScriptJSON(t, scriptPath, env, - "continue", - "--workspace", "ws-1", - "--assistant", "codex", - "--text", "resume", - ) - - if got, _ := payload["command"].(string); got != "continue" { - t.Fatalf("command = %q, want %q", got, "continue") - } - if got, _ := payload["status"].(string); got != "command_error" { - t.Fatalf("status = %q, want %q", got, "command_error") - } - summary, _ := payload["summary"].(string) - if !strings.Contains(summary, "--assistant requires --auto-start") { - t.Fatalf("summary = %q, want --assistant requires --auto-start", summary) - } -} diff --git a/internal/cli/openclaw_dx_script_helpers_test.go b/internal/cli/openclaw_dx_script_helpers_test.go deleted file mode 100644 index c93c4220..00000000 --- a/internal/cli/openclaw_dx_script_helpers_test.go +++ /dev/null @@ -1,46 +0,0 @@ -package cli - -import ( - "encoding/json" - "os" - "os/exec" - "testing" -) - -func writeExecutable(t *testing.T, path, body string) { - t.Helper() - if err := os.WriteFile(path, []byte(body), 0o755); err != nil { - t.Fatalf("write %s: %v", path, err) - } -} - -func runScriptJSON(t *testing.T, scriptPath string, env []string, args ...string) map[string]any { - t.Helper() - cmd := exec.Command(scriptPath, args...) - cmd.Env = env - out, err := cmd.Output() - if err != nil { - t.Fatalf("%s %v failed: %v", scriptPath, args, err) - } - var payload map[string]any - if err := json.Unmarshal(out, &payload); err != nil { - t.Fatalf("decode json: %v\nraw: %s", err, string(out)) - } - return payload -} - -func runScriptJSONInDir(t *testing.T, scriptPath, dir string, env []string, args ...string) map[string]any { - t.Helper() - cmd := exec.Command(scriptPath, args...) - cmd.Env = env - cmd.Dir = dir - out, err := cmd.Output() - if err != nil { - t.Fatalf("%s %v failed: %v", scriptPath, args, err) - } - var payload map[string]any - if err := json.Unmarshal(out, &payload); err != nil { - t.Fatalf("decode json: %v\nraw: %s", err, string(out)) - } - return payload -} diff --git a/internal/cli/openclaw_dx_script_projects_test.go b/internal/cli/openclaw_dx_script_projects_test.go deleted file mode 100644 index 2e33520b..00000000 --- a/internal/cli/openclaw_dx_script_projects_test.go +++ /dev/null @@ -1,480 +0,0 @@ -package cli - -import ( - "os" - "os/exec" - "path/filepath" - "strings" - "testing" -) - -func TestOpenClawDXScriptDoesNotUseBash4AssociativeArrays(t *testing.T) { - scriptPath := filepath.Join("..", "..", "skills", "amux", "scripts", "openclaw-dx.sh") - body, err := os.ReadFile(scriptPath) - if err != nil { - t.Fatalf("read script: %v", err) - } - if strings.Contains(string(body), "declare -A") { - t.Fatalf("openclaw-dx.sh should avoid Bash 4 associative arrays for macOS Bash 3 compatibility") - } -} - -func TestOpenClawDXProjectAdd_CreatesWorkspace(t *testing.T) { - requireBinary(t, "jq") - requireBinary(t, "bash") - - scriptPath := filepath.Join("..", "..", "skills", "amux", "scripts", "openclaw-dx.sh") - fakeBinDir := t.TempDir() - fakeAmuxPath := filepath.Join(fakeBinDir, "amux") - writeExecutable(t, fakeAmuxPath, `#!/usr/bin/env bash -set -euo pipefail -if [[ "${1:-}" == "--json" ]]; then - shift -fi -case "${1:-} ${2:-}" in - "project add") - printf '%s' '{"ok":true,"data":{"name":"demo","path":"/tmp/demo"},"error":null}' - ;; - "workspace create") - printf '%s' '{"ok":true,"data":{"id":"ws-mobile","name":"mobile","repo":"/tmp/demo","root":"/tmp/ws-mobile","assistant":"codex"},"error":null}' - ;; - *) - printf '{"ok":false,"error":{"code":"unexpected","message":"unexpected args: %s"}}' "$*" - ;; -esac -`) - - env := os.Environ() - env = withEnv(env, "PATH", fakeBinDir+":"+os.Getenv("PATH")) - - payload := runScriptJSON(t, scriptPath, env, - "project", "add", - "--path", "/tmp/demo", - "--workspace", "mobile", - "--assistant", "codex", - ) - - if got, _ := payload["command"].(string); got != "project.add" { - t.Fatalf("command = %q, want %q", got, "project.add") - } - if got, _ := payload["status"].(string); got != "ok" { - t.Fatalf("status = %q, want %q", got, "ok") - } - data, ok := payload["data"].(map[string]any) - if !ok { - t.Fatalf("data missing or wrong type: %T", payload["data"]) - } - workspace, ok := data["workspace"].(map[string]any) - if !ok { - t.Fatalf("workspace missing or wrong type: %T", data["workspace"]) - } - if got, _ := workspace["id"].(string); got != "ws-mobile" { - t.Fatalf("workspace.id = %q, want %q", got, "ws-mobile") - } - quickActions, ok := payload["quick_actions"].([]any) - if !ok || len(quickActions) == 0 { - t.Fatalf("quick_actions missing or empty: %#v", payload["quick_actions"]) - } -} - -func TestOpenClawDXProjectAdd_InferPathFromGitRoot(t *testing.T) { - requireBinary(t, "jq") - requireBinary(t, "bash") - requireBinary(t, "git") - - scriptPath, err := filepath.Abs(filepath.Join("..", "..", "skills", "amux", "scripts", "openclaw-dx.sh")) - if err != nil { - t.Fatalf("resolve script path: %v", err) - } - fakeBinDir := t.TempDir() - fakeAmuxPath := filepath.Join(fakeBinDir, "amux") - projectPathFile := filepath.Join(fakeBinDir, "project-path.txt") - - repoDir := t.TempDir() - if out, err := exec.Command("git", "-C", repoDir, "init", "-b", "main").CombinedOutput(); err != nil { - t.Fatalf("git init: %v\n%s", err, string(out)) - } - if err := os.WriteFile(filepath.Join(repoDir, "README.md"), []byte("hello\n"), 0o644); err != nil { - t.Fatalf("write README: %v", err) - } - - writeExecutable(t, fakeAmuxPath, `#!/usr/bin/env bash -set -euo pipefail -if [[ "${1:-}" == "--json" ]]; then - shift -fi -case "${1:-} ${2:-}" in - "project add") - project_path="${3:-}" - printf '%s' "$project_path" > "${PROJECT_PATH_FILE:?missing PROJECT_PATH_FILE}" - printf '{"ok":true,"data":{"name":"demo","path":"%s"},"error":null}' "$project_path" - ;; - "workspace create") - printf '%s' '{"ok":true,"data":{"id":"ws-mobile","name":"mobile","repo":"/tmp/demo","root":"/tmp/ws-mobile","assistant":"codex"},"error":null}' - ;; - *) - printf '{"ok":false,"error":{"code":"unexpected","message":"unexpected args: %s"}}' "$*" - ;; -esac -`) - - env := os.Environ() - env = withEnv(env, "PATH", fakeBinDir+":"+os.Getenv("PATH")) - env = withEnv(env, "PROJECT_PATH_FILE", projectPathFile) - - payload := runScriptJSONInDir(t, scriptPath, repoDir, env, - "project", "add", - "--workspace", "mobile", - "--assistant", "codex", - ) - - if got, _ := payload["command"].(string); got != "project.add" { - t.Fatalf("command = %q, want %q", got, "project.add") - } - if got, _ := payload["status"].(string); got != "ok" { - t.Fatalf("status = %q, want %q", got, "ok") - } - calledProjectPathRaw, err := os.ReadFile(projectPathFile) - if err != nil { - t.Fatalf("read project path file: %v", err) - } - wantRepoDir := repoDir - if resolvedRepoDir, err := filepath.EvalSymlinks(repoDir); err == nil && resolvedRepoDir != "" { - wantRepoDir = resolvedRepoDir - } - if got := strings.TrimSpace(string(calledProjectPathRaw)); got != wantRepoDir { - t.Fatalf("project add path = %q, want %q", got, wantRepoDir) - } - data, ok := payload["data"].(map[string]any) - if !ok { - t.Fatalf("data missing or wrong type: %T", payload["data"]) - } - if got, _ := data["path_source"].(string); got != "cwd_or_git_root" { - t.Fatalf("path_source = %q, want %q", got, "cwd_or_git_root") - } -} - -func TestOpenClawDXProjectList_QueryFiltersProjects(t *testing.T) { - requireBinary(t, "jq") - requireBinary(t, "bash") - - scriptPath := filepath.Join("..", "..", "skills", "amux", "scripts", "openclaw-dx.sh") - fakeBinDir := t.TempDir() - fakeAmuxPath := filepath.Join(fakeBinDir, "amux") - - writeExecutable(t, fakeAmuxPath, `#!/usr/bin/env bash -set -euo pipefail -if [[ "${1:-}" == "--json" ]]; then - shift -fi -case "${1:-} ${2:-}" in - "project list") - printf '%s' '{"ok":true,"data":[{"name":"api","path":"/tmp/api"},{"name":"mobile","path":"/tmp/mobile"},{"name":"web","path":"/tmp/web"}],"error":null}' - ;; - *) - printf '{"ok":false,"error":{"code":"unexpected","message":"unexpected args: %s"}}' "$*" - ;; -esac -`) - - env := os.Environ() - env = withEnv(env, "PATH", fakeBinDir+":"+os.Getenv("PATH")) - - payload := runScriptJSON(t, scriptPath, env, - "project", "list", - "--query", "api", - ) - - if got, _ := payload["command"].(string); got != "project.list" { - t.Fatalf("command = %q, want %q", got, "project.list") - } - if got, _ := payload["status"].(string); got != "ok" { - t.Fatalf("status = %q, want %q", got, "ok") - } - data, ok := payload["data"].(map[string]any) - if !ok { - t.Fatalf("data missing or wrong type: %T", payload["data"]) - } - if got, _ := data["query"].(string); got != "api" { - t.Fatalf("query = %q, want %q", got, "api") - } - if got, _ := data["count"].(float64); got != 1 { - t.Fatalf("count = %v, want 1", got) - } - projects, ok := data["projects"].([]any) - if !ok || len(projects) != 1 { - t.Fatalf("projects = %#v, want len=1", data["projects"]) - } - project, ok := projects[0].(map[string]any) - if !ok { - t.Fatalf("projects[0] wrong type: %T", projects[0]) - } - if got, _ := project["name"].(string); got != "api" { - t.Fatalf("project name = %q, want %q", got, "api") - } -} - -func TestOpenClawDXProjectList_PaginatesAndAddsNavigationActions(t *testing.T) { - requireBinary(t, "jq") - requireBinary(t, "bash") - - scriptPath := filepath.Join("..", "..", "skills", "amux", "scripts", "openclaw-dx.sh") - fakeBinDir := t.TempDir() - fakeAmuxPath := filepath.Join(fakeBinDir, "amux") - - writeExecutable(t, fakeAmuxPath, `#!/usr/bin/env bash -set -euo pipefail -if [[ "${1:-}" == "--json" ]]; then - shift -fi -case "${1:-} ${2:-}" in - "project list") - printf '%s' '{"ok":true,"data":[{"name":"app-1","path":"/tmp/app-1"},{"name":"app-2","path":"/tmp/app-2"},{"name":"app-3","path":"/tmp/app-3"},{"name":"app-4","path":"/tmp/app-4"},{"name":"app-5","path":"/tmp/app-5"}],"error":null}' - ;; - *) - printf '{"ok":false,"error":{"code":"unexpected","message":"unexpected args: %s"}}' "$*" - ;; -esac -`) - - env := os.Environ() - env = withEnv(env, "PATH", fakeBinDir+":"+os.Getenv("PATH")) - - payload := runScriptJSON(t, scriptPath, env, - "project", "list", - "--limit", "2", - "--page", "2", - ) - - if got, _ := payload["command"].(string); got != "project.list" { - t.Fatalf("command = %q, want %q", got, "project.list") - } - data, ok := payload["data"].(map[string]any) - if !ok { - t.Fatalf("data missing or wrong type: %T", payload["data"]) - } - if got, _ := data["page"].(float64); got != 2 { - t.Fatalf("page = %v, want 2", got) - } - if got, _ := data["total_pages"].(float64); got != 3 { - t.Fatalf("total_pages = %v, want 3", got) - } - if got, _ := data["has_prev"].(bool); !got { - t.Fatalf("has_prev = %v, want true", got) - } - if got, _ := data["has_next"].(bool); !got { - t.Fatalf("has_next = %v, want true", got) - } - projectsPage, ok := data["projects_page"].([]any) - if !ok || len(projectsPage) != 2 { - t.Fatalf("projects_page = %#v, want len=2", data["projects_page"]) - } - firstPageProject, ok := projectsPage[0].(map[string]any) - if !ok { - t.Fatalf("projects_page[0] wrong type: %T", projectsPage[0]) - } - if got, _ := firstPageProject["name"].(string); got != "app-3" { - t.Fatalf("projects_page[0].name = %q, want app-3", got) - } - - quickActions, ok := payload["quick_actions"].([]any) - if !ok { - t.Fatalf("quick_actions missing or wrong type: %T", payload["quick_actions"]) - } - var sawPrev bool - var sawNext bool - for _, raw := range quickActions { - action, ok := raw.(map[string]any) - if !ok { - continue - } - id, _ := action["id"].(string) - command, _ := action["command"].(string) - switch id { - case "prev_page": - sawPrev = strings.Contains(command, "--page 1") - case "next_page": - sawNext = strings.Contains(command, "--page 3") - } - } - if !sawPrev { - t.Fatalf("expected prev_page quick action targeting page 1: %#v", quickActions) - } - if !sawNext { - t.Fatalf("expected next_page quick action targeting page 3: %#v", quickActions) - } -} - -func TestOpenClawDXProjectList_QuickActionCallbackDataIsOpenClawSafe(t *testing.T) { - requireBinary(t, "jq") - requireBinary(t, "bash") - - scriptPath := filepath.Join("..", "..", "skills", "amux", "scripts", "openclaw-dx.sh") - fakeBinDir := t.TempDir() - fakeAmuxPath := filepath.Join(fakeBinDir, "amux") - - writeExecutable(t, fakeAmuxPath, `#!/usr/bin/env bash -set -euo pipefail -if [[ "${1:-}" == "--json" ]]; then - shift -fi -case "${1:-} ${2:-}" in - "project list") - printf '%s' '{"ok":true,"data":[{"name":"demo","path":"/tmp/demo"}],"error":null}' - ;; - *) - printf '{"ok":false,"error":{"code":"unexpected","message":"unexpected args: %s"}}' "$*" - ;; -esac -`) - - env := os.Environ() - env = withEnv(env, "PATH", fakeBinDir+":"+os.Getenv("PATH")) - - payload := runScriptJSON(t, scriptPath, env, - "project", "list", - "--limit", "1", - ) - - quickActions, ok := payload["quick_actions"].([]any) - if !ok || len(quickActions) == 0 { - t.Fatalf("quick_actions missing or empty: %#v", payload["quick_actions"]) - } - - seen := map[string]bool{} - for _, raw := range quickActions { - action, ok := raw.(map[string]any) - if !ok { - continue - } - callbackData, _ := action["callback_data"].(string) - if !strings.HasPrefix(callbackData, "dx:") { - t.Fatalf("callback_data = %q, want dx:* token", callbackData) - } - if len(callbackData) > 64 { - t.Fatalf("callback_data len = %d, want <= 64 (%q)", len(callbackData), callbackData) - } - if seen[callbackData] { - t.Fatalf("duplicate callback_data token: %q", callbackData) - } - seen[callbackData] = true - } -} - -func TestOpenClawDXProjectList_DataIncludesContextSnapshot(t *testing.T) { - requireBinary(t, "jq") - requireBinary(t, "bash") - - scriptPath := filepath.Join("..", "..", "skills", "amux", "scripts", "openclaw-dx.sh") - fakeBinDir := t.TempDir() - fakeAmuxPath := filepath.Join(fakeBinDir, "amux") - contextPath := filepath.Join(t.TempDir(), "context.json") - if err := os.WriteFile(contextPath, []byte(`{"project":{"path":"/tmp/demo","name":"demo"},"workspace":{"id":"ws-1","name":"mobile","repo":"/tmp/demo","assistant":"codex"},"agent":{"id":"agent-1","workspace_id":"ws-1","assistant":"codex"}}`), 0o644); err != nil { - t.Fatalf("write context file: %v", err) - } - - writeExecutable(t, fakeAmuxPath, `#!/usr/bin/env bash -set -euo pipefail -if [[ "${1:-}" == "--json" ]]; then - shift -fi -case "${1:-} ${2:-}" in - "project list") - printf '%s' '{"ok":true,"data":[{"name":"demo","path":"/tmp/demo"}],"error":null}' - ;; - *) - printf '{"ok":false,"error":{"code":"unexpected","message":"unexpected args: %s"}}' "$*" - ;; -esac -`) - - env := os.Environ() - env = withEnv(env, "PATH", fakeBinDir+":"+os.Getenv("PATH")) - env = withEnv(env, "OPENCLAW_DX_CONTEXT_FILE", contextPath) - - payload := runScriptJSON(t, scriptPath, env, "project", "list") - - data, ok := payload["data"].(map[string]any) - if !ok { - t.Fatalf("data missing or wrong type: %T", payload["data"]) - } - context, ok := data["context"].(map[string]any) - if !ok { - t.Fatalf("data.context missing or wrong type: %T", data["context"]) - } - project, ok := context["project"].(map[string]any) - if !ok { - t.Fatalf("context.project missing or wrong type: %T", context["project"]) - } - if got, _ := project["path"].(string); got != "/tmp/demo" { - t.Fatalf("context.project.path = %q, want /tmp/demo", got) - } -} - -func TestOpenClawDXWorkspaceList_UsesContextProjectWhenProjectMissing(t *testing.T) { - requireBinary(t, "jq") - requireBinary(t, "bash") - - scriptPath := filepath.Join("..", "..", "skills", "amux", "scripts", "openclaw-dx.sh") - fakeBinDir := t.TempDir() - fakeAmuxPath := filepath.Join(fakeBinDir, "amux") - argsLog := filepath.Join(fakeBinDir, "amux-args.log") - contextPath := filepath.Join(t.TempDir(), "context.json") - if err := os.WriteFile(contextPath, []byte(`{"project":{"path":"/tmp/demo","name":"demo"}}`), 0o644); err != nil { - t.Fatalf("write context file: %v", err) - } - - writeExecutable(t, fakeAmuxPath, `#!/usr/bin/env bash -set -euo pipefail -if [[ "${1:-}" == "--json" ]]; then - shift -fi -printf '%s\n' "$*" >> "${ARGS_LOG:?missing ARGS_LOG}" -case "${1:-} ${2:-}" in - "workspace list") - printf '%s' '{"ok":true,"data":[{"id":"ws-1","name":"mobile","repo":"/tmp/demo","root":"/tmp/ws-1","assistant":"codex"}],"error":null}' - ;; - "agent list") - printf '%s' '{"ok":true,"data":[],"error":null}' - ;; - "terminal list") - printf '%s' '{"ok":true,"data":[],"error":null}' - ;; - *) - printf '{"ok":false,"error":{"code":"unexpected","message":"unexpected args: %s"}}' "$*" - ;; -esac -`) - - env := os.Environ() - env = withEnv(env, "PATH", fakeBinDir+":"+os.Getenv("PATH")) - env = withEnv(env, "ARGS_LOG", argsLog) - env = withEnv(env, "OPENCLAW_DX_CONTEXT_FILE", contextPath) - - payload := runScriptJSON(t, scriptPath, env, - "workspace", "list", - "--limit", "1", - ) - - if got, _ := payload["command"].(string); got != "workspace.list" { - t.Fatalf("command = %q, want %q", got, "workspace.list") - } - data, ok := payload["data"].(map[string]any) - if !ok { - t.Fatalf("data missing or wrong type: %T", payload["data"]) - } - if got, _ := data["project"].(string); got != "/tmp/demo" { - t.Fatalf("project = %q, want /tmp/demo", got) - } - if got, _ := data["project_from_context"].(bool); !got { - t.Fatalf("project_from_context = %v, want true", got) - } - - argsRaw, err := os.ReadFile(argsLog) - if err != nil { - t.Fatalf("read args log: %v", err) - } - if !strings.Contains(string(argsRaw), "workspace list --repo /tmp/demo") { - t.Fatalf("workspace list did not use context project, args:\n%s", string(argsRaw)) - } -} diff --git a/internal/cli/openclaw_dx_script_status_terminal_test.go b/internal/cli/openclaw_dx_script_status_terminal_test.go deleted file mode 100644 index cd88c210..00000000 --- a/internal/cli/openclaw_dx_script_status_terminal_test.go +++ /dev/null @@ -1,445 +0,0 @@ -package cli - -import ( - "os" - "os/exec" - "path/filepath" - "strings" - "testing" -) - -func TestOpenClawDXGitShip_NoChangesWithPushPushesAheadCommits(t *testing.T) { - requireBinary(t, "jq") - requireBinary(t, "bash") - requireBinary(t, "git") - - scriptPath := filepath.Join("..", "..", "skills", "amux", "scripts", "openclaw-dx.sh") - fakeBinDir := t.TempDir() - fakeAmuxPath := filepath.Join(fakeBinDir, "amux") - - repoDir := t.TempDir() - if out, err := exec.Command("git", "-C", repoDir, "init", "-b", "main").CombinedOutput(); err != nil { - t.Fatalf("git init: %v\n%s", err, string(out)) - } - if out, err := exec.Command("git", "-C", repoDir, "config", "user.email", "dx@example.com").CombinedOutput(); err != nil { - t.Fatalf("git config email: %v\n%s", err, string(out)) - } - if out, err := exec.Command("git", "-C", repoDir, "config", "user.name", "DX Bot").CombinedOutput(); err != nil { - t.Fatalf("git config name: %v\n%s", err, string(out)) - } - - remoteDir := filepath.Join(t.TempDir(), "remote.git") - if out, err := exec.Command("git", "init", "--bare", remoteDir).CombinedOutput(); err != nil { - t.Fatalf("git init bare: %v\n%s", err, string(out)) - } - if out, err := exec.Command("git", "-C", repoDir, "remote", "add", "origin", remoteDir).CombinedOutput(); err != nil { - t.Fatalf("git remote add: %v\n%s", err, string(out)) - } - - if err := os.WriteFile(filepath.Join(repoDir, "README.md"), []byte("hello\n"), 0o644); err != nil { - t.Fatalf("write README: %v", err) - } - if out, err := exec.Command("git", "-C", repoDir, "add", "README.md").CombinedOutput(); err != nil { - t.Fatalf("git add: %v\n%s", err, string(out)) - } - if out, err := exec.Command("git", "-C", repoDir, "commit", "-m", "initial").CombinedOutput(); err != nil { - t.Fatalf("git commit initial: %v\n%s", err, string(out)) - } - if out, err := exec.Command("git", "-C", repoDir, "push", "-u", "origin", "HEAD").CombinedOutput(); err != nil { - t.Fatalf("git push initial: %v\n%s", err, string(out)) - } - - if err := os.WriteFile(filepath.Join(repoDir, "README.md"), []byte("hello\nworld\n"), 0o644); err != nil { - t.Fatalf("modify README: %v", err) - } - if out, err := exec.Command("git", "-C", repoDir, "add", "README.md").CombinedOutput(); err != nil { - t.Fatalf("git add second: %v\n%s", err, string(out)) - } - if out, err := exec.Command("git", "-C", repoDir, "commit", "-m", "second").CombinedOutput(); err != nil { - t.Fatalf("git commit second: %v\n%s", err, string(out)) - } - - localHeadBeforePush, err := exec.Command("git", "-C", repoDir, "rev-parse", "HEAD").Output() - if err != nil { - t.Fatalf("git rev-parse local: %v", err) - } - remoteHeadBeforePush, err := exec.Command("git", "--git-dir", remoteDir, "rev-parse", "refs/heads/main").Output() - if err != nil { - t.Fatalf("git rev-parse remote before push: %v", err) - } - if strings.TrimSpace(string(localHeadBeforePush)) == strings.TrimSpace(string(remoteHeadBeforePush)) { - t.Fatalf("expected local HEAD to be ahead before push") - } - - writeExecutable(t, fakeAmuxPath, `#!/usr/bin/env bash -set -euo pipefail -if [[ "${1:-}" == "--json" ]]; then - shift -fi -case "${1:-} ${2:-}" in - "workspace list") - printf '%s' "${FAKE_WORKSPACE_LIST_JSON:?missing FAKE_WORKSPACE_LIST_JSON}" - ;; - *) - printf '{"ok":false,"error":{"code":"unexpected","message":"unexpected args: %s"}}' "$*" - ;; -esac -`) - - workspaceListJSON := `{"ok":true,"data":[{"id":"ws-1","name":"demo","repo":"` + repoDir + `","root":"` + repoDir + `"}],"error":null}` - env := os.Environ() - env = withEnv(env, "PATH", fakeBinDir+":"+os.Getenv("PATH")) - env = withEnv(env, "FAKE_WORKSPACE_LIST_JSON", workspaceListJSON) - - payload := runScriptJSON(t, scriptPath, env, - "git", "ship", - "--workspace", "ws-1", - "--push", - ) - - if got, _ := payload["status"].(string); got != "ok" { - t.Fatalf("status = %q, want %q", got, "ok") - } - summary, _ := payload["summary"].(string) - if !strings.Contains(summary, "pushed existing commits") { - t.Fatalf("summary = %q, want pushed existing commits", summary) - } - data, ok := payload["data"].(map[string]any) - if !ok { - t.Fatalf("data missing or wrong type: %T", payload["data"]) - } - if pushed, _ := data["pushed"].(bool); !pushed { - t.Fatalf("pushed = %v, want true", pushed) - } - - remoteHeadAfterPush, err := exec.Command("git", "--git-dir", remoteDir, "rev-parse", "refs/heads/main").Output() - if err != nil { - t.Fatalf("git rev-parse remote after push: %v", err) - } - if strings.TrimSpace(string(localHeadBeforePush)) != strings.TrimSpace(string(remoteHeadAfterPush)) { - t.Fatalf("expected remote HEAD to match local HEAD after push") - } -} - -func TestOpenClawDXWorkspaceDecide_RecommendsNestedFromParent(t *testing.T) { - requireBinary(t, "jq") - requireBinary(t, "bash") - - scriptPath := filepath.Join("..", "..", "skills", "amux", "scripts", "openclaw-dx.sh") - fakeBinDir := t.TempDir() - fakeAmuxPath := filepath.Join(fakeBinDir, "amux") - - writeExecutable(t, fakeAmuxPath, `#!/usr/bin/env bash -set -euo pipefail -if [[ "${1:-}" == "--json" ]]; then - shift -fi -if [[ "${1:-}" == "workspace" && "${2:-}" == "list" && "${3:-}" == "--archived" ]]; then - printf '%s' '{"ok":true,"data":[{"id":"ws-parent","name":"feature","repo":"/tmp/demo","assistant":"codex"}],"error":null}' - exit 0 -fi -if [[ "${1:-}" == "workspace" && "${2:-}" == "list" && "${3:-}" == "--repo" ]]; then - printf '%s' '{"ok":true,"data":[{"id":"ws-parent","name":"feature","repo":"/tmp/demo","assistant":"codex"}],"error":null}' - exit 0 -fi -if [[ "${1:-}" == "agent" && "${2:-}" == "list" ]]; then - printf '%s' '{"ok":true,"data":[{"agent_id":"agent-1","workspace_id":"ws-parent"}],"error":null}' - exit 0 -fi -printf '{"ok":false,"error":{"code":"unexpected","message":"unexpected args: %s"}}' "$*" -`) - - env := os.Environ() - env = withEnv(env, "PATH", fakeBinDir+":"+os.Getenv("PATH")) - - payload := runScriptJSON(t, scriptPath, env, - "workspace", "decide", - "--from-workspace", "ws-parent", - "--task", "Refactor largest tech debt area.", - "--assistant", "codex", - "--name", "refactor", - ) - - if got, _ := payload["command"].(string); got != "workspace.decide" { - t.Fatalf("command = %q, want %q", got, "workspace.decide") - } - if got, _ := payload["status"].(string); got != "ok" { - t.Fatalf("status = %q, want %q", got, "ok") - } - data, ok := payload["data"].(map[string]any) - if !ok { - t.Fatalf("data missing or wrong type: %T", payload["data"]) - } - if got, _ := data["recommendation"].(string); got != "nested" { - t.Fatalf("recommendation = %q, want %q", got, "nested") - } - suggested, _ := payload["suggested_command"].(string) - if !strings.Contains(suggested, "workflow kickoff") || !strings.Contains(suggested, "--scope nested") { - t.Fatalf("suggested_command = %q, want nested kickoff", suggested) - } -} - -func TestOpenClawDXWorkspaceDecide_ProjectOnlyDoesNotRequireAlternateCommand(t *testing.T) { - requireBinary(t, "jq") - requireBinary(t, "bash") - - scriptPath := filepath.Join("..", "..", "skills", "amux", "scripts", "openclaw-dx.sh") - fakeBinDir := t.TempDir() - fakeAmuxPath := filepath.Join(fakeBinDir, "amux") - - writeExecutable(t, fakeAmuxPath, `#!/usr/bin/env bash -set -euo pipefail -if [[ "${1:-}" == "--json" ]]; then - shift -fi -if [[ "${1:-}" == "workspace" && "${2:-}" == "list" && "${3:-}" == "--repo" ]]; then - printf '%s' '{"ok":true,"data":[],"error":null}' - exit 0 -fi -if [[ "${1:-}" == "agent" && "${2:-}" == "list" ]]; then - printf '%s' '{"ok":true,"data":[],"error":null}' - exit 0 -fi -printf '{"ok":false,"error":{"code":"unexpected","message":"unexpected args: %s"}}' "$*" -`) - - env := os.Environ() - env = withEnv(env, "PATH", fakeBinDir+":"+os.Getenv("PATH")) - - payload := runScriptJSON(t, scriptPath, env, - "workspace", "decide", - "--project", "/tmp/demo", - "--task", "Ship initial feature set.", - "--assistant", "codex", - "--name", "mainline", - ) - - if got, _ := payload["command"].(string); got != "workspace.decide" { - t.Fatalf("command = %q, want %q", got, "workspace.decide") - } - if got, _ := payload["status"].(string); got != "ok" { - t.Fatalf("status = %q, want %q", got, "ok") - } - data, ok := payload["data"].(map[string]any) - if !ok { - t.Fatalf("data missing or wrong type: %T", payload["data"]) - } - if got, _ := data["recommendation"].(string); got != "project" { - t.Fatalf("recommendation = %q, want %q", got, "project") - } - suggested, _ := payload["suggested_command"].(string) - if !strings.Contains(suggested, "--project /tmp/demo") { - t.Fatalf("suggested_command = %q, want project kickoff command", suggested) - } -} - -func TestOpenClawDXTerminalPreset_StartsNextJSPreset(t *testing.T) { - requireBinary(t, "jq") - requireBinary(t, "bash") - - scriptPath := filepath.Join("..", "..", "skills", "amux", "scripts", "openclaw-dx.sh") - fakeBinDir := t.TempDir() - fakeAmuxPath := filepath.Join(fakeBinDir, "amux") - argsLog := filepath.Join(fakeBinDir, "terminal-args.log") - - writeExecutable(t, fakeAmuxPath, `#!/usr/bin/env bash -set -euo pipefail -if [[ "${1:-}" == "--json" ]]; then - shift -fi -printf '%s\n' "$*" > "${TERMINAL_ARGS_LOG:?missing TERMINAL_ARGS_LOG}" -if [[ "${1:-}" == "terminal" && "${2:-}" == "run" ]]; then - printf '%s' '{"ok":true,"data":{"session_name":"term-1","created":true},"error":null}' - exit 0 -fi -printf '{"ok":false,"error":{"code":"unexpected","message":"unexpected args: %s"}}' "$*" -`) - - env := os.Environ() - env = withEnv(env, "PATH", fakeBinDir+":"+os.Getenv("PATH")) - env = withEnv(env, "TERMINAL_ARGS_LOG", argsLog) - - payload := runScriptJSON(t, scriptPath, env, - "terminal", "preset", - "--workspace", "ws-1", - "--kind", "nextjs", - "--manager", "pnpm", - "--port", "3100", - "--host", "127.0.0.1", - ) - - if got, _ := payload["command"].(string); got != "terminal.preset" { - t.Fatalf("command = %q, want %q", got, "terminal.preset") - } - if got, _ := payload["status"].(string); got != "ok" { - t.Fatalf("status = %q, want %q", got, "ok") - } - data, ok := payload["data"].(map[string]any) - if !ok { - t.Fatalf("data missing or wrong type: %T", payload["data"]) - } - if got, _ := data["session_name"].(string); got != "term-1" { - t.Fatalf("session_name = %q, want %q", got, "term-1") - } - argsRaw, err := os.ReadFile(argsLog) - if err != nil { - t.Fatalf("read terminal args: %v", err) - } - args := strings.TrimSpace(string(argsRaw)) - if !strings.Contains(args, "terminal run --workspace ws-1") || - !strings.Contains(args, `NEXT_TELEMETRY_DISABLED=1; pnpm dev -- --port "3100" --hostname "127.0.0.1"`) || - !strings.Contains(args, "--enter=true") { - t.Fatalf("terminal args = %q, expected workspace/pnpm/port/host/enter", args) - } -} - -func TestOpenClawDXStatus_SurfacesCompletedAlertAndReviewActions(t *testing.T) { - requireBinary(t, "jq") - requireBinary(t, "bash") - - scriptPath := filepath.Join("..", "..", "skills", "amux", "scripts", "openclaw-dx.sh") - fakeBinDir := t.TempDir() - fakeAmuxPath := filepath.Join(fakeBinDir, "amux") - - writeExecutable(t, fakeAmuxPath, `#!/usr/bin/env bash -set -euo pipefail -if [[ "${1:-}" == "--json" ]]; then - shift -fi -case "${1:-} ${2:-}" in - "project list") - printf '%s' '{"ok":true,"data":[{"name":"demo","path":"/tmp/demo"}],"error":null}' - ;; - "workspace list") - printf '%s' '{"ok":true,"data":[{"id":"ws-1","name":"demo","repo":"/tmp/demo","created":"2026-01-01T00:00:00Z"}],"error":null}' - ;; - "agent list") - printf '%s' '{"ok":true,"data":[{"session_name":"sess-1","agent_id":"agent-1","workspace_id":"ws-1","tab_id":"tab-1","type":"agent"}],"error":null}' - ;; - "terminal list") - printf '%s' '{"ok":true,"data":[],"error":null}' - ;; - "session list") - printf '%s' '{"ok":true,"data":[{"session_name":"sess-1","workspace_id":"ws-1","type":"agent","attached":false,"age_seconds":100}],"error":null}' - ;; - "session prune") - printf '%s' '{"ok":true,"data":{"dry_run":true,"pruned":[],"total":0,"errors":[]},"error":null}' - ;; - "agent capture") - printf '%s' '{"ok":true,"data":{"session_name":"sess-1","status":"captured","summary":"Implemented fix and tests passed.","needs_input":false,"input_hint":""},"error":null}' - ;; - *) - printf '{"ok":false,"error":{"code":"unexpected","message":"unexpected args: %s"}}' "$*" - ;; -esac -`) - - env := os.Environ() - env = withEnv(env, "PATH", fakeBinDir+":"+os.Getenv("PATH")) - - payload := runScriptJSON(t, scriptPath, env, "status", "--workspace", "ws-1") - - if got, _ := payload["command"].(string); got != "status" { - t.Fatalf("command = %q, want %q", got, "status") - } - if got, _ := payload["status"].(string); got != "attention" { - t.Fatalf("status = %q, want %q", got, "attention") - } - suggested, _ := payload["suggested_command"].(string) - if !strings.Contains(suggested, "review --workspace ws-1") { - t.Fatalf("suggested_command = %q, want review command", suggested) - } - data, ok := payload["data"].(map[string]any) - if !ok { - t.Fatalf("data missing or wrong type: %T", payload["data"]) - } - alerts, ok := data["alerts"].([]any) - if !ok || len(alerts) == 0 { - t.Fatalf("alerts missing or empty: %#v", data["alerts"]) - } - var sawCompleted bool - for _, raw := range alerts { - alert, ok := raw.(map[string]any) - if !ok { - continue - } - if alert["type"] == "completed" { - sawCompleted = true - break - } - } - if !sawCompleted { - t.Fatalf("expected completed alert in %#v", alerts) - } - quickActions, ok := payload["quick_actions"].([]any) - if !ok { - t.Fatalf("quick_actions missing or wrong type: %T", payload["quick_actions"]) - } - var sawReviewDone bool - var sawShipDone bool - for _, raw := range quickActions { - action, ok := raw.(map[string]any) - if !ok { - continue - } - id, _ := action["id"].(string) - if id == "review_done" { - sawReviewDone = true - } - if id == "ship_done" { - sawShipDone = true - } - } - if !sawReviewDone || !sawShipDone { - t.Fatalf("expected review_done and ship_done quick actions, got %#v", quickActions) - } -} - -func TestOpenClawDXStatus_InvalidResultCommandEnvFallsBackToStatus(t *testing.T) { - requireBinary(t, "jq") - requireBinary(t, "bash") - - scriptPath := filepath.Join("..", "..", "skills", "amux", "scripts", "openclaw-dx.sh") - fakeBinDir := t.TempDir() - fakeAmuxPath := filepath.Join(fakeBinDir, "amux") - - writeExecutable(t, fakeAmuxPath, `#!/usr/bin/env bash -set -euo pipefail -if [[ "${1:-}" == "--json" ]]; then - shift -fi -case "${1:-} ${2:-}" in - "project list") - printf '%s' '{"ok":true,"data":[],"error":null}' - ;; - "workspace list") - printf '%s' '{"ok":true,"data":[],"error":null}' - ;; - "agent list") - printf '%s' '{"ok":true,"data":[],"error":null}' - ;; - "terminal list") - printf '%s' '{"ok":true,"data":[],"error":null}' - ;; - "session list") - printf '%s' '{"ok":true,"data":[],"error":null}' - ;; - "session prune") - printf '%s' '{"ok":true,"data":{"dry_run":true,"pruned":[],"total":0,"errors":[]},"error":null}' - ;; - *) - printf '{"ok":false,"error":{"code":"unexpected","message":"unexpected args: %s"}}' "$*" - ;; -esac -`) - - env := os.Environ() - env = withEnv(env, "PATH", fakeBinDir+":"+os.Getenv("PATH")) - env = withEnv(env, "OPENCLAW_DX_STATUS_RESULT_COMMAND", "status;rm -rf /") - - payload := runScriptJSON(t, scriptPath, env, "status") - - if got, _ := payload["command"].(string); got != "status" { - t.Fatalf("command = %q, want %q", got, "status") - } -} diff --git a/internal/cli/openclaw_dx_script_workflow_test.go b/internal/cli/openclaw_dx_script_workflow_test.go deleted file mode 100644 index b642e027..00000000 --- a/internal/cli/openclaw_dx_script_workflow_test.go +++ /dev/null @@ -1,358 +0,0 @@ -package cli - -import ( - "os" - "path/filepath" - "strings" - "testing" -) - -func TestOpenClawDXWorkflowKickoff_RegistersProjectCreatesWorkspaceAndStartsTurn(t *testing.T) { - requireBinary(t, "jq") - requireBinary(t, "bash") - - scriptPath := filepath.Join("..", "..", "skills", "amux", "scripts", "openclaw-dx.sh") - fakeBinDir := t.TempDir() - fakeAmuxPath := filepath.Join(fakeBinDir, "amux") - fakeTurnPath := filepath.Join(fakeBinDir, "fake-turn.sh") - - writeExecutable(t, fakeAmuxPath, `#!/usr/bin/env bash -set -euo pipefail -if [[ "${1:-}" == "--json" ]]; then - shift -fi -case "${1:-} ${2:-}" in - "project list") - printf '%s' '{"ok":true,"data":[],"error":null}' - ;; - "project add") - printf '%s' '{"ok":true,"data":{"name":"demo","path":"/tmp/demo"},"error":null}' - ;; - "workspace create") - printf '%s' '{"ok":true,"data":{"id":"ws-mobile","name":"mobile","repo":"/tmp/demo","root":"/tmp/ws-mobile","assistant":"codex"},"error":null}' - ;; - *) - printf '{"ok":false,"error":{"code":"unexpected","message":"unexpected args: %s"}}' "$*" - ;; -esac -`) - - writeExecutable(t, fakeTurnPath, `#!/usr/bin/env bash -set -euo pipefail -printf '%s' '{"ok":true,"mode":"run","status":"idle","overall_status":"completed","summary":"Implemented first debt fix.","agent_id":"agent-1","workspace_id":"ws-mobile","assistant":"codex","next_action":"Run review.","suggested_command":"skills/amux/scripts/openclaw-dx.sh review --workspace ws-mobile --assistant codex","quick_actions":[{"id":"continue","label":"Continue","command":"skills/amux/scripts/openclaw-dx.sh continue --workspace ws-mobile --text \"Continue\" --enter","style":"primary","prompt":"Continue current work"}],"channel":{"message":"done","chunks":["done"],"chunks_meta":[{"index":1,"total":1,"text":"done"}],"inline_buttons":[]}}' -`) - - env := os.Environ() - env = withEnv(env, "PATH", fakeBinDir+":"+os.Getenv("PATH")) - env = withEnv(env, "OPENCLAW_DX_TURN_SCRIPT", fakeTurnPath) - env = withEnv(env, "OPENCLAW_DX_SELF_SCRIPT", scriptPath) - env = withEnv(env, "OPENCLAW_PRESENT_SCRIPT", "/nonexistent") - - payload := runScriptJSON(t, scriptPath, env, - "workflow", "kickoff", - "--project", "/tmp/demo", - "--name", "mobile", - "--assistant", "codex", - "--prompt", "Fix the highest-impact debt item.", - ) - - if got, _ := payload["command"].(string); got != "workflow.kickoff" { - t.Fatalf("command = %q, want %q", got, "workflow.kickoff") - } - if got, _ := payload["workflow"].(string); got != "kickoff" { - t.Fatalf("workflow = %q, want %q", got, "kickoff") - } - kickoff, ok := payload["kickoff"].(map[string]any) - if !ok { - t.Fatalf("kickoff missing or wrong type: %T", payload["kickoff"]) - } - if got, _ := kickoff["workspace_id"].(string); got != "ws-mobile" { - t.Fatalf("workspace_id = %q, want %q", got, "ws-mobile") - } - - quickActions, ok := payload["quick_actions"].([]any) - if !ok || len(quickActions) == 0 { - t.Fatalf("quick_actions missing or empty: %#v", payload["quick_actions"]) - } - var sawStatusWS bool - var sawReviewWS bool - for _, raw := range quickActions { - action, ok := raw.(map[string]any) - if !ok { - continue - } - id, _ := action["id"].(string) - if id == "status_ws" { - sawStatusWS = true - } - if id == "review_ws" { - sawReviewWS = true - } - } - if !sawStatusWS || !sawReviewWS { - t.Fatalf("expected kickoff quick actions status_ws and review_ws, got %#v", quickActions) - } -} - -func TestOpenClawDXWorkflowKickoff_IgnoresPresentScriptAndPrintsJSON(t *testing.T) { - requireBinary(t, "jq") - requireBinary(t, "bash") - - scriptPath := filepath.Join("..", "..", "skills", "amux", "scripts", "openclaw-dx.sh") - fakeBinDir := t.TempDir() - fakeAmuxPath := filepath.Join(fakeBinDir, "amux") - fakeTurnPath := filepath.Join(fakeBinDir, "fake-turn.sh") - fakePresentPath := filepath.Join(fakeBinDir, "present.sh") - - writeExecutable(t, fakeAmuxPath, `#!/usr/bin/env bash -set -euo pipefail -if [[ "${1:-}" == "--json" ]]; then - shift -fi -case "${1:-} ${2:-}" in - "project list") - printf '%s' '{"ok":true,"data":[],"error":null}' - ;; - "project add") - printf '%s' '{"ok":true,"data":{"name":"demo","path":"/tmp/demo"},"error":null}' - ;; - "workspace create") - printf '%s' '{"ok":true,"data":{"id":"ws-mobile","name":"mobile","repo":"/tmp/demo","root":"/tmp/ws-mobile","assistant":"codex"},"error":null}' - ;; - *) - printf '{"ok":false,"error":{"code":"unexpected","message":"unexpected args: %s"}}' "$*" - ;; -esac -`) - - writeExecutable(t, fakeTurnPath, `#!/usr/bin/env bash -set -euo pipefail -printf '%s' '{"ok":true,"mode":"run","status":"idle","overall_status":"completed","summary":"Implemented first debt fix.","agent_id":"agent-1","workspace_id":"ws-mobile","assistant":"codex","next_action":"Run review.","suggested_command":"skills/amux/scripts/openclaw-dx.sh review --workspace ws-mobile --assistant codex","quick_actions":[],"channel":{"message":"done","chunks":["done"],"chunks_meta":[{"index":1,"total":1,"text":"done"}],"inline_buttons":[]}}' -`) - - writeExecutable(t, fakePresentPath, `#!/usr/bin/env bash -set -euo pipefail -echo "PRESENT_SCRIPT_SHOULD_NOT_RUN" -`) - - env := os.Environ() - env = withEnv(env, "PATH", fakeBinDir+":"+os.Getenv("PATH")) - env = withEnv(env, "OPENCLAW_DX_TURN_SCRIPT", fakeTurnPath) - env = withEnv(env, "OPENCLAW_DX_SELF_SCRIPT", scriptPath) - env = withEnv(env, "OPENCLAW_PRESENT_SCRIPT", fakePresentPath) - - payload := runScriptJSON(t, scriptPath, env, - "workflow", "kickoff", - "--project", "/tmp/demo", - "--name", "mobile", - "--assistant", "codex", - "--prompt", "Fix the highest-impact debt item.", - ) - - if got, _ := payload["command"].(string); got != "workflow.kickoff" { - t.Fatalf("command = %q, want %q", got, "workflow.kickoff") - } -} - -func TestOpenClawDXWorkflowDual_RunsImplementationThenReview(t *testing.T) { - requireBinary(t, "jq") - requireBinary(t, "bash") - - scriptPath := filepath.Join("..", "..", "skills", "amux", "scripts", "openclaw-dx.sh") - fakeBinDir := t.TempDir() - fakeAmuxPath := filepath.Join(fakeBinDir, "amux") - fakeTurnPath := filepath.Join(fakeBinDir, "fake-turn.sh") - - writeExecutable(t, fakeAmuxPath, `#!/usr/bin/env bash -set -euo pipefail -if [[ "${1:-}" == "--json" ]]; then - shift -fi -case "${1:-} ${2:-}" in - "workspace list") - printf '%s' '{"ok":true,"data":[{"id":"ws-1","name":"demo","repo":"/tmp/demo"}],"error":null}' - ;; - *) - printf '%s' '{"ok":true,"data":{},"error":null}' - ;; -esac -`) - - writeExecutable(t, fakeTurnPath, `#!/usr/bin/env bash -set -euo pipefail -assistant="" -for ((i=1; i<=$#; i++)); do - if [[ "${!i}" == "--assistant" ]]; then - next=$((i+1)) - assistant="${!next}" - fi -done -if [[ "$assistant" == "claude" ]]; then - printf '%s' '{"ok":true,"mode":"run","status":"idle","overall_status":"completed","summary":"Implemented refactor and tests.","agent_id":"agent-impl","workspace_id":"ws-1","assistant":"claude","next_action":"Run review.","suggested_command":"skills/amux/scripts/openclaw-dx.sh review --workspace ws-1 --assistant codex","quick_actions":[],"channel":{"message":"impl done","chunks":["impl done"],"chunks_meta":[{"index":1,"total":1,"text":"impl done"}],"inline_buttons":[]}}' - exit 0 -fi -printf '%s' '{"ok":true,"mode":"run","status":"idle","overall_status":"completed","summary":"Review complete with no blockers.","agent_id":"agent-review","workspace_id":"ws-1","assistant":"codex","next_action":"Ship changes.","suggested_command":"skills/amux/scripts/openclaw-dx.sh git ship --workspace ws-1","quick_actions":[],"channel":{"message":"review done","chunks":["review done"],"chunks_meta":[{"index":1,"total":1,"text":"review done"}],"inline_buttons":[]}}' -`) - - env := os.Environ() - env = withEnv(env, "PATH", fakeBinDir+":"+os.Getenv("PATH")) - env = withEnv(env, "OPENCLAW_DX_TURN_SCRIPT", fakeTurnPath) - env = withEnv(env, "OPENCLAW_DX_SELF_SCRIPT", scriptPath) - env = withEnv(env, "OPENCLAW_PRESENT_SCRIPT", "/nonexistent") - - payload := runScriptJSON(t, scriptPath, env, - "workflow", "dual", - "--workspace", "ws-1", - "--implement-assistant", "claude", - "--review-assistant", "codex", - ) - - if got, _ := payload["command"].(string); got != "workflow.dual" { - t.Fatalf("command = %q, want %q", got, "workflow.dual") - } - if got, _ := payload["status"].(string); got != "ok" { - t.Fatalf("status = %q, want %q", got, "ok") - } - data, ok := payload["data"].(map[string]any) - if !ok { - t.Fatalf("data missing or wrong type: %T", payload["data"]) - } - if got, _ := data["workspace"].(string); got != "ws-1" { - t.Fatalf("workspace = %q, want %q", got, "ws-1") - } - implementation, ok := data["implementation"].(map[string]any) - if !ok { - t.Fatalf("implementation missing or wrong type: %T", data["implementation"]) - } - if got, _ := implementation["assistant"].(string); got != "claude" { - t.Fatalf("implementation assistant = %q, want %q", got, "claude") - } - review, ok := data["review"].(map[string]any) - if !ok { - t.Fatalf("review missing or wrong type: %T", data["review"]) - } - if got, _ := review["assistant"].(string); got != "codex" { - t.Fatalf("review assistant = %q, want %q", got, "codex") - } - - quickActions, ok := payload["quick_actions"].([]any) - if !ok || len(quickActions) == 0 { - t.Fatalf("quick_actions missing or empty: %#v", payload["quick_actions"]) - } - var sawShip bool - for _, raw := range quickActions { - action, ok := raw.(map[string]any) - if !ok { - continue - } - id, _ := action["id"].(string) - if id == "ship" { - sawShip = true - break - } - } - if !sawShip { - t.Fatalf("expected ship quick action, got %#v", quickActions) - } -} - -func TestOpenClawDXWorkflowDual_NeedsInputAutoFallbackRunsReview(t *testing.T) { - requireBinary(t, "jq") - requireBinary(t, "bash") - - scriptPath := filepath.Join("..", "..", "skills", "amux", "scripts", "openclaw-dx.sh") - fakeBinDir := t.TempDir() - fakeAmuxPath := filepath.Join(fakeBinDir, "amux") - fakeTurnPath := filepath.Join(fakeBinDir, "fake-turn.sh") - - writeExecutable(t, fakeAmuxPath, `#!/usr/bin/env bash -set -euo pipefail -if [[ "${1:-}" == "--json" ]]; then - shift -fi -case "${1:-} ${2:-}" in - "workspace list") - printf '%s' '{"ok":true,"data":[{"id":"ws-1","name":"demo","repo":"/tmp/demo"}],"error":null}' - ;; - *) - printf '%s' '{"ok":true,"data":{},"error":null}' - ;; -esac -`) - - writeExecutable(t, fakeTurnPath, `#!/usr/bin/env bash -set -euo pipefail -assistant="" -for ((i=1; i<=$#; i++)); do - if [[ "${!i}" == "--assistant" ]]; then - next=$((i+1)) - assistant="${!next}" - fi -done -if [[ "$assistant" == "claude" ]]; then - printf '%s' '{"ok":true,"mode":"run","status":"needs_input","overall_status":"needs_input","summary":"Needs local permission selection.","agent_id":"agent-impl","workspace_id":"ws-1","assistant":"claude","next_action":"Switch to a non-interactive assistant (e.g. codex) for this step.","suggested_command":"","quick_actions":[],"channel":{"message":"needs input","chunks":["needs input"],"chunks_meta":[{"index":1,"total":1,"text":"needs input"}],"inline_buttons":[]}}' - exit 0 -fi -printf '%s' '{"ok":true,"mode":"run","status":"idle","overall_status":"completed","summary":"review should not run","agent_id":"agent-review","workspace_id":"ws-1","assistant":"codex","next_action":"Ship.","suggested_command":"skills/amux/scripts/openclaw-dx.sh git ship --workspace ws-1","quick_actions":[],"channel":{"message":"review","chunks":["review"],"chunks_meta":[{"index":1,"total":1,"text":"review"}],"inline_buttons":[]}}' -`) - - env := os.Environ() - env = withEnv(env, "PATH", fakeBinDir+":"+os.Getenv("PATH")) - env = withEnv(env, "OPENCLAW_DX_TURN_SCRIPT", fakeTurnPath) - env = withEnv(env, "OPENCLAW_DX_SELF_SCRIPT", scriptPath) - env = withEnv(env, "OPENCLAW_PRESENT_SCRIPT", "/nonexistent") - - payload := runScriptJSON(t, scriptPath, env, - "workflow", "dual", - "--workspace", "ws-1", - "--implement-assistant", "claude", - "--review-assistant", "codex", - ) - - if got, _ := payload["status"].(string); got != "ok" { - t.Fatalf("status = %q, want %q", got, "ok") - } - suggested, _ := payload["suggested_command"].(string) - if !strings.Contains(suggested, "git ship --workspace ws-1") { - t.Fatalf("suggested_command = %q, want ship command", suggested) - } - data, ok := payload["data"].(map[string]any) - if !ok { - t.Fatalf("data missing or wrong type: %T", payload["data"]) - } - if got, _ := data["implement_assistant"].(string); got != "codex" { - t.Fatalf("implement_assistant = %q, want %q after fallback", got, "codex") - } - if got, _ := data["review_assistant"].(string); got != "codex" { - t.Fatalf("review_assistant = %q, want %q", got, "codex") - } - if got, _ := data["review_skipped_reason"].(string); got != "" { - t.Fatalf("review_skipped_reason = %q, want empty", got) - } - - quickActions, ok := payload["quick_actions"].([]any) - if !ok || len(quickActions) == 0 { - t.Fatalf("quick_actions missing or empty: %#v", payload["quick_actions"]) - } - var sawShip bool - var sawRunReview bool - for _, raw := range quickActions { - action, ok := raw.(map[string]any) - if !ok { - continue - } - id, _ := action["id"].(string) - if id == "ship" { - sawShip = true - } - if id == "run_review" { - sawRunReview = true - } - } - if !sawShip { - t.Fatalf("expected ship quick action, got %#v", quickActions) - } - if sawRunReview { - t.Fatalf("did not expect run_review quick action when review already ran: %#v", quickActions) - } -} diff --git a/internal/cli/openclaw_dx_script_workspace_test.go b/internal/cli/openclaw_dx_script_workspace_test.go deleted file mode 100644 index cea01cf1..00000000 --- a/internal/cli/openclaw_dx_script_workspace_test.go +++ /dev/null @@ -1,300 +0,0 @@ -package cli - -import ( - "os" - "path/filepath" - "strings" - "testing" -) - -func TestOpenClawDXWorkspaceCreate_NestedFromWorkspace(t *testing.T) { - requireBinary(t, "jq") - requireBinary(t, "bash") - - scriptPath := filepath.Join("..", "..", "skills", "amux", "scripts", "openclaw-dx.sh") - fakeBinDir := t.TempDir() - fakeAmuxPath := filepath.Join(fakeBinDir, "amux") - calledNameFile := filepath.Join(fakeBinDir, "called-name.txt") - - writeExecutable(t, fakeAmuxPath, `#!/usr/bin/env bash -set -euo pipefail -if [[ "${1:-}" == "--json" ]]; then - shift -fi -case "${1:-} ${2:-}" in - "workspace list") - printf '%s' '{"ok":true,"data":[{"id":"parent-ws","name":"feature","repo":"/tmp/demo","assistant":"codex"}],"error":null}' - ;; - "workspace create") - ws_name="${3:-}" - printf '%s' "$ws_name" > "${CALLED_NAME_FILE:?missing CALLED_NAME_FILE}" - printf '{"ok":true,"data":{"id":"ws-nested","name":"%s","repo":"/tmp/demo","root":"/tmp/ws-nested","assistant":"codex"},"error":null}' "$ws_name" - ;; - *) - printf '{"ok":false,"error":{"code":"unexpected","message":"unexpected args: %s"}}' "$*" - ;; -esac -`) - - env := os.Environ() - env = withEnv(env, "PATH", fakeBinDir+":"+os.Getenv("PATH")) - env = withEnv(env, "CALLED_NAME_FILE", calledNameFile) - - payload := runScriptJSON(t, scriptPath, env, - "workspace", "create", - "--name", "refactor", - "--from-workspace", "parent-ws", - "--scope", "nested", - ) - - if got, _ := payload["command"].(string); got != "workspace.create" { - t.Fatalf("command = %q, want %q", got, "workspace.create") - } - data, ok := payload["data"].(map[string]any) - if !ok { - t.Fatalf("data missing or wrong type: %T", payload["data"]) - } - if got, _ := data["final_name"].(string); got != "feature.refactor" { - t.Fatalf("final_name = %q, want %q", got, "feature.refactor") - } - calledNameRaw, err := os.ReadFile(calledNameFile) - if err != nil { - t.Fatalf("read called name: %v", err) - } - if got := strings.TrimSpace(string(calledNameRaw)); got != "feature.refactor" { - t.Fatalf("workspace create name = %q, want %q", got, "feature.refactor") - } -} - -func TestOpenClawDXProjectPick_DisambiguationUsesIndexSelectors(t *testing.T) { - requireBinary(t, "jq") - requireBinary(t, "bash") - - scriptPath := filepath.Join("..", "..", "skills", "amux", "scripts", "openclaw-dx.sh") - fakeBinDir := t.TempDir() - fakeAmuxPath := filepath.Join(fakeBinDir, "amux") - - writeExecutable(t, fakeAmuxPath, `#!/usr/bin/env bash -set -euo pipefail -if [[ "${1:-}" == "--json" ]]; then - shift -fi -case "${1:-} ${2:-}" in - "project list") - printf '%s' '{"ok":true,"data":[{"name":"repo","path":"/tmp/repo-a"},{"name":"repo","path":"/tmp/repo-b"},{"name":"other","path":"/tmp/other"}],"error":null}' - ;; - *) - printf '{"ok":false,"error":{"code":"unexpected","message":"unexpected args: %s"}}' "$*" - ;; -esac -`) - - env := os.Environ() - env = withEnv(env, "PATH", fakeBinDir+":"+os.Getenv("PATH")) - - payload := runScriptJSON(t, scriptPath, env, - "project", "pick", - "--name", "repo", - ) - - if got, _ := payload["status"].(string); got != "attention" { - t.Fatalf("status = %q, want %q", got, "attention") - } - quickActions, ok := payload["quick_actions"].([]any) - if !ok || len(quickActions) == 0 { - t.Fatalf("quick_actions missing or empty: %#v", payload["quick_actions"]) - } - var sawIndexSelect bool - for _, raw := range quickActions { - action, ok := raw.(map[string]any) - if !ok { - continue - } - cmd, _ := action["command"].(string) - if strings.Contains(cmd, "project pick --index ") { - sawIndexSelect = true - break - } - } - if !sawIndexSelect { - t.Fatalf("expected index-based project pick command in quick actions: %#v", quickActions) - } -} - -func TestOpenClawDXProjectAdd_PropagatesStructuredAmuxError(t *testing.T) { - requireBinary(t, "jq") - requireBinary(t, "bash") - - scriptPath := filepath.Join("..", "..", "skills", "amux", "scripts", "openclaw-dx.sh") - fakeBinDir := t.TempDir() - fakeAmuxPath := filepath.Join(fakeBinDir, "amux") - - writeExecutable(t, fakeAmuxPath, `#!/usr/bin/env bash -set -euo pipefail -if [[ "${1:-}" == "--json" ]]; then - shift -fi -case "${1:-} ${2:-}" in - "project add") - printf '%s' '{"ok":false,"error":{"code":"add_failed","message":"project path already registered"}}' - ;; - *) - printf '%s' '{"ok":true,"data":{},"error":null}' - ;; -esac -`) - - env := os.Environ() - env = withEnv(env, "PATH", fakeBinDir+":"+os.Getenv("PATH")) - - payload := runScriptJSON(t, scriptPath, env, - "project", "add", - "--path", "/tmp/demo", - ) - - if got, _ := payload["status"].(string); got != "command_error" { - t.Fatalf("status = %q, want %q", got, "command_error") - } - summary, _ := payload["summary"].(string) - if !strings.Contains(summary, "project path already registered") { - t.Fatalf("summary = %q, want propagated amux error message", summary) - } - data, ok := payload["data"].(map[string]any) - if !ok { - t.Fatalf("data missing or wrong type: %T", payload["data"]) - } - errData, ok := data["error"].(map[string]any) - if !ok { - t.Fatalf("data.error missing or wrong type: %T", data["error"]) - } - if got, _ := errData["code"].(string); got != "add_failed" { - t.Fatalf("error.code = %q, want %q", got, "add_failed") - } -} - -func TestOpenClawDXProjectAdd_InitialCommitGuidanceForWorkspaceCreate(t *testing.T) { - requireBinary(t, "jq") - requireBinary(t, "bash") - - scriptPath := filepath.Join("..", "..", "skills", "amux", "scripts", "openclaw-dx.sh") - fakeBinDir := t.TempDir() - fakeAmuxPath := filepath.Join(fakeBinDir, "amux") - - writeExecutable(t, fakeAmuxPath, `#!/usr/bin/env bash -set -euo pipefail -if [[ "${1:-}" == "--json" ]]; then - shift -fi -case "${1:-} ${2:-}" in - "project add") - printf '%s' '{"ok":true,"data":{"name":"demo","path":"/tmp/demo"},"error":null}' - ;; - "workspace create") - printf '%s' '{"ok":false,"error":{"code":"create_failed","message":"git worktree add -b mobile /tmp/ws/mobile HEAD: fatal: invalid reference: HEAD"}}' - ;; - *) - printf '%s' '{"ok":true,"data":{},"error":null}' - ;; -esac -`) - - env := os.Environ() - env = withEnv(env, "PATH", fakeBinDir+":"+os.Getenv("PATH")) - - payload := runScriptJSON(t, scriptPath, env, - "project", "add", - "--path", "/tmp/demo", - "--workspace", "mobile", - ) - - if got, _ := payload["status"].(string); got != "attention" { - t.Fatalf("status = %q, want %q", got, "attention") - } - summary, _ := payload["summary"].(string) - if !strings.Contains(summary, "no initial commit") { - t.Fatalf("summary = %q, want initial-commit guidance", summary) - } - suggested, _ := payload["suggested_command"].(string) - if !strings.Contains(suggested, "git -C /tmp/demo add -A") { - t.Fatalf("suggested_command = %q, want git initial commit command", suggested) - } - quickActions, ok := payload["quick_actions"].([]any) - if !ok || len(quickActions) == 0 { - t.Fatalf("quick_actions missing or empty: %#v", payload["quick_actions"]) - } - var sawRetry bool - for _, raw := range quickActions { - action, ok := raw.(map[string]any) - if !ok { - continue - } - id, _ := action["id"].(string) - if id == "retry" { - sawRetry = true - break - } - } - if !sawRetry { - t.Fatalf("expected retry quick action in %#v", quickActions) - } -} - -func TestOpenClawDXWorkspaceCreate_RecoversFromExistingBranchConflict(t *testing.T) { - requireBinary(t, "jq") - requireBinary(t, "bash") - - scriptPath := filepath.Join("..", "..", "skills", "amux", "scripts", "openclaw-dx.sh") - fakeBinDir := t.TempDir() - fakeAmuxPath := filepath.Join(fakeBinDir, "amux") - - writeExecutable(t, fakeAmuxPath, `#!/usr/bin/env bash -set -euo pipefail -if [[ "${1:-}" == "--json" ]]; then - shift -fi -case "${1:-} ${2:-}" in - "workspace create") - printf '%s' '{"ok":false,"error":{"code":"create_failed","message":"fatal: a branch named '\''main'\'' already exists"}}' - ;; - "workspace list") - printf '%s' '{"ok":true,"data":[{"id":"ws-main","name":"main","repo":"/tmp/demo","root":"/tmp/demo","assistant":"codex"}],"error":null}' - ;; - *) - printf '%s' '{"ok":false,"error":{"code":"unexpected","message":"unexpected args"}}' - ;; -esac -`) - - env := os.Environ() - env = withEnv(env, "PATH", fakeBinDir+":"+os.Getenv("PATH")) - - payload := runScriptJSON(t, scriptPath, env, - "workspace", "create", - "--name", "main", - "--project", "/tmp/demo", - "--assistant", "codex", - ) - - if got, _ := payload["status"].(string); got != "attention" { - t.Fatalf("status = %q, want %q", got, "attention") - } - summary, _ := payload["summary"].(string) - if !strings.Contains(summary, "Workspace already exists") { - t.Fatalf("summary = %q, want conflict recovery summary", summary) - } - suggested, _ := payload["suggested_command"].(string) - if !strings.Contains(suggested, "start --workspace ws-main") { - t.Fatalf("suggested_command = %q, want start on existing workspace", suggested) - } - data, ok := payload["data"].(map[string]any) - if !ok { - t.Fatalf("data missing or wrong type: %T", payload["data"]) - } - existing, ok := data["existing_workspace"].(map[string]any) - if !ok { - t.Fatalf("existing_workspace missing or wrong type: %T", data["existing_workspace"]) - } - if got, _ := existing["id"].(string); got != "ws-main" { - t.Fatalf("existing_workspace.id = %q, want %q", got, "ws-main") - } -} diff --git a/internal/cli/openclaw_script_test.go b/internal/cli/openclaw_script_test.go deleted file mode 100644 index b0033c22..00000000 --- a/internal/cli/openclaw_script_test.go +++ /dev/null @@ -1,180 +0,0 @@ -package cli - -import ( - "encoding/json" - "os" - "os/exec" - "path/filepath" - "strings" - "testing" -) - -func runScriptJSONWithInput(t *testing.T, scriptPath string, env []string, input string, args ...string) map[string]any { - t.Helper() - cmd := exec.Command(scriptPath, args...) - cmd.Env = env - if input != "" { - cmd.Stdin = strings.NewReader(input) - } - out, err := cmd.Output() - if err != nil { - t.Fatalf("%s %v failed: %v", scriptPath, args, err) - } - var payload map[string]any - if err := json.Unmarshal(out, &payload); err != nil { - t.Fatalf("decode json: %v\nraw: %s", err, string(out)) - } - return payload -} - -func TestOpenClawPresentScript_AugmentsChannelEnvelope(t *testing.T) { - requireBinary(t, "jq") - requireBinary(t, "bash") - - scriptPath := filepath.Join("..", "..", "skills", "amux", "scripts", "openclaw-present.sh") - env := withEnv(os.Environ(), "OPENCLAW_CHANNEL", "msteams") - input := `{"ok":true,"summary":"ok","message":"Build complete","quick_actions":[{"id":"status","label":"Status","command":"amux --json status","style":"primary","prompt":"Check status"}],"channel":{"message":"Build complete","chunks_meta":[{"index":1,"total":1,"text":"Build complete"}]}}` - - payload := runScriptJSONWithInput(t, scriptPath, env, input) - - openclaw, ok := payload["openclaw"].(map[string]any) - if !ok { - t.Fatalf("openclaw missing or wrong type: %T", payload["openclaw"]) - } - if got, _ := openclaw["selected_channel"].(string); got != "msteams" { - t.Fatalf("openclaw.selected_channel = %q, want msteams", got) - } - presentation, ok := openclaw["presentation"].(map[string]any) - if !ok { - t.Fatalf("openclaw.presentation missing or wrong type: %T", openclaw["presentation"]) - } - suggestedActions, ok := presentation["suggested_actions"].([]any) - if !ok || len(suggestedActions) != 1 { - t.Fatalf("openclaw.presentation.suggested_actions = %#v, want len=1", presentation["suggested_actions"]) - } - - quickActions, ok := payload["quick_actions"].([]any) - if !ok || len(quickActions) != 1 { - t.Fatalf("quick_actions = %#v, want len=1", payload["quick_actions"]) - } - firstAction, ok := quickActions[0].(map[string]any) - if !ok { - t.Fatalf("quick_actions[0] wrong type: %T", quickActions[0]) - } - if got, _ := firstAction["action_id"].(string); got != "status" { - t.Fatalf("quick_actions[0].action_id = %q, want status", got) - } - - actionMap, ok := payload["quick_action_by_id"].(map[string]any) - if !ok { - t.Fatalf("quick_action_by_id missing or wrong type: %T", payload["quick_action_by_id"]) - } - if got, _ := actionMap["status"].(string); got != "amux --json status" { - t.Fatalf("quick_action_by_id[status] = %q, want %q", got, "amux --json status") - } -} - -func TestOpenClawStepWrapper_UsesChannelAndWrapperSuggestions(t *testing.T) { - requireBinary(t, "jq") - requireBinary(t, "bash") - - scriptPath := filepath.Join("..", "..", "skills", "amux", "scripts", "openclaw-step.sh") - fakeBinDir := t.TempDir() - fakeAmuxPath := filepath.Join(fakeBinDir, "amux") - writeExecutable(t, fakeAmuxPath, `#!/usr/bin/env bash -set -euo pipefail -if [[ "${1:-}" == "--json" ]]; then - shift -fi -if [[ "${1:-}" == "agent" && "${2:-}" == "run" ]]; then - printf '%s' "${FAKE_AMUX_RUN_JSON:?missing FAKE_AMUX_RUN_JSON}" - exit 0 -fi -echo "unexpected args: $*" >&2 -exit 2 -`) - - runJSON := `{"ok":true,"data":{"session_name":"sess-wrap-1","agent_id":"agent-wrap-1","workspace_id":"ws-wrap-1","assistant":"codex","response":{"status":"timed_out","latest_line":"Still running build","summary":"Timed out; build still running.","delta":"Still running build","needs_input":false,"input_hint":"","timed_out":true,"session_exited":false,"changed":true}}}` - env := os.Environ() - env = withEnv(env, "PATH", fakeBinDir+":"+os.Getenv("PATH")) - env = withEnv(env, "FAKE_AMUX_RUN_JSON", runJSON) - env = withEnv(env, "OPENCLAW_CHANNEL", "slack") - - payload := runScriptJSON(t, scriptPath, env, - "run", - "--workspace", "ws-wrap-1", - "--assistant", "codex", - "--prompt", "continue", - "--wait-timeout", "1s", - "--idle-threshold", "1s", - ) - - suggested, _ := payload["suggested_command"].(string) - if !strings.Contains(suggested, "openclaw-step.sh send --agent") { - t.Fatalf("suggested_command = %q, expected openclaw-step wrapper command", suggested) - } - - openclaw, ok := payload["openclaw"].(map[string]any) - if !ok { - t.Fatalf("openclaw missing or wrong type: %T", payload["openclaw"]) - } - if got, _ := openclaw["selected_channel"].(string); got != "slack" { - t.Fatalf("openclaw.selected_channel = %q, want slack", got) - } -} - -func TestOpenClawDXWrapper_UsesChannelAndWrapperSuggestions(t *testing.T) { - requireBinary(t, "jq") - requireBinary(t, "bash") - - scriptPath := filepath.Join("..", "..", "skills", "amux", "scripts", "openclaw-dx.sh") - fakeBinDir := t.TempDir() - fakeAmuxPath := filepath.Join(fakeBinDir, "amux") - writeExecutable(t, fakeAmuxPath, `#!/usr/bin/env bash -set -euo pipefail -if [[ "${1:-}" == "--json" ]]; then - shift -fi -case "${1:-} ${2:-}" in - "project list") - printf '%s' '{"ok":true,"data":[{"name":"api","path":"/tmp/api"},{"name":"mobile","path":"/tmp/mobile"}],"error":null}' - ;; - *) - printf '{"ok":false,"error":{"code":"unexpected","message":"unexpected args: %s"}}' "$*" - ;; -esac -`) - - env := os.Environ() - env = withEnv(env, "PATH", fakeBinDir+":"+os.Getenv("PATH")) - env = withEnv(env, "OPENCLAW_CHANNEL", "discord") - - payload := runScriptJSON(t, scriptPath, env, - "project", "list", - "--query", "api", - ) - - suggested, _ := payload["suggested_command"].(string) - if !strings.Contains(suggested, "openclaw-dx.sh") { - t.Fatalf("suggested_command = %q, expected openclaw-dx wrapper command", suggested) - } - - openclaw, ok := payload["openclaw"].(map[string]any) - if !ok { - t.Fatalf("openclaw missing or wrong type: %T", payload["openclaw"]) - } - if got, _ := openclaw["selected_channel"].(string); got != "discord" { - t.Fatalf("openclaw.selected_channel = %q, want discord", got) - } - quickActions, ok := payload["quick_actions"].([]any) - if !ok || len(quickActions) == 0 { - t.Fatalf("quick_actions missing or empty: %#v", payload["quick_actions"]) - } - firstAction, ok := quickActions[0].(map[string]any) - if !ok { - t.Fatalf("quick_actions[0] wrong type: %T", quickActions[0]) - } - if got, _ := firstAction["action_id"].(string); got == "" { - t.Fatalf("quick_actions[0].action_id is empty") - } -} diff --git a/internal/cli/openclaw_step_script_channel_test.go b/internal/cli/openclaw_step_script_channel_test.go deleted file mode 100644 index 6f82b7fd..00000000 --- a/internal/cli/openclaw_step_script_channel_test.go +++ /dev/null @@ -1,262 +0,0 @@ -package cli - -import ( - "encoding/json" - "os" - "os/exec" - "path/filepath" - "strings" - "testing" -) - -func TestOpenClawStepScriptRun_QuietVerbositySuppressesDetailSections(t *testing.T) { - requireBinary(t, "jq") - requireBinary(t, "bash") - - scriptPath := filepath.Join("..", "..", "skills", "amux", "scripts", "openclaw-step.sh") - fakeBinDir := t.TempDir() - fakeAmuxPath := filepath.Join(fakeBinDir, "amux") - if err := os.WriteFile(fakeAmuxPath, []byte(`#!/usr/bin/env bash -set -euo pipefail -if [[ "${1:-}" == "--json" ]]; then - shift -fi -if [[ "${1:-}" == "agent" && "${2:-}" == "run" ]]; then - printf '%s' "${FAKE_AMUX_RUN_JSON:?missing FAKE_AMUX_RUN_JSON}" - exit 0 -fi -echo "unexpected args: $*" >&2 -exit 2 -`), 0o755); err != nil { - t.Fatalf("write fake amux: %v", err) - } - - runJSON := `{"ok":true,"data":{"session_name":"sess-quiet","agent_id":"agent-quiet","workspace_id":"ws-quiet","assistant":"codex","response":{"status":"idle","latest_line":"done","summary":"done","delta":"line1\nline2\nline3\nline4","needs_input":false,"input_hint":"","timed_out":false,"session_exited":false,"changed":true}}}` - - cmd := exec.Command( - scriptPath, - "run", - "--workspace", "ws-quiet", - "--assistant", "codex", - "--prompt", "test prompt", - "--wait-timeout", "1s", - "--idle-threshold", "1s", - ) - env := os.Environ() - env = withEnv(env, "PATH", fakeBinDir+":"+os.Getenv("PATH")) - env = withEnv(env, "FAKE_AMUX_RUN_JSON", runJSON) - env = withEnv(env, "OPENCLAW_STEP_VERBOSITY", "quiet") - cmd.Env = env - out, err := cmd.Output() - if err != nil { - t.Fatalf("openclaw-step.sh run failed: %v", err) - } - - var payload map[string]any - if err := json.Unmarshal(out, &payload); err != nil { - t.Fatalf("decode json: %v\nraw: %s", err, string(out)) - } - - if got, _ := payload["verbosity"].(string); got != "quiet" { - t.Fatalf("verbosity = %q, want %q", got, "quiet") - } - channel, ok := payload["channel"].(map[string]any) - if !ok { - t.Fatalf("channel missing or wrong type: %T", payload["channel"]) - } - msg, _ := channel["message"].(string) - if strings.Contains(msg, "Details:") || strings.Contains(msg, "Command:") || strings.Contains(msg, "Next:") { - t.Fatalf("openclaw.message should suppress detail sections in quiet mode: %q", msg) - } -} - -func TestOpenClawStepScriptRun_DisablesInlineButtonsWhenScopeOff(t *testing.T) { - requireBinary(t, "jq") - requireBinary(t, "bash") - - scriptPath := filepath.Join("..", "..", "skills", "amux", "scripts", "openclaw-step.sh") - fakeBinDir := t.TempDir() - fakeAmuxPath := filepath.Join(fakeBinDir, "amux") - if err := os.WriteFile(fakeAmuxPath, []byte(`#!/usr/bin/env bash -set -euo pipefail -if [[ "${1:-}" == "--json" ]]; then - shift -fi -if [[ "${1:-}" == "agent" && "${2:-}" == "run" ]]; then - printf '%s' "${FAKE_AMUX_RUN_JSON:?missing FAKE_AMUX_RUN_JSON}" - exit 0 -fi -echo "unexpected args: $*" >&2 -exit 2 -`), 0o755); err != nil { - t.Fatalf("write fake amux: %v", err) - } - - runJSON := `{"ok":true,"data":{"session_name":"sess-inline-off","agent_id":"agent-inline-off","workspace_id":"ws-inline-off","assistant":"codex","response":{"status":"idle","latest_line":"done","summary":"done","delta":"line1\nline2","needs_input":false,"input_hint":"","timed_out":false,"session_exited":false,"changed":true}}}` - - cmd := exec.Command( - scriptPath, - "run", - "--workspace", "ws-inline-off", - "--assistant", "codex", - "--prompt", "test prompt", - "--wait-timeout", "1s", - "--idle-threshold", "1s", - ) - env := os.Environ() - env = withEnv(env, "PATH", fakeBinDir+":"+os.Getenv("PATH")) - env = withEnv(env, "FAKE_AMUX_RUN_JSON", runJSON) - env = withEnv(env, "OPENCLAW_INLINE_BUTTONS_SCOPE", "off") - cmd.Env = env - out, err := cmd.Output() - if err != nil { - t.Fatalf("openclaw-step.sh run failed: %v", err) - } - - var payload map[string]any - if err := json.Unmarshal(out, &payload); err != nil { - t.Fatalf("decode json: %v\nraw: %s", err, string(out)) - } - - channel, ok := payload["channel"].(map[string]any) - if !ok { - t.Fatalf("channel missing or wrong type: %T", payload["channel"]) - } - if got, _ := channel["inline_buttons_scope"].(string); got != "off" { - t.Fatalf("openclaw.inline_buttons_scope = %q, want off", got) - } - if got, _ := channel["inline_buttons_enabled"].(bool); got { - t.Fatalf("openclaw.inline_buttons_enabled = %v, want false", got) - } - if inlineButtons, ok := channel["inline_buttons"].([]any); !ok || len(inlineButtons) != 0 { - t.Fatalf("openclaw.inline_buttons = %#v, want empty", channel["inline_buttons"]) - } - quickActions, ok := payload["quick_actions"].([]any) - if !ok || len(quickActions) == 0 { - t.Fatalf("quick_actions missing or empty: %#v", payload["quick_actions"]) - } -} - -func TestOpenClawStepScriptRun_AddsChunkContinuationMetadata(t *testing.T) { - requireBinary(t, "jq") - requireBinary(t, "bash") - - scriptPath := filepath.Join("..", "..", "skills", "amux", "scripts", "openclaw-step.sh") - fakeBinDir := t.TempDir() - fakeAmuxPath := filepath.Join(fakeBinDir, "amux") - if err := os.WriteFile(fakeAmuxPath, []byte(`#!/usr/bin/env bash -set -euo pipefail -if [[ "${1:-}" == "--json" ]]; then - shift -fi -if [[ "${1:-}" == "agent" && "${2:-}" == "run" ]]; then - printf '%s' "${FAKE_AMUX_RUN_JSON:?missing FAKE_AMUX_RUN_JSON}" - exit 0 -fi -echo "unexpected args: $*" >&2 -exit 2 -`), 0o755); err != nil { - t.Fatalf("write fake amux: %v", err) - } - - runJSON := `{"ok":true,"data":{"session_name":"sess-4","agent_id":"agent-4","workspace_id":"ws-4","assistant":"codex","response":{"status":"idle","latest_line":"done","summary":"This is a very long summary intended to force OpenClaw chunking into multiple segments with continuation metadata for better mobile readability.","delta":"line1\nline2\nline3\nline4\nline5\nline6","needs_input":false,"input_hint":"","timed_out":false,"session_exited":false,"changed":true}}}` - - cmd := exec.Command( - scriptPath, - "run", - "--workspace", "ws-4", - "--assistant", "codex", - "--prompt", "test prompt", - "--wait-timeout", "1s", - "--idle-threshold", "1s", - ) - env := os.Environ() - env = withEnv(env, "PATH", fakeBinDir+":"+os.Getenv("PATH")) - env = withEnv(env, "FAKE_AMUX_RUN_JSON", runJSON) - env = withEnv(env, "OPENCLAW_STEP_CHUNK_CHARS", "80") - cmd.Env = env - out, err := cmd.Output() - if err != nil { - t.Fatalf("openclaw-step.sh run failed: %v", err) - } - - var payload map[string]any - if err := json.Unmarshal(out, &payload); err != nil { - t.Fatalf("decode json: %v\nraw: %s", err, string(out)) - } - - channel, ok := payload["channel"].(map[string]any) - if !ok { - t.Fatalf("channel missing or wrong type: %T", payload["channel"]) - } - chunks, ok := channel["chunks"].([]any) - if !ok || len(chunks) < 2 { - t.Fatalf("openclaw.chunks expected at least 2 chunks: %#v", channel["chunks"]) - } - secondChunk, _ := chunks[1].(string) - if !strings.HasPrefix(secondChunk, "continued (2/") { - t.Fatalf("second chunk missing continuation prefix: %q", secondChunk) - } - chunksMeta, ok := channel["chunks_meta"].([]any) - if !ok || len(chunksMeta) != len(chunks) { - t.Fatalf("openclaw.chunks_meta mismatch: chunks=%d meta=%#v", len(chunks), channel["chunks_meta"]) - } -} - -func TestOpenClawStepScriptRun_UsesAbsoluteSelfPathInSuggestedCommand(t *testing.T) { - requireBinary(t, "jq") - requireBinary(t, "bash") - - relScriptPath := filepath.Join("..", "..", "skills", "amux", "scripts", "openclaw-step.sh") - scriptPath, err := filepath.Abs(relScriptPath) - if err != nil { - t.Fatalf("abs script path: %v", err) - } - - fakeBinDir := t.TempDir() - fakeAmuxPath := filepath.Join(fakeBinDir, "amux") - if err := os.WriteFile(fakeAmuxPath, []byte(`#!/usr/bin/env bash -set -euo pipefail -if [[ "${1:-}" == "--json" ]]; then - shift -fi -if [[ "${1:-}" == "agent" && "${2:-}" == "run" ]]; then - printf '%s' "${FAKE_AMUX_RUN_JSON:?missing FAKE_AMUX_RUN_JSON}" - exit 0 -fi -echo "unexpected args: $*" >&2 -exit 2 -`), 0o755); err != nil { - t.Fatalf("write fake amux: %v", err) - } - - runJSON := `{"ok":true,"data":{"session_name":"sess-abs","agent_id":"agent-abs","workspace_id":"ws-abs","assistant":"codex","response":{"status":"timed_out","latest_line":"","summary":"","delta":"","needs_input":false,"input_hint":"","timed_out":true,"session_exited":false,"changed":false}}}` - - cmd := exec.Command( - scriptPath, - "run", - "--workspace", "ws-abs", - "--assistant", "codex", - "--prompt", "test prompt", - "--wait-timeout", "1s", - "--idle-threshold", "1s", - ) - cmd.Dir = t.TempDir() - env := os.Environ() - env = withEnv(env, "PATH", fakeBinDir+":"+os.Getenv("PATH")) - env = withEnv(env, "FAKE_AMUX_RUN_JSON", runJSON) - cmd.Env = env - out, err := cmd.Output() - if err != nil { - t.Fatalf("openclaw-step.sh run failed: %v", err) - } - - var payload map[string]any - if err := json.Unmarshal(out, &payload); err != nil { - t.Fatalf("decode json: %v\nraw: %s", err, string(out)) - } - suggested, _ := payload["suggested_command"].(string) - if !strings.HasPrefix(suggested, "skills/amux/scripts/openclaw-step.sh send --agent agent-abs") { - t.Fatalf("suggested_command = %q, want openclaw-step command prefix", suggested) - } -} diff --git a/internal/cli/openclaw_step_script_core_test.go b/internal/cli/openclaw_step_script_core_test.go deleted file mode 100644 index 3262ff74..00000000 --- a/internal/cli/openclaw_step_script_core_test.go +++ /dev/null @@ -1,450 +0,0 @@ -package cli - -import ( - "encoding/json" - "os" - "os/exec" - "path/filepath" - "strings" - "testing" -) - -func withEnv(env []string, key, value string) []string { - prefix := key + "=" - out := make([]string, 0, len(env)+1) - for _, item := range env { - if strings.HasPrefix(item, prefix) { - continue - } - out = append(out, item) - } - return append(out, prefix+value) -} - -func requireBinary(t *testing.T, name string) { - t.Helper() - if _, err := exec.LookPath(name); err != nil { - t.Skipf("%s not available in PATH", name) - } -} - -func TestOpenClawStepScriptRun_RecoversTimedOutNoOutputFromCapture(t *testing.T) { - requireBinary(t, "jq") - requireBinary(t, "bash") - - scriptPath := filepath.Join("..", "..", "skills", "amux", "scripts", "openclaw-step.sh") - fakeBinDir := t.TempDir() - fakeAmuxPath := filepath.Join(fakeBinDir, "amux") - if err := os.WriteFile(fakeAmuxPath, []byte(`#!/usr/bin/env bash -set -euo pipefail -if [[ "${1:-}" == "--json" ]]; then - shift -fi -case "${1:-} ${2:-}" in - "agent run") - printf '%s' "${FAKE_AMUX_RUN_JSON:?missing FAKE_AMUX_RUN_JSON}" - ;; - "agent send") - printf '%s' "${FAKE_AMUX_SEND_JSON:-${FAKE_AMUX_RUN_JSON:?missing FAKE_AMUX_SEND_JSON}}" - ;; - "agent capture") - printf '%s' "${FAKE_AMUX_CAPTURE_JSON:?missing FAKE_AMUX_CAPTURE_JSON}" - ;; - *) - echo "unexpected args: $*" >&2 - exit 2 - ;; -esac -`), 0o755); err != nil { - t.Fatalf("write fake amux: %v", err) - } - - runJSON := `{"ok":true,"data":{"session_name":"sess-1","agent_id":"agent-1","workspace_id":"ws-1","assistant":"codex","response":{"status":"timed_out","latest_line":"(no output yet)","summary":"(no output yet)","delta":"","needs_input":false,"input_hint":"","timed_out":true,"session_exited":false,"changed":false}}}` - captureJSON := `{"ok":true,"data":{"content":"\u001b[2m? for shortcuts\u001b[0m\n• Recovered status update"}}` - - cmd := exec.Command( - scriptPath, - "run", - "--workspace", "ws-1", - "--assistant", "codex", - "--prompt", "test prompt", - "--wait-timeout", "1s", - "--idle-threshold", "1s", - ) - env := os.Environ() - env = withEnv(env, "PATH", fakeBinDir+":"+os.Getenv("PATH")) - env = withEnv(env, "FAKE_AMUX_RUN_JSON", runJSON) - env = withEnv(env, "FAKE_AMUX_CAPTURE_JSON", captureJSON) - env = withEnv(env, "OPENCLAW_STEP_TIMEOUT_RECOVERY_POLLS", "1") - env = withEnv(env, "OPENCLAW_STEP_TIMEOUT_RECOVERY_INTERVAL", "0") - env = withEnv(env, "OPENCLAW_STEP_TIMEOUT_RECOVERY_LINES", "80") - cmd.Env = env - out, err := cmd.Output() - if err != nil { - t.Fatalf("openclaw-step.sh run failed: %v", err) - } - - var payload map[string]any - if err := json.Unmarshal(out, &payload); err != nil { - t.Fatalf("decode json: %v\nraw: %s", err, string(out)) - } - - if got, _ := payload["ok"].(bool); !got { - t.Fatalf("ok = %v, want true", got) - } - if got, _ := payload["status"].(string); got != "timed_out" { - t.Fatalf("status = %q, want %q", got, "timed_out") - } - if got, _ := payload["verbosity"].(string); got != "normal" { - t.Fatalf("verbosity = %q, want %q", got, "normal") - } - if got, _ := payload["status_emoji"].(string); got != "⏱️" { - t.Fatalf("status_emoji = %q, want %q", got, "⏱️") - } - if got, _ := payload["recovered_from_capture"].(bool); !got { - t.Fatalf("recovered_from_capture = %v, want true", got) - } - idempotencyKey, _ := payload["idempotency_key"].(string) - if !strings.HasPrefix(idempotencyKey, "tgstep-") { - t.Fatalf("idempotency_key = %q, want prefix tgstep-", idempotencyKey) - } - if got, _ := payload["summary"].(string); got != "• Recovered status update" { - t.Fatalf("summary = %q", got) - } - channel, ok := payload["channel"].(map[string]any) - if !ok { - t.Fatalf("channel missing or wrong type: %T", payload["channel"]) - } - if chunkChars, _ := channel["chunk_chars"].(float64); chunkChars != 1200 { - t.Fatalf("openclaw.chunk_chars = %v, want 1200", chunkChars) - } - msg, _ := channel["message"].(string) - if !strings.Contains(msg, "Recovered status update") { - t.Fatalf("openclaw.message = %q, expected summary text", msg) - } - if got, _ := channel["verbosity"].(string); got != "normal" { - t.Fatalf("openclaw.verbosity = %q, want %q", got, "normal") - } - if got, _ := channel["inline_buttons_scope"].(string); got != "allowlist" { - t.Fatalf("openclaw.inline_buttons_scope = %q, want %q", got, "allowlist") - } - if got, _ := channel["inline_buttons_enabled"].(bool); !got { - t.Fatalf("openclaw.inline_buttons_enabled = %v, want true", got) - } - if got, _ := channel["callback_data_max_bytes"].(float64); got != 64 { - t.Fatalf("openclaw.callback_data_max_bytes = %v, want 64", got) - } - chunks, ok := channel["chunks"].([]any) - if !ok || len(chunks) == 0 { - t.Fatalf("openclaw.chunks missing or empty: %#v", channel["chunks"]) - } - chunksMeta, ok := channel["chunks_meta"].([]any) - if !ok || len(chunksMeta) == 0 { - t.Fatalf("openclaw.chunks_meta missing or empty: %#v", channel["chunks_meta"]) - } - inlineButtons, ok := channel["inline_buttons"].([]any) - if !ok || len(inlineButtons) == 0 { - t.Fatalf("openclaw.inline_buttons missing or empty: %#v", channel["inline_buttons"]) - } - actionTokens, ok := channel["action_tokens"].([]any) - if !ok || len(actionTokens) == 0 { - t.Fatalf("openclaw.action_tokens missing or empty: %#v", channel["action_tokens"]) - } - actionsFallback, _ := channel["actions_fallback"].(string) - if !strings.Contains(actionsFallback, "qa:") { - t.Fatalf("openclaw.actions_fallback = %q, expected qa: token list", actionsFallback) - } - quickActions, ok := payload["quick_actions"].([]any) - if !ok || len(quickActions) == 0 { - t.Fatalf("quick_actions missing or empty: %#v", payload["quick_actions"]) - } - quickActionMap, ok := payload["quick_action_map"].(map[string]any) - if !ok || len(quickActionMap) == 0 { - t.Fatalf("quick_action_map missing or empty: %#v", payload["quick_action_map"]) - } - quickActionPrompts, ok := payload["quick_action_prompts"].(map[string]any) - if !ok || len(quickActionPrompts) == 0 { - t.Fatalf("quick_action_prompts missing or empty: %#v", payload["quick_action_prompts"]) - } - for _, actionRaw := range quickActions { - action, ok := actionRaw.(map[string]any) - if !ok { - t.Fatalf("quick action has wrong type: %T", actionRaw) - } - callbackData, _ := action["callback_data"].(string) - if !strings.HasPrefix(callbackData, "qa:") { - t.Fatalf("callback_data = %q, want qa: prefix", callbackData) - } - if len(callbackData) > 64 { - t.Fatalf("callback_data too long (%d): %q", len(callbackData), callbackData) - } - } - delivery, ok := payload["delivery"].(map[string]any) - if !ok { - t.Fatalf("delivery missing or wrong type: %T", payload["delivery"]) - } - if got, _ := delivery["action"].(string); got != "edit" { - t.Fatalf("delivery.action = %q, want %q", got, "edit") - } - if got, _ := delivery["coalesce"].(bool); !got { - t.Fatalf("delivery.coalesce = %v, want true", got) - } - if got, _ := delivery["replace_previous"].(bool); !got { - t.Fatalf("delivery.replace_previous = %v, want true", got) - } - - resp, ok := payload["response"].(map[string]any) - if !ok { - t.Fatalf("response missing or wrong type: %T", payload["response"]) - } - if got, _ := resp["delta_compact"].(string); got != "• Recovered status update" { - t.Fatalf("delta_compact = %q", got) - } - recovery, ok := payload["recovery"].(map[string]any) - if !ok { - t.Fatalf("recovery missing or wrong type: %T", payload["recovery"]) - } - if got, _ := recovery["attempted"].(bool); !got { - t.Fatalf("recovery.attempted = %v, want true", got) - } - if got, _ := recovery["polls_used"].(float64); got != 1 { - t.Fatalf("recovery.polls_used = %v, want 1", got) - } -} - -func TestOpenClawStepScriptRun_SetsBlockedPermissionMode(t *testing.T) { - requireBinary(t, "jq") - requireBinary(t, "bash") - - scriptPath := filepath.Join("..", "..", "skills", "amux", "scripts", "openclaw-step.sh") - fakeBinDir := t.TempDir() - fakeAmuxPath := filepath.Join(fakeBinDir, "amux") - if err := os.WriteFile(fakeAmuxPath, []byte(`#!/usr/bin/env bash -set -euo pipefail -if [[ "${1:-}" == "--json" ]]; then - shift -fi -if [[ "${1:-}" == "agent" && "${2:-}" == "run" ]]; then - printf '%s' "${FAKE_AMUX_RUN_JSON:?missing FAKE_AMUX_RUN_JSON}" - exit 0 -fi -echo "unexpected args: $*" >&2 -exit 2 -`), 0o755); err != nil { - t.Fatalf("write fake amux: %v", err) - } - - runJSON := `{"ok":true,"data":{"session_name":"sess-2","agent_id":"agent-2","workspace_id":"ws-2","assistant":"claude","response":{"status":"needs_input","latest_line":"Assistant is waiting for local permission-mode selection.","summary":"Needs input: Assistant is waiting for local permission-mode selection.","delta":"Assistant is waiting for local permission-mode selection.","needs_input":true,"input_hint":"Assistant is waiting for local permission-mode selection.","timed_out":false,"session_exited":false,"changed":true}}}` - - cmd := exec.Command( - scriptPath, - "run", - "--workspace", "ws-2", - "--assistant", "claude", - "--prompt", "test prompt", - "--wait-timeout", "1s", - "--idle-threshold", "1s", - ) - env := os.Environ() - env = withEnv(env, "PATH", fakeBinDir+":"+os.Getenv("PATH")) - env = withEnv(env, "FAKE_AMUX_RUN_JSON", runJSON) - cmd.Env = env - out, err := cmd.Output() - if err != nil { - t.Fatalf("openclaw-step.sh run failed: %v", err) - } - - var payload map[string]any - if err := json.Unmarshal(out, &payload); err != nil { - t.Fatalf("decode json: %v\nraw: %s", err, string(out)) - } - - if got, _ := payload["status"].(string); got != "needs_input" { - t.Fatalf("status = %q, want %q", got, "needs_input") - } - if got, _ := payload["status_emoji"].(string); got != "❓" { - t.Fatalf("status_emoji = %q, want %q", got, "❓") - } - if got, _ := payload["blocked_permission_mode"].(bool); !got { - t.Fatalf("blocked_permission_mode = %v, want true", got) - } - quickActions, ok := payload["quick_actions"].([]any) - if !ok || len(quickActions) == 0 { - t.Fatalf("quick_actions missing or empty: %#v", payload["quick_actions"]) - } - idempotencyKey, _ := payload["idempotency_key"].(string) - if !strings.HasPrefix(idempotencyKey, "tgstep-") { - t.Fatalf("idempotency_key = %q, want prefix tgstep-", idempotencyKey) - } - nextAction, _ := payload["next_action"].(string) - if !strings.Contains(nextAction, "non-interactive assistant") { - t.Fatalf("next_action = %q, expected non-interactive hint", nextAction) - } -} - -func TestOpenClawStepScriptRun_AutoIdempotencyCanBeDisabled(t *testing.T) { - requireBinary(t, "jq") - requireBinary(t, "bash") - - scriptPath := filepath.Join("..", "..", "skills", "amux", "scripts", "openclaw-step.sh") - fakeBinDir := t.TempDir() - fakeAmuxPath := filepath.Join(fakeBinDir, "amux") - if err := os.WriteFile(fakeAmuxPath, []byte(`#!/usr/bin/env bash -set -euo pipefail -if [[ "${1:-}" == "--json" ]]; then - shift -fi -if [[ "${1:-}" == "agent" && "${2:-}" == "run" ]]; then - printf '%s' "${FAKE_AMUX_RUN_JSON:?missing FAKE_AMUX_RUN_JSON}" - exit 0 -fi -echo "unexpected args: $*" >&2 -exit 2 -`), 0o755); err != nil { - t.Fatalf("write fake amux: %v", err) - } - - runJSON := `{"ok":true,"data":{"session_name":"sess-3","agent_id":"agent-3","workspace_id":"ws-3","assistant":"codex","response":{"status":"idle","latest_line":"done","summary":"done","delta":"done","needs_input":false,"input_hint":"","timed_out":false,"session_exited":false,"changed":true}}}` - - cmd := exec.Command( - scriptPath, - "run", - "--workspace", "ws-3", - "--assistant", "codex", - "--prompt", "test prompt", - "--wait-timeout", "1s", - "--idle-threshold", "1s", - ) - env := os.Environ() - env = withEnv(env, "PATH", fakeBinDir+":"+os.Getenv("PATH")) - env = withEnv(env, "FAKE_AMUX_RUN_JSON", runJSON) - env = withEnv(env, "OPENCLAW_STEP_AUTO_IDEMPOTENCY", "false") - cmd.Env = env - out, err := cmd.Output() - if err != nil { - t.Fatalf("openclaw-step.sh run failed: %v", err) - } - - var payload map[string]any - if err := json.Unmarshal(out, &payload); err != nil { - t.Fatalf("decode json: %v\nraw: %s", err, string(out)) - } - - if got, _ := payload["idempotency_key"].(string); got != "" { - t.Fatalf("idempotency_key = %q, want empty when auto idempotency disabled", got) - } -} - -func TestOpenClawStepScriptRun_UpgradesWeakSummaryFromDelta(t *testing.T) { - requireBinary(t, "jq") - requireBinary(t, "bash") - - scriptPath := filepath.Join("..", "..", "skills", "amux", "scripts", "openclaw-step.sh") - fakeBinDir := t.TempDir() - fakeAmuxPath := filepath.Join(fakeBinDir, "amux") - if err := os.WriteFile(fakeAmuxPath, []byte(`#!/usr/bin/env bash -set -euo pipefail -if [[ "${1:-}" == "--json" ]]; then - shift -fi -if [[ "${1:-}" == "agent" && "${2:-}" == "run" ]]; then - printf '%s' "${FAKE_AMUX_RUN_JSON:?missing FAKE_AMUX_RUN_JSON}" - exit 0 -fi -echo "unexpected args: $*" >&2 -exit 2 -`), 0o755); err != nil { - t.Fatalf("write fake amux: %v", err) - } - - runJSON := `{"ok":true,"data":{"session_name":"sess-weak","agent_id":"agent-weak","workspace_id":"ws-weak","assistant":"codex","response":{"status":"idle","latest_line":"output tracking.","summary":"output tracking.","delta":"Search rg --files\n- internal/cli/cmd_agent_watch.go:207 computeNewLines can duplicate lines when output is rewritten","needs_input":false,"input_hint":"","timed_out":false,"session_exited":false,"changed":true}}}` - - cmd := exec.Command( - scriptPath, - "run", - "--workspace", "ws-weak", - "--assistant", "codex", - "--prompt", "test prompt", - "--wait-timeout", "1s", - "--idle-threshold", "1s", - ) - env := os.Environ() - env = withEnv(env, "PATH", fakeBinDir+":"+os.Getenv("PATH")) - env = withEnv(env, "FAKE_AMUX_RUN_JSON", runJSON) - cmd.Env = env - out, err := cmd.Output() - if err != nil { - t.Fatalf("openclaw-step.sh run failed: %v", err) - } - - var payload map[string]any - if err := json.Unmarshal(out, &payload); err != nil { - t.Fatalf("decode json: %v\nraw: %s", err, string(out)) - } - - summary, _ := payload["summary"].(string) - if !strings.Contains(summary, "internal/cli/cmd_agent_watch.go:207") { - t.Fatalf("summary = %q, expected upgraded delta-based file reference", summary) - } -} - -func TestOpenClawStepScriptRun_RedactsSecretsInOutput(t *testing.T) { - requireBinary(t, "jq") - requireBinary(t, "bash") - - scriptPath := filepath.Join("..", "..", "skills", "amux", "scripts", "openclaw-step.sh") - fakeBinDir := t.TempDir() - fakeAmuxPath := filepath.Join(fakeBinDir, "amux") - if err := os.WriteFile(fakeAmuxPath, []byte(`#!/usr/bin/env bash -set -euo pipefail -if [[ "${1:-}" == "--json" ]]; then - shift -fi -if [[ "${1:-}" == "agent" && "${2:-}" == "run" ]]; then - printf '%s' "${FAKE_AMUX_RUN_JSON:?missing FAKE_AMUX_RUN_JSON}" - exit 0 -fi -echo "unexpected args: $*" >&2 -exit 2 -`), 0o755); err != nil { - t.Fatalf("write fake amux: %v", err) - } - - runJSON := `{"ok":true,"data":{"session_name":"sess-secret","agent_id":"agent-secret","workspace_id":"ws-secret","assistant":"codex","response":{"status":"idle","latest_line":"token=ghp_abcde1234567890","summary":"Use token sk-ant-api1-abcdefghijklmnopqrstuv in env","delta":"Authorization: Bearer sk-ant-api1-abcdefghijklmnopqrstuv123456\nSECRET=supersecretvalue123456","needs_input":false,"input_hint":"","timed_out":false,"session_exited":false,"changed":true}}}` - - cmd := exec.Command( - scriptPath, - "run", - "--workspace", "ws-secret", - "--assistant", "codex", - "--prompt", "test prompt", - "--wait-timeout", "1s", - "--idle-threshold", "1s", - ) - env := os.Environ() - env = withEnv(env, "PATH", fakeBinDir+":"+os.Getenv("PATH")) - env = withEnv(env, "FAKE_AMUX_RUN_JSON", runJSON) - cmd.Env = env - out, err := cmd.Output() - if err != nil { - t.Fatalf("openclaw-step.sh run failed: %v", err) - } - - var payload map[string]any - if err := json.Unmarshal(out, &payload); err != nil { - t.Fatalf("decode json: %v\nraw: %s", err, string(out)) - } - - summary, _ := payload["summary"].(string) - if strings.Contains(summary, "ghp_abcde1234567890") || strings.Contains(summary, "sk-ant-api1-abcdefghijklmnopqrstuv123456") { - t.Fatalf("summary leaked secret: %q", summary) - } - channel, ok := payload["channel"].(map[string]any) - if !ok { - t.Fatalf("channel missing or wrong type: %T", payload["channel"]) - } - msg, _ := channel["message"].(string) - if strings.Contains(msg, "Bearer sk-ant-api1-abcdefghijklmnopqrstuv123456") || strings.Contains(msg, "SECRET=supersecretvalue123456") { - t.Fatalf("openclaw.message leaked secret: %q", msg) - } -} diff --git a/internal/cli/openclaw_step_script_wrapped_summary_test.go b/internal/cli/openclaw_step_script_wrapped_summary_test.go deleted file mode 100644 index fdefad93..00000000 --- a/internal/cli/openclaw_step_script_wrapped_summary_test.go +++ /dev/null @@ -1,490 +0,0 @@ -package cli - -import ( - "encoding/json" - "os" - "os/exec" - "path/filepath" - "strings" - "testing" -) - -func TestOpenClawStepScriptRun_RebuildsWrappedBulletSummaryFromDelta(t *testing.T) { - requireBinary(t, "jq") - requireBinary(t, "bash") - - scriptPath := filepath.Join("..", "..", "skills", "amux", "scripts", "openclaw-step.sh") - fakeBinDir := t.TempDir() - fakeAmuxPath := filepath.Join(fakeBinDir, "amux") - if err := os.WriteFile(fakeAmuxPath, []byte(`#!/usr/bin/env bash -set -euo pipefail -if [[ "${1:-}" == "--json" ]]; then - shift -fi -if [[ "${1:-}" == "agent" && "${2:-}" == "run" ]]; then - printf '%s' "${FAKE_AMUX_RUN_JSON:?missing FAKE_AMUX_RUN_JSON}" - exit 0 -fi -echo "unexpected args: $*" >&2 -exit 2 -`), 0o755); err != nil { - t.Fatalf("write fake amux: %v", err) - } - - runJSON := `{"ok":true,"data":{"session_name":"sess-wrap","agent_id":"agent-wrap","workspace_id":"ws-wrap","assistant":"codex","response":{"status":"idle","latest_line":"output tracking.","summary":"output tracking.","delta":"- Added NOTES.md with one mobile DX tip (adb reverse for Android emulator\nlocalhost mapping): NOTES.md","needs_input":false,"input_hint":"","timed_out":false,"session_exited":false,"changed":true}}}` - - cmd := exec.Command( - scriptPath, - "run", - "--workspace", "ws-wrap", - "--assistant", "codex", - "--prompt", "test prompt", - "--wait-timeout", "1s", - "--idle-threshold", "1s", - ) - env := os.Environ() - env = withEnv(env, "PATH", fakeBinDir+":"+os.Getenv("PATH")) - env = withEnv(env, "FAKE_AMUX_RUN_JSON", runJSON) - cmd.Env = env - out, err := cmd.Output() - if err != nil { - t.Fatalf("openclaw-step.sh run failed: %v", err) - } - - var payload map[string]any - if err := json.Unmarshal(out, &payload); err != nil { - t.Fatalf("decode json: %v\nraw: %s", err, string(out)) - } - - summary, _ := payload["summary"].(string) - if !strings.Contains(summary, "Added NOTES.md with one mobile DX tip") || !strings.Contains(summary, "localhost mapping): NOTES.md") { - t.Fatalf("summary = %q, expected rebuilt wrapped bullet summary", summary) - } -} - -func TestOpenClawStepScriptRun_AvoidsFileOnlyBulletSummary(t *testing.T) { - requireBinary(t, "jq") - requireBinary(t, "bash") - - scriptPath := filepath.Join("..", "..", "skills", "amux", "scripts", "openclaw-step.sh") - fakeBinDir := t.TempDir() - fakeAmuxPath := filepath.Join(fakeBinDir, "amux") - if err := os.WriteFile(fakeAmuxPath, []byte(`#!/usr/bin/env bash -set -euo pipefail -if [[ "${1:-}" == "--json" ]]; then - shift -fi -if [[ "${1:-}" == "agent" && "${2:-}" == "run" ]]; then - printf '%s' "${FAKE_AMUX_RUN_JSON:?missing FAKE_AMUX_RUN_JSON}" - exit 0 -fi -echo "unexpected args: $*" >&2 -exit 2 -`), 0o755); err != nil { - t.Fatalf("write fake amux: %v", err) - } - - runJSON := `{"ok":true,"data":{"session_name":"sess-file-list","agent_id":"agent-file-list","workspace_id":"ws-file-list","assistant":"codex","response":{"status":"idle","latest_line":"- NOTES.md","summary":"- NOTES.md","delta":"Updated both docs:\n- Added run/build instructions to README.md.\n- Added NOTES.md with one mobile DX tip about combining app run + logs.\nFiles changed:\n- README.md\n- NOTES.md","needs_input":false,"input_hint":"","timed_out":false,"session_exited":false,"changed":true}}}` - - cmd := exec.Command( - scriptPath, - "run", - "--workspace", "ws-file-list", - "--assistant", "codex", - "--prompt", "test prompt", - "--wait-timeout", "1s", - "--idle-threshold", "1s", - ) - env := os.Environ() - env = withEnv(env, "PATH", fakeBinDir+":"+os.Getenv("PATH")) - env = withEnv(env, "FAKE_AMUX_RUN_JSON", runJSON) - cmd.Env = env - out, err := cmd.Output() - if err != nil { - t.Fatalf("openclaw-step.sh run failed: %v", err) - } - - var payload map[string]any - if err := json.Unmarshal(out, &payload); err != nil { - t.Fatalf("decode json: %v\nraw: %s", err, string(out)) - } - - summary, _ := payload["summary"].(string) - if summary == "- NOTES.md" || !strings.Contains(summary, "Added NOTES.md with one mobile DX tip") { - t.Fatalf("summary = %q, expected non-fragment descriptive summary", summary) - } -} - -func TestOpenClawStepScriptRun_PrefersNonColonDetailSummary(t *testing.T) { - requireBinary(t, "jq") - requireBinary(t, "bash") - - scriptPath := filepath.Join("..", "..", "skills", "amux", "scripts", "openclaw-step.sh") - fakeBinDir := t.TempDir() - fakeAmuxPath := filepath.Join(fakeBinDir, "amux") - if err := os.WriteFile(fakeAmuxPath, []byte(`#!/usr/bin/env bash -set -euo pipefail -if [[ "${1:-}" == "--json" ]]; then - shift -fi -if [[ "${1:-}" == "agent" && "${2:-}" == "run" ]]; then - printf '%s' "${FAKE_AMUX_RUN_JSON:?missing FAKE_AMUX_RUN_JSON}" - exit 0 -fi -echo "unexpected args: $*" >&2 -exit 2 -`), 0o755); err != nil { - t.Fatalf("write fake amux: %v", err) - } - - runJSON := `{"ok":true,"data":{"session_name":"sess-colon","agent_id":"agent-colon","workspace_id":"ws-colon","assistant":"codex","response":{"status":"idle","latest_line":"output tracking.","summary":"output tracking.","delta":"• Added one concise status line to NOTES.md:\n- Status: docs updated and ready.","needs_input":false,"input_hint":"","timed_out":false,"session_exited":false,"changed":true}}}` - - cmd := exec.Command( - scriptPath, - "run", - "--workspace", "ws-colon", - "--assistant", "codex", - "--prompt", "test prompt", - "--wait-timeout", "1s", - "--idle-threshold", "1s", - ) - env := os.Environ() - env = withEnv(env, "PATH", fakeBinDir+":"+os.Getenv("PATH")) - env = withEnv(env, "FAKE_AMUX_RUN_JSON", runJSON) - cmd.Env = env - out, err := cmd.Output() - if err != nil { - t.Fatalf("openclaw-step.sh run failed: %v", err) - } - - var payload map[string]any - if err := json.Unmarshal(out, &payload); err != nil { - t.Fatalf("decode json: %v\nraw: %s", err, string(out)) - } - - summary, _ := payload["summary"].(string) - if strings.HasSuffix(summary, ":") || !strings.Contains(summary, "Status: docs updated and ready.") { - t.Fatalf("summary = %q, expected detail line instead of trailing-colon heading", summary) - } -} - -func TestOpenClawStepScriptRun_KeepsQuotedCommaSummaryText(t *testing.T) { - requireBinary(t, "jq") - requireBinary(t, "bash") - - scriptPath := filepath.Join("..", "..", "skills", "amux", "scripts", "openclaw-step.sh") - fakeBinDir := t.TempDir() - fakeAmuxPath := filepath.Join(fakeBinDir, "amux") - if err := os.WriteFile(fakeAmuxPath, []byte(`#!/usr/bin/env bash -set -euo pipefail -if [[ "${1:-}" == "--json" ]]; then - shift -fi -if [[ "${1:-}" == "agent" && "${2:-}" == "run" ]]; then - printf '%s' "${FAKE_AMUX_RUN_JSON:?missing FAKE_AMUX_RUN_JSON}" - exit 0 -fi -echo "unexpected args: $*" >&2 -exit 2 -`), 0o755); err != nil { - t.Fatalf("write fake amux: %v", err) - } - - runJSON := `{"ok":true,"data":{"session_name":"sess-quoted","agent_id":"agent-quoted","workspace_id":"ws-quoted","assistant":"codex","response":{"status":"idle","latest_line":"Added \"foo\", \"bar\" and updated README.md","summary":"Added \"foo\", \"bar\" and updated README.md","delta":"Added \"foo\", \"bar\" and updated README.md","needs_input":false,"input_hint":"","timed_out":false,"session_exited":false,"changed":true}}}` - - cmd := exec.Command( - scriptPath, - "run", - "--workspace", "ws-quoted", - "--assistant", "codex", - "--prompt", "test prompt", - "--wait-timeout", "1s", - "--idle-threshold", "1s", - ) - env := os.Environ() - env = withEnv(env, "PATH", fakeBinDir+":"+os.Getenv("PATH")) - env = withEnv(env, "FAKE_AMUX_RUN_JSON", runJSON) - cmd.Env = env - out, err := cmd.Output() - if err != nil { - t.Fatalf("openclaw-step.sh run failed: %v", err) - } - - var payload map[string]any - if err := json.Unmarshal(out, &payload); err != nil { - t.Fatalf("decode json: %v\nraw: %s", err, string(out)) - } - - summary, _ := payload["summary"].(string) - if !strings.Contains(summary, `Added "foo", "bar" and updated README.md`) { - t.Fatalf("summary = %q, expected quoted comma text to be preserved", summary) - } -} - -func TestOpenClawStepScriptRun_KeepsQuotedColonSummaryText(t *testing.T) { - requireBinary(t, "jq") - requireBinary(t, "bash") - - scriptPath := filepath.Join("..", "..", "skills", "amux", "scripts", "openclaw-step.sh") - fakeBinDir := t.TempDir() - fakeAmuxPath := filepath.Join(fakeBinDir, "amux") - if err := os.WriteFile(fakeAmuxPath, []byte(`#!/usr/bin/env bash -set -euo pipefail -if [[ "${1:-}" == "--json" ]]; then - shift -fi -if [[ "${1:-}" == "agent" && "${2:-}" == "run" ]]; then - printf '%s' "${FAKE_AMUX_RUN_JSON:?missing FAKE_AMUX_RUN_JSON}" - exit 0 -fi -echo "unexpected args: $*" >&2 -exit 2 -`), 0o755); err != nil { - t.Fatalf("write fake amux: %v", err) - } - - runJSON := `{"ok":true,"data":{"session_name":"sess-quoted-colon","agent_id":"agent-quoted-colon","workspace_id":"ws-quoted-colon","assistant":"codex","response":{"status":"idle","latest_line":"Added \"foo\", \"bar\": updated docs","summary":"Added \"foo\", \"bar\": updated docs","delta":"Added \"foo\", \"bar\": updated docs","needs_input":false,"input_hint":"","timed_out":false,"session_exited":false,"changed":true}}}` - - cmd := exec.Command( - scriptPath, - "run", - "--workspace", "ws-quoted-colon", - "--assistant", "codex", - "--prompt", "test prompt", - "--wait-timeout", "1s", - "--idle-threshold", "1s", - ) - env := os.Environ() - env = withEnv(env, "PATH", fakeBinDir+":"+os.Getenv("PATH")) - env = withEnv(env, "FAKE_AMUX_RUN_JSON", runJSON) - cmd.Env = env - out, err := cmd.Output() - if err != nil { - t.Fatalf("openclaw-step.sh run failed: %v", err) - } - - var payload map[string]any - if err := json.Unmarshal(out, &payload); err != nil { - t.Fatalf("decode json: %v\nraw: %s", err, string(out)) - } - - summary, _ := payload["summary"].(string) - if !strings.Contains(summary, `Added "foo", "bar": updated docs`) { - t.Fatalf("summary = %q, expected quoted colon text to be preserved", summary) - } -} - -func TestOpenClawStepScriptRun_KeepsTrailingQuoteSummaryText(t *testing.T) { - requireBinary(t, "jq") - requireBinary(t, "bash") - - scriptPath := filepath.Join("..", "..", "skills", "amux", "scripts", "openclaw-step.sh") - fakeBinDir := t.TempDir() - fakeAmuxPath := filepath.Join(fakeBinDir, "amux") - if err := os.WriteFile(fakeAmuxPath, []byte(`#!/usr/bin/env bash -set -euo pipefail -if [[ "${1:-}" == "--json" ]]; then - shift -fi -if [[ "${1:-}" == "agent" && "${2:-}" == "run" ]]; then - printf '%s' "${FAKE_AMUX_RUN_JSON:?missing FAKE_AMUX_RUN_JSON}" - exit 0 -fi -echo "unexpected args: $*" >&2 -exit 2 -`), 0o755); err != nil { - t.Fatalf("write fake amux: %v", err) - } - - runJSON := `{"ok":true,"data":{"session_name":"sess-trailing-quote","agent_id":"agent-trailing-quote","workspace_id":"ws-trailing-quote","assistant":"codex","response":{"status":"idle","latest_line":"Updated value to \"on\"","summary":"Updated value to \"on\"","delta":"Updated value to \"on\"","needs_input":false,"input_hint":"","timed_out":false,"session_exited":false,"changed":true}}}` - - cmd := exec.Command( - scriptPath, - "run", - "--workspace", "ws-trailing-quote", - "--assistant", "codex", - "--prompt", "test prompt", - "--wait-timeout", "1s", - "--idle-threshold", "1s", - ) - env := os.Environ() - env = withEnv(env, "PATH", fakeBinDir+":"+os.Getenv("PATH")) - env = withEnv(env, "FAKE_AMUX_RUN_JSON", runJSON) - cmd.Env = env - out, err := cmd.Output() - if err != nil { - t.Fatalf("openclaw-step.sh run failed: %v", err) - } - - var payload map[string]any - if err := json.Unmarshal(out, &payload); err != nil { - t.Fatalf("decode json: %v\nraw: %s", err, string(out)) - } - - summary, _ := payload["summary"].(string) - if summary != `Updated value to "on"` { - t.Fatalf("summary = %q, expected trailing quote to be preserved", summary) - } -} - -func TestOpenClawStepScriptRun_KeepsTrailingBraceSummaryText(t *testing.T) { - requireBinary(t, "jq") - requireBinary(t, "bash") - - scriptPath := filepath.Join("..", "..", "skills", "amux", "scripts", "openclaw-step.sh") - fakeBinDir := t.TempDir() - fakeAmuxPath := filepath.Join(fakeBinDir, "amux") - if err := os.WriteFile(fakeAmuxPath, []byte(`#!/usr/bin/env bash -set -euo pipefail -if [[ "${1:-}" == "--json" ]]; then - shift -fi -if [[ "${1:-}" == "agent" && "${2:-}" == "run" ]]; then - printf '%s' "${FAKE_AMUX_RUN_JSON:?missing FAKE_AMUX_RUN_JSON}" - exit 0 -fi -echo "unexpected args: $*" >&2 -exit 2 -`), 0o755); err != nil { - t.Fatalf("write fake amux: %v", err) - } - - runJSON := `{"ok":true,"data":{"session_name":"sess-trailing-brace","agent_id":"agent-trailing-brace","workspace_id":"ws-trailing-brace","assistant":"codex","response":{"status":"idle","latest_line":"Use map[string]any{}","summary":"Use map[string]any{}","delta":"Use map[string]any{}","needs_input":false,"input_hint":"","timed_out":false,"session_exited":false,"changed":true}}}` - - cmd := exec.Command( - scriptPath, - "run", - "--workspace", "ws-trailing-brace", - "--assistant", "codex", - "--prompt", "test prompt", - "--wait-timeout", "1s", - "--idle-threshold", "1s", - ) - env := os.Environ() - env = withEnv(env, "PATH", fakeBinDir+":"+os.Getenv("PATH")) - env = withEnv(env, "FAKE_AMUX_RUN_JSON", runJSON) - cmd.Env = env - out, err := cmd.Output() - if err != nil { - t.Fatalf("openclaw-step.sh run failed: %v", err) - } - - var payload map[string]any - if err := json.Unmarshal(out, &payload); err != nil { - t.Fatalf("decode json: %v\nraw: %s", err, string(out)) - } - - summary, _ := payload["summary"].(string) - if summary != `Use map[string]any{}` { - t.Fatalf("summary = %q, expected trailing brace to be preserved", summary) - } -} - -func TestOpenClawStepScriptRun_PreservesEscapedQuoteSequences(t *testing.T) { - requireBinary(t, "jq") - requireBinary(t, "bash") - - scriptPath := filepath.Join("..", "..", "skills", "amux", "scripts", "openclaw-step.sh") - fakeBinDir := t.TempDir() - fakeAmuxPath := filepath.Join(fakeBinDir, "amux") - if err := os.WriteFile(fakeAmuxPath, []byte(`#!/usr/bin/env bash -set -euo pipefail -if [[ "${1:-}" == "--json" ]]; then - shift -fi -if [[ "${1:-}" == "agent" && "${2:-}" == "run" ]]; then - printf '%s' "${FAKE_AMUX_RUN_JSON:?missing FAKE_AMUX_RUN_JSON}" - exit 0 -fi -echo "unexpected args: $*" >&2 -exit 2 -`), 0o755); err != nil { - t.Fatalf("write fake amux: %v", err) - } - - runJSON := `{"ok":true,"data":{"session_name":"sess-escaped-quote","agent_id":"agent-escaped-quote","workspace_id":"ws-escaped-quote","assistant":"codex","response":{"status":"idle","latest_line":"Escaped quote sequence: \\\"value\\\"","summary":"Escaped quote sequence: \\\"value\\\"","delta":"Escaped quote sequence: \\\"value\\\"","needs_input":false,"input_hint":"","timed_out":false,"session_exited":false,"changed":true}}}` - - cmd := exec.Command( - scriptPath, - "run", - "--workspace", "ws-escaped-quote", - "--assistant", "codex", - "--prompt", "test prompt", - "--wait-timeout", "1s", - "--idle-threshold", "1s", - ) - env := os.Environ() - env = withEnv(env, "PATH", fakeBinDir+":"+os.Getenv("PATH")) - env = withEnv(env, "FAKE_AMUX_RUN_JSON", runJSON) - cmd.Env = env - out, err := cmd.Output() - if err != nil { - t.Fatalf("openclaw-step.sh run failed: %v", err) - } - - var payload map[string]any - if err := json.Unmarshal(out, &payload); err != nil { - t.Fatalf("decode json: %v\nraw: %s", err, string(out)) - } - - summary, _ := payload["summary"].(string) - if summary != `Escaped quote sequence: \"value\"` { - t.Fatalf("summary = %q, expected escaped quote sequence to be preserved", summary) - } -} - -func TestOpenClawStepScriptRun_DoesNotCarryWrappedFragmentAcrossSections(t *testing.T) { - requireBinary(t, "jq") - requireBinary(t, "bash") - - scriptPath := filepath.Join("..", "..", "skills", "amux", "scripts", "openclaw-step.sh") - fakeBinDir := t.TempDir() - fakeAmuxPath := filepath.Join(fakeBinDir, "amux") - if err := os.WriteFile(fakeAmuxPath, []byte(`#!/usr/bin/env bash -set -euo pipefail -if [[ "${1:-}" == "--json" ]]; then - shift -fi -if [[ "${1:-}" == "agent" && "${2:-}" == "run" ]]; then - printf '%s' "${FAKE_AMUX_RUN_JSON:?missing FAKE_AMUX_RUN_JSON}" - exit 0 -fi -echo "unexpected args: $*" >&2 -exit 2 -`), 0o755); err != nil { - t.Fatalf("write fake amux: %v", err) - } - - runJSON := `{"ok":true,"data":{"session_name":"sess-fragment-reset","agent_id":"agent-fragment-reset","workspace_id":"ws-fragment-reset","assistant":"codex","response":{"status":"idle","latest_line":"output tracking.","summary":"output tracking.","delta":"- Updated README.md with setup docs.\nNotes:\nlocalhost mapping): NOTES.md","needs_input":false,"input_hint":"","timed_out":false,"session_exited":false,"changed":true}}}` - - cmd := exec.Command( - scriptPath, - "run", - "--workspace", "ws-fragment-reset", - "--assistant", "codex", - "--prompt", "test prompt", - "--wait-timeout", "1s", - "--idle-threshold", "1s", - ) - env := os.Environ() - env = withEnv(env, "PATH", fakeBinDir+":"+os.Getenv("PATH")) - env = withEnv(env, "FAKE_AMUX_RUN_JSON", runJSON) - cmd.Env = env - out, err := cmd.Output() - if err != nil { - t.Fatalf("openclaw-step.sh run failed: %v", err) - } - - var payload map[string]any - if err := json.Unmarshal(out, &payload); err != nil { - t.Fatalf("decode json: %v\nraw: %s", err, string(out)) - } - - summary, _ := payload["summary"].(string) - if !strings.Contains(summary, "Updated README.md with setup docs.") { - t.Fatalf("summary = %q, expected README update detail", summary) - } - if strings.Contains(summary, "localhost mapping): NOTES.md") { - t.Fatalf("summary = %q, expected stale wrapped fragment to be excluded", summary) - } -} diff --git a/internal/cli/openclaw_turn_script_channel_test.go b/internal/cli/openclaw_turn_script_channel_test.go deleted file mode 100644 index 2463d8ab..00000000 --- a/internal/cli/openclaw_turn_script_channel_test.go +++ /dev/null @@ -1,159 +0,0 @@ -package cli - -import ( - "encoding/json" - "os" - "os/exec" - "path/filepath" - "strings" - "testing" -) - -func TestOpenClawTurnScript_AddsChunkContinuationMetadata(t *testing.T) { - requireBinary(t, "jq") - requireBinary(t, "bash") - - scriptPath := filepath.Join("..", "..", "skills", "amux", "scripts", "openclaw-turn.sh") - fakeStepDir := t.TempDir() - fakeStepPath := filepath.Join(fakeStepDir, "fake-step.sh") - counterPath := filepath.Join(fakeStepDir, "counter.txt") - - if err := os.WriteFile(fakeStepPath, []byte(`#!/usr/bin/env bash -set -euo pipefail -count_file="${FAKE_STEP_COUNT_FILE:?missing FAKE_STEP_COUNT_FILE}" -count=0 -if [[ -f "$count_file" ]]; then - count="$(cat "$count_file")" -fi -count=$((count + 1)) -printf '%s' "$count" > "$count_file" -if [[ "$count" -eq 1 ]]; then - printf '%s' "${FAKE_STEP_1_JSON:?missing FAKE_STEP_1_JSON}" -else - printf '%s' "${FAKE_STEP_2_JSON:?missing FAKE_STEP_2_JSON}" -fi -`), 0o755); err != nil { - t.Fatalf("write fake step script: %v", err) - } - - step1 := `{"ok":true,"mode":"run","status":"timed_out","summary":"Warming up and gathering repository context.","agent_id":"agent-3","workspace_id":"ws-3","assistant":"codex","response":{"substantive_output":false,"needs_input":false},"next_action":"Continue.","suggested_command":"skills/amux/scripts/openclaw-step.sh send --agent agent-3 --text \"Continue\" --enter --wait-timeout 60s --idle-threshold 10s"}` - step2 := `{"ok":true,"mode":"send","status":"idle","summary":"Implemented a long list of refactors and added tests and docs and validation so the final OpenClaw update should be split into multiple chunks for readability.","agent_id":"agent-3","workspace_id":"ws-3","assistant":"codex","response":{"substantive_output":true,"needs_input":false},"next_action":"Review patch.","suggested_command":""}` - - cmd := exec.Command( - scriptPath, - "run", - "--workspace", "ws-3", - "--assistant", "codex", - "--prompt", "Large update", - "--max-steps", "3", - "--turn-budget", "120", - "--wait-timeout", "1s", - "--idle-threshold", "1s", - ) - env := os.Environ() - env = withEnv(env, "FAKE_STEP_COUNT_FILE", counterPath) - env = withEnv(env, "FAKE_STEP_1_JSON", step1) - env = withEnv(env, "FAKE_STEP_2_JSON", step2) - env = withEnv(env, "OPENCLAW_TURN_STEP_SCRIPT", fakeStepPath) - env = withEnv(env, "OPENCLAW_TURN_CHUNK_CHARS", "80") - cmd.Env = env - out, err := cmd.Output() - if err != nil { - t.Fatalf("openclaw-turn.sh run failed: %v", err) - } - - var payload map[string]any - if err := json.Unmarshal(out, &payload); err != nil { - t.Fatalf("decode json: %v\nraw: %s", err, string(out)) - } - - channel, ok := payload["channel"].(map[string]any) - if !ok { - t.Fatalf("channel missing or wrong type: %T", payload["channel"]) - } - chunks, ok := channel["chunks"].([]any) - if !ok || len(chunks) < 2 { - t.Fatalf("openclaw.chunks expected at least 2 chunks: %#v", channel["chunks"]) - } - secondChunk, _ := chunks[1].(string) - if !strings.HasPrefix(secondChunk, "continued (2/") { - t.Fatalf("second chunk missing continuation prefix: %q", secondChunk) - } -} - -func TestOpenClawTurnScript_UsesSiblingStepScriptWhenInvokedOutsideRepoRoot(t *testing.T) { - requireBinary(t, "jq") - requireBinary(t, "bash") - - sourceScriptPath := filepath.Join("..", "..", "skills", "amux", "scripts", "openclaw-turn.sh") - src, err := os.ReadFile(sourceScriptPath) - if err != nil { - t.Fatalf("read source script: %v", err) - } - - scriptDir := t.TempDir() - runDir := t.TempDir() - copiedScriptPath := filepath.Join(scriptDir, "openclaw-turn.sh") - if err := os.WriteFile(copiedScriptPath, src, 0o755); err != nil { - t.Fatalf("write copied script: %v", err) - } - - argsLog := filepath.Join(scriptDir, "step-args.log") - fakeStepPath := filepath.Join(scriptDir, "openclaw-step.sh") - if err := os.WriteFile(fakeStepPath, []byte(`#!/usr/bin/env bash -set -euo pipefail -printf '%s\n' "$*" > "${STEP_ARGS_LOG:?missing STEP_ARGS_LOG}" -printf '%s' '{"ok":true,"mode":"run","status":"idle","summary":"Sibling step used.","agent_id":"agent-sib","workspace_id":"ws-sib","assistant":"codex","response":{"substantive_output":true,"needs_input":false},"next_action":"","suggested_command":""}' -`), 0o755); err != nil { - t.Fatalf("write fake step script: %v", err) - } - - cmd := exec.Command( - copiedScriptPath, - "run", - "--workspace", "ws-sib", - "--assistant", "codex", - "--prompt", "test prompt", - "--max-steps", "1", - "--turn-budget", "30", - "--wait-timeout", "1s", - "--idle-threshold", "1s", - ) - cmd.Dir = runDir - env := os.Environ() - env = withEnv(env, "STEP_ARGS_LOG", argsLog) - cmd.Env = env - out, err := cmd.Output() - if err != nil { - t.Fatalf("openclaw-turn.sh run failed: %v", err) - } - - var payload map[string]any - if err := json.Unmarshal(out, &payload); err != nil { - t.Fatalf("decode json: %v\nraw: %s", err, string(out)) - } - if got, _ := payload["status"].(string); got != "idle" { - t.Fatalf("status = %q, want %q", got, "idle") - } - if got, _ := payload["overall_status"].(string); got != "completed" { - t.Fatalf("overall_status = %q, want %q", got, "completed") - } - - argsRaw, err := os.ReadFile(argsLog) - if err != nil { - t.Fatalf("read step args: %v", err) - } - args := strings.TrimSpace(string(argsRaw)) - if !strings.Contains(args, "run") || !strings.Contains(args, "--workspace ws-sib") { - t.Fatalf("step args = %q, expected run/workspace flags", args) - } - - quickActionMap, ok := payload["quick_action_map"].(map[string]any) - if !ok { - t.Fatalf("quick_action_map missing or wrong type: %T", payload["quick_action_map"]) - } - statusCmd, _ := quickActionMap["qa:status"].(string) - if !strings.HasPrefix(statusCmd, "skills/amux/scripts/openclaw-step.sh send --agent agent-sib") { - t.Fatalf("status quick action = %q, expected openclaw-step command", statusCmd) - } -} diff --git a/internal/cli/openclaw_turn_script_core_test.go b/internal/cli/openclaw_turn_script_core_test.go deleted file mode 100644 index bba0acbc..00000000 --- a/internal/cli/openclaw_turn_script_core_test.go +++ /dev/null @@ -1,405 +0,0 @@ -package cli - -import ( - "encoding/json" - "os" - "os/exec" - "path/filepath" - "strings" - "testing" -) - -func TestOpenClawTurnScript_CompletesAfterFollowupStep(t *testing.T) { - requireBinary(t, "jq") - requireBinary(t, "bash") - - scriptPath := filepath.Join("..", "..", "skills", "amux", "scripts", "openclaw-turn.sh") - fakeStepDir := t.TempDir() - fakeStepPath := filepath.Join(fakeStepDir, "fake-step.sh") - counterPath := filepath.Join(fakeStepDir, "counter.txt") - - if err := os.WriteFile(fakeStepPath, []byte(`#!/usr/bin/env bash -set -euo pipefail -count_file="${FAKE_STEP_COUNT_FILE:?missing FAKE_STEP_COUNT_FILE}" -count=0 -if [[ -f "$count_file" ]]; then - count="$(cat "$count_file")" -fi -count=$((count + 1)) -printf '%s' "$count" > "$count_file" -case "$count" in - 1) printf '%s' "${FAKE_STEP_1_JSON:?missing FAKE_STEP_1_JSON}" ;; - *) printf '%s' "${FAKE_STEP_2_JSON:?missing FAKE_STEP_2_JSON}" ;; -esac -`), 0o755); err != nil { - t.Fatalf("write fake step script: %v", err) - } - - step1 := `{"ok":true,"mode":"run","status":"timed_out","summary":"Timed out waiting for first visible output; agent may still be starting.","agent_id":"agent-1","workspace_id":"ws-1","assistant":"codex","response":{"substantive_output":false,"needs_input":false},"next_action":"Run one focused follow-up step.","suggested_command":"skills/amux/scripts/openclaw-step.sh send --agent agent-1 --text \"Continue\" --enter --wait-timeout 60s --idle-threshold 10s"}` - step2 := `{"ok":true,"mode":"send","status":"idle","summary":"Refactor applied and tests passed.","agent_id":"agent-1","workspace_id":"ws-1","assistant":"codex","response":{"substantive_output":true,"needs_input":false},"next_action":"Review uncommitted changes.","suggested_command":""}` - - cmd := exec.Command( - scriptPath, - "run", - "--workspace", "ws-1", - "--assistant", "codex", - "--prompt", "Improve debt hotspots", - "--max-steps", "3", - "--turn-budget", "120", - "--wait-timeout", "1s", - "--idle-threshold", "1s", - ) - env := os.Environ() - env = withEnv(env, "FAKE_STEP_COUNT_FILE", counterPath) - env = withEnv(env, "FAKE_STEP_1_JSON", step1) - env = withEnv(env, "FAKE_STEP_2_JSON", step2) - env = withEnv(env, "OPENCLAW_TURN_STEP_SCRIPT", fakeStepPath) - cmd.Env = env - out, err := cmd.Output() - if err != nil { - t.Fatalf("openclaw-turn.sh run failed: %v", err) - } - - var payload map[string]any - if err := json.Unmarshal(out, &payload); err != nil { - t.Fatalf("decode json: %v\nraw: %s", err, string(out)) - } - - if got, _ := payload["ok"].(bool); !got { - t.Fatalf("ok = %v, want true", got) - } - if got, _ := payload["overall_status"].(string); got != "completed" { - t.Fatalf("overall_status = %q, want %q", got, "completed") - } - if got, _ := payload["status"].(string); got != "idle" { - t.Fatalf("status = %q, want %q", got, "idle") - } - if got, _ := payload["verbosity"].(string); got != "normal" { - t.Fatalf("verbosity = %q, want %q", got, "normal") - } - if got, _ := payload["steps_used"].(float64); got != 2 { - t.Fatalf("steps_used = %v, want 2", got) - } - if got, _ := payload["progress_percent"].(float64); got != 66 { - t.Fatalf("progress_percent = %v, want 66", got) - } - events, ok := payload["events"].([]any) - if !ok || len(events) != 2 { - t.Fatalf("events = %#v, want len=2", payload["events"]) - } - milestones, ok := payload["milestones"].([]any) - if !ok || len(milestones) != 2 { - t.Fatalf("milestones = %#v, want len=2", payload["milestones"]) - } - channel, ok := payload["channel"].(map[string]any) - if !ok { - t.Fatalf("channel missing or wrong type: %T", payload["channel"]) - } - chunks, ok := channel["chunks"].([]any) - if !ok || len(chunks) == 0 { - t.Fatalf("openclaw.chunks missing or empty: %#v", channel["chunks"]) - } - chunksMeta, ok := channel["chunks_meta"].([]any) - if !ok || len(chunksMeta) == 0 { - t.Fatalf("openclaw.chunks_meta missing or empty: %#v", channel["chunks_meta"]) - } - progressUpdates, ok := payload["progress_updates"].([]any) - if !ok || len(progressUpdates) != 2 { - t.Fatalf("progress_updates = %#v, want len=2", payload["progress_updates"]) - } - delivery, ok := payload["delivery"].(map[string]any) - if !ok { - t.Fatalf("delivery missing or wrong type: %T", payload["delivery"]) - } - if got, _ := delivery["action"].(string); got != "send" { - t.Fatalf("delivery.action = %q, want %q", got, "send") - } - if got, _ := delivery["drop_pending"].(bool); !got { - t.Fatalf("delivery.drop_pending = %v, want true", got) - } - quickActions, ok := payload["quick_actions"].([]any) - if !ok || len(quickActions) == 0 { - t.Fatalf("quick_actions missing or empty: %#v", payload["quick_actions"]) - } - quickActionMap, ok := payload["quick_action_map"].(map[string]any) - if !ok || len(quickActionMap) == 0 { - t.Fatalf("quick_action_map missing or empty: %#v", payload["quick_action_map"]) - } - quickActionPrompts, ok := payload["quick_action_prompts"].(map[string]any) - if !ok || len(quickActionPrompts) == 0 { - t.Fatalf("quick_action_prompts missing or empty: %#v", payload["quick_action_prompts"]) - } - for _, actionRaw := range quickActions { - action, ok := actionRaw.(map[string]any) - if !ok { - t.Fatalf("quick action has wrong type: %T", actionRaw) - } - callbackData, _ := action["callback_data"].(string) - if !strings.HasPrefix(callbackData, "qa:") { - t.Fatalf("callback_data = %q, want qa: prefix", callbackData) - } - if len(callbackData) > 64 { - t.Fatalf("callback_data too long (%d): %q", len(callbackData), callbackData) - } - } - channelProgressUpdates, ok := channel["progress_updates"].([]any) - if !ok || len(channelProgressUpdates) != 2 { - t.Fatalf("openclaw.progress_updates = %#v, want len=2", channel["progress_updates"]) - } - if got, _ := channel["verbosity"].(string); got != "normal" { - t.Fatalf("openclaw.verbosity = %q, want %q", got, "normal") - } - if got, _ := channel["inline_buttons_scope"].(string); got != "allowlist" { - t.Fatalf("openclaw.inline_buttons_scope = %q, want allowlist", got) - } - if got, _ := channel["inline_buttons_enabled"].(bool); !got { - t.Fatalf("openclaw.inline_buttons_enabled = %v, want true", got) - } - if got, _ := channel["callback_data_max_bytes"].(float64); got != 64 { - t.Fatalf("openclaw.callback_data_max_bytes = %v, want 64", got) - } - inlineButtons, ok := channel["inline_buttons"].([]any) - if !ok || len(inlineButtons) == 0 { - t.Fatalf("openclaw.inline_buttons missing or empty: %#v", channel["inline_buttons"]) - } - actionTokens, ok := channel["action_tokens"].([]any) - if !ok || len(actionTokens) == 0 { - t.Fatalf("openclaw.action_tokens missing or empty: %#v", channel["action_tokens"]) - } - actionsFallback, _ := channel["actions_fallback"].(string) - if !strings.Contains(actionsFallback, "qa:") { - t.Fatalf("openclaw.actions_fallback = %q, expected qa: token list", actionsFallback) - } -} - -func TestOpenClawTurnScript_CoalescesDuplicateMilestonesAndStopsOnTimeoutStreak(t *testing.T) { - requireBinary(t, "jq") - requireBinary(t, "bash") - - scriptPath := filepath.Join("..", "..", "skills", "amux", "scripts", "openclaw-turn.sh") - fakeStepDir := t.TempDir() - fakeStepPath := filepath.Join(fakeStepDir, "fake-step.sh") - counterPath := filepath.Join(fakeStepDir, "counter.txt") - - if err := os.WriteFile(fakeStepPath, []byte(`#!/usr/bin/env bash -set -euo pipefail -count_file="${FAKE_STEP_COUNT_FILE:?missing FAKE_STEP_COUNT_FILE}" -count=0 -if [[ -f "$count_file" ]]; then - count="$(cat "$count_file")" -fi -count=$((count + 1)) -printf '%s' "$count" > "$count_file" -if [[ "$count" -eq 1 ]]; then - printf '%s' "${FAKE_STEP_1_JSON:?missing FAKE_STEP_1_JSON}" -else - printf '%s' "${FAKE_STEP_2_JSON:?missing FAKE_STEP_2_JSON}" -fi -`), 0o755); err != nil { - t.Fatalf("write fake step script: %v", err) - } - - step1 := `{"ok":true,"mode":"run","status":"timed_out","summary":"Agent warming up.","agent_id":"agent-2","workspace_id":"ws-2","assistant":"codex","response":{"substantive_output":false,"needs_input":false},"next_action":"Retry with a short follow-up.","suggested_command":"skills/amux/scripts/openclaw-step.sh send --agent agent-2 --text \"Continue\" --enter --wait-timeout 60s --idle-threshold 10s"}` - step2 := `{"ok":true,"mode":"send","status":"timed_out","summary":"Agent warming up.","agent_id":"agent-2","workspace_id":"ws-2","assistant":"codex","response":{"substantive_output":false,"needs_input":false},"next_action":"Retry with a short follow-up.","suggested_command":"skills/amux/scripts/openclaw-step.sh send --agent agent-2 --text \"Continue\" --enter --wait-timeout 60s --idle-threshold 10s"}` - - cmd := exec.Command( - scriptPath, - "run", - "--workspace", "ws-2", - "--assistant", "codex", - "--prompt", "Investigate progress", - "--max-steps", "4", - "--turn-budget", "120", - "--wait-timeout", "1s", - "--idle-threshold", "1s", - ) - env := os.Environ() - env = withEnv(env, "FAKE_STEP_COUNT_FILE", counterPath) - env = withEnv(env, "FAKE_STEP_1_JSON", step1) - env = withEnv(env, "FAKE_STEP_2_JSON", step2) - env = withEnv(env, "OPENCLAW_TURN_STEP_SCRIPT", fakeStepPath) - cmd.Env = env - out, err := cmd.Output() - if err != nil { - t.Fatalf("openclaw-turn.sh run failed: %v", err) - } - - var payload map[string]any - if err := json.Unmarshal(out, &payload); err != nil { - t.Fatalf("decode json: %v\nraw: %s", err, string(out)) - } - - if got, _ := payload["overall_status"].(string); got != "timed_out" { - t.Fatalf("overall_status = %q, want %q", got, "timed_out") - } - if got, _ := payload["steps_used"].(float64); got != 2 { - t.Fatalf("steps_used = %v, want 2", got) - } - if got, _ := payload["progress_percent"].(float64); got != 50 { - t.Fatalf("progress_percent = %v, want 50", got) - } - if got, _ := payload["timeout_streak"].(float64); got != 2 { - t.Fatalf("timeout_streak = %v, want 2", got) - } - if got, _ := payload["budget_exhausted"].(bool); got { - t.Fatalf("budget_exhausted = true, want false") - } - events, ok := payload["events"].([]any) - if !ok || len(events) != 2 { - t.Fatalf("events = %#v, want len=2", payload["events"]) - } - milestones, ok := payload["milestones"].([]any) - if !ok || len(milestones) != 1 { - t.Fatalf("milestones = %#v, want len=1 (coalesced)", payload["milestones"]) - } - delivery, ok := payload["delivery"].(map[string]any) - if !ok { - t.Fatalf("delivery missing or wrong type: %T", payload["delivery"]) - } - if got, _ := delivery["action"].(string); got != "edit" { - t.Fatalf("delivery.action = %q, want %q", got, "edit") - } - if got, _ := delivery["replace_previous"].(bool); !got { - t.Fatalf("delivery.replace_previous = %v, want true", got) - } - progressUpdates, ok := payload["progress_updates"].([]any) - if !ok || len(progressUpdates) != 1 { - t.Fatalf("progress_updates = %#v, want len=1", payload["progress_updates"]) - } -} - -func TestOpenClawTurnScript_QuietVerbositySuppressesExtraSections(t *testing.T) { - requireBinary(t, "jq") - requireBinary(t, "bash") - - scriptPath := filepath.Join("..", "..", "skills", "amux", "scripts", "openclaw-turn.sh") - fakeStepDir := t.TempDir() - fakeStepPath := filepath.Join(fakeStepDir, "fake-step.sh") - counterPath := filepath.Join(fakeStepDir, "counter.txt") - - if err := os.WriteFile(fakeStepPath, []byte(`#!/usr/bin/env bash -set -euo pipefail -count_file="${FAKE_STEP_COUNT_FILE:?missing FAKE_STEP_COUNT_FILE}" -count=0 -if [[ -f "$count_file" ]]; then - count="$(cat "$count_file")" -fi -count=$((count + 1)) -printf '%s' "$count" > "$count_file" -printf '%s' "${FAKE_STEP_1_JSON:?missing FAKE_STEP_1_JSON}" -`), 0o755); err != nil { - t.Fatalf("write fake step script: %v", err) - } - - step1 := `{"ok":true,"mode":"run","status":"needs_input","summary":"Need approval to continue.","agent_id":"agent-q","workspace_id":"ws-q","assistant":"codex","response":{"substantive_output":true,"needs_input":true},"next_action":"Choose A or B.","suggested_command":"skills/amux/scripts/openclaw-step.sh send --agent agent-q --text \"A\" --enter --wait-timeout 60s --idle-threshold 10s"}` - - cmd := exec.Command( - scriptPath, - "run", - "--workspace", "ws-q", - "--assistant", "codex", - "--prompt", "Need input", - "--max-steps", "2", - "--turn-budget", "120", - "--wait-timeout", "1s", - "--idle-threshold", "1s", - ) - env := os.Environ() - env = withEnv(env, "FAKE_STEP_COUNT_FILE", counterPath) - env = withEnv(env, "FAKE_STEP_1_JSON", step1) - env = withEnv(env, "OPENCLAW_TURN_STEP_SCRIPT", fakeStepPath) - env = withEnv(env, "OPENCLAW_TURN_VERBOSITY", "quiet") - cmd.Env = env - out, err := cmd.Output() - if err != nil { - t.Fatalf("openclaw-turn.sh run failed: %v", err) - } - - var payload map[string]any - if err := json.Unmarshal(out, &payload); err != nil { - t.Fatalf("decode json: %v\nraw: %s", err, string(out)) - } - - if got, _ := payload["verbosity"].(string); got != "quiet" { - t.Fatalf("verbosity = %q, want quiet", got) - } - channel, ok := payload["channel"].(map[string]any) - if !ok { - t.Fatalf("channel missing or wrong type: %T", payload["channel"]) - } - msg, _ := channel["message"].(string) - if strings.Contains(msg, "Next:") || strings.Contains(msg, "Command:") || strings.Contains(msg, "Meta:") || strings.Contains(msg, "Progress:") { - t.Fatalf("openclaw.message should suppress extra sections in quiet mode: %q", msg) - } -} - -func TestOpenClawTurnScript_DisablesInlineButtonsWhenScopeOff(t *testing.T) { - requireBinary(t, "jq") - requireBinary(t, "bash") - - scriptPath := filepath.Join("..", "..", "skills", "amux", "scripts", "openclaw-turn.sh") - fakeStepDir := t.TempDir() - fakeStepPath := filepath.Join(fakeStepDir, "fake-step.sh") - counterPath := filepath.Join(fakeStepDir, "counter.txt") - - if err := os.WriteFile(fakeStepPath, []byte(`#!/usr/bin/env bash -set -euo pipefail -count_file="${FAKE_STEP_COUNT_FILE:?missing FAKE_STEP_COUNT_FILE}" -count=0 -if [[ -f "$count_file" ]]; then - count="$(cat "$count_file")" -fi -count=$((count + 1)) -printf '%s' "$count" > "$count_file" -printf '%s' "${FAKE_STEP_1_JSON:?missing FAKE_STEP_1_JSON}" -`), 0o755); err != nil { - t.Fatalf("write fake step script: %v", err) - } - - step1 := `{"ok":true,"mode":"run","status":"needs_input","summary":"Need approval to continue.","agent_id":"agent-inline-off","workspace_id":"ws-inline-off","assistant":"codex","response":{"substantive_output":true,"needs_input":true},"next_action":"Choose A or B.","suggested_command":"skills/amux/scripts/openclaw-step.sh send --agent agent-inline-off --text \"A\" --enter --wait-timeout 60s --idle-threshold 10s"}` - - cmd := exec.Command( - scriptPath, - "run", - "--workspace", "ws-inline-off", - "--assistant", "codex", - "--prompt", "Need input", - "--max-steps", "2", - "--turn-budget", "120", - "--wait-timeout", "1s", - "--idle-threshold", "1s", - ) - env := os.Environ() - env = withEnv(env, "FAKE_STEP_COUNT_FILE", counterPath) - env = withEnv(env, "FAKE_STEP_1_JSON", step1) - env = withEnv(env, "OPENCLAW_TURN_STEP_SCRIPT", fakeStepPath) - env = withEnv(env, "OPENCLAW_INLINE_BUTTONS_SCOPE", "off") - cmd.Env = env - out, err := cmd.Output() - if err != nil { - t.Fatalf("openclaw-turn.sh run failed: %v", err) - } - - var payload map[string]any - if err := json.Unmarshal(out, &payload); err != nil { - t.Fatalf("decode json: %v\nraw: %s", err, string(out)) - } - - channel, ok := payload["channel"].(map[string]any) - if !ok { - t.Fatalf("channel missing or wrong type: %T", payload["channel"]) - } - if got, _ := channel["inline_buttons_scope"].(string); got != "off" { - t.Fatalf("openclaw.inline_buttons_scope = %q, want off", got) - } - if got, _ := channel["inline_buttons_enabled"].(bool); got { - t.Fatalf("openclaw.inline_buttons_enabled = %v, want false", got) - } - if inlineButtons, ok := channel["inline_buttons"].([]any); !ok || len(inlineButtons) != 0 { - t.Fatalf("openclaw.inline_buttons = %#v, want empty", channel["inline_buttons"]) - } - quickActions, ok := payload["quick_actions"].([]any) - if !ok || len(quickActions) == 0 { - t.Fatalf("quick_actions missing or empty: %#v", payload["quick_actions"]) - } -} diff --git a/internal/cli/output.go b/internal/cli/output.go deleted file mode 100644 index 2ee9b031..00000000 --- a/internal/cli/output.go +++ /dev/null @@ -1,169 +0,0 @@ -package cli - -import ( - "encoding/json" - "fmt" - "io" - "sync" - "time" -) - -// Envelope wraps all --json responses. -type Envelope struct { - OK bool `json:"ok"` - Data any `json:"data"` - Error *ErrorInfo `json:"error"` - Meta Meta `json:"meta"` - SchemaVersion string `json:"schema_version"` - RequestID string `json:"request_id,omitempty"` - Command string `json:"command,omitempty"` -} - -// ErrorInfo describes an error in the JSON envelope. -type ErrorInfo struct { - Code string `json:"code"` - Message string `json:"message"` - Details any `json:"details,omitempty"` -} - -// Meta contains response metadata. -type Meta struct { - GeneratedAt string `json:"generated_at"` - AmuxVersion string `json:"amux_version"` -} - -const EnvelopeSchemaVersion = "amux.cli.v1" - -type responseContext struct { - mu sync.RWMutex - requestID string - command string -} - -var cliResponseContext responseContext - -// Exit codes. -const ( - ExitOK = 0 - ExitUsage = 2 - ExitNotFound = 3 - ExitDependency = 4 - ExitUnsafeBlocked = 5 - ExitInternalError = 1 -) - -func newMeta(version string) Meta { - return Meta{ - GeneratedAt: time.Now().UTC().Format(time.RFC3339), - AmuxVersion: version, - } -} - -func setResponseContext(requestID, command string) { - cliResponseContext.mu.Lock() - defer cliResponseContext.mu.Unlock() - cliResponseContext.requestID = requestID - cliResponseContext.command = command -} - -func clearResponseContext() { - setResponseContext("", "") -} - -func currentResponseContext() (string, string) { - cliResponseContext.mu.RLock() - defer cliResponseContext.mu.RUnlock() - return cliResponseContext.requestID, cliResponseContext.command -} - -func successEnvelope(data any, version string) Envelope { - requestID, command := currentResponseContext() - return Envelope{ - OK: true, - Data: data, - Meta: newMeta(version), - SchemaVersion: EnvelopeSchemaVersion, - RequestID: requestID, - Command: command, - } -} - -func errorEnvelope(code, message string, details any, version string) Envelope { - requestID, command := currentResponseContext() - return Envelope{ - OK: false, - Error: &ErrorInfo{ - Code: code, - Message: message, - Details: details, - }, - Meta: newMeta(version), - SchemaVersion: EnvelopeSchemaVersion, - RequestID: requestID, - Command: command, - } -} - -func encodeEnvelope(env Envelope) ([]byte, error) { - return json.MarshalIndent(env, "", " ") -} - -func writeEnvelope(w io.Writer, env Envelope) { - data, err := encodeEnvelope(env) - if err != nil { - // Best effort fallback keeps JSON contract intact. - fallback := []byte(`{"ok":false,"error":{"code":"encode_failed","message":"failed to encode response"},"data":null}` + "\n") - _, _ = w.Write(fallback) - return - } - _, _ = w.Write(append(data, '\n')) -} - -// PrintJSON writes a success envelope to w. -func PrintJSON(w io.Writer, data any, version string) { - writeEnvelope(w, successEnvelope(data, version)) -} - -// ReturnError writes an error envelope to w. -func ReturnError(w io.Writer, code, message string, details any, version string) { - writeEnvelope(w, errorEnvelope(code, message, details, version)) -} - -// PrintHuman calls fn to produce human-readable output. -func PrintHuman(w io.Writer, fn func(io.Writer)) { - fn(w) -} - -// Errorf prints a human-readable error to w. -func Errorf(w io.Writer, format string, args ...any) { - fmt.Fprintf(w, "Error: "+format+"\n", args...) -} - -// returnOperationError handles the JSON/human error branching for simple -// commands that don't use cmdCtx. It returns exitCode for the caller to -// propagate directly. -func returnOperationError( - w, wErr io.Writer, gf GlobalFlags, version string, - exitCode int, errorCode string, err error, details any, - humanFmt string, humanArgs ...any, -) int { - if gf.JSON { - ReturnError(w, errorCode, err.Error(), details, version) - } else { - Errorf(wErr, humanFmt, humanArgs...) - } - return exitCode -} - -// initServicesOrFail creates Services and handles the error output for simple -// commands. If the returned exit code is >= 0, the caller should return it -// immediately (svc will be nil). -func initServicesOrFail(w, wErr io.Writer, gf GlobalFlags, version string) (*Services, int) { - svc, err := NewServices(version) - if err != nil { - return nil, returnOperationError(w, wErr, gf, version, - ExitInternalError, "init_failed", err, nil, - "failed to initialize: %v", err) - } - return svc, -1 -} diff --git a/internal/cli/output_test.go b/internal/cli/output_test.go deleted file mode 100644 index 311c2119..00000000 --- a/internal/cli/output_test.go +++ /dev/null @@ -1,76 +0,0 @@ -package cli - -import ( - "bytes" - "encoding/json" - "testing" -) - -func TestPrintJSON(t *testing.T) { - var buf bytes.Buffer - setResponseContext("req-1", "status") - defer clearResponseContext() - PrintJSON(&buf, map[string]string{"hello": "world"}, "test-v1") - - var env Envelope - if err := json.Unmarshal(buf.Bytes(), &env); err != nil { - t.Fatalf("failed to decode JSON: %v", err) - } - if !env.OK { - t.Error("expected ok=true") - } - if env.Error != nil { - t.Error("expected error=nil") - } - if env.Meta.AmuxVersion != "test-v1" { - t.Errorf("expected version test-v1, got %s", env.Meta.AmuxVersion) - } - if env.Meta.GeneratedAt == "" { - t.Error("expected generated_at to be set") - } - if env.SchemaVersion != EnvelopeSchemaVersion { - t.Errorf("expected schema version %q, got %q", EnvelopeSchemaVersion, env.SchemaVersion) - } - if env.RequestID != "req-1" { - t.Errorf("expected request_id req-1, got %q", env.RequestID) - } - if env.Command != "status" { - t.Errorf("expected command status, got %q", env.Command) - } -} - -func TestReturnError(t *testing.T) { - var buf bytes.Buffer - ReturnError(&buf, "test_err", "something broke", nil, "test-v1") - - var env Envelope - if err := json.Unmarshal(buf.Bytes(), &env); err != nil { - t.Fatalf("failed to decode JSON: %v", err) - } - if env.OK { - t.Error("expected ok=false") - } - if env.Error == nil { - t.Fatal("expected error to be set") - } - if env.Error.Code != "test_err" { - t.Errorf("expected code test_err, got %s", env.Error.Code) - } - if env.Error.Message != "something broke" { - t.Errorf("expected message 'something broke', got %s", env.Error.Message) - } -} - -func TestReturnErrorWithDetails(t *testing.T) { - var buf bytes.Buffer - details := map[string]string{"field": "value"} - ReturnError(&buf, "detail_err", "with details", details, "test-v1") - - var env Envelope - if err := json.Unmarshal(buf.Bytes(), &env); err != nil { - t.Fatalf("failed to decode JSON: %v", err) - } - if env.Error.Details == nil { - t.Error("expected details to be set") - } -} diff --git a/internal/cli/parse_args.go b/internal/cli/parse_args.go deleted file mode 100644 index 36894dee..00000000 --- a/internal/cli/parse_args.go +++ /dev/null @@ -1,42 +0,0 @@ -package cli - -import ( - "flag" - "fmt" - "strings" -) - -// parseSinglePositionalWithFlags supports both: -// -// command --flag value -// command --flag value -func parseSinglePositionalWithFlags(fs *flag.FlagSet, args []string) (string, error) { - if len(args) == 0 { - if err := fs.Parse(args); err != nil { - return "", err - } - return "", nil - } - - // Most command docs use positional-first syntax. - if !strings.HasPrefix(args[0], "-") { - if err := fs.Parse(args[1:]); err != nil { - return "", err - } - if fs.NArg() > 0 { - return "", fmt.Errorf("unexpected arguments: %s", strings.Join(fs.Args(), " ")) - } - return args[0], nil - } - - if err := fs.Parse(args); err != nil { - return "", err - } - if fs.NArg() < 1 { - return "", nil - } - if fs.NArg() > 1 { - return "", fmt.Errorf("unexpected arguments: %s", strings.Join(fs.Args()[1:], " ")) - } - return fs.Arg(0), nil -} diff --git a/internal/cli/parse_args_test.go b/internal/cli/parse_args_test.go deleted file mode 100644 index 15e078f5..00000000 --- a/internal/cli/parse_args_test.go +++ /dev/null @@ -1,70 +0,0 @@ -package cli - -import ( - "flag" - "testing" -) - -func TestParseSinglePositionalWithFlagsPositionalFirst(t *testing.T) { - fs := flag.NewFlagSet("test", flag.ContinueOnError) - project := fs.String("project", "", "") - - pos, err := parseSinglePositionalWithFlags(fs, []string{"feature-x", "--project", "/tmp/repo"}) - if err != nil { - t.Fatalf("parseSinglePositionalWithFlags() error = %v", err) - } - if pos != "feature-x" { - t.Fatalf("expected positional feature-x, got %q", pos) - } - if *project != "/tmp/repo" { - t.Fatalf("expected --project=/tmp/repo, got %q", *project) - } -} - -func TestParseSinglePositionalWithFlagsFlagsFirst(t *testing.T) { - fs := flag.NewFlagSet("test", flag.ContinueOnError) - project := fs.String("project", "", "") - - pos, err := parseSinglePositionalWithFlags(fs, []string{"--project", "/tmp/repo", "feature-x"}) - if err != nil { - t.Fatalf("parseSinglePositionalWithFlags() error = %v", err) - } - if pos != "feature-x" { - t.Fatalf("expected positional feature-x, got %q", pos) - } - if *project != "/tmp/repo" { - t.Fatalf("expected --project=/tmp/repo, got %q", *project) - } -} - -func TestParseSinglePositionalWithFlagsRejectsExtraPositionalPositionalFirst(t *testing.T) { - fs := flag.NewFlagSet("test", flag.ContinueOnError) - project := fs.String("project", "", "") - - _, err := parseSinglePositionalWithFlags( - fs, - []string{"feature-x", "extra-positional", "--project", "/tmp/repo"}, - ) - if err == nil { - t.Fatalf("expected error for extra positional argument") - } - if *project != "" { - t.Fatalf("expected --project to remain unset when parse fails, got %q", *project) - } -} - -func TestParseSinglePositionalWithFlagsRejectsExtraPositionalFlagsFirst(t *testing.T) { - fs := flag.NewFlagSet("test", flag.ContinueOnError) - project := fs.String("project", "", "") - - _, err := parseSinglePositionalWithFlags( - fs, - []string{"--project", "/tmp/repo", "feature-x", "extra-positional"}, - ) - if err == nil { - t.Fatalf("expected error for extra positional argument") - } - if *project != "/tmp/repo" { - t.Fatalf("expected --project=/tmp/repo, got %q", *project) - } -} diff --git a/internal/cli/root.go b/internal/cli/root.go deleted file mode 100644 index 96aca4e7..00000000 --- a/internal/cli/root.go +++ /dev/null @@ -1,226 +0,0 @@ -package cli - -import ( - "fmt" - "io" - "log/slog" - "os" - "time" -) - -// GlobalFlags holds flags that apply to all subcommands. -type GlobalFlags struct { - JSON bool - NoColor bool - Quiet bool - Cwd string - Timeout time.Duration - RequestID string -} - -// Run is the CLI entry point. Returns an exit code. -func Run(args []string, version, commit, date string) int { - gf, rest, err := ParseGlobalFlags(args) - w := os.Stdout - wErr := os.Stderr - setResponseContext(gf.RequestID, commandFromArgs(rest)) - defer clearResponseContext() - if err != nil { - if parseErrorWantsJSON(args, gf) { - ReturnError(w, "usage_error", err.Error(), nil, version) - } else { - Errorf(wErr, "%v", err) - } - return ExitUsage - } - restore, err := applyRunGlobals(gf) - if err != nil { - if gf.JSON { - details := map[string]any{"cwd": gf.Cwd} - ReturnError(w, "invalid_cwd", err.Error(), details, version) - } else { - Errorf(wErr, "invalid --cwd: %v", err) - } - return ExitUsage - } - defer restore() - - if len(rest) == 0 { - if gf.JSON { - ReturnError(w, "usage_error", "Usage: amux [flags]", nil, version) - } else { - PrintUsage(wErr) - } - return ExitUsage - } - - cmd := rest[0] - cmdArgs := rest[1:] - - switch cmd { - case "status": - return cmdStatus(w, wErr, gf, cmdArgs, version) - case "doctor": - return cmdDoctor(w, wErr, gf, cmdArgs, version) - case "capabilities": - return cmdCapabilities(w, wErr, gf, cmdArgs, version) - case "logs": - return cmdLogs(w, wErr, gf, cmdArgs, version) - case "workspace": - return routeWorkspace(w, wErr, gf, cmdArgs, version) - case "agent": - return routeAgent(w, wErr, gf, cmdArgs, version) - case "session": - return routeSession(w, wErr, gf, cmdArgs, version) - case "terminal": - return routeTerminal(w, wErr, gf, cmdArgs, version) - case "project": - return routeProject(w, wErr, gf, cmdArgs, version) - case "version": - if gf.JSON { - PrintJSON(w, map[string]string{ - "version": version, - "commit": commit, - "date": date, - }, version) - return ExitOK - } - fmt.Fprintf(w, "amux %s (commit: %s, built: %s)\n", version, commit, date) - return ExitOK - case "help": - if gf.JSON { - PrintJSON(w, map[string]string{ - "usage": usageText(), - }, version) - return ExitOK - } - PrintUsage(w) - return ExitOK - default: - if gf.JSON { - ReturnError(w, "unknown_command", "Unknown command: "+cmd, nil, version) - } else { - fmt.Fprintf(wErr, "Unknown command: %s\n\n", cmd) - PrintUsage(wErr) - } - return ExitUsage - } -} - -func applyRunGlobals(gf GlobalFlags) (func(), error) { - prevTimeout := setCLITmuxTimeoutOverride(gf.Timeout) - - wdChanged := false - prevWD := "" - if gf.Cwd != "" { - var err error - prevWD, err = os.Getwd() - if err != nil { - setCLITmuxTimeoutOverride(prevTimeout) - return nil, err - } - if err := os.Chdir(gf.Cwd); err != nil { - setCLITmuxTimeoutOverride(prevTimeout) - return nil, err - } - wdChanged = true - } - - restore := func() { - setCLITmuxTimeoutOverride(prevTimeout) - if wdChanged { - if err := os.Chdir(prevWD); err != nil { - slog.Debug("failed to restore working directory", "path", prevWD, "error", err) - } - } - } - return restore, nil -} - -func routeWorkspace(w, wErr io.Writer, gf GlobalFlags, args []string, version string) int { - return routeSubcommand(w, wErr, gf, args, version, "workspace", []subcommand{ - {names: []string{"list", "ls"}, handler: cmdWorkspaceList}, - {names: []string{"create"}, handler: cmdWorkspaceCreate}, - {names: []string{"remove", "rm"}, handler: cmdWorkspaceRemove}, - }) -} - -func routeAgent(w, wErr io.Writer, gf GlobalFlags, args []string, version string) int { - return routeSubcommand(w, wErr, gf, args, version, "agent", []subcommand{ - {names: []string{"list", "ls"}, handler: cmdAgentList}, - {names: []string{"capture"}, handler: cmdAgentCapture}, - {names: []string{"run"}, handler: cmdAgentRun}, - {names: []string{"send"}, handler: cmdAgentSend}, - {names: []string{"stop"}, handler: cmdAgentStop}, - {names: []string{"watch"}, handler: cmdAgentWatch}, - {names: []string{"job"}, handler: routeAgentJob}, - }) -} - -// PrintUsage writes CLI help text. -func PrintUsage(w io.Writer) { - fmt.Fprint(w, usageText()) -} - -func usageText() string { - return `Usage: amux [flags] - -Commands: - status Health check and summary - doctor Diagnostics check list - capabilities Machine-readable CLI capabilities - logs tail Tail the amux log file - workspace list List workspaces - workspace create Create a workspace - workspace remove Remove a workspace - agent list List running agents - agent capture Capture agent pane output - agent run Start an agent - agent send Send text to an agent - agent stop Stop an agent - agent watch Watch agent output (NDJSON stream) - agent job status Get queued send job status - agent job cancel Cancel queued send job (pending only) - agent job wait Wait for queued send job completion - terminal list List terminal sessions - terminal run Send command to workspace terminal (auto-create if missing) - terminal logs Capture/watch workspace terminal output - project list List registered projects - project add Register a project - project remove Unregister a project - session list List all tmux sessions - session prune Clean up stale sessions - version Print version info - help Show this help - tui Launch TUI (default when TTY) - -Global Flags: - --json Output as JSON envelope - --request-id Caller-provided request correlation ID - --no-color Disable color output - --quiet, -q Suppress non-essential output - --cwd Set working directory - --timeout Command timeout (e.g. 30s) -` -} - -func commandFromArgs(args []string) string { - if len(args) == 0 { - return "" - } - cmd := args[0] - if len(args) < 2 { - return cmd - } - switch cmd { - case "agent": - if len(args) >= 3 && args[1] == "job" { - return cmd + " " + args[1] + " " + args[2] - } - return cmd + " " + args[1] - case "workspace", "logs", "session", "project", "terminal": - return cmd + " " + args[1] - default: - return cmd - } -} diff --git a/internal/cli/root_dispatch_test.go b/internal/cli/root_dispatch_test.go deleted file mode 100644 index 3ec7473d..00000000 --- a/internal/cli/root_dispatch_test.go +++ /dev/null @@ -1,176 +0,0 @@ -package cli - -import ( - "bytes" - "encoding/json" - "strings" - "testing" -) - -func TestRouteWorkspaceJSON(t *testing.T) { - tests := []struct { - name string - args []string - wantCode string - wantMsg string - }{ - { - name: "empty args", - args: nil, - wantCode: "usage_error", - wantMsg: "Usage: amux workspace", - }, - { - name: "unknown subcommand", - args: []string{"bogus"}, - wantCode: "unknown_command", - wantMsg: "Unknown workspace subcommand: bogus", - }, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - var w bytes.Buffer - var wErr bytes.Buffer - gf := GlobalFlags{JSON: true} - code := routeWorkspace(&w, &wErr, gf, tt.args, "test") - if code != ExitUsage { - t.Fatalf("exit code = %d, want %d", code, ExitUsage) - } - var env Envelope - if err := json.Unmarshal(w.Bytes(), &env); err != nil { - t.Fatalf("failed to parse JSON output: %v\nraw: %s", err, w.String()) - } - if env.OK { - t.Fatalf("expected ok=false") - } - if env.Error.Code != tt.wantCode { - t.Errorf("error code = %q, want %q", env.Error.Code, tt.wantCode) - } - if !strings.Contains(env.Error.Message, tt.wantMsg) { - t.Errorf("error message = %q, want to contain %q", env.Error.Message, tt.wantMsg) - } - }) - } -} - -func TestRouteAgentJSON(t *testing.T) { - tests := []struct { - name string - args []string - wantCode string - wantMsg string - }{ - { - name: "empty args", - args: nil, - wantCode: "usage_error", - wantMsg: "Usage: amux agent", - }, - { - name: "unknown subcommand", - args: []string{"bogus"}, - wantCode: "unknown_command", - wantMsg: "Unknown agent subcommand: bogus", - }, - { - name: "agent job missing subcommand", - args: []string{"job"}, - wantCode: "usage_error", - wantMsg: "Usage: amux agent job", - }, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - var w bytes.Buffer - var wErr bytes.Buffer - gf := GlobalFlags{JSON: true} - code := routeAgent(&w, &wErr, gf, tt.args, "test") - if code != ExitUsage { - t.Fatalf("exit code = %d, want %d", code, ExitUsage) - } - var env Envelope - if err := json.Unmarshal(w.Bytes(), &env); err != nil { - t.Fatalf("failed to parse JSON output: %v\nraw: %s", err, w.String()) - } - if env.OK { - t.Fatalf("expected ok=false") - } - if env.Error.Code != tt.wantCode { - t.Errorf("error code = %q, want %q", env.Error.Code, tt.wantCode) - } - if !strings.Contains(env.Error.Message, tt.wantMsg) { - t.Errorf("error message = %q, want to contain %q", env.Error.Message, tt.wantMsg) - } - }) - } -} - -func TestRouteTerminalJSON(t *testing.T) { - tests := []struct { - name string - args []string - wantCode string - wantMsg string - }{ - { - name: "empty args", - args: nil, - wantCode: "usage_error", - wantMsg: "Usage: amux terminal", - }, - { - name: "unknown subcommand", - args: []string{"bogus"}, - wantCode: "unknown_command", - wantMsg: "Unknown terminal subcommand: bogus", - }, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - var w bytes.Buffer - var wErr bytes.Buffer - gf := GlobalFlags{JSON: true} - code := routeTerminal(&w, &wErr, gf, tt.args, "test") - if code != ExitUsage { - t.Fatalf("exit code = %d, want %d", code, ExitUsage) - } - var env Envelope - if err := json.Unmarshal(w.Bytes(), &env); err != nil { - t.Fatalf("failed to parse JSON output: %v\nraw: %s", err, w.String()) - } - if env.OK { - t.Fatalf("expected ok=false") - } - if env.Error.Code != tt.wantCode { - t.Errorf("error code = %q, want %q", env.Error.Code, tt.wantCode) - } - if !strings.Contains(env.Error.Message, tt.wantMsg) { - t.Errorf("error message = %q, want to contain %q", env.Error.Message, tt.wantMsg) - } - }) - } -} - -func TestCommandFromArgs(t *testing.T) { - tests := []struct { - name string - args []string - want string - }{ - {name: "empty", args: nil, want: ""}, - {name: "single command", args: []string{"status"}, want: "status"}, - {name: "agent send", args: []string{"agent", "send", "s"}, want: "agent send"}, - {name: "agent job status", args: []string{"agent", "job", "status", "id"}, want: "agent job status"}, - {name: "agent job wait", args: []string{"agent", "job", "wait", "id"}, want: "agent job wait"}, - {name: "workspace list", args: []string{"workspace", "list"}, want: "workspace list"}, - {name: "terminal logs", args: []string{"terminal", "logs", "--workspace", "abc"}, want: "terminal logs"}, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - if got := commandFromArgs(tt.args); got != tt.want { - t.Fatalf("commandFromArgs(%v) = %q, want %q", tt.args, got, tt.want) - } - }) - } -} diff --git a/internal/cli/root_globals.go b/internal/cli/root_globals.go deleted file mode 100644 index ae441d3b..00000000 --- a/internal/cli/root_globals.go +++ /dev/null @@ -1,344 +0,0 @@ -package cli - -import ( - "errors" - "fmt" - "strings" - "time" -) - -// ParseGlobalFlags extracts global flags from CLI args. -// -// It always consumes global flags in the prefix, then attempts to consume -// additional global flags after the command path while preserving values of -// command-local flags that require arguments (for example: `agent send --text`). -func ParseGlobalFlags(args []string) (GlobalFlags, []string, error) { - var gf GlobalFlags - - // Parse prefix globals first so command detection remains stable. - i := 0 - for i < len(args) { - consumed, next, err := parseGlobalFlagAt(args, i, &gf) - if err != nil { - return gf, nil, err - } - if !consumed { - break - } - i = next - } - - rest := append([]string(nil), args[i:]...) - if len(rest) == 0 { - return gf, nil, nil - } - - pathTokenIndexes, localValueFlags, pathKey, err := commandPathParseRules(rest) - if err != nil { - return gf, nil, err - } - filtered := make([]string, 0, len(rest)) - - expectLocalValue := false - for j := 0; j < len(rest); j++ { - arg := rest[j] - - if _, isPathToken := pathTokenIndexes[j]; isPathToken { - filtered = append(filtered, arg) - continue - } - - if expectLocalValue { - filtered = append(filtered, arg) - expectLocalValue = false - continue - } - - if localFlagRequiresValue(localValueFlags, arg) { - filtered = append(filtered, arg) - if localFlagConsumesRemainder(pathKey, arg) { - if j+1 < len(rest) { - filtered = append(filtered, rest[j+1:]...) - } - break - } - if !strings.Contains(arg, "=") { - expectLocalValue = true - } - continue - } - - consumed, next, err := parseGlobalFlagAt(rest, j, &gf) - if err != nil { - return gf, nil, err - } - if consumed { - j = next - 1 - continue - } - - filtered = append(filtered, arg) - } - - if len(filtered) == 0 { - return gf, nil, nil - } - return gf, filtered, nil -} - -func parseGlobalFlagAt(args []string, i int, gf *GlobalFlags) (bool, int, error) { - if i < 0 || i >= len(args) { - return false, i, nil - } - arg := args[i] - switch arg { - case "--json": - if gf != nil { - gf.JSON = true - } - return true, i + 1, nil - case "--no-color": - if gf != nil { - gf.NoColor = true - } - return true, i + 1, nil - case "--quiet", "-q": - if gf != nil { - gf.Quiet = true - } - return true, i + 1, nil - case "--cwd": - if i+1 >= len(args) { - return true, i + 1, errors.New("--cwd requires a value") - } - if args[i+1] == "" { - return true, i + 2, errors.New("--cwd requires a non-empty value") - } - if gf != nil { - gf.Cwd = args[i+1] - } - return true, i + 2, nil - case "--timeout": - if i+1 >= len(args) { - return true, i + 1, errors.New("--timeout requires a value") - } - d, err := time.ParseDuration(args[i+1]) - if err != nil { - return true, i + 2, fmt.Errorf("invalid --timeout value: %w", err) - } - if gf != nil { - gf.Timeout = d - } - return true, i + 2, nil - case "--request-id": - if i+1 >= len(args) { - return true, i + 1, errors.New("--request-id requires a value") - } - if gf != nil { - gf.RequestID = strings.TrimSpace(args[i+1]) - } - return true, i + 2, nil - default: - if strings.HasPrefix(arg, "--cwd=") { - val := strings.TrimPrefix(arg, "--cwd=") - if val == "" { - return true, i + 1, errors.New("--cwd requires a non-empty value") - } - if gf != nil { - gf.Cwd = val - } - return true, i + 1, nil - } - if strings.HasPrefix(arg, "--timeout=") { - val := strings.TrimPrefix(arg, "--timeout=") - d, err := time.ParseDuration(val) - if err != nil { - return true, i + 1, fmt.Errorf("invalid --timeout value: %w", err) - } - if gf != nil { - gf.Timeout = d - } - return true, i + 1, nil - } - if strings.HasPrefix(arg, "--request-id=") { - if gf != nil { - gf.RequestID = strings.TrimSpace(strings.TrimPrefix(arg, "--request-id=")) - } - return true, i + 1, nil - } - } - return false, i + 1, nil -} - -func commandPathParseRules(args []string) (map[int]struct{}, map[string]struct{}, string, error) { - if len(args) == 0 { - return nil, nil, "", nil - } - if strings.HasPrefix(args[0], "-") { - return nil, nil, "", nil - } - - pathTokens := []string{args[0]} - pathIndexes := []int{0} - - next := 1 - switch args[0] { - case "workspace", "logs", "agent", "session", "project", "terminal": - token, idx, following, ok, err := nextCommandToken(args, next) - if err != nil { - return nil, nil, "", err - } - if ok { - pathTokens = append(pathTokens, token) - pathIndexes = append(pathIndexes, idx) - next = following - } - } - - if len(pathTokens) >= 2 && args[0] == "agent" && pathTokens[1] == "job" { - token, idx, _, ok, err := nextCommandToken(args, next) - if err != nil { - return nil, nil, "", err - } - if ok { - pathTokens = append(pathTokens, token) - pathIndexes = append(pathIndexes, idx) - } - } - - tokenIndexSet := make(map[int]struct{}, len(pathIndexes)) - for _, idx := range pathIndexes { - tokenIndexSet[idx] = struct{}{} - } - - pathKey := strings.Join(pathTokens, " ") - return tokenIndexSet, localFlagsRequiringValue(pathKey), pathKey, nil -} - -func nextCommandToken(args []string, start int) (token string, tokenIndex, next int, ok bool, err error) { - for i := start; i < len(args); { - arg := args[i] - if strings.HasPrefix(arg, "-") { - consumed, following, parseErr := parseGlobalFlagAt(args, i, nil) - if parseErr != nil { - return "", 0, 0, false, parseErr - } - if consumed { - i = following - continue - } - return "", 0, 0, false, nil - } - return arg, i, i + 1, true, nil - } - return "", 0, 0, false, nil -} - -func localFlagsRequiringValue(pathKey string) map[string]struct{} { - switch pathKey { - case "workspace list", "workspace ls": - return map[string]struct{}{"--repo": {}, "--project": {}} - case "workspace create": - return map[string]struct{}{ - "--project": {}, - "--assistant": {}, - "--base": {}, - "--idempotency-key": {}, - } - case "workspace remove", "workspace rm": - return map[string]struct{}{"--idempotency-key": {}} - case "agent list", "agent ls": - return map[string]struct{}{"--workspace": {}} - case "agent capture": - return map[string]struct{}{"--lines": {}} - case "agent run": - return map[string]struct{}{ - "--workspace": {}, - "--assistant": {}, - "--name": {}, - "--prompt": {}, - "--idempotency-key": {}, - "--wait-timeout": {}, - "--idle-threshold": {}, - } - case "agent send": - return map[string]struct{}{ - "--agent": {}, - "--text": {}, - "--idempotency-key": {}, - "--job-id": {}, - "--wait-timeout": {}, - "--idle-threshold": {}, - } - case "agent stop": - return map[string]struct{}{ - "--agent": {}, - "--grace-period": {}, - "--idempotency-key": {}, - } - case "agent watch": - return map[string]struct{}{ - "--lines": {}, - "--interval": {}, - "--idle-threshold": {}, - "--heartbeat": {}, - } - // --timeout intentionally shadows the global --timeout flag; - // local flags are checked before global parsing, so the value - // is preserved for the subcommand. The global --timeout can - // still be set via prefix position (before "agent"). - case "agent job wait": - return map[string]struct{}{ - "--timeout": {}, - "--interval": {}, - } - case "agent job cancel": - return map[string]struct{}{ - "--idempotency-key": {}, - } - case "terminal list": - return map[string]struct{}{"--workspace": {}} - case "terminal run": - return map[string]struct{}{ - "--workspace": {}, - "--text": {}, - } - case "terminal logs": - return map[string]struct{}{ - "--workspace": {}, - "--lines": {}, - "--interval": {}, - "--idle-threshold": {}, - } - case "logs tail": - return map[string]struct{}{"--lines": {}} - case "session prune": - return map[string]struct{}{"--older-than": {}} - default: - return nil - } -} - -func localFlagRequiresValue(localValueFlags map[string]struct{}, arg string) bool { - if len(localValueFlags) == 0 || !strings.HasPrefix(arg, "-") { - return false - } - - name := arg - if idx := strings.Index(name, "="); idx >= 0 { - name = name[:idx] - } - _, ok := localValueFlags[name] - return ok -} - -// localFlagConsumesRemainder returns true when the flag captures all remaining -// arguments as its value. This exists for "terminal run --text", where the -// payload is an arbitrary shell command that may contain flag-like tokens -// (e.g., "ls -la") that must not be parsed as global flags. -func localFlagConsumesRemainder(pathKey, arg string) bool { - if pathKey != "terminal run" { - return false - } - return arg == "--text" || strings.HasPrefix(arg, "--text=") -} diff --git a/internal/cli/root_globals_apply_integration_test.go b/internal/cli/root_globals_apply_integration_test.go deleted file mode 100644 index 7490e0f1..00000000 --- a/internal/cli/root_globals_apply_integration_test.go +++ /dev/null @@ -1,88 +0,0 @@ -package cli - -import ( - "os" - "path/filepath" - "testing" - "time" -) - -func TestApplyRunGlobalsAppliesAndRestores(t *testing.T) { - prevTimeout := setCLITmuxTimeoutOverride(0) - defer setCLITmuxTimeoutOverride(prevTimeout) - - originalWD, err := os.Getwd() - if err != nil { - t.Fatalf("Getwd() error = %v", err) - } - - targetWD := t.TempDir() - restore, err := applyRunGlobals(GlobalFlags{ - Cwd: targetWD, - Timeout: 250 * time.Millisecond, - }) - if err != nil { - t.Fatalf("applyRunGlobals() error = %v", err) - } - - gotWD, err := os.Getwd() - if err != nil { - t.Fatalf("Getwd() after apply error = %v", err) - } - if gotWD != targetWD { - gotCanonical, err := filepath.EvalSymlinks(gotWD) - if err != nil { - t.Fatalf("EvalSymlinks(got cwd) error = %v", err) - } - wantCanonical, err := filepath.EvalSymlinks(targetWD) - if err != nil { - t.Fatalf("EvalSymlinks(target cwd) error = %v", err) - } - if gotCanonical != wantCanonical { - t.Fatalf("cwd after apply = %q, want %q", gotWD, targetWD) - } - } - if got := currentCLITmuxTimeoutOverride(); got != 250*time.Millisecond { - t.Fatalf("timeout override after apply = %v, want %v", got, 250*time.Millisecond) - } - - restore() - - gotWD, err = os.Getwd() - if err != nil { - t.Fatalf("Getwd() after restore error = %v", err) - } - if gotWD != originalWD { - gotCanonical, err := filepath.EvalSymlinks(gotWD) - if err != nil { - t.Fatalf("EvalSymlinks(got restored cwd) error = %v", err) - } - wantCanonical, err := filepath.EvalSymlinks(originalWD) - if err != nil { - t.Fatalf("EvalSymlinks(original cwd) error = %v", err) - } - if gotCanonical != wantCanonical { - t.Fatalf("cwd after restore = %q, want %q", gotWD, originalWD) - } - } - if got := currentCLITmuxTimeoutOverride(); got != 0 { - t.Fatalf("timeout override after restore = %v, want 0", got) - } -} - -func TestApplyRunGlobalsInvalidCwdRestoresTimeout(t *testing.T) { - prevTimeout := setCLITmuxTimeoutOverride(0) - defer setCLITmuxTimeoutOverride(prevTimeout) - - _, err := applyRunGlobals(GlobalFlags{ - Cwd: filepath.Join(t.TempDir(), "missing"), - Timeout: time.Second, - }) - if err == nil { - t.Fatalf("expected error for invalid cwd") - } - - if got := currentCLITmuxTimeoutOverride(); got != 0 { - t.Fatalf("timeout override after invalid cwd = %v, want 0", got) - } -} diff --git a/internal/cli/root_globals_parse_integration_test.go b/internal/cli/root_globals_parse_integration_test.go deleted file mode 100644 index 86aaaf54..00000000 --- a/internal/cli/root_globals_parse_integration_test.go +++ /dev/null @@ -1,187 +0,0 @@ -package cli - -import ( - "reflect" - "testing" - "time" -) - -func TestParseGlobalFlags(t *testing.T) { - tests := []struct { - name string - args []string - wantGF GlobalFlags - wantRest []string - wantErr bool - }{ - { - name: "prefix extraction", - args: []string{"--json", "--quiet", "status"}, - wantGF: GlobalFlags{JSON: true, Quiet: true}, - wantRest: []string{"status"}, - }, - { - name: "global after command extracted", - args: []string{"--json", "status", "--quiet"}, - wantGF: GlobalFlags{JSON: true, Quiet: true}, - wantRest: []string{"status"}, - }, - { - name: "subcommand value preserved", - args: []string{"agent", "send", "s", "--text", "--json"}, - wantGF: GlobalFlags{}, - wantRest: []string{"agent", "send", "s", "--text", "--json"}, - }, - { - name: "global parsed after local value flag", - args: []string{"agent", "send", "s", "--text", "hello", "--json"}, - wantGF: GlobalFlags{JSON: true}, - wantRest: []string{"agent", "send", "s", "--text", "hello"}, - }, - { - name: "global after nested subcommand extracted", - args: []string{"workspace", "list", "--cwd", "/tmp"}, - wantGF: GlobalFlags{Cwd: "/tmp"}, - wantRest: []string{"workspace", "list"}, - }, - { - name: "global between command and subcommand extracted", - args: []string{"workspace", "--cwd", "/tmp", "list"}, - wantGF: GlobalFlags{Cwd: "/tmp"}, - wantRest: []string{"workspace", "list"}, - }, - { - name: "workspace list project alias value preserved", - args: []string{"workspace", "list", "--project", "/tmp/repo", "--json"}, - wantGF: GlobalFlags{JSON: true}, - wantRest: []string{"workspace", "list", "--project", "/tmp/repo"}, - }, - { - name: "global timeout after command extracted", - args: []string{"status", "--timeout", "2s"}, - wantGF: GlobalFlags{Timeout: 2 * time.Second}, - wantRest: []string{"status"}, - }, - { - name: "local timeout on agent job wait is preserved", - args: []string{"agent", "job", "wait", "job-1", "--timeout", "2s", "--json"}, - wantGF: GlobalFlags{JSON: true}, - wantRest: []string{"agent", "job", "wait", "job-1", "--timeout", "2s"}, - }, - { - name: "interleaved global still infers nested command path", - args: []string{"agent", "--json", "job", "wait", "job-1", "--timeout", "2s"}, - wantGF: GlobalFlags{JSON: true}, - wantRest: []string{"agent", "job", "wait", "job-1", "--timeout", "2s"}, - }, - { - name: "cwd= form", - args: []string{"--cwd=/tmp", "status"}, - wantGF: GlobalFlags{Cwd: "/tmp"}, - wantRest: []string{"status"}, - }, - { - name: "request-id flag", - args: []string{"--request-id", "req-123", "status"}, - wantGF: GlobalFlags{RequestID: "req-123"}, - wantRest: []string{"status"}, - }, - { - name: "only globals", - args: []string{"--json", "--no-color"}, - wantGF: GlobalFlags{JSON: true, NoColor: true}, - wantRest: nil, - }, - { - name: "empty args", - args: nil, - wantGF: GlobalFlags{}, - wantRest: nil, - }, - { - name: "unknown flag stops extraction", - args: []string{"--json", "--unknown", "status"}, - wantGF: GlobalFlags{JSON: true}, - wantRest: []string{"--unknown", "status"}, - }, - { - name: "malformed timeout equals form", - args: []string{"--timeout=1sec", "status"}, - wantGF: GlobalFlags{}, - wantErr: true, - }, - { - name: "malformed timeout space form", - args: []string{"--timeout", "abc", "status"}, - wantGF: GlobalFlags{}, - wantErr: true, - }, - { - name: "bare --cwd missing value", - args: []string{"--cwd"}, - wantErr: true, - }, - { - name: "bare --timeout missing value", - args: []string{"--timeout"}, - wantErr: true, - }, - { - name: "session prune older-than preserved", - args: []string{"session", "prune", "--older-than", "1h", "--json"}, - wantGF: GlobalFlags{JSON: true}, - wantRest: []string{"session", "prune", "--older-than", "1h"}, - }, - { - name: "session prune global between command and subcommand", - args: []string{"session", "--json", "prune", "--older-than", "30m"}, - wantGF: GlobalFlags{JSON: true}, - wantRest: []string{"session", "prune", "--older-than", "30m"}, - }, - { - name: "terminal run local text value that looks global is preserved", - args: []string{"terminal", "run", "--workspace", "0123456789abcdef", "--text", "--json"}, - wantGF: GlobalFlags{}, - wantRest: []string{"terminal", "run", "--workspace", "0123456789abcdef", "--text", "--json"}, - }, - { - name: "terminal run global between command and subcommand extracted", - args: []string{"terminal", "--json", "run", "--workspace", "0123456789abcdef", "--text", "npm run dev"}, - wantGF: GlobalFlags{JSON: true}, - wantRest: []string{"terminal", "run", "--workspace", "0123456789abcdef", "--text", "npm run dev"}, - }, - { - name: "terminal run preserves unquoted command tail that looks global", - args: []string{"terminal", "run", "--workspace", "0123456789abcdef", "--text", "npm", "--quiet"}, - wantGF: GlobalFlags{}, - wantRest: []string{"terminal", "run", "--workspace", "0123456789abcdef", "--text", "npm", "--quiet"}, - }, - { - name: "terminal run preserves command tail after text equals form", - args: []string{"terminal", "run", "--workspace", "0123456789abcdef", "--text=npm", "--cwd"}, - wantGF: GlobalFlags{}, - wantRest: []string{"terminal", "run", "--workspace", "0123456789abcdef", "--text=npm", "--cwd"}, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - gotGF, gotRest, gotErr := ParseGlobalFlags(tt.args) - if tt.wantErr { - if gotErr == nil { - t.Fatalf("expected error, got nil") - } - return - } - if gotErr != nil { - t.Fatalf("unexpected error: %v", gotErr) - } - if !reflect.DeepEqual(gotGF, tt.wantGF) { - t.Errorf("GlobalFlags = %+v, want %+v", gotGF, tt.wantGF) - } - if !reflect.DeepEqual(gotRest, tt.wantRest) { - t.Errorf("rest = %v, want %v", gotRest, tt.wantRest) - } - }) - } -} diff --git a/internal/cli/root_globals_test.go b/internal/cli/root_globals_test.go deleted file mode 100644 index 4e0e8239..00000000 --- a/internal/cli/root_globals_test.go +++ /dev/null @@ -1,17 +0,0 @@ -package cli - -import "testing" - -func TestParseGlobalFlagsRejectsEmptyCwdEqualsValue(t *testing.T) { - _, _, err := ParseGlobalFlags([]string{"--cwd=", "status"}) - if err == nil { - t.Fatalf("expected error for empty --cwd= value") - } -} - -func TestParseGlobalFlagsRejectsEmptyCwdSpaceValue(t *testing.T) { - _, _, err := ParseGlobalFlags([]string{"--cwd", "", "status"}) - if err == nil { - t.Fatalf("expected error for empty --cwd value") - } -} diff --git a/internal/cli/root_parse_error_mode.go b/internal/cli/root_parse_error_mode.go deleted file mode 100644 index 6323d6b5..00000000 --- a/internal/cli/root_parse_error_mode.go +++ /dev/null @@ -1,117 +0,0 @@ -package cli - -import "strings" - -func parseErrorWantsJSON(args []string, gf GlobalFlags) bool { - if gf.JSON { - return true - } - for i := 0; i < len(args); { - arg := args[i] - if arg == "--json" { - return true - } - // Parse prefix globals. For malformed globals, keep scanning to allow - // a later explicit --json to opt into JSON error formatting. - consumed, next, _ := parseGlobalFlagAt(args, i, nil) - if !consumed { - rest := args[i:] - pathTokenIndexes, localValueFlags, pathKey := commandPathParseRulesForParseError(rest) - expectLocalValue := false - for j := 0; j < len(rest); j++ { - restArg := rest[j] - if _, isPathToken := pathTokenIndexes[j]; isPathToken { - continue - } - if expectLocalValue { - expectLocalValue = false - continue - } - if localFlagRequiresValue(localValueFlags, restArg) { - if localFlagConsumesRemainder(pathKey, restArg) { - return false - } - if !strings.Contains(restArg, "=") { - expectLocalValue = true - } - continue - } - if restArg == "--json" { - return true - } - consumedRest, nextRest, _ := parseGlobalFlagAt(rest, j, nil) - if consumedRest { - if nextRest > j { - j = nextRest - 1 - } - continue - } - } - return false - } - if next <= i { - i++ - } else { - i = next - } - } - return false -} - -func commandPathParseRulesForParseError(args []string) (map[int]struct{}, map[string]struct{}, string) { - if len(args) == 0 { - return nil, nil, "" - } - if strings.HasPrefix(args[0], "-") { - return nil, nil, "" - } - - pathTokens := []string{args[0]} - pathIndexes := []int{0} - - next := 1 - switch args[0] { - case "workspace", "logs", "agent", "session", "project", "terminal": - token, idx, following, ok := nextCommandTokenForParseError(args, next) - if ok { - pathTokens = append(pathTokens, token) - pathIndexes = append(pathIndexes, idx) - next = following - } - } - - if len(pathTokens) >= 2 && args[0] == "agent" && pathTokens[1] == "job" { - token, idx, _, ok := nextCommandTokenForParseError(args, next) - if ok { - pathTokens = append(pathTokens, token) - pathIndexes = append(pathIndexes, idx) - } - } - - tokenIndexSet := make(map[int]struct{}, len(pathIndexes)) - for _, idx := range pathIndexes { - tokenIndexSet[idx] = struct{}{} - } - pathKey := strings.Join(pathTokens, " ") - return tokenIndexSet, localFlagsRequiringValue(pathKey), pathKey -} - -func nextCommandTokenForParseError(args []string, start int) (token string, tokenIndex, next int, ok bool) { - for i := start; i < len(args); { - arg := args[i] - if strings.HasPrefix(arg, "-") { - consumed, following, _ := parseGlobalFlagAt(args, i, nil) - if consumed { - if following <= i { - i++ - } else { - i = following - } - continue - } - return "", 0, 0, false - } - return arg, i, i + 1, true - } - return "", 0, 0, false -} diff --git a/internal/cli/root_parse_error_mode_test.go b/internal/cli/root_parse_error_mode_test.go deleted file mode 100644 index 824d08d5..00000000 --- a/internal/cli/root_parse_error_mode_test.go +++ /dev/null @@ -1,66 +0,0 @@ -package cli - -import ( - "encoding/json" - "strings" - "testing" -) - -func TestRunParseErrorDoesNotForceJSONForCommandValueJSONToken(t *testing.T) { - code, stdout, stderr := runWithCapturedStdIO( - t, - []string{"--timeout=abc", "agent", "send", "s", "--text", "--json"}, - ) - if code != ExitUsage { - t.Fatalf("Run() code = %d, want %d", code, ExitUsage) - } - if strings.TrimSpace(stdout) != "" { - t.Fatalf("expected empty stdout in human parse-error mode, got %q", stdout) - } - if !strings.Contains(stderr, "invalid --timeout value") { - t.Fatalf("expected parse error on stderr, got %q", stderr) - } -} - -func TestRunParseErrorUsesJSONWhenTrailingGlobalJSONFollowsMalformedGlobal(t *testing.T) { - code, stdout, stderr := runWithCapturedStdIO( - t, - []string{"status", "--timeout=abc", "--json"}, - ) - if code != ExitUsage { - t.Fatalf("Run() code = %d, want %d", code, ExitUsage) - } - if strings.TrimSpace(stderr) != "" { - t.Fatalf("expected empty stderr in JSON mode, got %q", stderr) - } - - var env Envelope - if err := json.Unmarshal([]byte(stdout), &env); err != nil { - t.Fatalf("json.Unmarshal() error = %v\nraw: %s", err, stdout) - } - if env.OK { - t.Fatalf("expected ok=false") - } - if env.Error == nil || env.Error.Code != "usage_error" { - t.Fatalf("expected usage_error, got %#v", env.Error) - } - if env.Error == nil || !strings.Contains(env.Error.Message, "invalid --timeout value") { - t.Fatalf("unexpected parse error message: %#v", env.Error) - } -} - -func TestRunParseErrorTerminalTextValueJSONTokenDoesNotForceJSON(t *testing.T) { - code, stdout, stderr := runWithCapturedStdIO( - t, - []string{"terminal", "run", "--workspace", "0123456789abcdef", "--text", "--json", "--cwd"}, - ) - if code != ExitUsage { - t.Fatalf("Run() code = %d, want %d", code, ExitUsage) - } - if strings.TrimSpace(stdout) != "" { - t.Fatalf("expected empty stdout in human parse-error mode, got %q", stdout) - } - if !strings.Contains(stderr, "flag provided but not defined: -cwd") { - t.Fatalf("expected parse error on stderr, got %q", stderr) - } -} diff --git a/internal/cli/root_run_test.go b/internal/cli/root_run_test.go deleted file mode 100644 index 182d55c5..00000000 --- a/internal/cli/root_run_test.go +++ /dev/null @@ -1,156 +0,0 @@ -package cli - -import ( - "encoding/json" - "io" - "os" - "strings" - "testing" -) - -func TestRunNoCommandJSONReturnsUsageErrorEnvelope(t *testing.T) { - code, stdout, stderr := runWithCapturedStdIO(t, []string{"--json"}) - if code != ExitUsage { - t.Fatalf("Run() code = %d, want %d", code, ExitUsage) - } - if strings.TrimSpace(stderr) != "" { - t.Fatalf("expected empty stderr in --json mode, got %q", stderr) - } - - var env Envelope - if err := json.Unmarshal([]byte(stdout), &env); err != nil { - t.Fatalf("json.Unmarshal() error = %v\nraw: %s", err, stdout) - } - if env.OK { - t.Fatalf("expected ok=false") - } - if env.Error == nil || env.Error.Code != "usage_error" { - t.Fatalf("expected usage_error, got %#v", env.Error) - } - if env.Error == nil || !strings.Contains(env.Error.Message, "Usage: amux [flags]") { - t.Fatalf("unexpected error message: %#v", env.Error) - } -} - -func TestRunParseErrorUsesJSONWhenFlagAppearsAfterMalformedGlobal(t *testing.T) { - code, stdout, stderr := runWithCapturedStdIO(t, []string{"--timeout=abc", "--json", "status"}) - if code != ExitUsage { - t.Fatalf("Run() code = %d, want %d", code, ExitUsage) - } - if strings.TrimSpace(stderr) != "" { - t.Fatalf("expected empty stderr in JSON mode, got %q", stderr) - } - - var env Envelope - if err := json.Unmarshal([]byte(stdout), &env); err != nil { - t.Fatalf("json.Unmarshal() error = %v\nraw: %s", err, stdout) - } - if env.OK { - t.Fatalf("expected ok=false") - } - if env.Error == nil || env.Error.Code != "usage_error" { - t.Fatalf("expected usage_error, got %#v", env.Error) - } - if env.Error == nil || !strings.Contains(env.Error.Message, "invalid --timeout value") { - t.Fatalf("unexpected parse error message: %#v", env.Error) - } -} - -func TestRunVersionJSONReturnsEnvelope(t *testing.T) { - code, stdout, stderr := runWithCapturedStdIO(t, []string{"--json", "version"}) - if code != ExitOK { - t.Fatalf("Run() code = %d, want %d", code, ExitOK) - } - if strings.TrimSpace(stderr) != "" { - t.Fatalf("expected empty stderr in --json mode, got %q", stderr) - } - - var env Envelope - if err := json.Unmarshal([]byte(stdout), &env); err != nil { - t.Fatalf("json.Unmarshal() error = %v\nraw: %s", err, stdout) - } - if !env.OK { - t.Fatalf("expected ok=true, got error=%#v", env.Error) - } - data, ok := env.Data.(map[string]any) - if !ok { - t.Fatalf("expected data object, got %T", env.Data) - } - if got, _ := data["version"].(string); got != "test-v1" { - t.Fatalf("version = %q, want %q", got, "test-v1") - } - if got, _ := data["commit"].(string); got != "test-commit" { - t.Fatalf("commit = %q, want %q", got, "test-commit") - } - if got, _ := data["date"].(string); got != "test-date" { - t.Fatalf("date = %q, want %q", got, "test-date") - } -} - -func TestRunHelpJSONReturnsEnvelope(t *testing.T) { - code, stdout, stderr := runWithCapturedStdIO(t, []string{"--json", "help"}) - if code != ExitOK { - t.Fatalf("Run() code = %d, want %d", code, ExitOK) - } - if strings.TrimSpace(stderr) != "" { - t.Fatalf("expected empty stderr in --json mode, got %q", stderr) - } - - var env Envelope - if err := json.Unmarshal([]byte(stdout), &env); err != nil { - t.Fatalf("json.Unmarshal() error = %v\nraw: %s", err, stdout) - } - if !env.OK { - t.Fatalf("expected ok=true, got error=%#v", env.Error) - } - data, ok := env.Data.(map[string]any) - if !ok { - t.Fatalf("expected data object, got %T", env.Data) - } - usage, _ := data["usage"].(string) - if !strings.Contains(usage, "Usage: amux [flags]") { - t.Fatalf("usage data missing expected header: %q", usage) - } -} - -func runWithCapturedStdIO(t *testing.T, args []string) (int, string, string) { - t.Helper() - - origStdout := os.Stdout - origStderr := os.Stderr - stdoutR, stdoutW, err := os.Pipe() - if err != nil { - t.Fatalf("os.Pipe(stdout) error = %v", err) - } - stderrR, stderrW, err := os.Pipe() - if err != nil { - t.Fatalf("os.Pipe(stderr) error = %v", err) - } - os.Stdout = stdoutW - os.Stderr = stderrW - - restore := func() { - os.Stdout = origStdout - os.Stderr = origStderr - } - defer restore() - - code := Run(args, "test-v1", "test-commit", "test-date") - - _ = stdoutW.Close() - _ = stderrW.Close() - - stdoutBytes, readStdoutErr := io.ReadAll(stdoutR) - if readStdoutErr != nil { - t.Fatalf("io.ReadAll(stdout) error = %v", readStdoutErr) - } - stderrBytes, readStderrErr := io.ReadAll(stderrR) - if readStderrErr != nil { - t.Fatalf("io.ReadAll(stderr) error = %v", readStderrErr) - } - - _ = stdoutR.Close() - _ = stderrR.Close() - - return code, string(stdoutBytes), string(stderrBytes) -} diff --git a/internal/cli/router.go b/internal/cli/router.go deleted file mode 100644 index d9fc6e6f..00000000 --- a/internal/cli/router.go +++ /dev/null @@ -1,56 +0,0 @@ -package cli - -import ( - "fmt" - "io" - "strings" -) - -type cmdHandler = func(w, wErr io.Writer, gf GlobalFlags, args []string, version string) int - -type subcommand struct { - names []string - handler cmdHandler -} - -// routeSubcommand dispatches to the matching subcommand handler. -// It handles empty args (usage error) and unknown subcommands uniformly. -func routeSubcommand( - w, wErr io.Writer, gf GlobalFlags, args []string, version string, - parent string, - subs []subcommand, -) int { - usage := buildRouterUsage(parent, subs) - if len(args) == 0 { - if gf.JSON { - ReturnError(w, "usage_error", usage, nil, version) - } else { - fmt.Fprintln(wErr, usage) - } - return ExitUsage - } - sub := args[0] - subArgs := args[1:] - for _, s := range subs { - for _, name := range s.names { - if sub == name { - return s.handler(w, wErr, gf, subArgs, version) - } - } - } - msg := "Unknown " + parent + " subcommand: " + sub - if gf.JSON { - ReturnError(w, "unknown_command", msg, nil, version) - } else { - fmt.Fprintln(wErr, msg) - } - return ExitUsage -} - -func buildRouterUsage(parent string, subs []subcommand) string { - names := make([]string, 0, len(subs)) - for _, s := range subs { - names = append(names, s.names[0]) - } - return "Usage: amux " + parent + " <" + strings.Join(names, "|") + "> [flags]" -} diff --git a/internal/cli/send_jobs_queue.go b/internal/cli/send_jobs_queue.go deleted file mode 100644 index f9b938fa..00000000 --- a/internal/cli/send_jobs_queue.go +++ /dev/null @@ -1,157 +0,0 @@ -package cli - -import ( - "crypto/sha1" - "encoding/hex" - "fmt" - "os" - "path/filepath" - "strings" - "time" -) - -var ( - sendJobQueuePollInterval = 20 * time.Millisecond - sendJobQueueMaxPollInterval = 1 * time.Second - sendJobQueueMaxWait = 2 * sendJobsStaleAfter -) - -func (s *sendJobStore) queueLockPath(sessionName string) string { - sum := sha1.Sum([]byte(sessionName)) - return filepath.Join(filepath.Dir(s.path), "cli-send-queue-"+hex.EncodeToString(sum[:8])+".lock") -} - -func waitForSessionQueueTurnForJob(store *sendJobStore, sessionName, jobID string) (*os.File, error) { - jobID = strings.TrimSpace(jobID) - start := time.Now() - delay := sendJobQueuePollInterval - for { - lockFile, err := lockIdempotencyFile(store.queueLockPath(sessionName), false) - if err != nil { - return nil, err - } - if jobID == "" { - return lockFile, nil - } - - queued, err := store.isQueuedJobForSession(sessionName, jobID) - if err != nil { - unlockIdempotencyFile(lockFile) - return nil, err - } - if !queued { - return lockFile, nil - } - - head, ok, err := store.nextQueuedJobForSession(sessionName) - if err != nil { - unlockIdempotencyFile(lockFile) - return nil, err - } - if !ok || head.ID == jobID { - return lockFile, nil - } - - unlockIdempotencyFile(lockFile) - // Polling keeps the cross-process queue lock simple and portable. - // A bounded max wait prevents orphaned processors from spinning forever. - if sendJobQueueMaxWait > 0 && time.Since(start) >= sendJobQueueMaxWait { - return nil, fmt.Errorf("timed out waiting for send queue turn for job %s", jobID) - } - time.Sleep(delay) - // Exponential backoff: double the delay each iteration, capped at 1s. - delay *= 2 - if delay > sendJobQueueMaxPollInterval { - delay = sendJobQueueMaxPollInterval - } - } -} - -func releaseSessionQueueTurn(lockFile *os.File) { - unlockIdempotencyFile(lockFile) -} - -func (s *sendJobStore) isQueuedJobForSession(sessionName, jobID string) (bool, error) { - lockFile, err := lockIdempotencyFile(s.lockPath(), false) - if err != nil { - return false, err - } - defer unlockIdempotencyFile(lockFile) - - state, err := s.loadState() - if err != nil { - return false, err - } - if s.reconcileStale(state) { - if err := s.saveState(state); err != nil { - return false, err - } - } - - job, ok := state.Jobs[jobID] - if !ok { - return false, nil - } - if strings.TrimSpace(job.SessionName) != strings.TrimSpace(sessionName) { - return false, nil - } - return isQueuedSendJobStatus(job.Status), nil -} - -func (s *sendJobStore) nextQueuedJobForSession(sessionName string) (sendJob, bool, error) { - lockFile, err := lockIdempotencyFile(s.lockPath(), false) - if err != nil { - return sendJob{}, false, err - } - defer unlockIdempotencyFile(lockFile) - - state, err := s.loadState() - if err != nil { - return sendJob{}, false, err - } - if s.reconcileStale(state) { - if err := s.saveState(state); err != nil { - return sendJob{}, false, err - } - } - - target := strings.TrimSpace(sessionName) - var head sendJob - var found bool - for _, job := range state.Jobs { - if strings.TrimSpace(job.SessionName) != target { - continue - } - if !isQueuedSendJobStatus(job.Status) { - continue - } - if !found || sendJobComesBefore(job, head) { - head = job - found = true - } - } - return head, found, nil -} - -func isQueuedSendJobStatus(status sendJobStatus) bool { - return status == sendJobPending || status == sendJobRunning -} - -func sendJobComesBefore(candidate, existing sendJob) bool { - if existing.Status == sendJobRunning && candidate.Status != sendJobRunning { - return false - } - if candidate.Status == sendJobRunning && existing.Status != sendJobRunning { - return true - } - if candidate.CreatedAt < existing.CreatedAt { - return true - } - if candidate.CreatedAt > existing.CreatedAt { - return false - } - if candidate.Sequence > 0 && existing.Sequence > 0 && candidate.Sequence != existing.Sequence { - return candidate.Sequence < existing.Sequence - } - return candidate.ID < existing.ID -} diff --git a/internal/cli/send_jobs_queue_test.go b/internal/cli/send_jobs_queue_test.go deleted file mode 100644 index e920358d..00000000 --- a/internal/cli/send_jobs_queue_test.go +++ /dev/null @@ -1,168 +0,0 @@ -package cli - -import ( - "os" - "strings" - "testing" - "time" -) - -func TestWaitForSessionQueueTurnForJobReturnsWhenCanceledBehindQueue(t *testing.T) { - t.Setenv("HOME", t.TempDir()) - - store, err := newSendJobStore() - if err != nil { - t.Fatalf("newSendJobStore() error = %v", err) - } - running, err := store.create("session-a", "") - if err != nil { - t.Fatalf("store.create(running) error = %v", err) - } - if _, err := store.setStatus(running.ID, sendJobRunning, ""); err != nil { - t.Fatalf("store.setStatus(running) error = %v", err) - } - canceled, err := store.create("session-a", "") - if err != nil { - t.Fatalf("store.create(canceled) error = %v", err) - } - trailing, err := store.create("session-a", "") - if err != nil { - t.Fatalf("store.create(trailing) error = %v", err) - } - if _, ok, didCancel, err := store.cancel(canceled.ID); err != nil { - t.Fatalf("store.cancel() error = %v", err) - } else if !ok || !didCancel { - t.Fatalf("expected cancel to succeed, ok=%v canceled=%v", ok, didCancel) - } - - type waitResult struct { - lock *os.File - err error - } - resultCh := make(chan waitResult, 1) - go func() { - lock, waitErr := waitForSessionQueueTurnForJob(store, "session-a", canceled.ID) - resultCh <- waitResult{lock: lock, err: waitErr} - }() - - select { - case result := <-resultCh: - if result.err != nil { - t.Fatalf("waitForSessionQueueTurnForJob() error = %v", result.err) - } - if result.lock == nil { - t.Fatalf("expected queue lock when canceled job exits wait loop") - } - releaseSessionQueueTurn(result.lock) - case <-time.After(250 * time.Millisecond): - t.Fatal("waitForSessionQueueTurnForJob() blocked for canceled job") - } - - trailingJob, ok, err := store.get(trailing.ID) - if err != nil { - t.Fatalf("store.get(trailing) error = %v", err) - } - if !ok { - t.Fatalf("expected trailing job to exist") - } - if trailingJob.Status != sendJobPending { - t.Fatalf("trailing status = %q, want %q", trailingJob.Status, sendJobPending) - } -} - -func TestSendJobStoreNextQueuedJobUsesSequenceWhenCreatedAtMatches(t *testing.T) { - t.Setenv("HOME", t.TempDir()) - - store, err := newSendJobStore() - if err != nil { - t.Fatalf("newSendJobStore() error = %v", err) - } - first, err := store.create("session-a", "") - if err != nil { - t.Fatalf("store.create(first) error = %v", err) - } - second, err := store.create("session-a", "") - if err != nil { - t.Fatalf("store.create(second) error = %v", err) - } - - lockFile, err := lockIdempotencyFile(store.lockPath(), false) - if err != nil { - t.Fatalf("lockIdempotencyFile() error = %v", err) - } - state, err := store.loadState() - if err != nil { - unlockIdempotencyFile(lockFile) - t.Fatalf("store.loadState() error = %v", err) - } - - now := time.Now().Unix() - firstJob := state.Jobs[first.ID] - secondJob := state.Jobs[second.ID] - firstJob.CreatedAt = now - firstJob.UpdatedAt = now - firstJob.Sequence = 20 - secondJob.CreatedAt = now - secondJob.UpdatedAt = now - secondJob.Sequence = 10 - state.NextSequence = 20 - state.Jobs[first.ID] = firstJob - state.Jobs[second.ID] = secondJob - if err := store.saveState(state); err != nil { - unlockIdempotencyFile(lockFile) - t.Fatalf("store.saveState() error = %v", err) - } - unlockIdempotencyFile(lockFile) - - head, ok, err := store.nextQueuedJobForSession("session-a") - if err != nil { - t.Fatalf("store.nextQueuedJobForSession() error = %v", err) - } - if !ok { - t.Fatalf("expected queued job head") - } - if head.ID != second.ID { - t.Fatalf("head job = %s, want %s (lower sequence first)", head.ID, second.ID) - } -} - -func TestWaitForSessionQueueTurnForJobTimesOutWhenHeadNeverAdvances(t *testing.T) { - t.Setenv("HOME", t.TempDir()) - - origPoll := sendJobQueuePollInterval - origMaxWait := sendJobQueueMaxWait - sendJobQueuePollInterval = 5 * time.Millisecond - sendJobQueueMaxWait = 40 * time.Millisecond - defer func() { - sendJobQueuePollInterval = origPoll - sendJobQueueMaxWait = origMaxWait - }() - - store, err := newSendJobStore() - if err != nil { - t.Fatalf("newSendJobStore() error = %v", err) - } - head, err := store.create("session-a", "") - if err != nil { - t.Fatalf("store.create(head) error = %v", err) - } - if _, err := store.setStatus(head.ID, sendJobRunning, ""); err != nil { - t.Fatalf("store.setStatus(head) error = %v", err) - } - follower, err := store.create("session-a", "") - if err != nil { - t.Fatalf("store.create(follower) error = %v", err) - } - - lock, waitErr := waitForSessionQueueTurnForJob(store, "session-a", follower.ID) - if lock != nil { - releaseSessionQueueTurn(lock) - t.Fatalf("expected no lock on timeout") - } - if waitErr == nil { - t.Fatalf("expected timeout waiting for queue turn") - } - if !strings.Contains(waitErr.Error(), "timed out waiting for send queue turn") { - t.Fatalf("unexpected wait error: %v", waitErr) - } -} diff --git a/internal/cli/send_jobs_sequence.go b/internal/cli/send_jobs_sequence.go deleted file mode 100644 index 7ff6e758..00000000 --- a/internal/cli/send_jobs_sequence.go +++ /dev/null @@ -1,18 +0,0 @@ -package cli - -func nextSendJobSequence(state *sendJobState) int64 { - if state == nil { - return 1 - } - if state.NextSequence <= 0 { - var maxSequence int64 - for _, job := range state.Jobs { - if job.Sequence > maxSequence { - maxSequence = job.Sequence - } - } - state.NextSequence = maxSequence - } - state.NextSequence++ - return state.NextSequence -} diff --git a/internal/cli/send_jobs_store.go b/internal/cli/send_jobs_store.go deleted file mode 100644 index f1f5c1c3..00000000 --- a/internal/cli/send_jobs_store.go +++ /dev/null @@ -1,361 +0,0 @@ -package cli - -import ( - "crypto/rand" - "encoding/hex" - "encoding/json" - "errors" - "io" - "log/slog" - "os" - "path/filepath" - "strconv" - "strings" - "time" - - "github.com/andyrewlee/amux/internal/config" -) - -const ( - sendJobsFilename = "cli-send-jobs.json" - sendJobsStateVersion = 1 - sendJobsRetention = 7 * 24 * time.Hour - sendJobsStaleAfter = 15 * time.Minute -) - -type sendJobStatus string - -const ( - sendJobPending sendJobStatus = "pending" - sendJobRunning sendJobStatus = "running" - sendJobCompleted sendJobStatus = "completed" - sendJobFailed sendJobStatus = "failed" - sendJobCanceled sendJobStatus = "canceled" -) - -type sendJob struct { - ID string `json:"id"` - Command string `json:"command"` - SessionName string `json:"session_name"` - AgentID string `json:"agent_id,omitempty"` - Status sendJobStatus `json:"status"` - Error string `json:"error,omitempty"` - Sequence int64 `json:"sequence,omitempty"` - CreatedAt int64 `json:"created_at"` - UpdatedAt int64 `json:"updated_at"` - CompletedAt int64 `json:"completed_at,omitempty"` -} - -type sendJobState struct { - Version int `json:"version"` - NextSequence int64 `json:"next_sequence,omitempty"` - Jobs map[string]sendJob `json:"jobs"` -} - -type sendJobStore struct { - path string -} - -type agentJobResult struct { - JobID string `json:"job_id"` - Status string `json:"status"` - SessionName string `json:"session_name,omitempty"` - AgentID string `json:"agent_id,omitempty"` - Error string `json:"error,omitempty"` - CreatedAt int64 `json:"created_at"` - UpdatedAt int64 `json:"updated_at"` - CompletedAt int64 `json:"completed_at,omitempty"` -} - -type agentJobCancelResult struct { - JobID string `json:"job_id"` - Status string `json:"status"` - Canceled bool `json:"canceled"` -} - -func newSendJobStore() (*sendJobStore, error) { - paths, err := config.DefaultPaths() - if err != nil { - return nil, err - } - return &sendJobStore{ - path: filepath.Join(paths.Home, sendJobsFilename), - }, nil -} - -func sendJobToResult(job sendJob) agentJobResult { - return agentJobResult{ - JobID: job.ID, - Status: string(job.Status), - SessionName: job.SessionName, - AgentID: job.AgentID, - Error: job.Error, - CreatedAt: job.CreatedAt, - UpdatedAt: job.UpdatedAt, - CompletedAt: job.CompletedAt, - } -} - -func (s *sendJobStore) create(sessionName, agentID string) (sendJob, error) { - lockFile, err := lockIdempotencyFile(s.lockPath(), false) - if err != nil { - return sendJob{}, err - } - defer unlockIdempotencyFile(lockFile) - - state, err := s.loadState() - if err != nil { - return sendJob{}, err - } - s.reconcileStale(state) - s.prune(state) - - now := time.Now().Unix() - job := sendJob{ - ID: newSendJobID(), - Command: "agent.send", - SessionName: sessionName, - AgentID: agentID, - Status: sendJobPending, - Sequence: nextSendJobSequence(state), - CreatedAt: now, - UpdatedAt: now, - } - state.Jobs[job.ID] = job - if err := s.saveState(state); err != nil { - return sendJob{}, err - } - return job, nil -} - -func (s *sendJobStore) get(jobID string) (sendJob, bool, error) { - // Exclusive lock: reconcileStale below may write back cleaned-up state. - lockFile, err := lockIdempotencyFile(s.lockPath(), false) - if err != nil { - return sendJob{}, false, err - } - defer unlockIdempotencyFile(lockFile) - - state, err := s.loadState() - if err != nil { - return sendJob{}, false, err - } - if s.reconcileStale(state) { - if err := s.saveState(state); err != nil { - return sendJob{}, false, err - } - } - job, ok := state.Jobs[jobID] - return job, ok, nil -} - -func (s *sendJobStore) cancel(jobID string) (sendJob, bool, bool, error) { - lockFile, err := lockIdempotencyFile(s.lockPath(), false) - if err != nil { - return sendJob{}, false, false, err - } - defer unlockIdempotencyFile(lockFile) - - state, err := s.loadState() - if err != nil { - return sendJob{}, false, false, err - } - if s.reconcileStale(state) { - if err := s.saveState(state); err != nil { - return sendJob{}, false, false, err - } - } - job, ok := state.Jobs[jobID] - if !ok { - return sendJob{}, false, false, nil - } - if job.Status != sendJobPending { - return job, true, false, nil - } - job.Status = sendJobCanceled - job.UpdatedAt = time.Now().Unix() - job.CompletedAt = job.UpdatedAt - state.Jobs[jobID] = job - if err := s.saveState(state); err != nil { - return sendJob{}, false, false, err - } - return job, true, true, nil -} - -func (s *sendJobStore) setStatus(jobID string, status sendJobStatus, errText string) (sendJob, error) { - lockFile, err := lockIdempotencyFile(s.lockPath(), false) - if err != nil { - return sendJob{}, err - } - defer unlockIdempotencyFile(lockFile) - - state, err := s.loadState() - if err != nil { - return sendJob{}, err - } - if s.reconcileStale(state) { - if err := s.saveState(state); err != nil { - return sendJob{}, err - } - } - job, ok := state.Jobs[jobID] - if !ok { - return sendJob{}, errors.New("job not found") - } - if !canTransitionSendJobStatus(job.Status, status) { - return job, nil - } - job.Status = status - job.Error = strings.TrimSpace(errText) - job.UpdatedAt = time.Now().Unix() - if status == sendJobCompleted || status == sendJobFailed || status == sendJobCanceled { - job.CompletedAt = job.UpdatedAt - } - state.Jobs[jobID] = job - if err := s.saveState(state); err != nil { - return sendJob{}, err - } - return job, nil -} - -func (s *sendJobStore) lockPath() string { - return s.path + ".lock" -} - -func (s *sendJobStore) loadState() (*sendJobState, error) { - data, err := os.ReadFile(s.path) - if os.IsNotExist(err) { - return &sendJobState{ - Version: sendJobsStateVersion, - Jobs: map[string]sendJob{}, - }, nil - } - if err != nil { - return nil, err - } - - var state sendJobState - if err := json.Unmarshal(data, &state); err != nil || state.Version != sendJobsStateVersion { - return &sendJobState{ - Version: sendJobsStateVersion, - Jobs: map[string]sendJob{}, - }, nil - } - if state.Jobs == nil { - state.Jobs = map[string]sendJob{} - } - return &state, nil -} - -func (s *sendJobStore) saveState(state *sendJobState) error { - if err := os.MkdirAll(filepath.Dir(s.path), 0o755); err != nil { - return err - } - payload, err := json.MarshalIndent(state, "", " ") - if err != nil { - return err - } - tmpPath := s.path + ".tmp" - if err := os.WriteFile(tmpPath, payload, 0o644); err != nil { - return err - } - if err := os.Rename(tmpPath, s.path); err != nil { - if removeErr := os.Remove(tmpPath); removeErr != nil { - slog.Debug("failed to remove temp file after rename failure", "path", tmpPath, "error", removeErr) - } - return err - } - return nil -} - -func (s *sendJobStore) prune(state *sendJobState) { - if state == nil || len(state.Jobs) == 0 { - return - } - cutoff := time.Now().Add(-sendJobsRetention).Unix() - for id, job := range state.Jobs { - if job.Status == sendJobPending || job.Status == sendJobRunning { - continue - } - if job.UpdatedAt <= cutoff { - delete(state.Jobs, id) - } - } -} - -func (s *sendJobStore) reconcileStale(state *sendJobState) bool { - if state == nil || len(state.Jobs) == 0 { - return false - } - now := time.Now().Unix() - staleCutoff := now - int64(sendJobsStaleAfter/time.Second) - changed := false - for id, job := range state.Jobs { - if job.Status != sendJobPending && job.Status != sendJobRunning { - continue - } - if job.UpdatedAt > staleCutoff { - continue - } - original := job.Status - job.Status = sendJobFailed - if job.Error == "" { - job.Error = staleJobReason(original) - } - job.UpdatedAt = now - job.CompletedAt = now - state.Jobs[id] = job - changed = true - } - return changed -} - -func staleJobReason(original sendJobStatus) string { - if original == sendJobRunning { - return "job marked failed after stale running timeout; processor may have exited" - } - return "job marked failed after stale pending timeout; processor may have exited" -} - -func newSendJobID() string { - var b [6]byte - if _, err := rand.Read(b[:]); err == nil { - return "sj_" + strconv.FormatInt(time.Now().UnixNano(), 36) + "_" + hex.EncodeToString(b[:]) - } - return "sj_" + strconv.FormatInt(time.Now().UnixNano(), 36) -} - -func isTerminalSendJobStatus(status sendJobStatus) bool { - return status == sendJobCompleted || status == sendJobFailed || status == sendJobCanceled -} - -func canTransitionSendJobStatus(from, to sendJobStatus) bool { - if from == to { - return true - } - switch from { - case sendJobPending: - return to == sendJobRunning || to == sendJobCompleted || to == sendJobFailed || to == sendJobCanceled - case sendJobRunning: - return to == sendJobCompleted || to == sendJobFailed - case sendJobCompleted, sendJobFailed, sendJobCanceled: - return false - default: - return false - } -} - -func writeJobStatusResult(w io.Writer, gf GlobalFlags, version string, job sendJob) { - if gf.JSON { - PrintJSON(w, sendJobToResult(job), version) - return - } - PrintHuman(w, func(w io.Writer) { - out := sendJobToResult(job) - if out.Error != "" { - _, _ = io.WriteString(w, "job "+out.JobID+" "+out.Status+" ("+out.Error+")\n") - return - } - _, _ = io.WriteString(w, "job "+out.JobID+" "+out.Status+"\n") - }) -} diff --git a/internal/cli/send_jobs_store_test.go b/internal/cli/send_jobs_store_test.go deleted file mode 100644 index 456926d1..00000000 --- a/internal/cli/send_jobs_store_test.go +++ /dev/null @@ -1,195 +0,0 @@ -package cli - -import ( - "strings" - "testing" - "time" -) - -func TestSendJobStoreGetReconcilesStalePendingJob(t *testing.T) { - t.Setenv("HOME", t.TempDir()) - - store, err := newSendJobStore() - if err != nil { - t.Fatalf("newSendJobStore() error = %v", err) - } - job, err := store.create("session-a", "") - if err != nil { - t.Fatalf("store.create() error = %v", err) - } - - if err := makeJobStale(store, job.ID, sendJobPending); err != nil { - t.Fatalf("makeJobStale() error = %v", err) - } - - got, ok, err := store.get(job.ID) - if err != nil { - t.Fatalf("store.get() error = %v", err) - } - if !ok { - t.Fatalf("expected job to exist") - } - if got.Status != sendJobFailed { - t.Fatalf("status = %q, want %q after read-path reconciliation", got.Status, sendJobFailed) - } - if !strings.Contains(got.Error, "stale pending timeout") { - t.Fatalf("error = %q, want stale pending timeout message", got.Error) - } - if got.CompletedAt == 0 { - t.Fatalf("expected completed_at to be set for reconciled stale job") - } -} - -func TestSendJobStoreGetReconcilesStaleRunningJob(t *testing.T) { - t.Setenv("HOME", t.TempDir()) - - store, err := newSendJobStore() - if err != nil { - t.Fatalf("newSendJobStore() error = %v", err) - } - job, err := store.create("session-a", "") - if err != nil { - t.Fatalf("store.create() error = %v", err) - } - if _, err := store.setStatus(job.ID, sendJobRunning, ""); err != nil { - t.Fatalf("store.setStatus() error = %v", err) - } - - if err := makeJobStale(store, job.ID, sendJobRunning); err != nil { - t.Fatalf("makeJobStale() error = %v", err) - } - - got, ok, err := store.get(job.ID) - if err != nil { - t.Fatalf("store.get() error = %v", err) - } - if !ok { - t.Fatalf("expected job to exist") - } - if got.Status != sendJobFailed { - t.Fatalf("status = %q, want %q after read-path reconciliation", got.Status, sendJobFailed) - } - if !strings.Contains(got.Error, "stale running timeout") { - t.Fatalf("error = %q, want stale running timeout message", got.Error) - } - if got.CompletedAt == 0 { - t.Fatalf("expected completed_at to be set for reconciled stale job") - } -} - -func TestSendJobStoreGetDoesNotReconcileFreshPending(t *testing.T) { - t.Setenv("HOME", t.TempDir()) - - store, err := newSendJobStore() - if err != nil { - t.Fatalf("newSendJobStore() error = %v", err) - } - job, err := store.create("session-a", "") - if err != nil { - t.Fatalf("store.create() error = %v", err) - } - - got, ok, err := store.get(job.ID) - if err != nil { - t.Fatalf("store.get() error = %v", err) - } - if !ok { - t.Fatalf("expected job to exist") - } - if got.Status != sendJobPending { - t.Fatalf("status = %q, want %q", got.Status, sendJobPending) - } -} - -func TestSendJobStoreSetStatusDoesNotOverrideCanceledJob(t *testing.T) { - t.Setenv("HOME", t.TempDir()) - - store, err := newSendJobStore() - if err != nil { - t.Fatalf("newSendJobStore() error = %v", err) - } - job, err := store.create("session-a", "") - if err != nil { - t.Fatalf("store.create() error = %v", err) - } - canceledJob, ok, canceled, err := store.cancel(job.ID) - if err != nil { - t.Fatalf("store.cancel() error = %v", err) - } - if !ok || !canceled { - t.Fatalf("expected cancel to succeed, ok=%v canceled=%v", ok, canceled) - } - - updated, err := store.setStatus(job.ID, sendJobRunning, "") - if err != nil { - t.Fatalf("store.setStatus() error = %v", err) - } - if updated.Status != sendJobCanceled { - t.Fatalf("status after running transition = %q, want %q", updated.Status, sendJobCanceled) - } - if _, err := store.setStatus(job.ID, sendJobFailed, "should-not-overwrite"); err != nil { - t.Fatalf("store.setStatus() error = %v", err) - } - - got, exists, err := store.get(job.ID) - if err != nil { - t.Fatalf("store.get() error = %v", err) - } - if !exists { - t.Fatalf("expected job to exist") - } - if got.Status != sendJobCanceled { - t.Fatalf("persisted status = %q, want %q", got.Status, sendJobCanceled) - } - if got.CompletedAt != canceledJob.CompletedAt { - t.Fatalf("completed_at changed from %d to %d", canceledJob.CompletedAt, got.CompletedAt) - } -} - -func TestSendJobStoreSetStatusDoesNotReopenCompletedJob(t *testing.T) { - t.Setenv("HOME", t.TempDir()) - - store, err := newSendJobStore() - if err != nil { - t.Fatalf("newSendJobStore() error = %v", err) - } - job, err := store.create("session-a", "") - if err != nil { - t.Fatalf("store.create() error = %v", err) - } - completed, err := store.setStatus(job.ID, sendJobCompleted, "") - if err != nil { - t.Fatalf("store.setStatus() error = %v", err) - } - if completed.Status != sendJobCompleted { - t.Fatalf("status = %q, want %q", completed.Status, sendJobCompleted) - } - - updated, err := store.setStatus(job.ID, sendJobRunning, "") - if err != nil { - t.Fatalf("store.setStatus() error = %v", err) - } - if updated.Status != sendJobCompleted { - t.Fatalf("status after running transition = %q, want %q", updated.Status, sendJobCompleted) - } -} - -func makeJobStale(store *sendJobStore, jobID string, status sendJobStatus) error { - lockFile, err := lockIdempotencyFile(store.lockPath(), false) - if err != nil { - return err - } - defer unlockIdempotencyFile(lockFile) - - state, err := store.loadState() - if err != nil { - return err - } - job := state.Jobs[jobID] - job.Status = status - job.Error = "" - job.UpdatedAt = time.Now().Add(-sendJobsStaleAfter - time.Minute).Unix() - job.CompletedAt = 0 - state.Jobs[jobID] = job - return store.saveState(state) -} diff --git a/internal/cli/services.go b/internal/cli/services.go deleted file mode 100644 index 2c35e4de..00000000 --- a/internal/cli/services.go +++ /dev/null @@ -1,74 +0,0 @@ -package cli - -import ( - "log/slog" - "os" - "strings" - "sync/atomic" - "time" - - "github.com/andyrewlee/amux/internal/config" - "github.com/andyrewlee/amux/internal/data" - "github.com/andyrewlee/amux/internal/tmux" -) - -// Services is a lightweight service container for CLI commands. -// Unlike app.New(), it starts no goroutines, watchers, or UI. -type Services struct { - Config *config.Config - Registry *data.Registry - Store *data.WorkspaceStore - TmuxOpts tmux.Options - Version string - QuerySessionRows func(opts tmux.Options) ([]sessionRow, error) -} - -var cliTmuxTimeoutOverrideNanos atomic.Int64 - -// NewServices constructs the minimal service set needed by CLI commands. -func NewServices(version string) (*Services, error) { - cfg, err := config.DefaultConfig() - if err != nil { - return nil, err - } - - // CLI commands execute in their own process, so these env assignments are - // intentionally process-scoped defaults for tmux integration. - // They do not leak across independent CLI invocations. - setEnvIfNonEmpty("AMUX_TMUX_SERVER", cfg.UI.TmuxServer) - setEnvIfNonEmpty("AMUX_TMUX_CONFIG", cfg.UI.TmuxConfigPath) - - registry := data.NewRegistry(cfg.Paths.RegistryPath) - store := data.NewWorkspaceStore(cfg.Paths.MetadataRoot) - opts := tmux.DefaultOptions() - if timeout := currentCLITmuxTimeoutOverride(); timeout > 0 { - opts.CommandTimeout = timeout - } - - return &Services{ - Config: cfg, - Registry: registry, - Store: store, - TmuxOpts: opts, - Version: version, - QuerySessionRows: defaultQuerySessionRows, - }, nil -} - -func setEnvIfNonEmpty(key, value string) { - value = strings.TrimSpace(value) - if value == "" { - return - } - if err := os.Setenv(key, value); err != nil { - slog.Debug("failed to set environment variable", "key", key, "error", err) - } -} - -func setCLITmuxTimeoutOverride(timeout time.Duration) time.Duration { - return time.Duration(cliTmuxTimeoutOverrideNanos.Swap(int64(timeout))) -} - -func currentCLITmuxTimeoutOverride() time.Duration { - return time.Duration(cliTmuxTimeoutOverrideNanos.Load()) -} diff --git a/internal/cli/services_test.go b/internal/cli/services_test.go deleted file mode 100644 index c1b861a5..00000000 --- a/internal/cli/services_test.go +++ /dev/null @@ -1,22 +0,0 @@ -package cli - -import ( - "testing" - "time" -) - -func TestNewServicesUsesTimeoutOverride(t *testing.T) { - prevTimeout := setCLITmuxTimeoutOverride(0) - defer setCLITmuxTimeoutOverride(prevTimeout) - - const want = 1750 * time.Millisecond - setCLITmuxTimeoutOverride(want) - - svc, err := NewServices("test-v1") - if err != nil { - t.Fatalf("NewServices() error = %v", err) - } - if svc.TmuxOpts.CommandTimeout != want { - t.Fatalf("tmux timeout = %v, want %v", svc.TmuxOpts.CommandTimeout, want) - } -} diff --git a/internal/cli/usage.go b/internal/cli/usage.go deleted file mode 100644 index fda6779f..00000000 --- a/internal/cli/usage.go +++ /dev/null @@ -1,25 +0,0 @@ -package cli - -import ( - "fmt" - "io" -) - -func returnUsageError(w, wErr io.Writer, gf GlobalFlags, usage, version string, parseErr error) int { - if gf.JSON { - message := usage - details := any(nil) - if parseErr != nil { - message = parseErr.Error() - details = map[string]any{"usage": usage} - } - ReturnError(w, "usage_error", message, details, version) - return ExitUsage - } - - if parseErr != nil { - Errorf(wErr, "%v", parseErr) - } - fmt.Fprintln(wErr, usage) - return ExitUsage -} diff --git a/internal/cli/workspace_id.go b/internal/cli/workspace_id.go deleted file mode 100644 index cdc9d865..00000000 --- a/internal/cli/workspace_id.go +++ /dev/null @@ -1,16 +0,0 @@ -package cli - -import ( - "fmt" - "strings" - - "github.com/andyrewlee/amux/internal/data" -) - -func parseWorkspaceIDFlag(raw string) (data.WorkspaceID, error) { - wsID := data.WorkspaceID(strings.TrimSpace(raw)) - if !data.IsValidWorkspaceID(wsID) { - return "", fmt.Errorf("invalid workspace id: %s", raw) - } - return wsID, nil -} diff --git a/internal/config/config_test.go b/internal/config/config_test.go index a565e9fa..bfc560e3 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -42,8 +42,8 @@ func TestDefaultConfigLoadsAssistantOverrides(t *testing.T) { } content := `{ "assistants": { - "openclaw": { - "command": "openclaw --fast" + "my-fast-agent": { + "command": "my-fast-agent --fast" }, "myagent": { "command": "myagent", @@ -64,12 +64,12 @@ func TestDefaultConfigLoadsAssistantOverrides(t *testing.T) { if got := cfg.ResolvedDefaultAssistant(); got != "claude" { t.Fatalf("ResolvedDefaultAssistant() = %q, want %q", got, "claude") } - oc, ok := cfg.Assistants["openclaw"] + customFast, ok := cfg.Assistants["my-fast-agent"] if !ok { - t.Fatalf("expected openclaw assistant to exist") + t.Fatalf("expected my-fast-agent assistant to exist") } - if oc.Command != "openclaw --fast" { - t.Fatalf("openclaw command = %q, want %q", oc.Command, "openclaw --fast") + if customFast.Command != "my-fast-agent --fast" { + t.Fatalf("my-fast-agent command = %q, want %q", customFast.Command, "my-fast-agent --fast") } custom, ok := cfg.Assistants["myagent"] diff --git a/internal/data/workspace.go b/internal/data/workspace.go index 5f1a33df..28136c69 100644 --- a/internal/data/workspace.go +++ b/internal/data/workspace.go @@ -63,7 +63,7 @@ type Workspace struct { Runtime string `json:"runtime"` // local-worktree, local-checkout, cloud-sandbox // Agent config - Assistant string `json:"assistant"` // Assistant profile ID (e.g. claude, codex, openclaw) + Assistant string `json:"assistant"` // Assistant profile ID (e.g. claude, codex, gemini) // Scripts Scripts ScriptsConfig `json:"scripts"` diff --git a/internal/data/workspace_store_advanced_test.go b/internal/data/workspace_store_advanced_test.go index 5ca1e33c..3757af5f 100644 --- a/internal/data/workspace_store_advanced_test.go +++ b/internal/data/workspace_store_advanced_test.go @@ -153,7 +153,7 @@ func TestWorkspaceStore_LoadAppliesDefaults(t *testing.T) { func TestWorkspaceStore_LoadAppliesConfiguredDefaultAssistant(t *testing.T) { root := t.TempDir() store := NewWorkspaceStore(root) - store.SetDefaultAssistant("openclaw") + store.SetDefaultAssistant("codex") ws := &Workspace{ Name: "configured-default-test", @@ -182,8 +182,8 @@ func TestWorkspaceStore_LoadAppliesConfiguredDefaultAssistant(t *testing.T) { if err != nil { t.Fatalf("Load() error = %v", err) } - if loaded.Assistant != "openclaw" { - t.Errorf("Assistant = %v, want %v", loaded.Assistant, "openclaw") + if loaded.Assistant != "codex" { + t.Errorf("Assistant = %v, want %v", loaded.Assistant, "codex") } } diff --git a/internal/data/workspace_store_metadata_test.go b/internal/data/workspace_store_metadata_test.go index ac74da02..c7b74d10 100644 --- a/internal/data/workspace_store_metadata_test.go +++ b/internal/data/workspace_store_metadata_test.go @@ -162,7 +162,7 @@ func TestWorkspaceStore_LoadMetadataFor_PreservesExistingAssistantWhenStoredEmpt Branch: "test", Repo: "/repo", Root: "/root", - Assistant: "openclaw", + Assistant: "codex", } id := discovered.ID() @@ -186,8 +186,8 @@ func TestWorkspaceStore_LoadMetadataFor_PreservesExistingAssistantWhenStoredEmpt if !found { t.Fatal("LoadMetadataFor() should have found metadata") } - if discovered.Assistant != "openclaw" { - t.Errorf("Assistant = %v, want 'openclaw'", discovered.Assistant) + if discovered.Assistant != "codex" { + t.Errorf("Assistant = %v, want 'codex'", discovered.Assistant) } } @@ -201,7 +201,7 @@ func TestWorkspaceStore_LoadMetadataFor_FallbackLookupPreservesExistingAssistant Branch: "test", Repo: "/repo", Root: "/root", - Assistant: "openclaw", + Assistant: "codex", } legacyID := WorkspaceID("legacy_test_ws_id") @@ -228,8 +228,8 @@ func TestWorkspaceStore_LoadMetadataFor_FallbackLookupPreservesExistingAssistant if !found { t.Fatal("LoadMetadataFor() should have found metadata via fallback lookup") } - if discovered.Assistant != "openclaw" { - t.Errorf("Assistant = %v, want 'openclaw'", discovered.Assistant) + if discovered.Assistant != "codex" { + t.Errorf("Assistant = %v, want 'codex'", discovered.Assistant) } } @@ -253,7 +253,7 @@ func TestWorkspaceStore_UpsertFromDiscovery_PreservesDiscoveredAssistantWhenStor Branch: "test", Repo: "/repo", Root: "/root", - Assistant: "openclaw", + Assistant: "codex", } if err := store.UpsertFromDiscovery(discovered); err != nil { t.Fatalf("UpsertFromDiscovery() error = %v", err) @@ -263,8 +263,8 @@ func TestWorkspaceStore_UpsertFromDiscovery_PreservesDiscoveredAssistantWhenStor if err != nil { t.Fatalf("Load() error = %v", err) } - if loaded.Assistant != "openclaw" { - t.Errorf("Assistant = %v, want 'openclaw'", loaded.Assistant) + if loaded.Assistant != "codex" { + t.Errorf("Assistant = %v, want 'codex'", loaded.Assistant) } } diff --git a/internal/data/workspace_store_tabs.go b/internal/data/workspace_store_tabs.go index 1325431c..6df651f4 100644 --- a/internal/data/workspace_store_tabs.go +++ b/internal/data/workspace_store_tabs.go @@ -10,7 +10,7 @@ import ( // Update atomically loads a workspace, passes it to fn for modification, and // saves it back. The workspace flock is held across the entire Load+fn+Save -// sequence, preventing lost-update races between concurrent CLI processes. +// sequence, preventing lost-update races between concurrent amux processes. func (s *WorkspaceStore) Update(id WorkspaceID, fn func(ws *Workspace) error) error { if err := validateWorkspaceID(id); err != nil { return err diff --git a/internal/e2e/cli_agent_jobs_test.go b/internal/e2e/cli_agent_jobs_test.go deleted file mode 100644 index 62448d2d..00000000 --- a/internal/e2e/cli_agent_jobs_test.go +++ /dev/null @@ -1,345 +0,0 @@ -package e2e - -import ( - "bytes" - "crypto/sha1" - "encoding/hex" - "encoding/json" - "errors" - "fmt" - "os" - "os/exec" - "path/filepath" - "strings" - "syscall" - "testing" - "time" -) - -type cliEnvelope struct { - OK bool `json:"ok"` - Data any `json:"data"` - Error *cliError `json:"error"` -} - -type cliError struct { - Code string `json:"code"` - Message string `json:"message"` -} - -func TestCLIAgentSendAsyncQueueOrdering(t *testing.T) { - skipIfNoTmux(t) - - home := t.TempDir() - server := fmt.Sprintf("amux-e2e-cli-%d", time.Now().UnixNano()) - defer killTmuxServer(t, server) - - sessionName := "amux-e2e-order" - logPath := filepath.Join(t.TempDir(), "ordered.log") - createTmuxSessionWithCommand(t, server, sessionName, "cat >> "+shellQuote(logPath)) - - _, first, _, _ := runAmuxJSON(t, home, server, - "agent", "send", sessionName, "--text", "first", "--enter", "--async", - ) - jobID1 := jsonStringField(t, first.Data, "job_id") - - time.Sleep(200 * time.Millisecond) - - _, second, _, _ := runAmuxJSON(t, home, server, - "agent", "send", sessionName, "--text", "second", "--enter", "--async", - ) - jobID2 := jsonStringField(t, second.Data, "job_id") - - code1, done1, _, _ := runAmuxJSON(t, home, server, - "agent", "job", "wait", jobID1, "--timeout", "8s", "--interval", "50ms", - ) - if code1 != 0 { - t.Fatalf("wait job1 exit code = %d, want 0 (env=%+v)", code1, done1) - } - if got := jsonStringField(t, done1.Data, "status"); got != "completed" { - t.Fatalf("job1 status = %q, want completed", got) - } - - code2, done2, _, _ := runAmuxJSON(t, home, server, - "agent", "job", "wait", jobID2, "--timeout", "8s", "--interval", "50ms", - ) - if code2 != 0 { - t.Fatalf("wait job2 exit code = %d, want 0 (env=%+v)", code2, done2) - } - if got := jsonStringField(t, done2.Data, "status"); got != "completed" { - t.Fatalf("job2 status = %q, want completed", got) - } - - lines := waitForLogLines(t, logPath, 2, 8*time.Second) - if lines[0] != "first" || lines[1] != "second" { - t.Fatalf("unexpected send order: %v", lines) - } -} - -func TestCLIAgentSendCancelRacePendingJob(t *testing.T) { - skipIfNoTmux(t) - - home := t.TempDir() - server := fmt.Sprintf("amux-e2e-cli-%d", time.Now().UnixNano()) - defer killTmuxServer(t, server) - - sessionName := "amux-e2e-cancel-race" - logPath := filepath.Join(t.TempDir(), "cancel.log") - createTmuxSessionWithCommand(t, server, sessionName, "cat >> "+shellQuote(logPath)) - - lockFile := lockSendQueueFile(t, home, sessionName) - defer func() { - unlockSendQueueFile(t, lockFile) - }() - - _, sendEnv, _, _ := runAmuxJSON(t, home, server, - "agent", "send", sessionName, "--text", "blocked", "--enter", "--async", - ) - jobID := jsonStringField(t, sendEnv.Data, "job_id") - if status := jsonStringField(t, sendEnv.Data, "status"); status != "pending" { - t.Fatalf("async send status = %q, want pending", status) - } - - _, cancelEnv, _, _ := runAmuxJSON(t, home, server, - "agent", "job", "cancel", jobID, - ) - if canceled := jsonBoolField(t, cancelEnv.Data, "canceled"); !canceled { - t.Fatalf("expected canceled=true, got false") - } - - unlockSendQueueFile(t, lockFile) - lockFile = nil - - code, waitEnv, _, _ := runAmuxJSON(t, home, server, - "agent", "job", "wait", jobID, "--timeout", "8s", "--interval", "50ms", - ) - if code != 0 { - t.Fatalf("wait job exit code = %d, want 0 (env=%+v)", code, waitEnv) - } - if got := jsonStringField(t, waitEnv.Data, "status"); got != "canceled" { - t.Fatalf("wait status = %q, want canceled", got) - } - - time.Sleep(200 * time.Millisecond) - content, _ := os.ReadFile(logPath) - if strings.Contains(string(content), "blocked") { - t.Fatalf("unexpected delivered text after cancel race: %q", string(content)) - } -} - -func TestCLIAgentSendIdempotentErrorReplay(t *testing.T) { - skipIfNoTmux(t) - - home := t.TempDir() - server := fmt.Sprintf("amux-e2e-cli-%d", time.Now().UnixNano()) - defer killTmuxServer(t, server) - - sessionName := "amux-e2e-replay" - logPath := filepath.Join(t.TempDir(), "replay.log") - idemKey := "e2e-idem-send-not-found" - - firstCode, firstEnv, firstOut, _ := runAmuxJSON(t, home, server, - "agent", "send", sessionName, "--text", "hello", "--enter", "--idempotency-key", idemKey, - ) - if firstCode == 0 { - t.Fatalf("expected non-zero exit for first missing-session send") - } - if firstEnv.Error == nil || firstEnv.Error.Code != "not_found" { - t.Fatalf("expected not_found error, got %+v", firstEnv.Error) - } - - createTmuxSessionWithCommand(t, server, sessionName, "cat >> "+shellQuote(logPath)) - - secondCode, secondEnv, secondOut, _ := runAmuxJSON(t, home, server, - "agent", "send", sessionName, "--text", "hello", "--enter", "--idempotency-key", idemKey, - ) - if secondCode != firstCode { - t.Fatalf("replay exit code = %d, want %d", secondCode, firstCode) - } - if secondEnv.Error == nil || secondEnv.Error.Code != "not_found" { - t.Fatalf("expected replayed not_found error, got %+v", secondEnv.Error) - } - if secondOut != firstOut { - t.Fatalf("expected exact replayed envelope\nfirst:\n%s\nsecond:\n%s", firstOut, secondOut) - } - - time.Sleep(250 * time.Millisecond) - content, _ := os.ReadFile(logPath) - if strings.TrimSpace(string(content)) != "" { - t.Fatalf("replayed error should not deliver text, got log: %q", string(content)) - } -} - -func TestCLIAgentStopGracefulFallbackKillsIgnoredInterrupt(t *testing.T) { - skipIfNoTmux(t) - - home := t.TempDir() - server := fmt.Sprintf("amux-e2e-cli-%d", time.Now().UnixNano()) - defer killTmuxServer(t, server) - - sessionName := "amux-e2e-stop-fallback" - createTmuxSessionWithCommand( - t, - server, - sessionName, - "trap '' INT; while :; do sleep 1; done", - ) - - code, env, _, _ := runAmuxJSON(t, home, server, - "agent", "stop", sessionName, "--grace-period", "150ms", - ) - if code != 0 { - t.Fatalf("agent stop exit code = %d, want 0 (env=%+v)", code, env) - } - if !env.OK { - t.Fatalf("expected ok=true, got error=%+v", env.Error) - } - waitForSessionGone(t, server, sessionName, 5*time.Second) -} - -func runAmuxJSON(t *testing.T, home, server string, args ...string) (int, cliEnvelope, string, string) { - t.Helper() - fullArgs := append([]string{"--json"}, args...) - code, out, errOut := runAmux(t, home, server, fullArgs...) - var env cliEnvelope - if err := json.Unmarshal([]byte(out), &env); err != nil { - t.Fatalf("decode json envelope: %v\nstdout:\n%s\nstderr:\n%s", err, out, errOut) - } - return code, env, out, errOut -} - -func runAmux(t *testing.T, home, server string, args ...string) (int, string, string) { - t.Helper() - - bin, cleanup, err := buildAmuxBinary() - if err != nil { - t.Fatalf("build amux binary: %v", err) - } - defer cleanup() - - cmd := exec.Command(bin, args...) - cmd.Env = append(stripGitEnv(os.Environ()), - "HOME="+home, - "AMUX_TMUX_SERVER="+server, - "AMUX_TMUX_CONFIG=/dev/null", - ) - var stdout bytes.Buffer - var stderr bytes.Buffer - cmd.Stdout = &stdout - cmd.Stderr = &stderr - err = cmd.Run() - exitCode := 0 - if err != nil { - var exitErr *exec.ExitError - if errors.As(err, &exitErr) { - exitCode = exitErr.ExitCode() - } else { - t.Fatalf("run amux %v: %v", args, err) - } - } - return exitCode, stdout.String(), stderr.String() -} - -func createTmuxSessionWithCommand(t *testing.T, server, sessionName, command string) { - t.Helper() - cmd := exec.Command( - "tmux", "-L", server, "-f", "/dev/null", - "new-session", "-d", "-s", sessionName, "sh", "-lc", command, - ) - out, err := cmd.CombinedOutput() - if err != nil { - t.Fatalf("create tmux session %s: %v\n%s", sessionName, err, string(out)) - } -} - -func waitForLogLines(t *testing.T, path string, count int, timeout time.Duration) []string { - t.Helper() - deadline := time.Now().Add(timeout) - for time.Now().Before(deadline) { - content, err := os.ReadFile(path) - if err == nil { - raw := strings.Split(strings.TrimSpace(string(content)), "\n") - var lines []string - for _, line := range raw { - line = strings.TrimSpace(line) - if line != "" { - lines = append(lines, line) - } - } - if len(lines) >= count { - return lines - } - } - time.Sleep(50 * time.Millisecond) - } - t.Fatalf("timeout waiting for %d lines in %s", count, path) - return nil -} - -func waitForSessionGone(t *testing.T, server, sessionName string, timeout time.Duration) { - t.Helper() - deadline := time.Now().Add(timeout) - for time.Now().Before(deadline) { - if !tmuxSessionExists(server, sessionName) { - return - } - time.Sleep(100 * time.Millisecond) - } - t.Fatalf("session %s still exists after %s", sessionName, timeout) -} - -func tmuxSessionExists(server, sessionName string) bool { - cmd := exec.Command("tmux", "-L", server, "-f", "/dev/null", "has-session", "-t", "="+sessionName) - return cmd.Run() == nil -} - -func lockSendQueueFile(t *testing.T, home, sessionName string) *os.File { - t.Helper() - sum := sha1.Sum([]byte(sessionName)) - lockPath := filepath.Join(home, ".amux", "cli-send-queue-"+hex.EncodeToString(sum[:8])+".lock") - if err := os.MkdirAll(filepath.Dir(lockPath), 0o755); err != nil { - t.Fatalf("mkdir lock dir: %v", err) - } - file, err := os.OpenFile(lockPath, os.O_CREATE|os.O_RDWR, 0o644) - if err != nil { - t.Fatalf("open lock file: %v", err) - } - if err := syscall.Flock(int(file.Fd()), syscall.LOCK_EX); err != nil { - _ = file.Close() - t.Fatalf("flock lock file: %v", err) - } - return file -} - -func unlockSendQueueFile(t *testing.T, file *os.File) { - t.Helper() - if file == nil { - return - } - _ = syscall.Flock(int(file.Fd()), syscall.LOCK_UN) - _ = file.Close() -} - -func jsonStringField(t *testing.T, data any, key string) string { - t.Helper() - obj, ok := data.(map[string]any) - if !ok { - t.Fatalf("expected object payload, got %T", data) - } - value, _ := obj[key].(string) - return value -} - -func jsonBoolField(t *testing.T, data any, key string) bool { - t.Helper() - obj, ok := data.(map[string]any) - if !ok { - t.Fatalf("expected object payload, got %T", data) - } - value, _ := obj[key].(bool) - return value -} - -func shellQuote(value string) string { - return "'" + strings.ReplaceAll(value, "'", "'\"'\"'") + "'" -} diff --git a/internal/validation/validation_test.go b/internal/validation/validation_test.go index 30391755..3c4296fb 100644 --- a/internal/validation/validation_test.go +++ b/internal/validation/validation_test.go @@ -114,7 +114,7 @@ func TestValidateAssistant(t *testing.T) { {"claude", "claude", false}, {"codex", "codex", false}, {"gemini", "gemini", false}, - {"openclaw", "openclaw", false}, + {"opencode", "opencode", false}, {"custom assistant", "my-agent", false}, {"cursor", "cursor", false}, {"alphanumeric unknown is valid", "gpt4", false}, diff --git a/skills/amux/SKILL.md b/skills/amux/SKILL.md deleted file mode 100644 index d0d83476..00000000 --- a/skills/amux/SKILL.md +++ /dev/null @@ -1,508 +0,0 @@ ---- -name: amux -description: Orchestrate AI coding agents via amux with managed workspaces, git worktrees, and async job queues. -metadata: - { "openclaw": { "emoji": "🔀", "os": ["darwin", "linux"], "requires": { "bins": ["amux", "tmux"] } } } ---- - -# amux Skill - -Orchestrate AI coding agents using `amux` — a workspace and agent lifecycle manager built on tmux. All commands support `--json` for structured output. - -## CRITICAL: Always Monitor and Report Back - -**Never fire-and-forget.** The user expects you to manage the full lifecycle: start the agent, wait for it to finish, summarize what happened, and suggest next steps. The user may be on their phone — they should never have to ask "how did it go?" - -**Every agent interaction follows this loop:** - -1. **Start or send** — Launch the agent or send a follow-up instruction (for OpenClaw, prefer `scripts/openclaw-step.sh`; otherwise use `--wait` for bounded steps and `agent watch` with heartbeat for long-running work) -2. **Confirm** — Immediately tell the user what you did ("Started Codex on the doctor workspace. Monitoring...") -3. **Summarize** — Prefer `response.delta` (new output since your send/run prompt). Fall back to `response.content` if delta is empty. Mention specific files changed, errors hit, or questions the agent is asking. -4. **Next steps** — Offer actionable follow-ups: create a PR, run tests, send another instruction, stop the agent. - -### OpenClaw Runtime Guardrails - -When running `amux` through OpenClaw `exec`/`process` tools, avoid monitor deadlocks: - -1. Prefer `scripts/openclaw-step.sh` for `run`/`send` steps. It performs exactly one bounded wait and returns normalized JSON with `status`, `summary`, `next_action`, and `suggested_command`. -2. For multi-step coding turns, prefer `scripts/openclaw-turn.sh` to enforce step caps, timeout-streak stops, milestone coalescing, and a guaranteed final summary payload. -3. For `agent run --wait` and `agent send --wait`, set tool `timeout` to at least `--wait-timeout + 90s` (minimum 180s) to cover startup + wait. -4. When calling through OpenClaw `exec`, set `yieldMs` to at least `wait-timeout + 60000` (milliseconds). Use `timeout` at least 45s larger than `yieldMs`. -5. Prefer `--wait` over `agent watch` for normal coding steps. `agent watch` is long-lived and can be killed by tool timeouts. -6. If `exec` returns `Command still running`, keep polling the same process until it reaches a terminal status (`completed`/`failed`/`killed`); do not launch a second overlapping `amux` command for that step. -7. If the process exits with timeout/SIGKILL and no output, retry once with a higher tool timeout and immediately send an interim user update. -8. If `response.status` is `timed_out`, summarize `response.summary` (fallback: `latest_line`/`delta`), then continue with one follow-up `agent send --wait` step (prefer send over raw capture in chat loops). -9. Never pass workspace **name** to `--workspace`. Always use `workspace create` JSON `data.id` (workspace_id). -10. Do not chain `workspace create && agent run` in one shell command. Run them as separate steps so workspace_id parsing is explicit and robust. -11. Use a bounded step budget: prefer 45-90s per `--wait` step (default 60s). If a step reaches timeout, immediately send a partial progress update and continue or conclude; do not loop silently. -12. If the same process remains `running` after 3 polls with no new output, send one interim status update and continue polling every 10-15s until terminal status. -13. Use `process log` for additional visibility when needed, but keep one authoritative process per step. -14. Always finish with a user-facing completion message before overall run timeout, even when partial: include what completed, what timed out, and one clear next action. -15. Keep each OpenClaw turn short: target at most 2-3 bounded amux steps per turn. If more work remains, stop and return a partial summary plus one explicit "continue" command. -16. Reserve time for the final response: after ~180s of tool work, stop launching new tools and emit a final text summary immediately. -17. On two consecutive `timed_out` step statuses, stop the turn and return a concise partial result + `suggested_command` instead of continuing loops. -18. If `response.status` is `needs_input` and the hint indicates local permission-mode selection (e.g. bypass permissions prompt), tell the user it is blocked by interactive permissions and switch to a non-interactive assistant (typically `codex`) for continuation. -19. Before `workspace create`, validate the repo path with `git -C rev-parse --verify HEAD`; if invalid, do not continue with that path. -20. For `workspace create`, always pass an explicit absolute repo path (`--repo`/`--project`) from user context; never rely on `.` in orchestrator workspaces. -21. Parse workspace id from `data.id` (fallback `data.workspace_id` for compatibility). Never continue with an empty workspace id. -22. Some channels may not support `message read` via OpenClaw tools. Never rely on `message read` in coding loops. -23. With `openclaw agent --deliver`, `status: ok` plus empty `result.payloads` is expected when updates were sent through `message` tool; treat this as success, not failure. -24. Even when delivering updates to a chat channel, always end with a final plain-text assistant summary so local operators also get a non-empty terminal result. -25. Do not run concurrent long agent turns on the same OpenClaw agent lane/session key; queueing can add large delays and confuse progress reporting. -26. If you must call `amux --json agent capture`, branch on `data.status`; treat `session_exited` as a terminal state to summarize, not an orchestration crash. - -### OpenClaw one-step wrapper (recommended) - -```bash -# 1. Start a bounded step (run) and read normalized fields -step=$(skills/amux/scripts/openclaw-step.sh run \ - --workspace \ - --assistant codex \ - --prompt "Add dark mode support" \ - --wait-timeout 60s \ - --idle-threshold 10s) - -# 2. Summarize with top-level `summary`; branch on `status` -echo "$step" | jq -r '.summary' -echo "$step" | jq -r '.status' -echo "$step" | jq -r '.next_action' -echo "$step" | jq -r '.suggested_command' -agent_id=$(echo "$step" | jq -r '.agent_id') -``` - -### Follow-up step (send) - -```bash -# 1. Send one bounded follow-up step -step=$(skills/amux/scripts/openclaw-step.sh send \ - --agent \ - --text "Also add tests" \ - --enter \ - --wait-timeout 60s \ - --idle-threshold 10s) - -# 2. Post concise update and continue based on status -echo "$step" | jq -r '.summary' -echo "$step" | jq -r '.status' -``` - -**Always** run exactly one bounded step, then post a user-facing summary. Never just say "sent" and go silent. - -If a step times out with no visible output yet, `openclaw-step.sh` now performs a short post-timeout capture recovery pass and may set: -- `recovered_from_capture: true` -- `suggested_command` with an exact bounded follow-up send command. -- `idempotency_key` auto-generated by default (disable with `OPENCLAW_STEP_AUTO_IDEMPOTENCY=false`). -- secret-safe output redaction for common tokens/credentials before channel delivery. -- `verbosity` controls via `OPENCLAW_STEP_VERBOSITY=quiet|normal|detailed` (with `OPENCLAW_STEP_DETAIL_LINES` override). -- `delivery` metadata (`key`, `action`, `priority`, `retry_after_seconds`, `replace_previous`, `drop_pending`) for edit-vs-send orchestration. -- context-aware `quick_actions` (tests/lint/security/review) with `callback_data` (`qa:*`), plus `quick_action_map`/`quick_action_prompts` for deterministic button-to-command mapping. -- `openclaw.presentation.chunks`/`openclaw.presentation.chunks_meta` for continuation-aware chunk delivery on the selected channel. -- channel payloads under `openclaw.channels.` and selected output under `openclaw.presentation`. -- inline button scope control via `OPENCLAW_INLINE_BUTTONS_SCOPE=off|dm|group|all|allowlist` (default `allowlist`). - -Use response fields in this order for mobile updates: -1. `summary` (top-level) -2. `response.delta_compact` (clean, de-chromed content) -3. `response.delta` (raw fallback) - -### Multi-step turn wrapper (recommended) - -```bash -turn=$(skills/amux/scripts/openclaw-turn.sh run \ - --workspace \ - --assistant codex \ - --prompt "Refactor the parser and add tests" \ - --max-steps 3 \ - --turn-budget 180 \ - --wait-timeout 60s \ - --idle-threshold 10s) - -echo "$turn" | jq -r '.overall_status' -echo "$turn" | jq -r '.summary' -echo "$turn" | jq -r '.next_action' -echo "$turn" | jq -r '.openclaw.presentation.chunks[]' -``` - -`openclaw-turn.sh` output includes: -- `overall_status` (`completed|needs_input|timed_out|session_exited|partial|partial_budget`) -- `events` (raw step payloads), `milestones` (coalesced concise updates) -- `delivery` + `progress_updates` (+ per-step progress percent) for outbox-style edit/coalesce behavior. -- `verbosity` controls via `OPENCLAW_TURN_VERBOSITY=quiet|normal|detailed`. -- `quick_actions` with `callback_data` (`qa:*`) plus `quick_action_map`/`quick_action_prompts`. -- `openclaw.channels` and `openclaw.presentation` for channel-specific rendering payloads. -- `channel.chunks`/`channel.chunks_meta` + channel button metadata. - -### OpenClaw DX control plane (project/workspace lifecycle) - -Use `skills/amux/scripts/openclaw-dx.sh` when the user is coding through OpenClaw on any channel and needs end-to-end lifecycle UX (not just one prompt turn): - -```bash -# Guided next-step recommendation (best for first-time mobile users) -skills/amux/scripts/openclaw-dx.sh guide [--project /abs/repo/path] [--workspace ] [--task "refactor ..."] [--assistant codex] [--channel slack] - -# Add/select project -skills/amux/scripts/openclaw-dx.sh project add --cwd --workspace mobile --assistant codex -skills/amux/scripts/openclaw-dx.sh project add --path /abs/repo/path -skills/amux/scripts/openclaw-dx.sh project list --query api -skills/amux/scripts/openclaw-dx.sh project pick --name api - -# One-shot kickoff (register project -> create workspace -> start coding turn) -skills/amux/scripts/openclaw-dx.sh workflow kickoff --project /abs/repo/path --name refactor --assistant codex --prompt "Fix highest-impact tech debt" - -# Project or nested workspace decision -skills/amux/scripts/openclaw-dx.sh workspace decide --project /abs/repo/path --task "Refactor checkout state" --assistant codex --name refactor -skills/amux/scripts/openclaw-dx.sh workspace create --name mobile --project /abs/repo/path --assistant codex -skills/amux/scripts/openclaw-dx.sh workspace create --name refactor --from-workspace --scope nested --assistant codex - -# Start/continue coding turns -skills/amux/scripts/openclaw-dx.sh start --workspace --assistant codex --prompt "..." -skills/amux/scripts/openclaw-dx.sh continue --workspace --text "..." --enter - -# Status/alerts and terminal flows -skills/amux/scripts/openclaw-dx.sh status -skills/amux/scripts/openclaw-dx.sh alerts -skills/amux/scripts/openclaw-dx.sh status --include-stale # include stale-session alerts when explicitly desired -skills/amux/scripts/openclaw-dx.sh terminal run --workspace --text "npm run dev" --enter -skills/amux/scripts/openclaw-dx.sh terminal logs --workspace --lines 120 - -# Cleanup, review, ship -skills/amux/scripts/openclaw-dx.sh cleanup --older-than 24h -skills/amux/scripts/openclaw-dx.sh review --workspace --assistant codex -skills/amux/scripts/openclaw-dx.sh git ship --workspace --message "feat: ..." [--push] - -# Dual-pass multi-agent handoff (implement -> review) -skills/amux/scripts/openclaw-dx.sh workflow dual --workspace --implement-assistant claude --review-assistant codex - -# Assistant readiness -skills/amux/scripts/openclaw-dx.sh assistants -``` - -`openclaw-dx.sh` emits normalized JSON with: -- `status` + `summary` for quick mobile updates. -- `quick_actions` + `openclaw.actions` (`map`, `prompts`, `fallback`). -- `openclaw.channels` and `openclaw.presentation` for channel-specific rendering. -- channel-specific button metadata in `openclaw.channels.`. -- `data.alerts` (needs-input/session/stale-session signals) for proactive follow-up prompts. -- `workflow.kickoff` and `workflow.dual` for one-command lifecycle/handoff orchestration. - -### OpenClaw exec/process pattern (required for long steps) - -```json -{ - "tool": "exec", - "command": "skills/amux/scripts/openclaw-step.sh run --workspace --assistant codex --prompt \"...\" --wait-timeout 60s --idle-threshold 10s", - "workdir": "/Users/andrewlee/founding/amux", - "yieldMs": 120000, - "timeout": 180 -} -``` - -If this backgrounds, continue polling the returned `sessionId`: - -```json -{ "tool": "process", "action": "poll", "sessionId": "" } -``` - -Only move to the next step after terminal process status and a user-facing summary. - -### The standard flow (direct amux --wait) - -```bash -# Start and wait in one command -result=$(amux --json agent run --workspace --assistant codex --prompt "Add dark mode support" --wait --wait-timeout 300s --idle-threshold 10s) -agent_id=$(echo "$result" | jq -r '.data.agent_id') -summary=$(echo "$result" | jq -r '.data.response.summary // .data.response.latest_line // .data.response.delta // ""') -``` - -### Checking response status - -The `response` object tells you what happened: -- `response.status: "idle" | "needs_input" | "timed_out" | "session_exited"` — canonical machine-readable status (preferred) -- `response.timed_out: true` — agent didn't go idle within the timeout. Capture output separately. -- `response.session_exited: true` — the agent's session ended. Show last output and offer to restart. -- `response.idle_seconds > 0` — agent went idle normally. Read `response.content` for the full output. -- `response.changed: true|false` — whether pane output changed after the send/run prompt. -- `response.summary` — single-line canonical summary for chat/push notifications. -- `response.delta` — only the new text since the prompt/send baseline (best for chat replies). -- `response.latest_line` — one-line summary of the newest output (best for push notifications). -- `response.needs_input` — true when output looks like a confirmation/question prompt. -- `response.input_hint` — best-effort line to show the user when `needs_input=true`. - -### If the agent asks a question - -If `response.status` is `needs_input` (or `response.needs_input=true`), the agent is blocked on an approval/prompt right now. Read `response.delta` first, then `response.content` if needed, and ask the user a direct follow-up question immediately. - -### If the agent errors or exits - -If `response.session_exited` is true, or if the response is missing: -- Tell the user the agent stopped -- Show the last output so they can see what happened -- Offer to restart with `agent run` - -## When to Use - -- User wants to start, manage, or interact with a coding agent (Claude, Codex, Aider, etc.) -- User wants to create an isolated workspace with a git worktree for a task -- User wants to monitor agent progress, send follow-up instructions, or stop agents -- User wants to run multiple agents in parallel on different tasks - -## JSON Envelope - -All `--json` commands return a structured envelope: - -```json -{ - "ok": true, - "data": { ... }, - "error": null, - "meta": { "generated_at": "...", "amux_version": "..." }, - "schema_version": "amux.cli.v1" -} -``` - -On error: `ok` is `false`, `error` has `code`, `message`, and optional `details`. - -**Always use `--json`** for programmatic access. Check `ok` field before accessing `data`. - -## Workspace Management - -### Create a workspace - -```bash -amux --json workspace create --project [--assistant ] -``` - -Returns `data.id` (workspace id) and `data.root` (the worktree path). **Save the workspace id** — you need it for all agent commands. If `--assistant` is omitted, amux uses the configured default assistant. - -The `root` path is the filesystem path to the workspace. Use it to read/write files directly. - -### List workspaces - -```bash -amux --json workspace list [--repo ] -``` - -(`--project` is accepted as a compatibility alias, but prefer `--repo`.) - -### Remove a workspace - -```bash -amux --json workspace remove -``` - -## Agent Lifecycle - -### Start an agent - -```bash -amux --json agent run --workspace --assistant claude [--prompt "..."] [--wait] [--wait-timeout 120s] [--idle-threshold 10s] -``` - -Returns `data.session_name` and `data.agent_id`. **Save both** — `session_name` is used for capture/watch, `agent_id` for send/stop. - -With `--wait` (requires `--prompt`): blocks until the agent responds and goes idle, then returns the response in `data.response`. This is the preferred flow — one command to start, send a prompt, and get the result. - -Supported assistants: `claude`, `codex`, `aider`, `goose`, `amp`, `cline`, `roo`, `gemini-cli`, `claude-cli`, `custom`. - -### List running agents - -```bash -amux --json agent list [--workspace ] -``` - -### Capture agent output (point-in-time snapshot) - -```bash -amux --json agent capture [--lines 50] -``` - -Returns `data.content` with the terminal output. - -`agent capture --json` also includes chat-friendly signals: -- `data.status: "captured" | "session_exited"` -- `data.summary` and `data.latest_line` for concise updates -- `data.needs_input` and `data.input_hint` when prompts/questions are detected - -### Send text to an agent - -```bash -amux --json agent send --agent --text "your instructions" --enter [--wait] [--wait-timeout 120s] [--idle-threshold 10s] -``` - -Use `--enter` to simulate pressing Enter after the text. Use `--wait` to block until the agent responds and goes idle (returns response in `data.response`). Use `--async` for non-blocking send with job tracking. `--wait` and `--async` are mutually exclusive. - -`agent send` JSON response includes: -- `sent` — whether the job status is completed. -- `delivered` — whether this invocation actually delivered text to tmux (false for replayed/already-completed jobs). - -### Stop an agent - -```bash -amux --json agent stop --agent --graceful -``` - -`--graceful` sends Ctrl-C first, waits for clean exit, then force-kills if needed. - -## Waiting and Monitoring - -### --wait flag (recommended) - -The `--wait` flag on `agent run` and `agent send` is the simplest way to wait for an agent. It blocks until the agent responds and goes idle, then returns the captured output in `data.response`. - -```bash -# Start and wait in one command -amux --json agent run --workspace --assistant claude --prompt "fix the bug" --wait --wait-timeout 300s --idle-threshold 10s - -# Send and wait in one command -amux --json agent send --agent --text "add tests" --enter --wait --wait-timeout 300s --idle-threshold 10s -``` - -- `--wait-timeout 120s` — max time to wait (default 120s). Returns `response.timed_out: true` on timeout. -- `--idle-threshold 10s` — time of no output change to consider "idle" (default 10s). - -### wait-for-idle.sh (legacy) - -For cases where you need to wait separately from the send: - -```bash -skills/amux/scripts/wait-for-idle.sh --session [--timeout 300] [--idle-threshold 10] -``` - -### agent watch (NDJSON streaming) - -For real-time monitoring. Emits events as they happen: - -```bash -amux agent watch [--lines 100] [--interval 500ms] [--idle-threshold 5s] [--heartbeat 10s] -``` - -**Event types:** - -| Event | Meaning | Key Fields | -|---|---|---| -| `snapshot` | Initial full capture | `content`, `hash`, `latest_line`, `summary` | -| `delta` | New lines since last change | `new_lines`, `hash`, `latest_line`, `summary`, `needs_input`, `input_hint` | -| `idle` | No changes for `--idle-threshold` | `idle_seconds`, `hash`, `latest_line`, `summary`, `needs_input`, `input_hint` | -| `heartbeat` | Periodic keepalive while unchanged | `heartbeat_seconds`, `hash`, `latest_line`, `summary`, `needs_input`, `input_hint` | -| `exited` | Session no longer exists | (none) | - -Set `--heartbeat 0` to disable heartbeat events. - -### Channel-first DX loop (proactive updates) - -For mobile/chat users, keep updates push-style so they never need to ask for status: - -1. Send an immediate acknowledgement with the exact plan. -2. Start work and capture `session_name`/`agent_id`. -3. For long tasks, prefer repeated bounded `openclaw-step.sh` steps; if watching, stream `agent watch --heartbeat 10s` and post short updates from `summary`/`latest_line`. -4. If `needs_input=true`, ask a direct multiple-choice question immediately (`A/B/C` style). -5. End with a completion summary in both channel output and local output: changed files, tests run, pass/fail, one next action. - -### poll-agent.sh (fallback) - -If `agent watch` is unavailable: - -```bash -skills/amux/scripts/poll-agent.sh --session --timeout 120 -``` - -### format-capture.sh - -Strip ANSI escape codes from captured output for cleaner reading: - -```bash -amux --json agent capture --lines 80 | jq -r '.data.content' | skills/amux/scripts/format-capture.sh --strip-ansi --trim -``` - -## Async Jobs - -For non-blocking send operations: - -```bash -# Send asynchronously — returns a job_id immediately -amux --json agent send --agent --text "..." --enter --async - -# Check job status -amux --json agent job status - -# Wait for completion (blocks until done) -amux --json agent job wait - -# Cancel a pending job -amux --json agent job cancel -``` - -Use `--idempotency-key ` on any mutating command for safe retries (7-day retention). - -## File Operations - -Access workspace files directly via the filesystem path returned by `workspace create` or `workspace list`: - -```bash -# Get workspace root path -root=$(amux --json workspace list | jq -r '.data[] | select(.workspace_id == "my-ws") | .root') - -# Read/write files directly -cat "$root/src/main.ts" -echo "new content" > "$root/src/config.ts" -``` - -No special amux command is needed for file access. - -## Multi-Agent Orchestration - -Run multiple agents on different workspaces simultaneously: - -```bash -# Create separate workspaces -amux --json workspace create frontend --project ~/app --assistant claude -amux --json workspace create backend --project ~/app --assistant claude - -# Start agents in each -amux --json agent run --workspace ws-frontend --assistant claude --prompt "Add dark mode to React components" -amux --json agent run --workspace ws-backend --assistant claude --prompt "Add /api/theme endpoint" - -# Monitor both (wait for each to finish, then summarize) -scripts/wait-for-idle.sh --session --timeout 300 & -scripts/wait-for-idle.sh --session --timeout 300 & -wait -``` - -Each workspace gets its own git worktree branch, so agents don't conflict. - -## Diagnostics - -```bash -amux --json status # Health check -amux --json doctor # Full diagnostics -amux --json capabilities # Machine-readable feature list -``` - -## Error Handling - -Always check the `ok` field in JSON responses: - -```bash -result=$(amux --json agent run --workspace bad-id --assistant claude 2>&1) -if echo "$result" | jq -e '.ok' > /dev/null 2>&1; then - session=$(echo "$result" | jq -r '.data.session_name') -else - error=$(echo "$result" | jq -r '.error.message') - # Handle error — tell the user what went wrong -fi -``` - -Common error codes: `init_failed`, `not_found`, `usage_error`, `capture_failed`. - -## Rules & Best Practices - -1. **Always monitor and report back** — never fire-and-forget. Prefer `--wait` for bounded steps; use `agent watch --heartbeat` for long tasks, then summarize results to the user. -2. **Always use `--json`** for all amux commands when calling from scripts or agents -3. **Save `workspace_id`, `session_name`, and `agent_id`** from creation responses — you need them for subsequent commands -4. **Use `--graceful`** when stopping agents to allow clean shutdown -5. **Use `--idempotency-key`** on mutating commands when retries are possible -6. **Check `ok` field** in every JSON response before accessing `data` -7. **Use `--async`** for send operations when you don't need to block on delivery -8. **Access files via the workspace root path** — no special amux command needed -9. **One workspace per task** — create separate workspaces for independent work items -10. **Stop agents when done** — don't leave idle agents running diff --git a/skills/amux/scripts/format-capture.sh b/skills/amux/scripts/format-capture.sh deleted file mode 100755 index 25377258..00000000 --- a/skills/amux/scripts/format-capture.sh +++ /dev/null @@ -1,61 +0,0 @@ -#!/usr/bin/env bash -# format-capture.sh — Strip ANSI escape codes and format terminal output. -# -# Usage: format-capture.sh [--strip-ansi] [--last-answer] [--trim] -# -# Reads from stdin. Options: -# --strip-ansi Remove ANSI escape sequences (default: on) -# --last-answer Extract only the last answer block (heuristic: text after last prompt) -# --trim Trim leading/trailing blank lines -# -# Example: -# amux --json agent capture | jq -r '.data.content' | format-capture.sh --strip-ansi --trim - -set -euo pipefail - -STRIP_ANSI=true -LAST_ANSWER=false -TRIM=false - -while [[ $# -gt 0 ]]; do - case "$1" in - --strip-ansi) STRIP_ANSI=true; shift ;; - --last-answer) LAST_ANSWER=true; shift ;; - --trim) TRIM=true; shift ;; - *) echo "Unknown flag: $1" >&2; exit 2 ;; - esac -done - -input=$(cat) - -# Strip ANSI escape codes -if [[ "$STRIP_ANSI" == "true" ]]; then - # Remove all ANSI escape sequences: CSI (ESC[), OSC (ESC]), and simple ESC sequences - input=$(echo "$input" | sed \ - -e 's/\x1b\[[0-9;]*[a-zA-Z]//g' \ - -e 's/\x1b\][^\x07]*\x07//g' \ - -e 's/\x1b\][^\x1b]*\x1b\\//g' \ - -e 's/\x1b[()][0-9A-B]//g' \ - -e 's/\x1b[=>]//g' \ - -e 's/\r//g') -fi - -# Extract last answer block -if [[ "$LAST_ANSWER" == "true" ]]; then - # Heuristic: look for common prompt patterns and take text after the last one - # Matches: $, >, >>>, %, #, claude>, and similar prompts - last_prompt_line=$(echo "$input" | grep -n '^\([$>%#]\|>>>\|.*[>$#%] \)' | tail -1 | cut -d: -f1 || echo "") - if [[ -n "$last_prompt_line" ]]; then - total_lines=$(echo "$input" | wc -l) - if [[ $last_prompt_line -lt $total_lines ]]; then - input=$(echo "$input" | tail -n +"$((last_prompt_line + 1))") - fi - fi -fi - -# Trim leading/trailing blank lines -if [[ "$TRIM" == "true" ]]; then - input=$(echo "$input" | sed '/./,$!d' | sed -e :a -e '/^\n*$/{$d;N;ba' -e '}') -fi - -echo "$input" diff --git a/skills/amux/scripts/openclaw-dogfood.sh b/skills/amux/scripts/openclaw-dogfood.sh deleted file mode 100755 index 81d345d9..00000000 --- a/skills/amux/scripts/openclaw-dogfood.sh +++ /dev/null @@ -1,468 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -SCRIPT_SOURCE="${BASH_SOURCE[0]:-$0}" -SCRIPT_DIR="$(cd "$(dirname "$SCRIPT_SOURCE")" >/dev/null 2>&1 && pwd -P)" -REPO_ROOT="$(cd "$SCRIPT_DIR/../../.." >/dev/null 2>&1 && pwd -P)" -DX_SCRIPT="$SCRIPT_DIR/openclaw-dx.sh" - -usage() { - cat <<'EOF' -Usage: - openclaw-dogfood.sh [--repo ] [--workspace ] [--assistant ] [--report-dir ] [--keep-temp] [--cleanup-temp] - -Runs a real OpenClaw/amux dogfood flow end-to-end: - - project add - - start coding - - continue coding - - create second workspace + start - - terminal run + logs - - workflow dual - - git ship - - status -EOF -} - -require_bin() { - if ! command -v "$1" >/dev/null 2>&1; then - echo "missing required binary: $1" >&2 - exit 1 - fi -} - -shell_quote() { - printf '%q' "$1" -} - -REPO_PATH="" -WORKSPACE_NAME="mobile-dogfood" -ASSISTANT="codex" -REPORT_DIR="" -KEEP_TEMP=false -REPORT_DIR_CREATED=false - -while [[ $# -gt 0 ]]; do - case "$1" in - --repo) - REPO_PATH="$2"; shift 2 ;; - --workspace) - WORKSPACE_NAME="$2"; shift 2 ;; - --assistant) - ASSISTANT="$2"; shift 2 ;; - --report-dir) - REPORT_DIR="$2"; shift 2 ;; - --keep-temp) - KEEP_TEMP=true; shift ;; - --cleanup-temp) - KEEP_TEMP=false; shift ;; - -h|--help) - usage - exit 0 ;; - *) - echo "unknown flag: $1" >&2 - usage - exit 2 ;; - esac -done - -RUN_TAG="$(date +%m%d%H%M%S)-$RANDOM" -PRIMARY_WORKSPACE="${WORKSPACE_NAME}-${RUN_TAG}" -SECONDARY_WORKSPACE="${WORKSPACE_NAME}-parallel-${RUN_TAG}" - -require_bin jq -require_bin git -require_bin amux -require_bin openclaw - -if [[ ! -x "$DX_SCRIPT" ]]; then - echo "missing executable script: $DX_SCRIPT" >&2 - exit 1 -fi - -TMP_ROOT="" -if [[ -z "${REPO_PATH// }" ]]; then - TMP_ROOT="$(mktemp -d "${TMPDIR:-/tmp}/amux-openclaw-dogfood-script.XXXXXX")" - REPO_PATH="$TMP_ROOT/repo" - mkdir -p "$REPO_PATH" - cat >"$REPO_PATH/main.go" <<'EOF' -package main - -import ( - "fmt" - "time" -) - -func main() { - fmt.Printf("%s hello from openclaw dogfood\n", time.Now().Format("2006-01-02")) -} -EOF - cat >"$REPO_PATH/README.md" <<'EOF' -# dogfood -EOF - ( - cd "$REPO_PATH" - git init -q - git add . - git -c user.name='Dogfood' -c user.email='dogfood@example.com' commit -qm 'init' - ) -fi - -if [[ -z "${REPORT_DIR// }" ]]; then - REPORT_DIR="$(mktemp -d "${TMPDIR:-/tmp}/amux-openclaw-dogfood-report.XXXXXX")" - REPORT_DIR_CREATED=true -fi -mkdir -p "$REPORT_DIR" -DX_CONTEXT_FILE="$REPORT_DIR/openclaw-dx-context.json" -CHANNEL_AGENT_CREATED=false -CHANNEL_AGENT_ID="${OPENCLAW_DOGFOOD_OPENCLAW_AGENT:-amux-dx}" -CHANNEL_AGENT_WORKSPACE="${OPENCLAW_DOGFOOD_CHANNEL_AGENT_WORKSPACE:-}" -CHANNEL_AGENT_MODEL="${OPENCLAW_DOGFOOD_CHANNEL_AGENT_MODEL:-openai-codex/gpt-5.3-codex}" -CHANNEL_EPHEMERAL_ENABLED="${OPENCLAW_DOGFOOD_CHANNEL_EPHEMERAL_AGENT:-true}" -CHANNEL_AGENT_ISOLATED_WORKSPACE="${OPENCLAW_DOGFOOD_CHANNEL_AGENT_ISOLATED_WORKSPACE:-true}" - -prepare_channel_agent() { - if [[ "$CHANNEL_EPHEMERAL_ENABLED" != "true" ]]; then - export OPENCLAW_DOGFOOD_OPENCLAW_AGENT="$CHANNEL_AGENT_ID" - return 0 - fi - - local base_id candidate add_json workspace_path - base_id="${CHANNEL_AGENT_ID:-amux-dx}" - candidate="${base_id}-dogfood-${RUN_TAG}" - add_json="$REPORT_DIR/openclaw-channel-agent-add.json" - workspace_path="$CHANNEL_AGENT_WORKSPACE" - if [[ "$CHANNEL_AGENT_ISOLATED_WORKSPACE" == "true" ]]; then - workspace_path="$REPORT_DIR/openclaw-channel-agent-workspace" - mkdir -p "$workspace_path" - cat >"$workspace_path/AGENTS.md" <<'EOF' -# AGENTS -- You are a strict terminal command runner for amux workflows. -- For command requests, execute the exact shell command via the exec tool. -- Return only raw stdout/stderr from that command. -- Do not summarize, paraphrase, or fabricate output. -- If execution did not happen, output exactly: EXEC_NOT_RUN -EOF - fi - if [[ -z "${workspace_path// }" ]]; then - workspace_path="$REPORT_DIR/openclaw-channel-agent-workspace" - mkdir -p "$workspace_path" - cat >"$workspace_path/AGENTS.md" <<'EOF' -# AGENTS -- You are a strict terminal command runner for amux workflows. -- Execute exact shell commands and return only raw stdout/stderr. -EOF - fi - if openclaw agents add "$candidate" \ - --workspace "$workspace_path" \ - --model "$CHANNEL_AGENT_MODEL" \ - --non-interactive \ - --json >"$add_json" 2>&1; then - CHANNEL_AGENT_ID="$candidate" - CHANNEL_AGENT_CREATED=true - fi - export OPENCLAW_DOGFOOD_OPENCLAW_AGENT="$CHANNEL_AGENT_ID" -} - -cleanup() { - if [[ "$CHANNEL_AGENT_CREATED" == "true" ]]; then - openclaw agents delete "$CHANNEL_AGENT_ID" --force --json >"$REPORT_DIR/openclaw-channel-agent-delete.json" 2>&1 || true - fi - if [[ "$KEEP_TEMP" == "true" ]]; then - return - fi - if [[ -n "${TMP_ROOT// }" && -d "$TMP_ROOT" ]]; then - rm -rf "$TMP_ROOT" - fi - if [[ "$REPORT_DIR_CREATED" == "true" && -n "${REPORT_DIR// }" && -d "$REPORT_DIR" ]]; then - rm -rf "$REPORT_DIR" - fi -} -trap cleanup EXIT - -prepare_channel_agent - -run_dx() { - local slug="$1" - shift - local out_file="$REPORT_DIR/$slug.raw" - local json_file="$REPORT_DIR/$slug.json" - local status_file="$REPORT_DIR/$slug.status" - local start_ts end_ts elapsed - start_ts="$(date +%s)" - local out - out="$(OPENCLAW_DX_CONTEXT_FILE="$DX_CONTEXT_FILE" "$DX_SCRIPT" "$@" 2>&1 || true)" - end_ts="$(date +%s)" - elapsed="$((end_ts - start_ts))" - printf '%s\n' "$out" >"$out_file" - printf '%s\n' "$out" | sed -n '/^[[:space:]]*{/,$p' >"$json_file" - if ! jq -e . >/dev/null 2>&1 <"$json_file"; then - printf '%s\n' "$out" | awk '/^[[:space:]]*\\{/{line=$0} END{print line}' >"$json_file" - fi - if jq -e . >/dev/null 2>&1 <"$json_file"; then - jq -r --arg elapsed "${elapsed}s" '.status + "|" + (.summary // "") + "|latency=" + $elapsed' <"$json_file" >"$status_file" - else - printf 'command_error|non-json terminal output|latency=%ss' "$elapsed" >"$status_file" - fi - printf '%s\t%s\n' "$slug" "$(cat "$status_file")" -} - -run_openclaw_local_ping() { - local slug="$1" - local session_id="$2" - local out_file="$REPORT_DIR/$slug.raw" - local json_file="$REPORT_DIR/$slug.json" - local status_file="$REPORT_DIR/$slug.status" - local start_ts end_ts elapsed - start_ts="$(date +%s)" - local out - out="$(openclaw agent --local --json --session-id "$session_id" --message "Dogfood ping: summarize current state in one line." 2>&1 || true)" - end_ts="$(date +%s)" - elapsed="$((end_ts - start_ts))" - printf '%s\n' "$out" >"$out_file" - printf '%s\n' "$out" | sed -n '/^{/,$p' >"$json_file" - if jq -e . >/dev/null 2>&1 <"$json_file"; then - jq -r --arg elapsed "${elapsed}s" ' - if ((.payloads // []) | length) > 0 then - "ok|" + ((.payloads[0].text // "openclaw local ping completed") | gsub("[\r\n]+"; " ")) + "|latency=" + $elapsed - elif (.status // "") | length > 0 then - (.status + "|" + ((.summary // "") | gsub("[\r\n]+"; " ")) + "|latency=" + $elapsed) - else - "ok|openclaw local ping completed|latency=" + $elapsed - end - ' <"$json_file" >"$status_file" - else - printf 'command_error|non-json terminal output|latency=%ss' "$elapsed" >"$status_file" - fi - printf '%s\t%s\n' "$slug" "$(cat "$status_file")" -} - -run_openclaw_channel_command() { - local slug="$1" - local session_id="$2" - local channel="$3" - local command_text="$4" - local expected_token="${5:-}" - local retry_on_missing_markers="${6:-true}" - local primary_agent="${OPENCLAW_DOGFOOD_OPENCLAW_AGENT:-amux-dx}" - local fallback_agent="${OPENCLAW_DOGFOOD_CHANNEL_FALLBACK_AGENT:-main}" - local agent_used="$primary_agent" - local require_nonce="${OPENCLAW_DOGFOOD_CHANNEL_REQUIRE_NONCE:-false}" - local require_proof="${OPENCLAW_DOGFOOD_CHANNEL_REQUIRE_PROOF:-true}" - local nonce_token nonce_file - local proof_token proof_file - nonce_token="" - nonce_file="" - proof_token="" - proof_file="" - if [[ "$require_nonce" == "true" ]]; then - nonce_token="$(date +%s)-$RANDOM-$RANDOM" - nonce_file="$(mktemp "${TMPDIR:-/tmp}/openclaw-dogfood-nonce.XXXXXX")" - printf '%s\n' "$nonce_token" >"$nonce_file" - fi - local out_file="$REPORT_DIR/$slug.raw" - local json_file="$REPORT_DIR/$slug.json" - local status_file="$REPORT_DIR/$slug.status" - local start_ts end_ts elapsed - start_ts="$(date +%s)" - local message_text - local command_with_nonce - if [[ "$require_nonce" == "true" ]]; then - command_with_nonce="cat $(shell_quote "$nonce_file"); $command_text" - else - command_with_nonce="$command_text" - fi - if [[ "$require_proof" == "true" ]]; then - proof_token="proof-$(date +%s)-$RANDOM-$RANDOM" - proof_file="$REPORT_DIR/$slug.proof" - rm -f "$proof_file" >/dev/null 2>&1 || true - command_with_nonce="$command_with_nonce; printf '%s\n' $(shell_quote "$proof_token") > $(shell_quote "$proof_file")" - fi - message_text=$'Run exactly this shell command.\nDo not substitute workspace IDs or paths.\nReturn only the raw command output.\n\n'"$command_with_nonce" - local out - run_channel_once() { - local agent_id="$1" - local sid="$2" - local prompt="$3" - openclaw agent --agent "$agent_id" --channel "$channel" --thinking off --session-id "$sid" --json --timeout "${OPENCLAW_DOGFOOD_CHANNEL_TIMEOUT_SECONDS:-180}" --message "$prompt" 2>&1 || true - } - render_channel_status() { - local file_path="$1" - local elapsed_label="$2" - jq -r --arg elapsed "$elapsed_label" ' - ((.result.payloads[0].text // .payloads[0].text // "") | tostring) as $txt - | ($txt | fromjson? // null) as $inner - | if ($inner != null and ($inner | type) == "object" and (($inner.status // "") | length) > 0) then - (($inner.status // "ok") + "|" + (($inner.summary // "openclaw channel command completed") | gsub("[\r\n]+"; " ")) + "|latency=" + $elapsed) - elif ((.result.payloads // []) | length) > 0 then - ((.status // "ok") + "|" + ((.result.payloads[0].text // "openclaw channel command completed") | gsub("[\r\n]+"; " ")) + "|latency=" + $elapsed) - elif ((.payloads // []) | length) > 0 then - ("ok|" + ((.payloads[0].text // "openclaw channel command completed") | gsub("[\r\n]+"; " ")) + "|latency=" + $elapsed) - else - ((.status // "ok") + "|" + ((.summary // "openclaw channel command completed") | gsub("[\r\n]+"; " ")) + "|latency=" + $elapsed) - end - ' <"$file_path" - } - - out="$(run_channel_once "$agent_used" "$session_id" "$message_text")" - if [[ "$agent_used" != "$fallback_agent" ]] && [[ "$out" == *"not found"* && "$out" == *"agent"* ]]; then - agent_used="$fallback_agent" - out="$(run_channel_once "$agent_used" "$session_id" "$message_text")" - fi - end_ts="$(date +%s)" - elapsed="$((end_ts - start_ts))" - printf '%s\n' "$out" >"$out_file" - printf '%s\n' "$out" | sed -n '/^{/,$p' >"$json_file" - if jq -e . >/dev/null 2>&1 <"$json_file"; then - render_channel_status "$json_file" "${elapsed}s" >"$status_file" - else - printf 'command_error|non-json terminal output|latency=%ss' "$elapsed" >"$status_file" - fi - local out_blob missing_markers - out_blob="$(cat "$json_file" 2>/dev/null || true)" - missing_markers=false - if [[ "$require_nonce" == "true" ]] && [[ "$out_blob" != *"$nonce_token"* ]]; then - missing_markers=true - fi - if [[ -n "${expected_token// }" ]] && [[ "$out_blob" != *"$expected_token"* ]]; then - missing_markers=true - fi - if [[ "$missing_markers" == "true" ]]; then - if [[ "$retry_on_missing_markers" != "true" ]]; then - printf 'attention|channel output missing execution markers|latency=%ss' "$elapsed" >"$status_file" - printf '%s|agent=%s\n' "$(cat "$status_file")" "$agent_used" >"$status_file" - if [[ -n "${nonce_file// }" ]]; then - rm -f "$nonce_file" >/dev/null 2>&1 || true - fi - if [[ -n "${proof_file// }" ]]; then - rm -f "$proof_file" >/dev/null 2>&1 || true - fi - printf '%s\t%s\n' "$slug" "$(cat "$status_file")" - return - fi - local retry_sid retry_prompt retry_out retry_elapsed retry_json - retry_sid="${session_id}-retry" - retry_prompt="$message_text"$'\n\n'"Previous output was invalid because expected execution markers were missing. Run the exact command now and return only raw output." - start_ts="$(date +%s)" - retry_out="$(run_channel_once "$agent_used" "$retry_sid" "$retry_prompt")" - retry_elapsed="$(( $(date +%s) - start_ts ))" - printf '%s\n' "$retry_out" >>"$out_file" - retry_json="$(printf '%s\n' "$retry_out" | sed -n '/^{/,$p')" - if { [[ "$require_nonce" != "true" ]] || [[ "$retry_json" == *"$nonce_token"* ]]; } && { [[ -z "${expected_token// }" ]] || [[ "$retry_json" == *"$expected_token"* ]]; }; then - printf '%s\n' "$retry_json" >"$json_file" - elapsed=$((elapsed + retry_elapsed)) - if jq -e . >/dev/null 2>&1 <"$json_file"; then - render_channel_status "$json_file" "${elapsed}s" >"$status_file" - fi - else - local fallback_elapsed fallback_out fallback_json - if [[ "$agent_used" != "$fallback_agent" ]]; then - start_ts="$(date +%s)" - fallback_out="$(run_channel_once "$fallback_agent" "${session_id}-fallback" "$retry_prompt")" - fallback_elapsed="$(( $(date +%s) - start_ts ))" - printf '%s\n' "$fallback_out" >>"$out_file" - fallback_json="$(printf '%s\n' "$fallback_out" | sed -n '/^{/,$p')" - if { [[ "$require_nonce" != "true" ]] || [[ "$fallback_json" == *"$nonce_token"* ]]; } && { [[ -z "${expected_token// }" ]] || [[ "$fallback_json" == *"$expected_token"* ]]; }; then - agent_used="$fallback_agent" - printf '%s\n' "$fallback_json" >"$json_file" - elapsed=$((elapsed + retry_elapsed + fallback_elapsed)) - if jq -e . >/dev/null 2>&1 <"$json_file"; then - render_channel_status "$json_file" "${elapsed}s" >"$status_file" - fi - else - printf 'attention|channel output missing execution markers|latency=%ss' "$((elapsed + retry_elapsed + fallback_elapsed))" >"$status_file" - fi - else - printf 'attention|channel output missing execution markers|latency=%ss' "$((elapsed + retry_elapsed))" >"$status_file" - fi - fi - fi - if [[ "$require_proof" == "true" ]]; then - local proof_value - proof_value="" - if [[ -f "$proof_file" ]]; then - proof_value="$(cat "$proof_file" 2>/dev/null || true)" - fi - if [[ "$proof_value" != "$proof_token" ]]; then - printf 'attention|channel output unverified: command execution proof missing|latency=%ss' "$elapsed" >"$status_file" - fi - fi - printf '%s|agent=%s\n' "$(cat "$status_file")" "$agent_used" >"$status_file" - if [[ -n "${nonce_file// }" ]]; then - rm -f "$nonce_file" >/dev/null 2>&1 || true - fi - if [[ -n "${proof_file// }" ]]; then - rm -f "$proof_file" >/dev/null 2>&1 || true - fi - printf '%s\t%s\n' "$slug" "$(cat "$status_file")" -} - -echo "dogfood_start repo=$(shell_quote "$REPO_PATH") report_dir=$(shell_quote "$REPORT_DIR")" -openclaw health --json >"$REPORT_DIR/openclaw-health.raw" 2>&1 || true - -run_dx project_add project add --path "$REPO_PATH" --workspace "$PRIMARY_WORKSPACE" --assistant "$ASSISTANT" -WS1_ID="$(jq -r '.data.workspace.id // .data.workspace_id // .data.context.workspace.id // ""' <"$REPORT_DIR/project_add.json")" -if [[ -z "${WS1_ID// }" ]]; then - echo "failed to resolve ws1 id from project_add" >&2 - exit 1 -fi -run_openclaw_local_ping openclaw_local_ping "$WS1_ID" -CHANNEL_STATUS_TOKEN="ch-status-${RUN_TAG}-${WS1_ID}" -CHANNEL_STATUS_CMD="cd $(shell_quote "$REPO_ROOT") && $(shell_quote "$DX_SCRIPT") status --workspace $(shell_quote "$WS1_ID") | jq -c --arg token $(shell_quote "$CHANNEL_STATUS_TOKEN") --arg ws $(shell_quote "$WS1_ID") '{status:(.status // \"\"),summary:(.summary // \"\"),workspace:(.data.workspaces[0].id // .data.context.workspace.id // \"\"),dogfood_channel_status_token:\$token,dogfood_expected_workspace:\$ws}'" -run_openclaw_channel_command openclaw_channel_status "dogfood-channel-${WS1_ID}-$RUN_TAG" "${OPENCLAW_DOGFOOD_CHANNEL:-telegram}" "$CHANNEL_STATUS_CMD" "$CHANNEL_STATUS_TOKEN" - -run_dx start_ws1 start --workspace "$WS1_ID" --assistant "$ASSISTANT" --prompt "Update README with run instructions and add NOTES.md with one mobile DX tip." --max-steps 2 --turn-budget 120 --wait-timeout 80s --idle-threshold 10s -run_dx continue_ws1 continue --workspace "$WS1_ID" --auto-start --text "Add one concise status line to NOTES.md and finish." --enter --max-steps 1 --turn-budget 90 --wait-timeout 70s --idle-threshold 10s - -run_dx workspace2_create workspace create --name "$SECONDARY_WORKSPACE" --project "$REPO_PATH" --assistant "$ASSISTANT" -WS2_ID="$(jq -r '.data.workspace.id // .data.workspace_id // ""' <"$REPORT_DIR/workspace2_create.json")" -if [[ -n "${WS2_ID// }" ]]; then - CHANNEL_WS2_CMD="cd $(shell_quote "$REPO_ROOT") && $(shell_quote "$DX_SCRIPT") terminal run --workspace $(shell_quote "$WS2_ID") --text \"echo channel-smoke > CHANNEL_SMOKE.txt\" --enter" - run_openclaw_channel_command openclaw_channel_terminal_ws2 "dogfood-channel-ws2-${WS2_ID}-$RUN_TAG" "${OPENCLAW_DOGFOOD_CHANNEL:-telegram}" "$CHANNEL_WS2_CMD" "" false - run_dx start_ws2 start --workspace "$WS2_ID" --assistant "$ASSISTANT" --prompt "Create TODO.md with three concise next steps for this repo." --max-steps 1 --turn-budget 90 --wait-timeout 70s --idle-threshold 10s -fi - -run_dx terminal_run_ws1 terminal run --workspace "$WS1_ID" --text "go run main.go" --enter -sleep 1 -run_dx terminal_logs_ws1 terminal logs --workspace "$WS1_ID" --lines 40 - -run_dx workflow_dual_ws1 workflow dual --workspace "$WS1_ID" --implement-assistant "$ASSISTANT" --implement-prompt "Append one concise mobile-coding tip to README.md and proceed even if there are unrelated uncommitted changes." --review-assistant "$ASSISTANT" --review-prompt "Review for clarity and correctness." --max-steps 1 --turn-budget 100 --wait-timeout 70s --idle-threshold 10s --auto-continue-impl true - -run_dx git_ship_ws1 git ship --workspace "$WS1_ID" --message "dogfood: scripted openclaw pass" -run_dx status_ws1 status --workspace "$WS1_ID" --capture-agents 8 --capture-lines 80 -if [[ -n "${WS2_ID// }" ]]; then - run_dx status_ws2 status --workspace "$WS2_ID" --capture-agents 8 --capture-lines 80 -fi -run_dx alerts_project alerts --project "$REPO_PATH" --capture-agents 8 --capture-lines 80 - -SUMMARY_FILE="$REPORT_DIR/summary.txt" -channel_unverified_count=0 -if ls "$REPORT_DIR"/*.status >/dev/null 2>&1; then - channel_unverified_count="$(grep -hE "channel output (unverified: command execution proof missing|missing execution markers)" "$REPORT_DIR"/*.status 2>/dev/null | wc -l | tr -d ' ' || true)" - if [[ -z "${channel_unverified_count// }" ]]; then - channel_unverified_count=0 - fi -fi -{ - echo "repo=$REPO_PATH" - echo "report_dir=$REPORT_DIR" - echo "dx_context_file=$DX_CONTEXT_FILE" - echo "workspace_primary=$WS1_ID" - echo "workspace_primary_name=$PRIMARY_WORKSPACE" - if [[ -n "${WS2_ID// }" ]]; then - echo "workspace_secondary=$WS2_ID" - echo "workspace_secondary_name=$SECONDARY_WORKSPACE" - fi - echo "steps:" - for f in "$REPORT_DIR"/*.status; do - [[ -f "$f" ]] || continue - echo " $(basename "$f" .status): $(cat "$f")" - done - echo "channel_unverified_count=$channel_unverified_count" -} >"$SUMMARY_FILE" - -echo "dogfood_complete summary_file=$(shell_quote "$SUMMARY_FILE")" -cat "$SUMMARY_FILE" -if [[ "${OPENCLAW_DOGFOOD_REQUIRE_CHANNEL_EXECUTION:-true}" == "true" ]] && [[ "$channel_unverified_count" =~ ^[0-9]+$ ]] && [[ "$channel_unverified_count" -gt 0 ]]; then - echo "dogfood_fail reason=channel_execution_unverified count=$channel_unverified_count" - exit 2 -fi diff --git a/skills/amux/scripts/openclaw-dx.sh b/skills/amux/scripts/openclaw-dx.sh deleted file mode 100755 index 0f26dbd9..00000000 --- a/skills/amux/scripts/openclaw-dx.sh +++ /dev/null @@ -1,5358 +0,0 @@ -#!/usr/bin/env bash -# openclaw-dx.sh — OpenClaw-first control plane for amux coding workflows. -# -# Covers project/workspace/agent/terminal/session/git/review flows in one UX layer. - -set -euo pipefail - -usage() { - cat >&2 <<'USAGE' -Usage: - openclaw-dx.sh project add [--path | --cwd] [--workspace ] [--assistant ] [--base ] - openclaw-dx.sh project list [--limit ] [--page ] [--query ] - openclaw-dx.sh project pick [--index | --name | --path ] [--workspace ] [--assistant ] [--base ] - - openclaw-dx.sh workspace create --name [--project ] [--from-workspace ] [--scope project|nested] [--assistant ] [--base ] - openclaw-dx.sh workspace list [--project ] [--workspace ] [--limit ] [--page ] - openclaw-dx.sh workspace decide [--project ] [--from-workspace ] [--task ] [--assistant ] [--name ] - - openclaw-dx.sh start --workspace --prompt [--assistant ] [--max-steps ] [--turn-budget ] [--wait-timeout ] [--idle-threshold ] - openclaw-dx.sh continue [--agent | --workspace ] [--text ] [--enter] [--auto-start] [--assistant ] [--max-steps ] [--turn-budget ] [--wait-timeout ] [--idle-threshold ] - - openclaw-dx.sh status [--project ] [--workspace ] [--limit ] [--capture-lines ] [--capture-agents ] [--older-than ] [--alerts-only] [--include-stale] [--recent-workspaces ] - openclaw-dx.sh alerts [same flags as status] - - openclaw-dx.sh terminal run --workspace --text [--enter] - openclaw-dx.sh terminal preset --workspace [--kind nextjs] [--port ] [--host ] [--manager auto|npm|pnpm|yarn|bun] - openclaw-dx.sh terminal logs --workspace [--lines ] - - openclaw-dx.sh cleanup [--older-than ] [--yes] - openclaw-dx.sh review --workspace [--assistant ] [--prompt ] [--max-steps ] [--turn-budget ] [--wait-timeout ] [--idle-threshold ] - openclaw-dx.sh git ship --workspace [--message ] [--push] - openclaw-dx.sh guide [--project ] [--workspace ] [--task ] [--assistant ] - - openclaw-dx.sh workflow kickoff --name [--project ] [--from-workspace ] [--scope project|nested] [--assistant ] --prompt [--base ] [--max-steps ] [--turn-budget ] [--wait-timeout ] [--idle-threshold ] - openclaw-dx.sh workflow dual --workspace [--implement-assistant ] [--implement-prompt ] [--review-assistant ] [--review-prompt ] [--max-steps ] [--turn-budget ] [--wait-timeout ] [--idle-threshold ] [--auto-continue-impl ] [--auto-continue-impl-prompt ] - - openclaw-dx.sh assistants [--workspace --probe] [--limit ] [--prompt ] [--max-steps ] [--turn-budget ] [--wait-timeout ] [--idle-threshold ] -USAGE -} - -json_escape() { - printf '%s' "$1" | jq -Rsa . -} - -shell_quote() { - printf '%q' "$1" -} - -is_positive_int() { - [[ "${1:-}" =~ ^[0-9]+$ ]] && [[ "$1" -gt 0 ]] -} - -is_valid_hostname() { - [[ "${1:-}" =~ ^[-A-Za-z0-9.:]+$ ]] -} - -normalize_inline_buttons_scope() { - local value="${1:-allowlist}" - case "$value" in - off|dm|group|all|allowlist) - printf '%s' "$value" - ;; - *) - printf 'allowlist' - ;; - esac -} - -redact_secrets_text() { - local input="$1" - printf '%s' "$input" | sed -E \ - -e 's/(sk-ant-api[0-9]*-[A-Za-z0-9_-]{10})[A-Za-z0-9_-]*/\1***/g' \ - -e 's/(sk-[A-Za-z0-9_-]{20})[A-Za-z0-9_-]*/\1***/g' \ - -e 's/(ghp_[A-Za-z0-9]{5})[A-Za-z0-9]*/\1***/g' \ - -e 's/(gho_[A-Za-z0-9]{5})[A-Za-z0-9]*/\1***/g' \ - -e 's/(github_pat_[A-Za-z0-9_]{5})[A-Za-z0-9_]*/\1***/g' \ - -e 's/(ghs_[A-Za-z0-9]{5})[A-Za-z0-9]*/\1***/g' \ - -e 's/(glpat-[A-Za-z0-9_-]{5})[A-Za-z0-9_-]*/\1***/g' \ - -e 's/(xoxb-[A-Za-z0-9]{5})[A-Za-z0-9-]*/\1***/g' \ - -e 's/(AKIA[0-9A-Z]{4})[0-9A-Z]{12}/\1************/g' \ - -e 's/(Bearer )[A-Za-z0-9+/_=.-]{8,}/\1***/g' \ - -e 's/((TOKEN|SECRET|PASSWORD|API_KEY|APIKEY|AUTH_TOKEN|PRIVATE_KEY|ACCESS_KEY|CLIENT_SECRET|WEBHOOK_SECRET)=)[^[:space:]'"'"'\"]{8,}/\1***/g' -} - -sanitize_workspace_name() { - local value="$1" - value="$(printf '%s' "$value" | tr '[:upper:]' '[:lower:]' | sed -E 's/[^a-z0-9._-]+/-/g; s/-+/-/g; s/\.+/./g; s/^-+//; s/-+$//; s/^\.+//; s/\.+$//')" - value="${value//../.}" - if [[ -z "$value" ]]; then - value="ws-$(date +%s)" - fi - if [[ ! "$value" =~ ^[a-z0-9] ]]; then - value="w${value}" - fi - printf '%s' "$value" -} - -compose_nested_workspace_name() { - local parent_name="$1" - local child_name="$2" - local parent_norm child_norm - parent_norm="$(sanitize_workspace_name "$parent_name")" - child_norm="$(sanitize_workspace_name "$child_name")" - if [[ "$child_norm" == "$parent_norm"* ]]; then - printf '%s' "$child_norm" - return - fi - printf '%s.%s' "$parent_norm" "$child_norm" -} - -normalize_json_or_default() { - local input="$1" - local fallback="$2" - if jq -e . >/dev/null 2>&1 <<<"$input"; then - printf '%s' "$input" - else - printf '%s' "$fallback" - fi -} - -AMUX_ERROR_OUTPUT="" -AMUX_ERROR_CAPTURE_FILE="" -if AMUX_ERROR_CAPTURE_FILE="$(mktemp "${TMPDIR:-/tmp}/amux-openclaw-dx-error.XXXXXX" 2>/dev/null)"; then - : -else - AMUX_ERROR_CAPTURE_FILE="${TMPDIR:-/tmp}/amux-openclaw-dx-error.$$" -fi -_openclaw_dx_cleanup() { - if [[ -n "${AMUX_ERROR_CAPTURE_FILE:-}" && -f "$AMUX_ERROR_CAPTURE_FILE" ]]; then - rm -f "$AMUX_ERROR_CAPTURE_FILE" 2>/dev/null || true - fi -} -trap _openclaw_dx_cleanup EXIT -amux_ok_json() { - local out - AMUX_ERROR_OUTPUT="" - if [[ -n "${AMUX_ERROR_CAPTURE_FILE:-}" ]]; then - : >"$AMUX_ERROR_CAPTURE_FILE" 2>/dev/null || true - fi - if ! out="$(amux --json "$@" 2>&1)"; then - AMUX_ERROR_OUTPUT="$out" - if [[ -n "${AMUX_ERROR_CAPTURE_FILE:-}" ]]; then - printf '%s' "$out" >"$AMUX_ERROR_CAPTURE_FILE" 2>/dev/null || true - fi - return 1 - fi - if ! jq -e . >/dev/null 2>&1 <<<"$out"; then - AMUX_ERROR_OUTPUT="$out" - if [[ -n "${AMUX_ERROR_CAPTURE_FILE:-}" ]]; then - printf '%s' "$out" >"$AMUX_ERROR_CAPTURE_FILE" 2>/dev/null || true - fi - return 1 - fi - local ok - ok="$(jq -r '.ok // false' <<<"$out")" - if [[ "$ok" != "true" ]]; then - AMUX_ERROR_OUTPUT="$out" - if [[ -n "${AMUX_ERROR_CAPTURE_FILE:-}" ]]; then - printf '%s' "$out" >"$AMUX_ERROR_CAPTURE_FILE" 2>/dev/null || true - fi - return 1 - fi - printf '%s' "$out" -} - -# Result envelope globals. -RESULT_OK=true -RESULT_COMMAND="" -RESULT_STATUS="ok" -RESULT_SUMMARY="" -RESULT_MESSAGE="" -RESULT_NEXT_ACTION="" -RESULT_SUGGESTED_COMMAND="" -RESULT_DATA='{}' -RESULT_QUICK_ACTIONS='[]' -RESULT_DELIVERY_ACTION="send" -RESULT_DELIVERY_PRIORITY=1 -RESULT_DELIVERY_RETRY_AFTER_SECONDS=0 -RESULT_DELIVERY_REPLACE_PREVIOUS=false -RESULT_DELIVERY_DROP_PENDING=true - -OPENCLAW_DX_CHUNK_CHARS="${OPENCLAW_DX_CHUNK_CHARS:-1200}" -if ! is_positive_int "$OPENCLAW_DX_CHUNK_CHARS"; then - OPENCLAW_DX_CHUNK_CHARS=1200 -fi - -INLINE_BUTTONS_SCOPE="$(normalize_inline_buttons_scope "${OPENCLAW_INLINE_BUTTONS_SCOPE:-allowlist}")" -INLINE_BUTTONS_ENABLED=true -if [[ "$INLINE_BUTTONS_SCOPE" == "off" ]]; then - INLINE_BUTTONS_ENABLED=false -fi - -DX_CMD_REF="skills/amux/scripts/openclaw-dx.sh" -TURN_CMD_REF="skills/amux/scripts/openclaw-turn.sh" -STEP_CMD_REF="skills/amux/scripts/openclaw-step.sh" - -normalize_command_refs() { - local value="$1" - value="${value//skills\/amux\/scripts\/openclaw-dx.sh/$DX_CMD_REF}" - value="${value//skills\/amux\/scripts\/openclaw-turn.sh/$TURN_CMD_REF}" - value="${value//skills\/amux\/scripts\/openclaw-step.sh/$STEP_CMD_REF}" - printf '%s' "$value" -} - -emit_result() { - local data_json quick_actions_json message_clean summary_clean next_clean suggested_clean context_json - data_json="$(normalize_json_or_default "$RESULT_DATA" '{}')" - quick_actions_json="$(normalize_json_or_default "$RESULT_QUICK_ACTIONS" '[]')" - context_json="$(context_read_json)" - - summary_clean="$(redact_secrets_text "$RESULT_SUMMARY")" - message_clean="$(redact_secrets_text "$RESULT_MESSAGE")" - next_clean="$(redact_secrets_text "$RESULT_NEXT_ACTION")" - suggested_clean="$(redact_secrets_text "$RESULT_SUGGESTED_COMMAND")" - - summary_clean="$(normalize_command_refs "$summary_clean")" - message_clean="$(normalize_command_refs "$message_clean")" - next_clean="$(normalize_command_refs "$next_clean")" - suggested_clean="$(normalize_command_refs "$suggested_clean")" - - quick_actions_json="$(jq -c --arg dx "$DX_CMD_REF" --arg turn "$TURN_CMD_REF" --arg step "$STEP_CMD_REF" ' - map( - .command = ((.command // "") - | gsub("skills/amux/scripts/openclaw-dx\\.sh"; $dx) - | gsub("skills/amux/scripts/openclaw-turn\\.sh"; $turn) - | gsub("skills/amux/scripts/openclaw-step\\.sh"; $step) - ) - ) - ' <<<"$quick_actions_json")" - - data_json="$(jq -c --arg dx "$DX_CMD_REF" --arg turn "$TURN_CMD_REF" --arg step "$STEP_CMD_REF" ' - def rewrite: - if type == "string" then - gsub("skills/amux/scripts/openclaw-dx\\.sh"; $dx) - | gsub("skills/amux/scripts/openclaw-turn\\.sh"; $turn) - | gsub("skills/amux/scripts/openclaw-step\\.sh"; $step) - elif type == "array" then - map(rewrite) - elif type == "object" then - with_entries(.value |= rewrite) - else - . - end; - rewrite - ' <<<"$data_json")" - - data_json="$(jq -cn --argjson payload "$data_json" --argjson context "$context_json" ' - if ($payload | type) == "object" then - $payload + {context: $context} - else - {value: $payload, context: $context} - end - ')" - - local result_payload - result_payload="$(jq -n \ - --argjson ok "$RESULT_OK" \ - --arg command "$RESULT_COMMAND" \ - --arg status "$RESULT_STATUS" \ - --arg summary "$summary_clean" \ - --arg message "$message_clean" \ - --arg next_action "$next_clean" \ - --arg suggested_command "$suggested_clean" \ - --argjson data "$data_json" \ - --argjson quick_actions "$quick_actions_json" \ - --arg inline_buttons_scope "$INLINE_BUTTONS_SCOPE" \ - --argjson inline_buttons_enabled "$INLINE_BUTTONS_ENABLED" \ - --argjson channel_chunk_chars "$OPENCLAW_DX_CHUNK_CHARS" \ - --arg delivery_action "$RESULT_DELIVERY_ACTION" \ - --argjson delivery_priority "$RESULT_DELIVERY_PRIORITY" \ - --argjson delivery_retry_after_seconds "$RESULT_DELIVERY_RETRY_AFTER_SECONDS" \ - --argjson delivery_replace_previous "$RESULT_DELIVERY_REPLACE_PREVIOUS" \ - --argjson delivery_drop_pending "$RESULT_DELIVERY_DROP_PENDING" \ - ' - def rindex_compat($s): - indices($s) | if length == 0 then null else .[-1] end; - def smart_split($txt; $size): - def next_cut($source): - ($source[0:$size]) as $head - | ($head | rindex_compat("\n\n")) as $double - | ($head | rindex_compat("\n")) as $single - | ($head | rindex_compat(" ")) as $space - | ($double // $single // $space) as $idx - | if $idx == null or $idx < ($size / 3) then $size else ($idx + 1) end; - def split_rec($source): - if ($source | length) <= $size then - [($source | ltrimstr("\n"))] - else - (next_cut($source)) as $cut - | [($source[0:$cut])] + split_rec($source[$cut:]) - end; - if ($txt | length) == 0 then - [] - else - split_rec($txt) - | map(select(length > 0)) - end; - def annotate_chunks($chunks): - ($chunks | length) as $count - | [range(0; $count) as $idx - | { - index: ($idx + 1), - total: $count, - text: ( - if $idx == 0 then - $chunks[$idx] - else - "continued (" + (($idx + 1) | tostring) + "/" + ($count | tostring) + ")\n" + $chunks[$idx] - end - ) - } - ]; - def build_action_rows($actions; $size): - if ($actions | length) == 0 then - [] - else - [range(0; ($actions | length); $size) as $idx - | ($actions[$idx:($idx + $size)] | map({text: .label, callback_data: .callback_data, style: .style})) - ] - end; - def action_tokens_text($actions): - ($actions | map(.callback_data) | join(" | ")); - def status_emoji($status): - if $status == "ok" then "✅" - elif $status == "needs_input" then "❓" - elif $status == "attention" then "⚠️" - elif $status == "command_error" or $status == "agent_error" then "🛑" - else "ℹ️" - end; - def action_token($id; $idx): - ( - ($id | tostring | ascii_downcase | gsub("[^a-z0-9_-]"; "_") | gsub("_+"; "_") | .[0:40]) - | if length == 0 then "action" else . end - ) as $clean - | ("dx:" + $clean + ":" + (($idx + 1) | tostring)); - def normalize_actions($actions): - ($actions // []) - | to_entries - | map( - . as $entry - | ($entry.value // {}) as $value - | { - id: ($value.id // "action"), - label: ($value.label // "Action"), - command: ($value.command // ""), - style: ( - ($value.style // "primary") as $style - | if ($style == "primary" or $style == "success" or $style == "danger") then - $style - else - "primary" - end - ), - prompt: ($value.prompt // ""), - callback_data: ( - if (($value.callback_data // "") | length) > 0 then - $value.callback_data - else - action_token(($value.id // "action"); $entry.key) - end - ) - } - ) - | map(. + {callback_data: (.callback_data[0:64])}); - - normalize_actions($quick_actions) as $actions - | ( - if ($message | length) > 0 then - $message - else - (status_emoji($status) + " " + $summary) - end - ) as $channel_message - | smart_split($channel_message; $channel_chunk_chars) as $chunks_raw - | annotate_chunks($chunks_raw) as $chunks_meta - | { - ok: $ok, - command: $command, - status: $status, - summary: $summary, - next_action: $next_action, - suggested_command: $suggested_command, - data: $data, - quick_actions: $actions, - quick_action_map: ($actions | map({key: .callback_data, value: .command}) | from_entries), - quick_action_prompts: ($actions | map({key: .callback_data, value: .prompt}) | from_entries), - delivery: { - key: ("dx:" + $command), - action: $delivery_action, - priority: $delivery_priority, - retry_after_seconds: $delivery_retry_after_seconds, - replace_previous: $delivery_replace_previous, - drop_pending: $delivery_drop_pending, - coalesce: true - }, - channel: { - message: $channel_message, - chunk_chars: $channel_chunk_chars, - chunks: ($chunks_meta | map(.text)), - chunks_meta: $chunks_meta, - inline_buttons_scope: $inline_buttons_scope, - inline_buttons_enabled: $inline_buttons_enabled, - callback_data_max_bytes: 64, - inline_buttons: ( - if $inline_buttons_enabled then - build_action_rows($actions; 2) - else - [] - end - ), - action_tokens: ($actions | map(.callback_data)), - actions_fallback: ( - if ($actions | length) == 0 then - "" - else - "Actions: " + action_tokens_text($actions) - end - ) - } - } - ')" - - if [[ "${OPENCLAW_DX_SKIP_PRESENT:-false}" != "true" && -x "$OPENCLAW_PRESENT_SCRIPT" ]]; then - "$OPENCLAW_PRESENT_SCRIPT" <<<"$result_payload" - else - printf '%s\n' "$result_payload" - fi -} - -emit_error() { - local command_name="$1" - local status="$2" - local summary="$3" - local detail="${4:-}" - RESULT_OK=false - RESULT_COMMAND="$command_name" - RESULT_STATUS="$status" - RESULT_SUMMARY="$summary" - RESULT_MESSAGE="🛑 $summary" - if [[ -n "${detail// }" ]]; then - RESULT_MESSAGE+=$'\n'"$detail" - fi - RESULT_NEXT_ACTION="Fix the error and retry this command." - RESULT_SUGGESTED_COMMAND="" - RESULT_DATA="$(jq -cn --arg detail "$detail" '{error: $detail}')" - RESULT_QUICK_ACTIONS='[]' - RESULT_DELIVERY_ACTION="send" - RESULT_DELIVERY_PRIORITY=0 - RESULT_DELIVERY_REPLACE_PREVIOUS=false - RESULT_DELIVERY_DROP_PENDING=true - emit_result -} - -emit_amux_error() { - local command_name="$1" - local out="${2:-$AMUX_ERROR_OUTPUT}" - if [[ -z "${out// }" ]] && [[ -n "${AMUX_ERROR_CAPTURE_FILE:-}" ]] && [[ -f "$AMUX_ERROR_CAPTURE_FILE" ]]; then - out="$(cat "$AMUX_ERROR_CAPTURE_FILE" 2>/dev/null || true)" - fi - local err_code="command_error" - local err_msg="amux command failed" - local err_details='{}' - if jq -e . >/dev/null 2>&1 <<<"$out"; then - err_code="$(jq -r '.error.code // "command_error"' <<<"$out")" - err_msg="$(jq -r '.error.message // "amux command failed"' <<<"$out")" - err_details="$(jq -c '.error.details // {}' <<<"$out")" - else - err_msg="$out" - fi - if [[ -z "${err_code// }" ]]; then - err_code="command_error" - fi - if [[ -z "${err_msg// }" ]]; then - if [[ -n "${out// }" ]]; then - err_msg="$(printf '%s' "$out" | tr '\n' ' ' | sed -E 's/[[:space:]]+/ /g' | cut -c 1-240)" - else - err_msg="amux command failed" - fi - fi - local status="command_error" - if [[ "$err_code" == *"agent"* ]]; then - status="agent_error" - fi - RESULT_OK=false - RESULT_COMMAND="$command_name" - RESULT_STATUS="$status" - RESULT_SUMMARY="$err_msg" - RESULT_MESSAGE="🛑 $err_msg" - RESULT_NEXT_ACTION="Fix the failing amux command input and retry." - RESULT_SUGGESTED_COMMAND="" - RESULT_DATA="$(jq -cn --arg code "$err_code" --arg message "$err_msg" --argjson details "$err_details" '{error: {code: $code, message: $message, details: $details}}')" - RESULT_QUICK_ACTIONS='[]' - RESULT_DELIVERY_ACTION="send" - RESULT_DELIVERY_PRIORITY=0 - RESULT_DELIVERY_REPLACE_PREVIOUS=false - RESULT_DELIVERY_DROP_PENDING=true - emit_result -} - -workspace_row_by_id() { - local workspace_id="$1" - local ws_out - if ! ws_out="$(amux_ok_json workspace list --archived)"; then - return 1 - fi - jq -c --arg id "$workspace_id" ' - (.data // []) - | if type == "array" then . else [] end - | map(select(.id == $id)) - | .[0] // empty - ' <<<"$ws_out" -} - -workspace_require_exists() { - local command_name="$1" - local workspace_id="$2" - local ws_row - if ! ws_row="$(workspace_row_by_id "$workspace_id")"; then - emit_amux_error "$command_name" - return 1 - fi - if [[ -z "${ws_row// }" ]]; then - emit_error "$command_name" "command_error" "workspace not found" "$workspace_id" - return 1 - fi - return 0 -} - -agent_for_workspace() { - local workspace_id="$1" - local agents_out - if ! agents_out="$(amux_ok_json agent list --workspace "$workspace_id")"; then - printf '' - return 0 - fi - local agents_json agent_count first_agent - agents_json="$(jq -c '.data // []' <<<"$agents_out")" - agent_count="$(jq -r 'length' <<<"$agents_json")" - first_agent="$(jq -r '.[0].agent_id // ""' <<<"$agents_json")" - - if [[ -z "$first_agent" ]]; then - printf '' - return 0 - fi - if [[ ! "$agent_count" =~ ^[0-9]+$ ]] || [[ "$agent_count" -le 1 ]]; then - printf '%s' "$first_agent" - return 0 - fi - - local capture_limit - capture_limit="${OPENCLAW_DX_AGENT_PICK_CAPTURE_LIMIT:-4}" - if [[ ! "$capture_limit" =~ ^[0-9]+$ ]] || [[ "$capture_limit" -le 0 ]]; then - capture_limit=4 - fi - - local best_agent fallback_needs_input_agent - best_agent="" - fallback_needs_input_agent="" - while IFS=$'\t' read -r session_name agent_id; do - [[ -z "${agent_id// }" ]] && continue - [[ -z "${session_name// }" ]] && continue - - local capture_out capture_status capture_needs_input capture_hint capture_hint_trim - if ! capture_out="$(amux_ok_json agent capture "$session_name" --lines 48)"; then - continue - fi - capture_status="$(jq -r '.data.status // "captured"' <<<"$capture_out")" - capture_needs_input="$(jq -r '.data.needs_input // false' <<<"$capture_out")" - capture_hint="$(jq -r '.data.input_hint // ""' <<<"$capture_out")" - capture_hint_trim="$(printf '%s' "$capture_hint" | tr -d '\r')" - capture_hint_trim="${capture_hint_trim#"${capture_hint_trim%%[![:space:]]*}"}" - capture_hint_trim="${capture_hint_trim%"${capture_hint_trim##*[![:space:]]}"}" - - if [[ "$capture_status" == "session_exited" ]]; then - continue - fi - if [[ "$capture_needs_input" == "true" && "$capture_hint_trim" == "Assistant is waiting for local permission-mode selection." ]]; then - continue - fi - if [[ "$capture_needs_input" == "false" ]]; then - best_agent="$agent_id" - break - fi - if [[ -z "$fallback_needs_input_agent" ]]; then - fallback_needs_input_agent="$agent_id" - fi - done < <(jq -r --argjson cap "$capture_limit" '.[:$cap][] | [.session_name // "", .agent_id // ""] | @tsv' <<<"$agents_json") - - if [[ -n "$best_agent" ]]; then - printf '%s' "$best_agent" - return 0 - fi - if [[ -n "$fallback_needs_input_agent" ]]; then - printf '%s' "$fallback_needs_input_agent" - return 0 - fi - printf '%s' "$first_agent" -} - -turn_reports_permission_mode_gate() { - local turn_json="$1" - if ! jq -e . >/dev/null 2>&1 <<<"$turn_json"; then - return 1 - fi - jq -e ' - ((.overall_status // .status // "") == "needs_input") - and ( - ((.events // []) | any( - (.response.needs_input // false) == true - and ((.response.input_hint // "") == "Assistant is waiting for local permission-mode selection.") - )) - or ((.next_action // "") | test("permission-mode selection"; "i")) - or ((.summary // "") | test("permission-mode selection"; "i")) - ) - ' >/dev/null 2>&1 <<<"$turn_json" -} - -turn_reports_no_workspace_change_claim() { - local turn_json="$1" - if ! jq -e . >/dev/null 2>&1 <<<"$turn_json"; then - return 1 - fi - jq -e ' - ((.summary // "") | test("Claimed file updates, but no workspace changes were detected\\."; "i")) - or ((.events // []) | any((.summary // "") | test("Claimed file updates, but no workspace changes were detected\\."; "i"))) - ' >/dev/null 2>&1 <<<"$turn_json" -} - -default_assistant_for_workspace() { - local workspace_id="$1" - local ws_row - if ! ws_row="$(workspace_row_by_id "$workspace_id")"; then - printf '' - return 0 - fi - if [[ -z "${ws_row// }" ]]; then - printf '' - return 0 - fi - jq -r '.assistant // ""' <<<"$ws_row" -} - -assistant_require_known() { - local command_name="$1" - local assistant="$2" - local normalized - normalized="$(printf '%s' "$assistant" | tr '[:upper:]' '[:lower:]')" - normalized="${normalized#"${normalized%%[![:space:]]*}"}" - normalized="${normalized%"${normalized##*[![:space:]]}"}" - if [[ -z "${normalized// }" ]]; then - emit_error "$command_name" "command_error" "invalid assistant" "$assistant" - return 1 - fi - if [[ "${#normalized}" -gt 100 ]]; then - emit_error "$command_name" "command_error" "invalid assistant" "$assistant" - return 1 - fi - if [[ "$normalized" =~ ^[a-z0-9][a-z0-9._-]*$ ]]; then - return 0 - fi - emit_error "$command_name" "command_error" "invalid assistant" "$assistant" - return 1 -} - -canonicalize_path() { - local path="$1" - if [[ -z "$path" ]]; then - printf '' - return 0 - fi - if [[ -d "$path" ]]; then - ( - cd "$path" >/dev/null 2>&1 && pwd -P - ) || printf '%s' "$path" - return 0 - fi - printf '%s' "$path" -} - -current_git_root() { - if ! command -v git >/dev/null 2>&1; then - printf '' - return 0 - fi - local root - root="$(git rev-parse --show-toplevel 2>/dev/null || true)" - if [[ -z "${root// }" ]]; then - printf '' - return 0 - fi - canonicalize_path "$root" -} - -default_project_path_hint() { - local inferred - inferred="$(current_git_root)" - if [[ -n "$inferred" ]]; then - printf '%s' "$inferred" - return 0 - fi - canonicalize_path "$(pwd -P)" -} - -context_file_path() { - local configured="${OPENCLAW_DX_CONTEXT_FILE:-}" - if [[ -n "${configured// }" ]]; then - printf '%s' "$configured" - return 0 - fi - local base="${XDG_STATE_HOME:-}" - if [[ -z "${base// }" ]]; then - if [[ -n "${HOME:-}" ]]; then - base="$HOME/.local/state" - else - base="/tmp" - fi - fi - printf '%s' "$base/amux/openclaw-dx-context.json" -} - -context_read_json() { - local path raw - path="$(context_file_path)" - if [[ ! -f "$path" ]]; then - printf '{}' - return 0 - fi - raw="$(cat "$path" 2>/dev/null || true)" - if jq -e . >/dev/null 2>&1 <<<"$raw"; then - jq -c . <<<"$raw" - else - printf '{}' - fi -} - -context_write_json() { - local payload="$1" - local path dir tmp - path="$(context_file_path)" - dir="$(dirname "$path")" - if ! mkdir -p "$dir" >/dev/null 2>&1; then - return 0 - fi - tmp="${path}.tmp.$$" - if ! printf '%s\n' "$payload" >"$tmp" 2>/dev/null; then - rm -f "$tmp" >/dev/null 2>&1 || true - return 0 - fi - mv "$tmp" "$path" >/dev/null 2>&1 || { - rm -f "$tmp" >/dev/null 2>&1 || true - return 0 - } -} - -context_timestamp_utc() { - date -u +"%Y-%m-%dT%H:%M:%SZ" -} - -context_project_path() { - jq -r '.project.path // ""' <<<"$(context_read_json)" -} - -context_workspace_id() { - jq -r '.workspace.id // ""' <<<"$(context_read_json)" -} - -context_agent_id() { - jq -r '.agent.id // ""' <<<"$(context_read_json)" -} - -context_assistant_hint() { - local workspace_id="${1:-}" - jq -r --arg ws "$workspace_id" ' - if ($ws | length) > 0 and ((.workspace.id // "") == $ws) and ((.workspace.assistant // "") | length) > 0 then - .workspace.assistant - elif ($ws | length) > 0 and ((.agent.workspace_id // "") == $ws) and ((.agent.assistant // "") | length) > 0 then - .agent.assistant - elif ((.agent.assistant // "") | length) > 0 then - .agent.assistant - else - .workspace.assistant // "" - end - ' <<<"$(context_read_json)" -} - -context_resolve_project() { - local explicit="${1:-}" - if [[ -n "${explicit// }" ]]; then - printf '%s' "$explicit" - return 0 - fi - context_project_path -} - -context_resolve_workspace() { - local explicit="${1:-}" - if [[ -n "${explicit// }" ]]; then - printf '%s' "$explicit" - return 0 - fi - context_workspace_id -} - -context_resolve_agent() { - local explicit="${1:-}" - if [[ -n "${explicit// }" ]]; then - printf '%s' "$explicit" - return 0 - fi - context_agent_id -} - -context_set_project() { - local project_path="$1" - local project_name="${2:-}" - local canonical ctx ts updated - canonical="$(canonicalize_path "$project_path")" - if [[ -z "${canonical// }" ]]; then - canonical="$project_path" - fi - if [[ -z "${canonical// }" ]]; then - return 0 - fi - - ctx="$(context_read_json)" - ts="$(context_timestamp_utc)" - updated="$(jq -c --arg path "$canonical" --arg name "$project_name" --arg ts "$ts" ' - (.project.path // "") as $prev_path - | .project = { - path: $path, - name: (if ($name | length) > 0 then $name elif $prev_path == $path then (.project.name // "") else "" end) - } - | if $prev_path != $path then - .workspace = null - | .agent = null - else - . - end - | .updated_at = $ts - ' <<<"$ctx")" - context_write_json "$updated" -} - -context_set_workspace() { - local workspace_id="$1" - local workspace_name="${2:-}" - local repo_path="${3:-}" - local assistant="${4:-}" - if [[ -z "${workspace_id// }" ]]; then - return 0 - fi - - local canonical_repo ctx ts updated - canonical_repo="$(canonicalize_path "$repo_path")" - if [[ -z "${canonical_repo// }" ]]; then - canonical_repo="$repo_path" - fi - ctx="$(context_read_json)" - ts="$(context_timestamp_utc)" - updated="$(jq -c \ - --arg id "$workspace_id" \ - --arg name "$workspace_name" \ - --arg repo "$canonical_repo" \ - --arg assistant "$assistant" \ - --arg ts "$ts" ' - (.workspace // {}) as $prev - | ($prev.id // "") as $prev_id - | .workspace = { - id: $id, - name: ( - if ($name | length) > 0 then - $name - elif $prev_id == $id then - ($prev.name // "") - else - "" - end - ), - repo: ( - if ($repo | length) > 0 then - $repo - elif $prev_id == $id then - ($prev.repo // "") - else - "" - end - ), - assistant: ( - if ($assistant | length) > 0 then - $assistant - elif $prev_id == $id then - ($prev.assistant // "") - else - "" - end - ) - } - | if ((.workspace.repo // "") | length) > 0 then - .project = ((.project // {}) + {path: .workspace.repo}) - else - . - end - | if $prev_id != $id then - .agent = null - else - . - end - | .updated_at = $ts - ' <<<"$ctx")" - context_write_json "$updated" -} - -context_set_agent() { - local agent_id="$1" - local workspace_id="${2:-}" - local assistant="${3:-}" - if [[ -z "${agent_id// }" ]]; then - return 0 - fi - local ctx ts updated - ctx="$(context_read_json)" - ts="$(context_timestamp_utc)" - updated="$(jq -c --arg id "$agent_id" --arg workspace_id "$workspace_id" --arg assistant "$assistant" --arg ts "$ts" ' - .agent = {id: $id, workspace_id: $workspace_id, assistant: $assistant} - | .updated_at = $ts - ' <<<"$ctx")" - context_write_json "$updated" -} - -context_set_workspace_with_lookup() { - local workspace_id="$1" - local assistant_override="${2:-}" - if [[ -z "${workspace_id// }" ]]; then - return 0 - fi - local ws_row ws_name ws_repo ws_assistant - if ws_row="$(workspace_row_by_id "$workspace_id")" && [[ -n "${ws_row// }" ]]; then - ws_name="$(jq -r '.name // ""' <<<"$ws_row")" - ws_repo="$(jq -r '.repo // ""' <<<"$ws_row")" - ws_assistant="$(jq -r '.assistant // ""' <<<"$ws_row")" - if [[ -n "${assistant_override// }" ]]; then - ws_assistant="$assistant_override" - fi - context_set_workspace "$workspace_id" "$ws_name" "$ws_repo" "$ws_assistant" - return 0 - fi - context_set_workspace "$workspace_id" "" "" "$assistant_override" -} - -project_row_by_path() { - local project_path="$1" - local canonical out - canonical="$(canonicalize_path "$project_path")" - if ! out="$(amux_ok_json project list)"; then - return 1 - fi - jq -c --arg raw "$project_path" --arg canonical "$canonical" ' - .data // [] - | map(select((.path // "") == $raw or (.path // "") == $canonical)) - | .[0] // empty - ' <<<"$out" -} - -ensure_project_registered() { - local project_path="$1" - local existing add_out - if existing="$(project_row_by_path "$project_path")" && [[ -n "${existing// }" ]]; then - printf '%s' "$existing" - return 0 - fi - if ! add_out="$(amux_ok_json project add "$project_path")"; then - return 1 - fi - jq -c '.data // {}' <<<"$add_out" -} - -completion_signal_present() { - local summary="${1:-}" - if [[ -z "${summary// }" ]]; then - return 1 - fi - local lower - lower="$(printf '%s' "$summary" | tr '[:upper:]' '[:lower:]')" - if [[ "$lower" == *"not done"* || "$lower" == *"not complete"* || "$lower" == *"still working"* ]]; then - return 1 - fi - case "$lower" in - *"done"*|*"completed"*|*"finished"*|*"tests passed"*|*"ready for review"*|*"ready to review"*|*"ready to ship"*|*"implemented"*|*"fixed "*) - return 0 - ;; - *) - return 1 - ;; - esac -} - -workspace_scope_hint_from_task() { - local task="${1:-}" - if [[ -z "${task// }" ]]; then - printf '' - return 0 - fi - local lower - lower="$(printf '%s' "$task" | tr '[:upper:]' '[:lower:]')" - case "$lower" in - *"refactor"*|*"review"*|*"audit"*|*"spike"*|*"experiment"*|*"parallel"*|*"hotfix"*|*"bugfix"*|*"cleanup"*|*"tech debt"*|*"debt"*|*"migration"*) - printf 'nested' - ;; - *"greenfield"*|*"from scratch"*|*"bootstrap"*|*"scaffold"*|*"new project"*|*"initial setup"*|*"init repo"*) - printf 'project' - ;; - *) - printf '' - ;; - esac -} - -run_self_json() { - local out - if [[ ! -x "$SELF_SCRIPT" ]]; then - return 1 - fi - out="$(OPENCLAW_DX_SKIP_PRESENT=true "$SELF_SCRIPT" "$@" 2>/dev/null || true)" - if ! jq -e . >/dev/null 2>&1 <<<"$out"; then - return 1 - fi - printf '%s' "$out" -} - -turn_needs_timeout_recovery() { - local turn_json="$1" - jq -e ' - ( - ((.overall_status // .status // "") == "timed_out") - or ((.status // "") == "timed_out") - ) - and ((.agent_id // "") | length > 0) - ' >/dev/null 2>&1 <<<"$turn_json" -} - -recover_timeout_turn_once() { - local turn_json="$1" - local wait_timeout="$2" - local idle_threshold="$3" - local step_script="${OPENCLAW_DX_STEP_SCRIPT:-$SCRIPT_DIR/openclaw-step.sh}" - - if [[ "${OPENCLAW_DX_TIMEOUT_RECOVERY:-true}" == "false" ]]; then - printf '%s' "$turn_json" - return - fi - if ! turn_needs_timeout_recovery "$turn_json"; then - printf '%s' "$turn_json" - return - fi - if [[ ! -x "$step_script" ]]; then - printf '%s' "$turn_json" - return - fi - - local agent_id - agent_id="$(jq -r '.agent_id // ""' <<<"$turn_json")" - if [[ -z "${agent_id// }" ]]; then - printf '%s' "$turn_json" - return - fi - - local recovery_text recovery_wait recovery_idle - recovery_text="${OPENCLAW_DX_TIMEOUT_RECOVERY_TEXT:-Continue from current state and provide a one-line status update plus files changed.}" - recovery_wait="${OPENCLAW_DX_TIMEOUT_RECOVERY_WAIT_TIMEOUT:-$wait_timeout}" - recovery_idle="${OPENCLAW_DX_TIMEOUT_RECOVERY_IDLE_THRESHOLD:-$idle_threshold}" - - local follow_json - follow_json="$(OPENCLAW_STEP_SKIP_PRESENT=true "$step_script" send \ - --agent "$agent_id" \ - --text "$recovery_text" \ - --enter \ - --wait-timeout "$recovery_wait" \ - --idle-threshold "$recovery_idle" 2>&1 || true)" - if ! jq -e . >/dev/null 2>&1 <<<"$follow_json"; then - printf '%s' "$turn_json" - return - fi - - local recovered - recovered="$(jq -r ' - ( - (.ok // false) - and - ( - (.response.substantive_output // false) - or (.response.changed // false) - or ( - ( - (.response.status // .status // .overall_status // "") - | ascii_downcase - | test("^(timed_out|command_error|error)$") - | not - ) - and ((.summary // "") | length > 0) - ) - ) - ) - ' <<<"$follow_json")" - if [[ "$recovered" != "true" ]]; then - printf '%s' "$turn_json" - return - fi - - printf '%s' "$follow_json" -} - -wait_timeout_to_seconds_or_zero() { - local raw="$1" - if [[ "$raw" =~ ^[0-9]+$ ]]; then - printf '%s' "$raw" - return - fi - if [[ "$raw" =~ ^([0-9]+)s$ ]]; then - printf '%s' "${BASH_REMATCH[1]}" - return - fi - if [[ "$raw" =~ ^([0-9]+)m$ ]]; then - printf '%s' "$((BASH_REMATCH[1] * 60))" - return - fi - if [[ "$raw" =~ ^([0-9]+)h$ ]]; then - printf '%s' "$((BASH_REMATCH[1] * 3600))" - return - fi - printf '0' -} - -normalize_turn_wait_timeout() { - local wait_timeout="$1" - local min_seconds="${OPENCLAW_DX_MIN_WAIT_TIMEOUT_SECONDS:-45}" - if ! [[ "$min_seconds" =~ ^[0-9]+$ ]]; then - min_seconds=45 - fi - if [[ "$min_seconds" -le 0 ]]; then - printf '%s' "$wait_timeout" - return - fi - local resolved_seconds - resolved_seconds="$(wait_timeout_to_seconds_or_zero "$wait_timeout")" - if [[ "$resolved_seconds" -eq 0 ]]; then - printf '%s' "$wait_timeout" - return - fi - if [[ "$resolved_seconds" -lt "$min_seconds" ]]; then - printf '%ss' "$min_seconds" - return - fi - printf '%s' "$wait_timeout" -} - -append_action() { - local actions_json="$1" - local id="$2" - local label="$3" - local command="$4" - local style="$5" - local prompt="$6" - jq -cn \ - --argjson actions "$actions_json" \ - --arg id "$id" \ - --arg lbl "$label" \ - --arg command "$command" \ - --arg style "$style" \ - --arg prompt "$prompt" \ - '$actions + [{id: $id, label: $lbl, command: $command, style: $style, prompt: $prompt}]' -} - -emit_turn_passthrough() { - local command_name="$1" - local workflow_name="$2" - local turn_json="$3" - - if ! jq -e . >/dev/null 2>&1 <<<"$turn_json"; then - local recovered_json - recovered_json="$(printf '%s\n' "$turn_json" | sed -n '/^[[:space:]]*{/,$p')" - if [[ -n "${recovered_json// }" ]] && jq -e . >/dev/null 2>&1 <<<"$recovered_json"; then - turn_json="$recovered_json" - else - recovered_json="$(printf '%s\n' "$turn_json" | awk '/^[[:space:]]*\\{/{line=$0} END{print line}')" - if [[ -n "${recovered_json// }" ]] && jq -e . >/dev/null 2>&1 <<<"$recovered_json"; then - turn_json="$recovered_json" - else - emit_error "$command_name" "command_error" "turn script returned non-JSON output" "$turn_json" - return - fi - fi - fi - - local normalized_json - normalized_json="$(jq -c \ - --arg command "$command_name" \ - --arg workflow "$workflow_name" \ - --arg dx_ref "$DX_CMD_REF" \ - --arg step_ref "$STEP_CMD_REF" \ - ' - def scrub_text: - if type == "string" then - gsub("\r"; "") - | sub("[[:space:]]*█+[[:space:]]*$"; "") - | sub("[[:space:]]+$"; "") - elif type == "array" then - map(scrub_text) - elif type == "object" then - with_entries(.value |= scrub_text) - else - . - end; - def fallback_next_action: - if ((.next_action // "") | length) > 0 then - .next_action - elif ((.overall_status // .status // "") == "needs_input") then - "Reply to the pending prompt, then continue the turn." - elif ((.overall_status // .status // "") == "completed" or (.status // "") == "idle") then - "Continue with a follow-up task or run status/review." - else - "Check status and continue with the next focused step." - end; - def fallback_suggested_command: - if ((.suggested_command // "") | length) > 0 then - .suggested_command - elif ((.overall_status // .status // "") == "needs_input") and ((.agent_id // "") | length) > 0 then - ($step_ref + " send --agent " + .agent_id + " --text \"Continue using the safest option and report status plus next action.\" --enter --wait-timeout 60s --idle-threshold 10s") - elif ((.quick_actions // []) | length) > 0 then - ( - (.quick_actions | map(.command // "") | map(select(length > 0)) | .[0]) - // "" - ) - elif ((.agent_id // "") | length) > 0 then - ($step_ref + " send --agent " + .agent_id + " --text \"Provide a one-line progress status.\" --enter --wait-timeout 60s --idle-threshold 10s") - elif ((.workspace_id // "") | length) > 0 then - ($dx_ref + " status --workspace " + .workspace_id) - else - "" - end; - (scrub_text) as $clean - | $clean - | del(.openclaw, .quick_action_by_id, .quick_action_prompts_by_id) - | .next_action = (fallback_next_action) - | .suggested_command = (fallback_suggested_command) - | . + {command: $command, workflow: $workflow} - ' <<<"$turn_json")" - normalized_json="$(jq -c --arg dx "$DX_CMD_REF" --arg turn "$TURN_CMD_REF" --arg step "$STEP_CMD_REF" ' - def rewrite: - if type == "string" then - gsub("skills/amux/scripts/openclaw-dx\\.sh"; $dx) - | gsub("skills/amux/scripts/openclaw-turn\\.sh"; $turn) - | gsub("skills/amux/scripts/openclaw-step\\.sh"; $step) - elif type == "array" then - map(rewrite) - elif type == "object" then - with_entries(.value |= rewrite) - else - . - end; - rewrite - ' <<<"$normalized_json")" - - local workspace_id agent_id assistant - workspace_id="$(jq -r '.workspace_id // ""' <<<"$normalized_json")" - agent_id="$(jq -r '.agent_id // ""' <<<"$normalized_json")" - assistant="$(jq -r '.assistant // ""' <<<"$normalized_json")" - if [[ -n "$workspace_id" ]]; then - context_set_workspace_with_lookup "$workspace_id" "$assistant" - fi - if [[ -n "$agent_id" ]]; then - context_set_agent "$agent_id" "$workspace_id" "$assistant" - fi - - if [[ "${OPENCLAW_DX_SKIP_PRESENT:-false}" != "true" && -x "$OPENCLAW_PRESENT_SCRIPT" ]]; then - "$OPENCLAW_PRESENT_SCRIPT" <<<"$normalized_json" - else - printf '%s\n' "$normalized_json" - fi -} - -cmd_project_add() { - local path="" - local use_cwd=false - local workspace_name="" - local assistant="" - local base="" - local inferred_from_cwd=false - - while [[ $# -gt 0 ]]; do - case "$1" in - --path) - path="$2"; shift 2 ;; - --cwd) - use_cwd=true; shift ;; - --workspace) - workspace_name="$2"; shift 2 ;; - --assistant) - assistant="$2"; shift 2 ;; - --base) - base="$2"; shift 2 ;; - *) - emit_error "project.add" "command_error" "unknown flag" "$1" - return - ;; - esac - done - - if [[ -z "$path" ]]; then - if [[ "$use_cwd" == "true" ]]; then - path="$(default_project_path_hint)" - inferred_from_cwd=true - else - path="$(current_git_root)" - if [[ -n "$path" ]]; then - inferred_from_cwd=true - fi - fi - fi - - if [[ -z "$path" ]]; then - local pwd_hint - pwd_hint="$(canonicalize_path "$(pwd -P)")" - emit_error "project.add" "command_error" "missing project path" "pass --path (or use --cwd in a git repo). current_dir=$pwd_hint" - return - fi - - local add_out - if ! add_out="$(amux_ok_json project add "$path")"; then - emit_amux_error "project.add" - return - fi - - local project_name project_path - project_name="$(jq -r '.data.name // ""' <<<"$add_out")" - project_path="$(jq -r '.data.path // ""' <<<"$add_out")" - - local workspace_data='null' - local workspace_id="" - - if [[ -n "$workspace_name" ]]; then - local ws_create_out - local ws_args=(workspace create "$workspace_name" --project "$project_path") - if [[ -n "$assistant" ]]; then - ws_args+=(--assistant "$assistant") - fi - if [[ -n "$base" ]]; then - ws_args+=(--base "$base") - fi - - if ! ws_create_out="$(amux_ok_json "${ws_args[@]}")"; then - local err_payload err_code err_message retry_cmd - err_payload="$AMUX_ERROR_OUTPUT" - if [[ -z "${err_payload// }" ]] && [[ -n "${AMUX_ERROR_CAPTURE_FILE:-}" ]] && [[ -f "$AMUX_ERROR_CAPTURE_FILE" ]]; then - err_payload="$(cat "$AMUX_ERROR_CAPTURE_FILE" 2>/dev/null || true)" - fi - err_code="" - err_message="" - if jq -e . >/dev/null 2>&1 <<<"$err_payload"; then - err_code="$(jq -r '.error.code // ""' <<<"$err_payload")" - err_message="$(jq -r '.error.message // ""' <<<"$err_payload")" - fi - if workspace_create_needs_initial_commit "$err_code" "$err_message"; then - retry_cmd="skills/amux/scripts/openclaw-dx.sh project add --path $(shell_quote "$project_path") --workspace $(shell_quote "$workspace_name") --assistant $(shell_quote "${assistant:-codex}")" - if [[ -n "$base" ]]; then - retry_cmd+=" --base $(shell_quote "$base")" - fi - emit_initial_commit_guidance "project.add" "$project_path" "$retry_cmd" "$err_message" - return - fi - emit_amux_error "project.add" "$err_payload" - return - fi - workspace_data="$(jq -c '.data' <<<"$ws_create_out")" - workspace_id="$(jq -r '.data.id // ""' <<<"$ws_create_out")" - fi - - context_set_project "$project_path" "$project_name" - if [[ -n "$workspace_id" ]]; then - local workspace_name_out workspace_assistant_out - workspace_name_out="$(jq -r '.name // ""' <<<"$workspace_data")" - workspace_assistant_out="$(jq -r '.assistant // ""' <<<"$workspace_data")" - context_set_workspace "$workspace_id" "$workspace_name_out" "$project_path" "$workspace_assistant_out" - fi - - RESULT_OK=true - RESULT_COMMAND="project.add" - RESULT_STATUS="ok" - if [[ -n "$workspace_id" ]]; then - RESULT_SUMMARY="Project ready and workspace created: $workspace_id" - else - RESULT_SUMMARY="Project registered: $project_name" - fi - - RESULT_NEXT_ACTION="Create/select a workspace and start a focused coding turn." - RESULT_SUGGESTED_COMMAND="" - if [[ -n "$workspace_id" ]]; then - RESULT_SUGGESTED_COMMAND="skills/amux/scripts/openclaw-dx.sh start --workspace $(shell_quote "$workspace_id") --assistant $(shell_quote "${assistant:-codex}") --prompt \"Analyze the biggest tech-debt items and fix the top one.\"" - else - RESULT_SUGGESTED_COMMAND="skills/amux/scripts/openclaw-dx.sh workspace create --name mobile --project $(shell_quote "$project_path") --assistant codex" - fi - - local actions='[]' - actions="$(append_action "$actions" "ws_list" "Workspaces" "skills/amux/scripts/openclaw-dx.sh workspace list --project $(shell_quote "$project_path")" "primary" "List workspaces for this project")" - if [[ -z "$workspace_id" ]]; then - actions="$(append_action "$actions" "ws_create" "Create WS" "skills/amux/scripts/openclaw-dx.sh workspace create --name mobile --project $(shell_quote "$project_path") --assistant codex" "success" "Create a workspace for mobile coding")" - else - actions="$(append_action "$actions" "start" "Start" "skills/amux/scripts/openclaw-dx.sh start --workspace $(shell_quote "$workspace_id") --assistant $(shell_quote "${assistant:-codex}") --prompt \"Analyze technical debt and implement the highest-impact fix.\"" "success" "Start a coding turn in this workspace")" - fi - actions="$(append_action "$actions" "status" "Status" "skills/amux/scripts/openclaw-dx.sh status" "primary" "Show global coding status")" - RESULT_QUICK_ACTIONS="$actions" - - RESULT_DATA="$(jq -cn --argjson project "$(jq -c '.data' <<<"$add_out")" --argjson workspace "$workspace_data" '{project: $project, workspace: $workspace}')" - if [[ "$inferred_from_cwd" == "true" ]]; then - RESULT_DATA="$(jq -c '. + {path_source: "cwd_or_git_root"}' <<<"$RESULT_DATA")" - fi - - RESULT_MESSAGE="✅ Project registered: $project_name"$'\n'"Path: $project_path" - if [[ "$inferred_from_cwd" == "true" ]]; then - RESULT_MESSAGE+=$'\n'"Source: inferred from current working git repo" - fi - if [[ -n "$workspace_id" ]]; then - local workspace_root - workspace_root="$(jq -r '.root // ""' <<<"$workspace_data")" - RESULT_MESSAGE+=$'\n'"Workspace: $workspace_id" - if [[ -n "$workspace_root" ]]; then - RESULT_MESSAGE+=$'\n'"Root: $workspace_root" - fi - fi - RESULT_MESSAGE+=$'\n'"Next: $RESULT_NEXT_ACTION" - - RESULT_DELIVERY_ACTION="send" - RESULT_DELIVERY_PRIORITY=1 - RESULT_DELIVERY_DROP_PENDING=true - emit_result -} - -cmd_project_list() { - local limit=12 - local page=1 - local query="" - while [[ $# -gt 0 ]]; do - case "$1" in - --limit) - limit="$2"; shift 2 ;; - --page) - page="$2"; shift 2 ;; - --query) - query="$2"; shift 2 ;; - *) - emit_error "project.list" "command_error" "unknown flag" "$1" - return - ;; - esac - done - - if ! is_positive_int "$limit"; then - limit=12 - fi - if ! is_positive_int "$page"; then - page=1 - fi - - local out - if ! out="$(amux_ok_json project list)"; then - emit_amux_error "project.list" - return - fi - - local sorted_all sorted count total_count preview lines - sorted_all="$(jq -c '.data // [] | sort_by(.name)' <<<"$out")" - total_count="$(jq -r 'length' <<<"$sorted_all")" - sorted="$sorted_all" - if [[ -n "${query// }" ]]; then - sorted="$(jq -c --arg q "$query" ' - ($q | ascii_downcase) as $needle - | map( - select( - ((.name // "" | ascii_downcase) | contains($needle)) - or ((.path // "" | ascii_downcase) | contains($needle)) - ) - ) - ' <<<"$sorted_all")" - fi - count="$(jq -r 'length' <<<"$sorted")" - local total_pages=1 - if [[ "$count" -gt 0 ]]; then - total_pages=$(( (count + limit - 1) / limit )) - fi - if [[ "$page" -gt "$total_pages" ]]; then - page="$total_pages" - fi - local offset - offset=$(( (page - 1) * limit )) - preview="$(jq -c --argjson offset "$offset" --argjson limit "$limit" '.[ $offset : ($offset + $limit) ]' <<<"$sorted")" - lines="$(jq -r --argjson offset "$offset" '. | to_entries | map("\(($offset + .key + 1)). \(.value.name) — \(.value.path)") | join("\n")' <<<"$preview")" - local has_prev=false - local has_next=false - if [[ "$count" -gt 0 && "$page" -gt 1 ]]; then - has_prev=true - fi - if [[ "$count" -gt 0 && "$page" -lt "$total_pages" ]]; then - has_next=true - fi - - RESULT_OK=true - RESULT_COMMAND="project.list" - RESULT_STATUS="ok" - RESULT_SUMMARY="$count project(s) registered" - if [[ -n "${query// }" ]]; then - RESULT_SUMMARY="$count project(s) matched \"$query\"" - fi - if [[ "$count" -gt 0 ]]; then - RESULT_SUMMARY+=" (page $page/$total_pages)" - fi - RESULT_NEXT_ACTION="Pick a project and create/select a workspace." - RESULT_SUGGESTED_COMMAND="" - if [[ "$count" -gt 0 ]]; then - local first_project_name - first_project_name="$(jq -r '.[0].name // ""' <<<"$preview")" - if [[ -n "$first_project_name" ]]; then - RESULT_SUGGESTED_COMMAND="skills/amux/scripts/openclaw-dx.sh project pick --name $(shell_quote "$first_project_name")" - else - RESULT_SUGGESTED_COMMAND="skills/amux/scripts/openclaw-dx.sh project pick --index 1" - fi - elif [[ -n "${query// }" ]]; then - RESULT_NEXT_ACTION="Try a broader query or register a new project." - fi - - local first_project_path first_project_name - first_project_path="$(jq -r '.[0].path // ""' <<<"$preview")" - first_project_name="$(jq -r '.[0].name // ""' <<<"$preview")" - local actions='[]' - if [[ -n "$first_project_name" ]]; then - actions="$(append_action "$actions" "pick1" "Pick #1" "skills/amux/scripts/openclaw-dx.sh project pick --name $(shell_quote "$first_project_name")" "primary" "Select the first project")" - fi - if [[ -n "$first_project_path" ]]; then - actions="$(append_action "$actions" "ws1" "WS #1" "skills/amux/scripts/openclaw-dx.sh workspace list --project $(shell_quote "$first_project_path")" "primary" "List workspaces for project #1")" - fi - local page_cmd_base="skills/amux/scripts/openclaw-dx.sh project list --limit $limit" - if [[ -n "${query// }" ]]; then - page_cmd_base+=" --query $(shell_quote "$query")" - fi - if [[ "$has_prev" == "true" ]]; then - actions="$(append_action "$actions" "prev_page" "Prev" "$page_cmd_base --page $((page - 1))" "primary" "Show previous projects page")" - fi - if [[ "$has_next" == "true" ]]; then - actions="$(append_action "$actions" "next_page" "Next" "$page_cmd_base --page $((page + 1))" "primary" "Show next projects page")" - fi - if [[ -n "${query// }" ]]; then - actions="$(append_action "$actions" "clear_query" "Clear Query" "skills/amux/scripts/openclaw-dx.sh project list" "primary" "Show all projects")" - fi - actions="$(append_action "$actions" "status" "Status" "skills/amux/scripts/openclaw-dx.sh status" "primary" "Show global coding status")" - RESULT_QUICK_ACTIONS="$actions" - - RESULT_DATA="$(jq -cn --arg query "$query" --argjson count "$count" --argjson total_count "$total_count" --argjson page "$page" --argjson limit "$limit" --argjson total_pages "$total_pages" --argjson has_prev "$has_prev" --argjson has_next "$has_next" --argjson projects "$sorted" --argjson projects_page "$preview" '{query: $query, count: $count, total_count: $total_count, page: $page, limit: $limit, total_pages: $total_pages, has_prev: $has_prev, has_next: $has_next, projects: $projects, projects_page: $projects_page}')" - - RESULT_MESSAGE="✅ $count project(s) registered" - if [[ -n "${query// }" ]]; then - RESULT_MESSAGE="✅ $count project(s) matched \"$query\" (from $total_count total)" - fi - if [[ "$count" -gt 0 ]]; then - RESULT_MESSAGE+=$'\n'"Page: $page/$total_pages" - fi - if [[ -n "${lines// }" ]]; then - RESULT_MESSAGE+=$'\n'"$lines" - fi - RESULT_MESSAGE+=$'\n'"Next: $RESULT_NEXT_ACTION" - - RESULT_DELIVERY_ACTION="send" - RESULT_DELIVERY_PRIORITY=1 - RESULT_DELIVERY_DROP_PENDING=true - emit_result -} - -cmd_project_pick() { - local index="" - local name_query="" - local path_query="" - local workspace_name="" - local assistant="" - local base="" - - while [[ $# -gt 0 ]]; do - case "$1" in - --index) - index="$2"; shift 2 ;; - --name) - name_query="$2"; shift 2 ;; - --path) - path_query="$2"; shift 2 ;; - --workspace) - workspace_name="$2"; shift 2 ;; - --assistant) - assistant="$2"; shift 2 ;; - --base) - base="$2"; shift 2 ;; - *) - emit_error "project.pick" "command_error" "unknown flag" "$1" - return - ;; - esac - done - - local selector_count=0 - [[ -n "$index" ]] && selector_count=$((selector_count + 1)) - [[ -n "${name_query// }" ]] && selector_count=$((selector_count + 1)) - [[ -n "${path_query// }" ]] && selector_count=$((selector_count + 1)) - if [[ "$selector_count" -gt 1 ]]; then - emit_error "project.pick" "command_error" "provide only one selector" "use --index, --name, or --path" - return - fi - if [[ -z "$index" && -z "${name_query// }" && -z "${path_query// }" ]]; then - emit_error "project.pick" "command_error" "missing selector" "provide --index , --name , or --path " - return - fi - local canonical_query="" - if [[ -n "${path_query// }" ]]; then - canonical_query="$(canonicalize_path "$path_query")" - name_query="$path_query" - fi - - local out sorted count selected selected_name selected_path selected_index resolved_by resolved_input - if ! out="$(amux_ok_json project list)"; then - emit_amux_error "project.pick" - return - fi - sorted="$(jq -c '.data // [] | sort_by(.name)' <<<"$out")" - count="$(jq -r 'length' <<<"$sorted")" - - resolved_by="index" - resolved_input="$index" - selected='null' - selected_index="" - - if [[ -n "${name_query// }" ]]; then - resolved_by="name" - resolved_input="$name_query" - local matches match_count match_lines - matches="$(jq -c --arg q "$name_query" --arg c "$canonical_query" ' - ($q | ascii_downcase) as $needle - | ($c | ascii_downcase) as $canonical_needle - | ( - to_entries - | map( - select( - ((.value.name // "") == $q) - or ((.value.path // "") == $q) - or ($c != "" and (.value.path // "") == $c) - ) - | (.value + {index: (.key + 1)}) - ) - ) as $exact - | if ($exact | length) > 0 then - $exact - else - ( - to_entries - | map( - select( - ((.value.name // "" | ascii_downcase) | contains($needle)) - or ((.value.path // "" | ascii_downcase) | contains($needle)) - or ($canonical_needle != "" and ((.value.path // "" | ascii_downcase) | contains($canonical_needle))) - ) - | (.value + {index: (.key + 1)}) - ) - ) - end - ' <<<"$sorted")" - match_count="$(jq -r 'length' <<<"$matches")" - if [[ "$match_count" -eq 0 ]]; then - emit_error "project.pick" "command_error" "no project matched query" "$name_query" - return - fi - if [[ "$match_count" -gt 1 ]]; then - match_lines="$(jq -r '. | map("\(.index). \(.name) — \(.path)") | join("\n")' <<<"$matches")" - - RESULT_OK=false - RESULT_COMMAND="project.pick" - RESULT_STATUS="attention" - RESULT_SUMMARY="Multiple projects matched \"$name_query\"" - RESULT_NEXT_ACTION="Pick one match by index (or use the exact project path)." - RESULT_SUGGESTED_COMMAND="" - - local actions='[]' - while IFS= read -r row; do - [[ -z "${row// }" ]] && continue - local row_name - row_name="$(jq -r '.name // ""' <<<"$row")" - local row_index - row_index="$(jq -r '.index // 0' <<<"$row")" - if ! is_positive_int "$row_index"; then - continue - fi - actions="$(append_action "$actions" "pick_${row_index}" "Pick #$row_index" "skills/amux/scripts/openclaw-dx.sh project pick --index $row_index" "primary" "Select $row_name")" - done < <(jq -c '.[0:6][]' <<<"$matches") - actions="$(append_action "$actions" "list" "List" "skills/amux/scripts/openclaw-dx.sh project list --query $(shell_quote "$name_query")" "primary" "Show filtered projects again")" - RESULT_QUICK_ACTIONS="$actions" - - RESULT_DATA="$(jq -cn --arg query "$name_query" --argjson matches "$matches" '{query: $query, matches: $matches}')" - RESULT_MESSAGE="⚠️ Multiple projects matched \"$name_query\""$'\n'"$match_lines"$'\n'"Next: $RESULT_NEXT_ACTION" - emit_result - return - fi - selected="$(jq -c '.[0]' <<<"$matches")" - selected_index="$(jq -r '.index // ""' <<<"$selected")" - else - if ! is_positive_int "$index"; then - emit_error "project.pick" "command_error" "--index must be a positive integer" - return - fi - if (( index > count )); then - emit_error "project.pick" "command_error" "project index out of range" "index=$index total=$count" - return - fi - selected="$(jq -c --argjson idx "$index" '.[($idx - 1)]' <<<"$sorted")" - selected_index="$index" - fi - - selected_name="$(jq -r '.name // ""' <<<"$selected")" - selected_path="$(jq -r '.path // ""' <<<"$selected")" - if [[ -z "${selected_path// }" ]]; then - emit_error "project.pick" "command_error" "selected project has no path" "$selected" - return - fi - - if [[ -z "$workspace_name" ]]; then - context_set_project "$selected_path" "$selected_name" - - RESULT_OK=true - RESULT_COMMAND="project.pick" - RESULT_STATUS="ok" - RESULT_SUMMARY="Selected project: $selected_name" - RESULT_NEXT_ACTION="Create a workspace on this project, or choose an existing workspace." - RESULT_SUGGESTED_COMMAND="skills/amux/scripts/openclaw-dx.sh workspace create --name mobile --project $(shell_quote "$selected_path") --assistant codex" - - local actions='[]' - actions="$(append_action "$actions" "ws_create" "Create WS" "$RESULT_SUGGESTED_COMMAND" "success" "Create a workspace on the selected project")" - actions="$(append_action "$actions" "ws_list" "Workspaces" "skills/amux/scripts/openclaw-dx.sh workspace list --project $(shell_quote "$selected_path")" "primary" "List project workspaces")" - RESULT_QUICK_ACTIONS="$actions" - - RESULT_DATA="$(jq -cn --arg resolved_by "$resolved_by" --arg resolved_input "$resolved_input" --argjson index "$selected_index" --argjson project "$selected" '{resolved_by: $resolved_by, resolved_input: $resolved_input, index: $index, project: $project}')" - RESULT_MESSAGE="✅ Selected project: $selected_name"$'\n'"Path: $selected_path" - if [[ -n "${selected_index// }" ]]; then - RESULT_MESSAGE+=$'\n'"Index: $selected_index" - fi - RESULT_MESSAGE+=$'\n'"Next: $RESULT_NEXT_ACTION" - emit_result - return - fi - - local ws_out ws_args - ws_args=(workspace create "$workspace_name" --project "$selected_path") - if [[ -n "$assistant" ]]; then - ws_args+=(--assistant "$assistant") - fi - if [[ -n "$base" ]]; then - ws_args+=(--base "$base") - fi - if ! ws_out="$(amux_ok_json "${ws_args[@]}")"; then - emit_amux_error "project.pick" - return - fi - - local ws_id ws_root - ws_id="$(jq -r '.data.id // ""' <<<"$ws_out")" - ws_root="$(jq -r '.data.root // ""' <<<"$ws_out")" - local ws_assistant - ws_assistant="$(jq -r '.data.assistant // ""' <<<"$ws_out")" - context_set_project "$selected_path" "$selected_name" - context_set_workspace "$ws_id" "$workspace_name" "$selected_path" "$ws_assistant" - - RESULT_OK=true - RESULT_COMMAND="project.pick" - RESULT_STATUS="ok" - RESULT_SUMMARY="Selected project and created workspace: $ws_id" - RESULT_NEXT_ACTION="Start a coding turn in the new workspace." - RESULT_SUGGESTED_COMMAND="skills/amux/scripts/openclaw-dx.sh start --workspace $(shell_quote "$ws_id") --assistant $(shell_quote "${assistant:-codex}") --prompt \"Analyze the biggest debt items and fix one high-impact issue.\"" - - local actions='[]' - actions="$(append_action "$actions" "start" "Start" "$RESULT_SUGGESTED_COMMAND" "success" "Start a coding turn")" - actions="$(append_action "$actions" "status" "Status" "skills/amux/scripts/openclaw-dx.sh status --workspace $(shell_quote "$ws_id")" "primary" "Show workspace status")" - RESULT_QUICK_ACTIONS="$actions" - - RESULT_DATA="$(jq -cn --arg resolved_by "$resolved_by" --arg resolved_input "$resolved_input" --argjson index "$selected_index" --argjson project "$selected" --argjson workspace "$(jq -c '.data' <<<"$ws_out")" '{resolved_by: $resolved_by, resolved_input: $resolved_input, index: $index, project: $project, workspace: $workspace}')" - RESULT_MESSAGE="✅ Project selected: $selected_name"$'\n'"Workspace: $ws_id"$'\n'"Root: $ws_root" - if [[ -n "${selected_index// }" ]]; then - RESULT_MESSAGE+=$'\n'"Index: $selected_index" - fi - RESULT_MESSAGE+=$'\n'"Next: $RESULT_NEXT_ACTION" - emit_result -} - -cmd_guide() { - local project="" - local workspace="" - local task="" - local assistant="${OPENCLAW_DX_GUIDE_ASSISTANT:-codex}" - - while [[ $# -gt 0 ]]; do - case "$1" in - --project) - project="$2"; shift 2 ;; - --workspace) - workspace="$2"; shift 2 ;; - --task) - shift - if [[ $# -eq 0 ]]; then - emit_error "guide" "command_error" "missing value for --task" - return - fi - task="$1"; shift - while [[ $# -gt 0 && "$1" != --* ]]; do - task+=" $1" - shift - done - ;; - --assistant) - assistant="$2"; shift 2 ;; - *) - emit_error "guide" "command_error" "unknown flag" "$1" - return - ;; - esac - done - - if [[ -z "${assistant// }" ]]; then - assistant="codex" - fi - - local projects_out projects_json project_count - if ! projects_out="$(amux_ok_json project list)"; then - emit_amux_error "guide" - return - fi - projects_json="$(jq -c '.data // [] | sort_by(.name)' <<<"$projects_out")" - project_count="$(jq -r 'length' <<<"$projects_json")" - - local selected_project='null' - local selected_project_name="" - local selected_project_path="" - local project_query="" - if [[ -n "${project// }" ]]; then - project_query="$(canonicalize_path "$project")" - selected_project="$(jq -c --arg q "$project" --arg c "$project_query" ' - (map(select((.path // "") == $q or (.path // "") == $c or (.name // "") == $q)) | .[0]) // - (map(select( - ((.name // "" | ascii_downcase) | contains(($q | ascii_downcase))) - or ((.path // "" | ascii_downcase) | contains(($q | ascii_downcase))) - )) | .[0]) // - null - ' <<<"$projects_json")" - fi - if [[ "$selected_project" == "null" && "$project_count" -eq 1 ]]; then - selected_project="$(jq -c '.[0]' <<<"$projects_json")" - fi - if [[ "$selected_project" != "null" ]]; then - selected_project_name="$(jq -r '.name // ""' <<<"$selected_project")" - selected_project_path="$(jq -r '.path // ""' <<<"$selected_project")" - fi - - local ws_out ws_json - if ! ws_out="$(amux_ok_json workspace list)"; then - emit_amux_error "guide" - return - fi - ws_json="$(jq -c '.data // []' <<<"$ws_out")" - - local selected_workspace='null' - local selected_workspace_id="" - local selected_workspace_name="" - local selected_workspace_repo="" - - if [[ -n "$workspace" ]]; then - selected_workspace="$(jq -c --arg id "$workspace" 'map(select(.id == $id)) | .[0] // null' <<<"$ws_json")" - if [[ "$selected_workspace" == "null" ]]; then - emit_error "guide" "command_error" "workspace not found" "$workspace" - return - fi - fi - - local agents_out agents_json - if ! agents_out="$(amux_ok_json agent list)"; then - emit_amux_error "guide" - return - fi - agents_json="$(jq -c '.data // []' <<<"$agents_out")" - - if [[ "$selected_workspace" == "null" && -n "$selected_project_path" ]]; then - local project_workspaces active_workspace_id - project_workspaces="$(jq -c --arg repo "$selected_project_path" 'map(select((.repo // "") == $repo))' <<<"$ws_json")" - active_workspace_id="$(jq -nr --argjson workspaces "$project_workspaces" --argjson agents "$agents_json" ' - ($workspaces | map(.id // "")) as $ids - | ($agents | map(select((.workspace_id // "") as $wid | ($ids | index($wid)) != null))) - | .[0].workspace_id // "" - ')" - if [[ -n "$active_workspace_id" ]]; then - selected_workspace="$(jq -c --arg id "$active_workspace_id" 'map(select(.id == $id)) | .[0] // null' <<<"$project_workspaces")" - elif [[ "$(jq -r 'length' <<<"$project_workspaces")" -gt 0 ]]; then - selected_workspace="$(jq -c '.[0]' <<<"$project_workspaces")" - fi - fi - - if [[ "$selected_workspace" != "null" ]]; then - selected_workspace_id="$(jq -r '.id // ""' <<<"$selected_workspace")" - selected_workspace_name="$(jq -r '.name // ""' <<<"$selected_workspace")" - selected_workspace_repo="$(jq -r '.repo // ""' <<<"$selected_workspace")" - fi - - if [[ "$selected_project" == "null" && -n "$selected_workspace_repo" ]]; then - selected_project="$(jq -c --arg repo "$selected_workspace_repo" ' - (map(select((.path // "") == $repo)) | .[0]) // - null - ' <<<"$projects_json")" - if [[ "$selected_project" != "null" ]]; then - selected_project_name="$(jq -r '.name // ""' <<<"$selected_project")" - selected_project_path="$(jq -r '.path // ""' <<<"$selected_project")" - else - selected_project_name="$(basename "$selected_workspace_repo")" - selected_project_path="$selected_workspace_repo" - selected_project="$(jq -cn --arg name "$selected_project_name" --arg path "$selected_project_path" '{name: $name, path: $path, inferred: true}')" - fi - fi - - local context_repo="$selected_project_path" - if [[ -z "$context_repo" && -n "$selected_workspace_repo" ]]; then - context_repo="$selected_workspace_repo" - fi - - local project_workspaces='[]' - if [[ -n "$context_repo" ]]; then - project_workspaces="$(jq -c --arg repo "$context_repo" 'map(select((.repo // "") == $repo))' <<<"$ws_json")" - fi - local project_workspace_count - project_workspace_count="$(jq -r 'length' <<<"$project_workspaces")" - - local workspace_agents='[]' - if [[ -n "$selected_workspace_id" ]]; then - workspace_agents="$(jq -c --arg id "$selected_workspace_id" 'map(select((.workspace_id // "") == $id))' <<<"$agents_json")" - fi - local workspace_agent_count primary_agent primary_session - workspace_agent_count="$(jq -r 'length' <<<"$workspace_agents")" - primary_agent="$(jq -r '.[0].agent_id // ""' <<<"$workspace_agents")" - primary_session="$(jq -r '.[0].session_name // ""' <<<"$workspace_agents")" - - local terms_out terms_json - if ! terms_out="$(amux_ok_json terminal list)"; then - terms_json='[]' - else - terms_json="$(jq -c '.data // []' <<<"$terms_out")" - fi - local workspace_terminal_count=0 - if [[ -n "$selected_workspace_id" ]]; then - workspace_terminal_count="$(jq -r --arg id "$selected_workspace_id" '[.[] | select((.workspace_id // "") == $id)] | length' <<<"$terms_json")" - fi - - local capture_lines="${OPENCLAW_DX_GUIDE_CAPTURE_LINES:-120}" - if ! is_positive_int "$capture_lines"; then - capture_lines=120 - fi - local capture_status="" - local capture_summary="" - local capture_needs_input="false" - local capture_hint="" - local capture_has_completion="false" - if [[ -n "$primary_session" ]]; then - local capture_out - if capture_out="$(amux_ok_json agent capture "$primary_session" --lines "$capture_lines")"; then - capture_status="$(jq -r '.data.status // ""' <<<"$capture_out")" - capture_summary="$(jq -r '.data.summary // .data.latest_line // ""' <<<"$capture_out")" - capture_needs_input="$(jq -r '.data.needs_input // false' <<<"$capture_out")" - capture_hint="$(jq -r '.data.input_hint // ""' <<<"$capture_out")" - if completion_signal_present "$capture_summary"; then - capture_has_completion="true" - fi - fi - fi - - local kickoff_prompt="$task" - if [[ -z "${kickoff_prompt// }" ]]; then - kickoff_prompt="Analyze current workspace, identify highest-impact work, implement it, and summarize validation plus next action." - fi - - local stage="unknown" - local reason="" - RESULT_OK=true - RESULT_COMMAND="guide" - RESULT_STATUS="ok" - RESULT_SUMMARY="" - RESULT_NEXT_ACTION="" - RESULT_SUGGESTED_COMMAND="" - - if [[ -z "$selected_project_path" ]]; then - if [[ "$project_count" -eq 0 ]]; then - stage="add_project" - reason="No project is registered yet." - RESULT_SUMMARY="Guide: register your first project" - RESULT_NEXT_ACTION="Register the current repo as a project, then create a workspace." - RESULT_SUGGESTED_COMMAND="skills/amux/scripts/openclaw-dx.sh project add --cwd --workspace mobile --assistant $(shell_quote "$assistant")" - else - stage="select_project" - reason="Project context is not selected." - RESULT_SUMMARY="Guide: choose a project" - RESULT_NEXT_ACTION="Pick one project to continue." - RESULT_SUGGESTED_COMMAND="skills/amux/scripts/openclaw-dx.sh project list" - fi - elif [[ "$project_workspace_count" -eq 0 ]]; then - stage="create_workspace" - reason="This project has no workspace yet." - RESULT_SUMMARY="Guide: create a workspace" - RESULT_NEXT_ACTION="Create a workspace, then start coding." - RESULT_SUGGESTED_COMMAND="skills/amux/scripts/openclaw-dx.sh workspace decide --project $(shell_quote "$selected_project_path") --task $(shell_quote "$kickoff_prompt") --assistant $(shell_quote "$assistant")" - elif [[ -z "$selected_workspace_id" ]]; then - stage="select_workspace" - reason="A workspace is required before starting or continuing agents." - RESULT_SUMMARY="Guide: select a workspace" - RESULT_NEXT_ACTION="Pick a workspace for this project." - RESULT_SUGGESTED_COMMAND="skills/amux/scripts/openclaw-dx.sh workspace list --project $(shell_quote "$selected_project_path")" - elif [[ "$workspace_agent_count" -eq 0 ]]; then - stage="start_agent" - reason="No active coding agent is running in this workspace." - RESULT_SUMMARY="Guide: start a coding turn" - RESULT_NEXT_ACTION="Start an agent turn in this workspace." - RESULT_SUGGESTED_COMMAND="skills/amux/scripts/openclaw-dx.sh start --workspace $(shell_quote "$selected_workspace_id") --assistant $(shell_quote "$assistant") --prompt $(shell_quote "$kickoff_prompt")" - elif [[ "$capture_needs_input" == "true" ]]; then - stage="reply_agent" - reason="Active agent is waiting for user input." - RESULT_STATUS="needs_input" - RESULT_SUMMARY="Guide: reply to blocked agent" - RESULT_NEXT_ACTION="Reply to the active prompt so work can continue." - if [[ -n "$primary_agent" ]]; then - RESULT_SUGGESTED_COMMAND="skills/amux/scripts/openclaw-dx.sh continue --agent $(shell_quote "$primary_agent") --text $(shell_quote "${capture_hint:-Continue with the safest option and report status plus next action.}") --enter" - else - RESULT_SUGGESTED_COMMAND="skills/amux/scripts/openclaw-dx.sh continue --workspace $(shell_quote "$selected_workspace_id") --text $(shell_quote "${capture_hint:-Continue with the safest option and report status plus next action.}") --enter" - fi - elif [[ "$capture_status" == "session_exited" ]]; then - stage="restart_agent" - reason="Agent session exited." - RESULT_STATUS="attention" - RESULT_SUMMARY="Guide: restart the coding agent" - RESULT_NEXT_ACTION="Restart an agent turn in this workspace." - RESULT_SUGGESTED_COMMAND="skills/amux/scripts/openclaw-dx.sh start --workspace $(shell_quote "$selected_workspace_id") --assistant $(shell_quote "$assistant") --prompt $(shell_quote "$kickoff_prompt")" - elif [[ "$capture_has_completion" == "true" ]]; then - stage="review_and_ship" - reason="Agent output indicates a completed change." - RESULT_STATUS="attention" - RESULT_SUMMARY="Guide: review and ship" - RESULT_NEXT_ACTION="Run review, then commit/push if clean." - RESULT_SUGGESTED_COMMAND="skills/amux/scripts/openclaw-dx.sh review --workspace $(shell_quote "$selected_workspace_id") --assistant codex" - else - stage="continue_agent" - reason="Agent is active and can continue with the next task." - RESULT_SUMMARY="Guide: continue current turn" - RESULT_NEXT_ACTION="Continue the agent or monitor status/alerts." - if [[ -n "$primary_agent" ]]; then - RESULT_SUGGESTED_COMMAND="skills/amux/scripts/openclaw-dx.sh continue --agent $(shell_quote "$primary_agent") --text \"Continue from current state and report status plus next action.\" --enter" - else - RESULT_SUGGESTED_COMMAND="skills/amux/scripts/openclaw-dx.sh continue --workspace $(shell_quote "$selected_workspace_id") --text \"Continue from current state and report status plus next action.\" --enter" - fi - fi - - local actions='[]' - local first_project_name first_project_path first_workspace_id - first_project_name="$(jq -r '.[0].name // ""' <<<"$projects_json")" - first_project_path="$(jq -r '.[0].path // ""' <<<"$projects_json")" - first_workspace_id="$(jq -r '.[0].id // ""' <<<"$project_workspaces")" - - case "$stage" in - add_project) - actions="$(append_action "$actions" "add_cwd" "Add Project" "skills/amux/scripts/openclaw-dx.sh project add --cwd --workspace mobile --assistant $(shell_quote "$assistant")" "success" "Register current directory and create a workspace")" - actions="$(append_action "$actions" "project_list" "Projects" "skills/amux/scripts/openclaw-dx.sh project list" "primary" "List registered projects")" - ;; - select_project) - actions="$(append_action "$actions" "project_list" "Projects" "skills/amux/scripts/openclaw-dx.sh project list" "primary" "List registered projects")" - if [[ -n "$first_project_name" ]]; then - actions="$(append_action "$actions" "pick_first" "Pick #1" "skills/amux/scripts/openclaw-dx.sh project pick --name $(shell_quote "$first_project_name")" "success" "Select the first listed project")" - fi - ;; - create_workspace) - actions="$(append_action "$actions" "decide_ws" "Decide WS" "skills/amux/scripts/openclaw-dx.sh workspace decide --project $(shell_quote "$selected_project_path") --task $(shell_quote "$kickoff_prompt") --assistant $(shell_quote "$assistant")" "success" "Get project vs nested workspace recommendation")" - actions="$(append_action "$actions" "create_ws" "Create WS" "skills/amux/scripts/openclaw-dx.sh workspace create --name mobile --project $(shell_quote "$selected_project_path") --assistant $(shell_quote "$assistant")" "primary" "Create a project workspace directly")" - ;; - select_workspace) - actions="$(append_action "$actions" "list_ws" "Workspaces" "skills/amux/scripts/openclaw-dx.sh workspace list --project $(shell_quote "$selected_project_path")" "primary" "List project workspaces")" - if [[ -n "$first_workspace_id" ]]; then - actions="$(append_action "$actions" "start_ws" "Start #1" "skills/amux/scripts/openclaw-dx.sh start --workspace $(shell_quote "$first_workspace_id") --assistant $(shell_quote "$assistant") --prompt $(shell_quote "$kickoff_prompt")" "success" "Start coding in the first workspace")" - fi - ;; - start_agent) - actions="$(append_action "$actions" "start" "Start" "skills/amux/scripts/openclaw-dx.sh start --workspace $(shell_quote "$selected_workspace_id") --assistant $(shell_quote "$assistant") --prompt $(shell_quote "$kickoff_prompt")" "success" "Start coding turn")" - actions="$(append_action "$actions" "dual" "Dual Pass" "skills/amux/scripts/openclaw-dx.sh workflow dual --workspace $(shell_quote "$selected_workspace_id") --implement-assistant claude --review-assistant codex" "primary" "Implement then review with separate assistants")" - actions="$(append_action "$actions" "terminal" "Next.js Dev" "skills/amux/scripts/openclaw-dx.sh terminal preset --workspace $(shell_quote "$selected_workspace_id") --kind nextjs" "primary" "Start Next.js dev server in this workspace")" - ;; - reply_agent) - actions="$(append_action "$actions" "reply" "Reply" "$RESULT_SUGGESTED_COMMAND" "danger" "Reply to blocked agent prompt")" - actions="$(append_action "$actions" "status_ws" "Status" "skills/amux/scripts/openclaw-dx.sh status --workspace $(shell_quote "$selected_workspace_id")" "primary" "Check workspace state")" - actions="$(append_action "$actions" "alerts_ws" "Alerts" "skills/amux/scripts/openclaw-dx.sh alerts --workspace $(shell_quote "$selected_workspace_id")" "primary" "Show blocking alerts only")" - ;; - restart_agent) - actions="$(append_action "$actions" "restart" "Restart" "$RESULT_SUGGESTED_COMMAND" "danger" "Restart agent in this workspace")" - actions="$(append_action "$actions" "status_ws" "Status" "skills/amux/scripts/openclaw-dx.sh status --workspace $(shell_quote "$selected_workspace_id")" "primary" "Check workspace state")" - ;; - review_and_ship) - actions="$(append_action "$actions" "review" "Review" "skills/amux/scripts/openclaw-dx.sh review --workspace $(shell_quote "$selected_workspace_id") --assistant codex" "success" "Review uncommitted changes")" - actions="$(append_action "$actions" "ship" "Ship" "skills/amux/scripts/openclaw-dx.sh git ship --workspace $(shell_quote "$selected_workspace_id")" "primary" "Commit current changes")" - actions="$(append_action "$actions" "dual" "Dual Pass" "skills/amux/scripts/openclaw-dx.sh workflow dual --workspace $(shell_quote "$selected_workspace_id") --implement-assistant claude --review-assistant codex" "primary" "Run implementation+review pass")" - ;; - continue_agent) - actions="$(append_action "$actions" "continue" "Continue" "$RESULT_SUGGESTED_COMMAND" "success" "Continue active coding turn")" - actions="$(append_action "$actions" "status_ws" "Status" "skills/amux/scripts/openclaw-dx.sh status --workspace $(shell_quote "$selected_workspace_id")" "primary" "Check workspace state")" - actions="$(append_action "$actions" "logs" "Terminal Logs" "skills/amux/scripts/openclaw-dx.sh terminal logs --workspace $(shell_quote "$selected_workspace_id") --lines 120" "primary" "Inspect terminal output")" - ;; - esac - - if [[ -n "$selected_workspace_id" ]]; then - actions="$(append_action "$actions" "cleanup" "Cleanup" "skills/amux/scripts/openclaw-dx.sh cleanup --older-than 24h --yes" "primary" "Prune stale sessions")" - elif [[ -n "$first_project_path" ]]; then - actions="$(append_action "$actions" "workspace_list_first" "WS #1" "skills/amux/scripts/openclaw-dx.sh workspace list --project $(shell_quote "$first_project_path")" "primary" "Inspect first project's workspaces")" - fi - actions="$(append_action "$actions" "status_global" "Global Status" "skills/amux/scripts/openclaw-dx.sh status" "primary" "Show global status across projects")" - RESULT_QUICK_ACTIONS="$actions" - - local workspace_count_total - workspace_count_total="$(jq -r 'length' <<<"$ws_json")" - RESULT_DATA="$(jq -cn \ - --arg stage "$stage" \ - --arg reason "$reason" \ - --arg project_query "$project" \ - --arg workspace_query "$workspace" \ - --arg task "$task" \ - --arg assistant "$assistant" \ - --arg selected_workspace_id "$selected_workspace_id" \ - --arg selected_workspace_name "$selected_workspace_name" \ - --arg selected_workspace_repo "$selected_workspace_repo" \ - --arg primary_agent "$primary_agent" \ - --arg primary_session "$primary_session" \ - --arg capture_status "$capture_status" \ - --arg capture_summary "$capture_summary" \ - --arg capture_hint "$capture_hint" \ - --argjson capture_needs_input "$capture_needs_input" \ - --argjson capture_has_completion "$capture_has_completion" \ - --argjson project_count "$project_count" \ - --argjson workspace_count_total "$workspace_count_total" \ - --argjson project_workspace_count "$project_workspace_count" \ - --argjson workspace_agent_count "$workspace_agent_count" \ - --argjson workspace_terminal_count "$workspace_terminal_count" \ - --argjson selected_project "$selected_project" \ - --argjson selected_workspace "$selected_workspace" \ - --argjson project_workspaces "$project_workspaces" \ - --argjson workspace_agents "$workspace_agents" \ - '{ - stage: $stage, - reason: $reason, - project_query: $project_query, - workspace_query: $workspace_query, - task: $task, - assistant: $assistant, - project_count: $project_count, - workspace_count_total: $workspace_count_total, - project_workspace_count: $project_workspace_count, - selected_project: $selected_project, - selected_workspace: $selected_workspace, - selected_workspace_id: $selected_workspace_id, - selected_workspace_name: $selected_workspace_name, - selected_workspace_repo: $selected_workspace_repo, - workspace_agent_count: $workspace_agent_count, - workspace_terminal_count: $workspace_terminal_count, - primary_agent: $primary_agent, - primary_session: $primary_session, - capture_status: $capture_status, - capture_summary: $capture_summary, - capture_needs_input: $capture_needs_input, - capture_hint: $capture_hint, - capture_has_completion: $capture_has_completion, - project_workspaces: $project_workspaces, - workspace_agents: $workspace_agents - }')" - - RESULT_MESSAGE="✅ Guide stage: $stage"$'\n'"Reason: $reason" - if [[ -n "$selected_project_path" ]]; then - RESULT_MESSAGE+=$'\n'"Project: ${selected_project_name:-$selected_project_path}"$'\n'"Path: $selected_project_path" - fi - if [[ -n "$selected_workspace_id" ]]; then - RESULT_MESSAGE+=$'\n'"Workspace: $selected_workspace_id ($selected_workspace_name)"$'\n'"Agents: $workspace_agent_count, Terminals: $workspace_terminal_count" - fi - if [[ -n "${capture_summary// }" ]]; then - RESULT_MESSAGE+=$'\n'"Latest: $capture_summary" - fi - RESULT_MESSAGE+=$'\n'"Next: $RESULT_NEXT_ACTION" - - emit_result -} - -workspace_create_emit_existing_recovery() { - local project="$1" - local requested_name="$2" - local requested_scope="$3" - local requested_assistant="$4" - local conflict_message="$5" - - local ws_out ws_rows existing - if ! ws_out="$(amux_ok_json workspace list --repo "$project")"; then - return 1 - fi - ws_rows="$(jq -c '.data // []' <<<"$ws_out")" - - existing="$(jq -c --arg name "$requested_name" --arg project "$project" ' - ( - map(select((.name // "") == $name)) - + map(select((.root // "") == $project)) - + map(select((.repo // "") == $project)) - + . - ) - | map(select((.id // "") | length > 0)) - | unique_by(.id) - | .[0] // empty - ' <<<"$ws_rows")" - if [[ -z "${existing// }" ]]; then - return 1 - fi - - local existing_id existing_name existing_root existing_assistant - existing_id="$(jq -r '.id // ""' <<<"$existing")" - existing_name="$(jq -r '.name // ""' <<<"$existing")" - existing_root="$(jq -r '.root // ""' <<<"$existing")" - existing_assistant="$(jq -r '.assistant // ""' <<<"$existing")" - if [[ -z "$existing_id" ]]; then - return 1 - fi - - context_set_workspace "$existing_id" "$existing_name" "$project" "$existing_assistant" - - local suggested_assistant - suggested_assistant="$requested_assistant" - if [[ -z "$suggested_assistant" ]]; then - suggested_assistant="$existing_assistant" - fi - if [[ -z "$suggested_assistant" ]]; then - suggested_assistant="codex" - fi - - local alt_name - alt_name="$(sanitize_workspace_name "${requested_name}-2")" - if [[ "$alt_name" == "$requested_name" ]]; then - alt_name="$(sanitize_workspace_name "${requested_name}-$(date +%H%M%S)")" - fi - - RESULT_OK=false - RESULT_COMMAND="workspace.create" - RESULT_STATUS="attention" - RESULT_SUMMARY="Workspace already exists: $existing_id" - RESULT_NEXT_ACTION="Reuse the existing workspace, or retry with a different workspace name." - RESULT_SUGGESTED_COMMAND="skills/amux/scripts/openclaw-dx.sh start --workspace $(shell_quote "$existing_id") --assistant $(shell_quote "$suggested_assistant") --prompt \"Summarize current status and continue with next high-impact task.\"" - - local actions='[]' - actions="$(append_action "$actions" "start_existing" "Use Existing" "$RESULT_SUGGESTED_COMMAND" "success" "Start in the existing workspace")" - actions="$(append_action "$actions" "list_ws" "Workspaces" "skills/amux/scripts/openclaw-dx.sh workspace list --project $(shell_quote "$project")" "primary" "List all workspaces for this project")" - actions="$(append_action "$actions" "retry_new_name" "New Name" "skills/amux/scripts/openclaw-dx.sh workspace create --name $(shell_quote "$alt_name") --project $(shell_quote "$project") --scope $(shell_quote "$requested_scope") --assistant $(shell_quote "$suggested_assistant")" "primary" "Retry workspace creation with a new name")" - RESULT_QUICK_ACTIONS="$actions" - - RESULT_DATA="$(jq -cn \ - --arg project "$project" \ - --arg requested_name "$requested_name" \ - --arg requested_scope "$requested_scope" \ - --arg requested_assistant "$requested_assistant" \ - --arg conflict_message "$conflict_message" \ - --arg alt_name "$alt_name" \ - --argjson existing "$existing" \ - '{project: $project, requested_name: $requested_name, requested_scope: $requested_scope, requested_assistant: $requested_assistant, conflict_message: $conflict_message, alt_name: $alt_name, existing_workspace: $existing}')" - - RESULT_MESSAGE="⚠️ Workspace name/branch conflict while creating \"$requested_name\""$'\n'"Reusing existing workspace: $existing_id ($existing_name)" - if [[ -n "$existing_root" ]]; then - RESULT_MESSAGE+=$'\n'"Root: $existing_root" - fi - if [[ -n "${conflict_message// }" ]]; then - RESULT_MESSAGE+=$'\n'"Conflict: $conflict_message" - fi - RESULT_MESSAGE+=$'\n'"Next: $RESULT_NEXT_ACTION" - - emit_result - return 0 -} - -workspace_create_needs_initial_commit() { - local err_code="${1:-}" - local err_message="${2:-}" - if [[ "$err_code" != "create_failed" ]]; then - return 1 - fi - local lower - lower="$(printf '%s' "$err_message" | tr '[:upper:]' '[:lower:]')" - if [[ "$lower" == *"invalid reference: head"* || "$lower" == *"not a valid object name head"* || "$lower" == *"ambiguous argument 'head'"* ]]; then - return 0 - fi - return 1 -} - -emit_initial_commit_guidance() { - local command_name="$1" - local project="$2" - local retry_command="$3" - local raw_error="$4" - local commit_cmd - commit_cmd="git -C $(shell_quote "$project") add -A && git -C $(shell_quote "$project") commit -m \"chore: initial commit\"" - - RESULT_OK=false - RESULT_COMMAND="$command_name" - RESULT_STATUS="attention" - RESULT_SUMMARY="Workspace creation blocked: repository has no initial commit" - RESULT_NEXT_ACTION="Create the first commit in this repository, then retry workspace creation." - RESULT_SUGGESTED_COMMAND="$commit_cmd" - - local actions='[]' - actions="$(append_action "$actions" "retry" "Retry" "$retry_command" "primary" "Retry workspace creation after initial commit")" - actions="$(append_action "$actions" "project_only" "Project Only" "skills/amux/scripts/openclaw-dx.sh project add --path $(shell_quote "$project")" "primary" "Register project without creating a workspace")" - RESULT_QUICK_ACTIONS="$actions" - - RESULT_DATA="$(jq -cn --arg project "$project" --arg retry_command "$retry_command" --arg commit_command "$commit_cmd" --arg error "$raw_error" '{project: $project, retry_command: $retry_command, commit_command: $commit_command, error: $error, reason: "initial_commit_required"}')" - RESULT_MESSAGE="⚠️ Workspace creation requires an initial commit"$'\n'"Project: $project"$'\n'"Error: $raw_error"$'\n'"Next: $RESULT_NEXT_ACTION" - emit_result -} - -cmd_workspace_create() { - local name="" - local project="" - local from_workspace="" - local scope="" - local assistant="" - local base="" - - while [[ $# -gt 0 ]]; do - case "$1" in - --name) - name="$2"; shift 2 ;; - --project) - project="$2"; shift 2 ;; - --from-workspace) - from_workspace="$2"; shift 2 ;; - --scope) - scope="$2"; shift 2 ;; - --assistant) - assistant="$2"; shift 2 ;; - --base) - base="$2"; shift 2 ;; - *) - emit_error "workspace.create" "command_error" "unknown flag" "$1" - return - ;; - esac - done - - if [[ -z "$name" ]]; then - emit_error "workspace.create" "command_error" "missing required flag: --name" - return - fi - - local parent_row='' - local parent_name="" - local parent_repo="" - - if [[ -n "$from_workspace" ]]; then - if ! parent_row="$(workspace_row_by_id "$from_workspace")"; then - emit_amux_error "workspace.create" - return - fi - if [[ -z "${parent_row// }" ]]; then - emit_error "workspace.create" "command_error" "--from-workspace not found" "$from_workspace" - return - fi - parent_name="$(jq -r '.name // ""' <<<"$parent_row")" - parent_repo="$(jq -r '.repo // ""' <<<"$parent_row")" - fi - - if [[ -z "$scope" ]]; then - if [[ -n "$from_workspace" ]]; then - scope="nested" - else - scope="project" - fi - fi - - case "$scope" in - project|nested) ;; - *) - emit_error "workspace.create" "command_error" "--scope must be project or nested" - return - ;; - esac - - if [[ "$scope" == "nested" && -z "$from_workspace" ]]; then - emit_error "workspace.create" "command_error" "nested scope requires --from-workspace" - return - fi - - if [[ -z "$project" && -n "$parent_repo" ]]; then - project="$parent_repo" - fi - if [[ -z "$project" ]]; then - project="$(context_resolve_project "")" - fi - - if [[ -z "$project" ]]; then - emit_error "workspace.create" "command_error" "missing project context" "provide --project or --from-workspace" - return - fi - - local final_name="$name" - if [[ "$scope" == "nested" ]]; then - final_name="$(compose_nested_workspace_name "$parent_name" "$name")" - fi - - local out - local args=(workspace create "$final_name" --project "$project") - if [[ -n "$assistant" ]]; then - args+=(--assistant "$assistant") - fi - if [[ -n "$base" ]]; then - args+=(--base "$base") - fi - if ! out="$(amux_ok_json "${args[@]}")"; then - local err_payload err_code err_message - err_payload="$AMUX_ERROR_OUTPUT" - if [[ -z "${err_payload// }" ]] && [[ -n "${AMUX_ERROR_CAPTURE_FILE:-}" ]] && [[ -f "$AMUX_ERROR_CAPTURE_FILE" ]]; then - err_payload="$(cat "$AMUX_ERROR_CAPTURE_FILE" 2>/dev/null || true)" - fi - err_code="" - err_message="" - if jq -e . >/dev/null 2>&1 <<<"$err_payload"; then - err_code="$(jq -r '.error.code // ""' <<<"$err_payload")" - err_message="$(jq -r '.error.message // ""' <<<"$err_payload")" - fi - if workspace_create_needs_initial_commit "$err_code" "$err_message"; then - local retry_cmd - retry_cmd="skills/amux/scripts/openclaw-dx.sh workspace create --name $(shell_quote "$name") --project $(shell_quote "$project") --scope $(shell_quote "$scope") --assistant $(shell_quote "${assistant:-codex}")" - if [[ -n "$from_workspace" ]]; then - retry_cmd="skills/amux/scripts/openclaw-dx.sh workspace create --name $(shell_quote "$name") --from-workspace $(shell_quote "$from_workspace") --scope $(shell_quote "$scope") --assistant $(shell_quote "${assistant:-codex}")" - fi - if [[ -n "$base" ]]; then - retry_cmd+=" --base $(shell_quote "$base")" - fi - emit_initial_commit_guidance "workspace.create" "$project" "$retry_cmd" "$err_message" - return - fi - if [[ "$err_code" == "create_failed" ]] && [[ "$err_message" == *"already exists"* || "$err_message" == *"already used by worktree"* ]]; then - if workspace_create_emit_existing_recovery "$project" "$final_name" "$scope" "$assistant" "$err_message"; then - return - fi - fi - emit_amux_error "workspace.create" "$err_payload" - return - fi - - local ws_id ws_root assistant_out - ws_id="$(jq -r '.data.id // ""' <<<"$out")" - ws_root="$(jq -r '.data.root // ""' <<<"$out")" - assistant_out="$(jq -r '.data.assistant // ""' <<<"$out")" - context_set_workspace "$ws_id" "$final_name" "$project" "$assistant_out" - - RESULT_OK=true - RESULT_COMMAND="workspace.create" - RESULT_STATUS="ok" - RESULT_SUMMARY="Workspace ready: $ws_id" - RESULT_NEXT_ACTION="Start coding in this workspace, or run terminal setup commands." - RESULT_SUGGESTED_COMMAND="skills/amux/scripts/openclaw-dx.sh start --workspace $(shell_quote "$ws_id") --assistant $(shell_quote "${assistant_out:-codex}") --prompt \"Analyze the biggest debt item and implement the fix.\"" - - local actions='[]' - actions="$(append_action "$actions" "start" "Start" "$RESULT_SUGGESTED_COMMAND" "success" "Start a coding turn in this workspace")" - actions="$(append_action "$actions" "term" "Terminal" "skills/amux/scripts/openclaw-dx.sh terminal run --workspace $(shell_quote "$ws_id") --text \"pwd\" --enter" "primary" "Run a terminal command in this workspace")" - actions="$(append_action "$actions" "status" "Status" "skills/amux/scripts/openclaw-dx.sh status --workspace $(shell_quote "$ws_id")" "primary" "Show workspace status")" - RESULT_QUICK_ACTIONS="$actions" - - RESULT_DATA="$(jq -cn --arg scope "$scope" --arg requested_name "$name" --arg final_name "$final_name" --arg parent_workspace "$from_workspace" --argjson workspace "$(jq -c '.data' <<<"$out")" '{scope: $scope, requested_name: $requested_name, final_name: $final_name, parent_workspace: $parent_workspace, workspace: $workspace}')" - - RESULT_MESSAGE="✅ Workspace ready: $ws_id"$'\n'"Name: $final_name"$'\n'"Scope: $scope"$'\n'"Project: $project"$'\n'"Root: $ws_root"$'\n'"Next: $RESULT_NEXT_ACTION" - emit_result -} - -cmd_workspace_list() { - local project="" - local workspace_id="" - local limit=20 - local page=1 - - while [[ $# -gt 0 ]]; do - case "$1" in - --project) - project="$2"; shift 2 ;; - --workspace) - workspace_id="$2"; shift 2 ;; - --limit) - limit="$2"; shift 2 ;; - --page) - page="$2"; shift 2 ;; - *) - emit_error "workspace.list" "command_error" "unknown flag" "$1" - return - ;; - esac - done - - if ! is_positive_int "$limit"; then - limit=20 - fi - if ! is_positive_int "$page"; then - page=1 - fi - - local project_from_context=false - if [[ -z "$project" ]]; then - project="$(context_resolve_project "")" - if [[ -n "$project" ]]; then - project_from_context=true - fi - fi - - local ws_out ws_args - ws_args=(workspace list) - if [[ -n "$project" ]]; then - ws_args+=(--repo "$project") - fi - if ! ws_out="$(amux_ok_json "${ws_args[@]}")"; then - emit_amux_error "workspace.list" - return - fi - - local ws_json - ws_json="$(jq -c '.data // []' <<<"$ws_out")" - if [[ -n "$workspace_id" ]]; then - ws_json="$(jq -c --arg id "$workspace_id" 'map(select(.id == $id))' <<<"$ws_json")" - fi - - local agents_out terminals_out agents_json terminals_json - if ! agents_out="$(amux_ok_json agent list)"; then - agents_json='[]' - else - agents_json="$(jq -c '.data // []' <<<"$agents_out")" - fi - if ! terminals_out="$(amux_ok_json terminal list)"; then - terminals_json='[]' - else - terminals_json="$(jq -c '.data // []' <<<"$terminals_out")" - fi - - local enriched sorted preview count lines - enriched="$(jq -cn --argjson ws "$ws_json" --argjson agents "$agents_json" --argjson terms "$terminals_json" ' - $ws - | map( - . as $w - | $w + { - agent_count: ($agents | map(select(.workspace_id == $w.id)) | length), - terminal_count: ($terms | map(select(.workspace_id == $w.id)) | length) - } - ) - ')" - sorted="$(jq -c 'sort_by(.created) | reverse' <<<"$enriched")" - count="$(jq -r 'length' <<<"$sorted")" - local total_pages=1 - if [[ "$count" -gt 0 ]]; then - total_pages=$(( (count + limit - 1) / limit )) - fi - if [[ "$page" -gt "$total_pages" ]]; then - page="$total_pages" - fi - local offset - offset=$(( (page - 1) * limit )) - preview="$(jq -c --argjson offset "$offset" --argjson limit "$limit" '.[ $offset : ($offset + $limit) ]' <<<"$sorted")" - - lines="$(jq -r --argjson offset "$offset" '. | to_entries | map("\(($offset + .key + 1)). \(.value.id) \(.value.name) (a:\(.value.agent_count), t:\(.value.terminal_count))") | join("\n")' <<<"$preview")" - local has_prev=false - local has_next=false - if [[ "$count" -gt 0 && "$page" -gt 1 ]]; then - has_prev=true - fi - if [[ "$count" -gt 0 && "$page" -lt "$total_pages" ]]; then - has_next=true - fi - - RESULT_OK=true - RESULT_COMMAND="workspace.list" - RESULT_STATUS="ok" - RESULT_SUMMARY="$count workspace(s)" - if [[ "$count" -gt 0 ]]; then - RESULT_SUMMARY+=" (page $page/$total_pages)" - fi - RESULT_NEXT_ACTION="Choose a workspace and start/continue a coding turn." - RESULT_SUGGESTED_COMMAND="" - - local first_ws - first_ws="$(jq -r '.[0].id // ""' <<<"$preview")" - if [[ -n "$first_ws" ]]; then - RESULT_SUGGESTED_COMMAND="skills/amux/scripts/openclaw-dx.sh start --workspace $(shell_quote "$first_ws") --assistant codex --prompt \"Summarize current objectives and pick the next coding task.\"" - fi - - if [[ -n "$project" ]]; then - context_set_project "$project" "" - fi - if [[ -n "$workspace_id" ]]; then - local selected_ws - selected_ws="$(jq -c '.[0] // null' <<<"$preview")" - if [[ "$selected_ws" != "null" ]]; then - local selected_name selected_repo selected_assistant - selected_name="$(jq -r '.name // ""' <<<"$selected_ws")" - selected_repo="$(jq -r '.repo // ""' <<<"$selected_ws")" - selected_assistant="$(jq -r '.assistant // ""' <<<"$selected_ws")" - context_set_workspace "$workspace_id" "$selected_name" "$selected_repo" "$selected_assistant" - fi - elif [[ "$count" -eq 1 ]]; then - local only_ws - only_ws="$(jq -c '.[0] // null' <<<"$preview")" - if [[ "$only_ws" != "null" ]]; then - local only_id only_name only_repo only_assistant - only_id="$(jq -r '.id // ""' <<<"$only_ws")" - only_name="$(jq -r '.name // ""' <<<"$only_ws")" - only_repo="$(jq -r '.repo // ""' <<<"$only_ws")" - only_assistant="$(jq -r '.assistant // ""' <<<"$only_ws")" - context_set_workspace "$only_id" "$only_name" "$only_repo" "$only_assistant" - fi - fi - - local actions='[]' - if [[ -n "$first_ws" ]]; then - actions="$(append_action "$actions" "start" "Start #1" "$RESULT_SUGGESTED_COMMAND" "success" "Start coding in the first listed workspace")" - actions="$(append_action "$actions" "status" "Status #1" "skills/amux/scripts/openclaw-dx.sh status --workspace $(shell_quote "$first_ws")" "primary" "Check status for the first listed workspace")" - fi - local list_cmd_base="skills/amux/scripts/openclaw-dx.sh workspace list --limit $limit" - if [[ -n "$project" ]]; then - list_cmd_base+=" --project $(shell_quote "$project")" - fi - if [[ -n "$workspace_id" ]]; then - list_cmd_base+=" --workspace $(shell_quote "$workspace_id")" - fi - if [[ "$has_prev" == "true" ]]; then - actions="$(append_action "$actions" "prev_page" "Prev" "$list_cmd_base --page $((page - 1))" "primary" "Show previous workspaces page")" - fi - if [[ "$has_next" == "true" ]]; then - actions="$(append_action "$actions" "next_page" "Next" "$list_cmd_base --page $((page + 1))" "primary" "Show next workspaces page")" - fi - actions="$(append_action "$actions" "global" "Global" "skills/amux/scripts/openclaw-dx.sh status" "primary" "Show global coding status")" - RESULT_QUICK_ACTIONS="$actions" - - RESULT_DATA="$(jq -cn --arg project "$project" --argjson project_from_context "$project_from_context" --argjson count "$count" --argjson page "$page" --argjson limit "$limit" --argjson total_pages "$total_pages" --argjson has_prev "$has_prev" --argjson has_next "$has_next" --argjson workspaces "$sorted" --argjson workspaces_page "$preview" '{project: $project, project_from_context: $project_from_context, count: $count, page: $page, limit: $limit, total_pages: $total_pages, has_prev: $has_prev, has_next: $has_next, workspaces: $workspaces, workspaces_page: $workspaces_page}')" - - RESULT_MESSAGE="✅ $count workspace(s)" - if [[ "$count" -gt 0 ]]; then - RESULT_MESSAGE+=$'\n'"Page: $page/$total_pages" - fi - if [[ "$project_from_context" == "true" ]]; then - RESULT_MESSAGE+=$'\n'"Project: $project (from active context)" - elif [[ -n "$project" ]]; then - RESULT_MESSAGE+=$'\n'"Project: $project" - fi - if [[ -n "${lines// }" ]]; then - RESULT_MESSAGE+=$'\n'"$lines" - fi - RESULT_MESSAGE+=$'\n'"Next: $RESULT_NEXT_ACTION" - emit_result -} - -cmd_workspace_decide() { - local project="" - local from_workspace="" - local task="" - local assistant="${OPENCLAW_DX_DECIDE_ASSISTANT:-codex}" - local name="" - - while [[ $# -gt 0 ]]; do - case "$1" in - --project) - project="$2"; shift 2 ;; - --from-workspace) - from_workspace="$2"; shift 2 ;; - --task) - shift - if [[ $# -eq 0 ]]; then - emit_error "workspace.decide" "command_error" "missing value for --task" - return - fi - task="$1"; shift - while [[ $# -gt 0 && "$1" != --* ]]; do - task+=" $1" - shift - done - ;; - --assistant) - assistant="$2"; shift 2 ;; - --name|--workspace-name) - name="$2"; shift 2 ;; - *) - emit_error "workspace.decide" "command_error" "unknown flag" "$1" - return - ;; - esac - done - - if [[ -z "$project" && -z "$from_workspace" ]]; then - project="$(context_resolve_project "")" - fi - if [[ -z "$project" && -z "$from_workspace" ]]; then - emit_error "workspace.decide" "command_error" "missing context" "provide --project or --from-workspace" - return - fi - - local parent_row="" - local parent_repo="" - local parent_name="" - local parent_id="" - if [[ -n "$from_workspace" ]]; then - if ! parent_row="$(workspace_row_by_id "$from_workspace")"; then - emit_amux_error "workspace.decide" - return - fi - if [[ -z "${parent_row// }" ]]; then - emit_error "workspace.decide" "command_error" "--from-workspace not found" "$from_workspace" - return - fi - parent_id="$(jq -r '.id // ""' <<<"$parent_row")" - parent_repo="$(jq -r '.repo // ""' <<<"$parent_row")" - parent_name="$(jq -r '.name // ""' <<<"$parent_row")" - if [[ -z "$project" ]]; then - project="$parent_repo" - fi - fi - - if [[ -z "$project" ]]; then - emit_error "workspace.decide" "command_error" "missing project context" "project could not be inferred" - return - fi - context_set_project "$project" "" - - local ws_out - if ! ws_out="$(amux_ok_json workspace list --repo "$project")"; then - emit_amux_error "workspace.decide" - return - fi - local ws_json - ws_json="$(jq -c '.data // []' <<<"$ws_out")" - - local agents_out - if ! agents_out="$(amux_ok_json agent list)"; then - emit_amux_error "workspace.decide" - return - fi - local agents_json - agents_json="$(jq -c '.data // []' <<<"$agents_out")" - - local existing_count active_project_agents - existing_count="$(jq -r 'length' <<<"$ws_json")" - active_project_agents="$(jq -nr --argjson ws "$ws_json" --argjson agents "$agents_json" ' - ($ws | map(.id // "")) as $ids - | $agents - | map(select((.workspace_id // "") as $wid | ($ids | index($wid)) != null)) - | length - ')" - - local scope_hint recommendation reason - scope_hint="$(workspace_scope_hint_from_task "$task")" - recommendation="project" - reason="Default to a project workspace." - - if [[ -n "$parent_id" ]]; then - recommendation="nested" - reason="Parent workspace specified; nested workspace keeps context isolated." - elif [[ "$scope_hint" == "nested" ]]; then - recommendation="nested" - reason="Task wording suggests an isolated or parallel change." - elif [[ "$scope_hint" == "project" ]]; then - recommendation="project" - reason="Task wording suggests primary project work." - elif [[ "$existing_count" -eq 0 ]]; then - recommendation="project" - reason="No workspace exists for this project yet." - elif [[ "$active_project_agents" -gt 0 ]]; then - recommendation="nested" - reason="There are active agents on this project; nested workspace reduces interference." - fi - - if [[ -z "$parent_id" && "$recommendation" == "nested" ]]; then - parent_id="$(jq -r '.[0].id // ""' <<<"$ws_json")" - parent_name="$(jq -r '.[0].name // ""' <<<"$ws_json")" - if [[ -z "$parent_id" ]]; then - recommendation="project" - reason="Nested workspace requested but no parent workspace exists yet." - fi - fi - - if [[ -z "$name" ]]; then - if [[ "$recommendation" == "nested" ]]; then - name="refactor" - else - name="mainline" - fi - fi - - local final_project_name final_nested_name - final_project_name="$(sanitize_workspace_name "$name")" - final_nested_name="$final_project_name" - if [[ "$recommendation" == "nested" && -n "$parent_name" ]]; then - final_nested_name="$(compose_nested_workspace_name "$parent_name" "$name")" - fi - - local project_create_cmd="" nested_create_cmd="" kickoff_prompt="" recommended_command="" alternate_command="" - project_create_cmd="skills/amux/scripts/openclaw-dx.sh workspace create --name $(shell_quote "$final_project_name") --project $(shell_quote "$project") --assistant $(shell_quote "$assistant")" - if [[ -n "$parent_id" ]]; then - nested_create_cmd="skills/amux/scripts/openclaw-dx.sh workspace create --name $(shell_quote "$name") --from-workspace $(shell_quote "$parent_id") --scope nested --assistant $(shell_quote "$assistant")" - else - nested_create_cmd="" - fi - - kickoff_prompt="$task" - if [[ -z "${kickoff_prompt// }" ]]; then - kickoff_prompt="Summarize objectives and implement the next highest-impact task." - fi - - if [[ "$recommendation" == "nested" && -n "$parent_id" ]]; then - recommended_command="skills/amux/scripts/openclaw-dx.sh workflow kickoff --from-workspace $(shell_quote "$parent_id") --scope nested --name $(shell_quote "$name") --assistant $(shell_quote "$assistant") --prompt $(shell_quote "$kickoff_prompt")" - alternate_command="skills/amux/scripts/openclaw-dx.sh workflow kickoff --project $(shell_quote "$project") --name $(shell_quote "$final_project_name") --assistant $(shell_quote "$assistant") --prompt $(shell_quote "$kickoff_prompt")" - else - recommended_command="skills/amux/scripts/openclaw-dx.sh workflow kickoff --project $(shell_quote "$project") --name $(shell_quote "$final_project_name") --assistant $(shell_quote "$assistant") --prompt $(shell_quote "$kickoff_prompt")" - if [[ -n "$parent_id" ]]; then - alternate_command="skills/amux/scripts/openclaw-dx.sh workflow kickoff --from-workspace $(shell_quote "$parent_id") --scope nested --name $(shell_quote "$name") --assistant $(shell_quote "$assistant") --prompt $(shell_quote "$kickoff_prompt")" - fi - fi - - RESULT_OK=true - RESULT_COMMAND="workspace.decide" - RESULT_STATUS="ok" - RESULT_SUMMARY="Recommended scope: $recommendation" - RESULT_NEXT_ACTION="Create the recommended workspace and start coding." - RESULT_SUGGESTED_COMMAND="$recommended_command" - - local actions='[]' - actions="$(append_action "$actions" "recommended" "Recommended" "$recommended_command" "success" "Create recommended workspace and start coding")" - actions="$(append_action "$actions" "project_ws" "Project WS" "$project_create_cmd" "primary" "Create a project-level workspace")" - if [[ -n "$nested_create_cmd" ]]; then - actions="$(append_action "$actions" "nested_ws" "Nested WS" "$nested_create_cmd" "primary" "Create a nested workspace")" - fi - if [[ -n "$alternate_command" ]]; then - actions="$(append_action "$actions" "alternate" "Alternate" "$alternate_command" "primary" "Run the alternate kickoff flow")" - fi - RESULT_QUICK_ACTIONS="$actions" - - RESULT_DATA="$(jq -cn \ - --arg recommendation "$recommendation" \ - --arg reason "$reason" \ - --arg project "$project" \ - --arg parent_workspace "$parent_id" \ - --arg parent_name "$parent_name" \ - --arg suggested_name "$name" \ - --arg final_project_name "$final_project_name" \ - --arg final_nested_name "$final_nested_name" \ - --arg recommended_command "$recommended_command" \ - --arg alternate_command "$alternate_command" \ - --arg project_create_command "$project_create_cmd" \ - --arg nested_create_command "$nested_create_cmd" \ - --argjson existing_count "$existing_count" \ - --argjson active_project_agents "$active_project_agents" \ - --argjson workspaces "$ws_json" \ - '{ - recommendation: $recommendation, - reason: $reason, - project: $project, - parent_workspace: $parent_workspace, - parent_name: $parent_name, - suggested_name: $suggested_name, - final_project_name: $final_project_name, - final_nested_name: $final_nested_name, - recommended_command: $recommended_command, - alternate_command: $alternate_command, - project_create_command: $project_create_command, - nested_create_command: $nested_create_command, - existing_workspaces: $existing_count, - active_project_agents: $active_project_agents, - workspaces: $workspaces - }')" - - RESULT_MESSAGE="✅ Workspace decision: $recommendation"$'\n'"Reason: $reason"$'\n'"Project: $project" - if [[ -n "$parent_id" ]]; then - RESULT_MESSAGE+=$'\n'"Parent workspace: $parent_id" - fi - RESULT_MESSAGE+=$'\n'"Project option: $project_create_cmd" - if [[ -n "$nested_create_cmd" ]]; then - RESULT_MESSAGE+=$'\n'"Nested option: $nested_create_cmd" - fi - RESULT_MESSAGE+=$'\n'"Next: $RESULT_NEXT_ACTION" - - emit_result -} - -cmd_start() { - local workspace="" - local assistant="" - local prompt="" - local max_steps="${OPENCLAW_DX_MAX_STEPS:-3}" - local turn_budget="${OPENCLAW_DX_TURN_BUDGET:-180}" - local wait_timeout="${OPENCLAW_DX_WAIT_TIMEOUT:-60s}" - local idle_threshold="${OPENCLAW_DX_IDLE_THRESHOLD:-10s}" - - while [[ $# -gt 0 ]]; do - case "$1" in - --workspace) - workspace="$2"; shift 2 ;; - --assistant) - assistant="$2"; shift 2 ;; - --prompt) - shift - if [[ $# -eq 0 ]]; then - emit_error "start" "command_error" "missing value for --prompt" - return - fi - prompt="$1"; shift - while [[ $# -gt 0 && "$1" != --* ]]; do - prompt+=" $1" - shift - done - ;; - --max-steps) - max_steps="$2"; shift 2 ;; - --turn-budget) - turn_budget="$2"; shift 2 ;; - --wait-timeout) - wait_timeout="$2"; shift 2 ;; - --idle-threshold) - idle_threshold="$2"; shift 2 ;; - *) - emit_error "start" "command_error" "unknown flag" "$1" - return - ;; - esac - done - - workspace="$(context_resolve_workspace "$workspace")" - wait_timeout="$(normalize_turn_wait_timeout "$wait_timeout")" - if [[ -z "$workspace" || -z "$prompt" ]]; then - emit_error "start" "command_error" "missing required flags" "start requires --prompt and a workspace (pass --workspace or set active context)" - return - fi - if ! workspace_require_exists "start" "$workspace"; then - return - fi - - if [[ -z "$assistant" ]]; then - assistant="$(default_assistant_for_workspace "$workspace")" - fi - if [[ -z "$assistant" ]]; then - assistant="$(context_assistant_hint "$workspace")" - fi - if [[ -z "$assistant" ]]; then - assistant="codex" - fi - if ! assistant_require_known "start" "$assistant"; then - return - fi - context_set_workspace_with_lookup "$workspace" "$assistant" - - if [[ ! -x "$TURN_SCRIPT" ]]; then - emit_error "start" "command_error" "turn script is not executable" "$TURN_SCRIPT" - return - fi - - local turn_json - turn_json="$(OPENCLAW_TURN_SKIP_PRESENT=true "$TURN_SCRIPT" run \ - --workspace "$workspace" \ - --assistant "$assistant" \ - --prompt "$prompt" \ - --max-steps "$max_steps" \ - --turn-budget "$turn_budget" \ - --wait-timeout "$wait_timeout" \ - --idle-threshold "$idle_threshold" 2>&1 || true)" - - local permission_retry_enabled permission_fallback_assistant - permission_retry_enabled="${OPENCLAW_DX_PERMISSION_RETRY:-true}" - permission_fallback_assistant="${OPENCLAW_DX_PERMISSION_FALLBACK_ASSISTANT:-gemini}" - if [[ "$permission_retry_enabled" != "false" ]] && turn_reports_permission_mode_gate "$turn_json"; then - if [[ -n "${permission_fallback_assistant// }" && "$permission_fallback_assistant" != "$assistant" ]]; then - local retry_turn_json - retry_turn_json="$(OPENCLAW_TURN_SKIP_PRESENT=true "$TURN_SCRIPT" run \ - --workspace "$workspace" \ - --assistant "$permission_fallback_assistant" \ - --prompt "$prompt" \ - --max-steps "$max_steps" \ - --turn-budget "$turn_budget" \ - --wait-timeout "$wait_timeout" \ - --idle-threshold "$idle_threshold" 2>&1 || true)" - if jq -e . >/dev/null 2>&1 <<<"$retry_turn_json"; then - turn_json="$retry_turn_json" - fi - fi - fi - - local nochange_retry_enabled nochange_fallback_assistant - nochange_retry_enabled="${OPENCLAW_DX_NOCHANGE_RETRY:-true}" - nochange_fallback_assistant="${OPENCLAW_DX_NOCHANGE_FALLBACK_ASSISTANT:-codex}" - if [[ "$nochange_retry_enabled" != "false" ]] && turn_reports_no_workspace_change_claim "$turn_json"; then - if [[ -n "${nochange_fallback_assistant// }" && "$nochange_fallback_assistant" != "$assistant" ]]; then - local nochange_retry_json - nochange_retry_json="$(OPENCLAW_TURN_SKIP_PRESENT=true "$TURN_SCRIPT" run \ - --workspace "$workspace" \ - --assistant "$nochange_fallback_assistant" \ - --prompt "$prompt" \ - --max-steps "$max_steps" \ - --turn-budget "$turn_budget" \ - --wait-timeout "$wait_timeout" \ - --idle-threshold "$idle_threshold" 2>&1 || true)" - if jq -e . >/dev/null 2>&1 <<<"$nochange_retry_json"; then - turn_json="$nochange_retry_json" - fi - fi - fi - - turn_json="$(recover_timeout_turn_once "$turn_json" "$wait_timeout" "$idle_threshold")" - - emit_turn_passthrough "start" "coding_turn" "$turn_json" -} - -cmd_continue() { - local agent="" - local workspace="" - local text="${OPENCLAW_DX_CONTINUE_TEXT:-Continue from current state and provide concise status and next action.}" - local enter=false - local auto_start=false - local start_assistant="" - local max_steps="${OPENCLAW_DX_MAX_STEPS:-3}" - local turn_budget="${OPENCLAW_DX_TURN_BUDGET:-180}" - local wait_timeout="${OPENCLAW_DX_WAIT_TIMEOUT:-60s}" - local idle_threshold="${OPENCLAW_DX_IDLE_THRESHOLD:-10s}" - - while [[ $# -gt 0 ]]; do - case "$1" in - --agent) - agent="$2"; shift 2 ;; - --workspace) - workspace="$2"; shift 2 ;; - --text) - shift - if [[ $# -eq 0 ]]; then - emit_error "continue" "command_error" "missing value for --text" - return - fi - text="$1"; shift - while [[ $# -gt 0 && "$1" != --* ]]; do - text+=" $1" - shift - done - ;; - --enter) - enter=true; shift ;; - --auto-start) - auto_start=true; shift ;; - --assistant) - start_assistant="$2"; shift 2 ;; - --max-steps) - max_steps="$2"; shift 2 ;; - --turn-budget) - turn_budget="$2"; shift 2 ;; - --wait-timeout) - wait_timeout="$2"; shift 2 ;; - --idle-threshold) - idle_threshold="$2"; shift 2 ;; - *) - emit_error "continue" "command_error" "unknown flag" "$1" - return - ;; - esac - done - - if [[ -n "$start_assistant" && "$auto_start" != "true" ]]; then - emit_error "continue" "command_error" "--assistant requires --auto-start" "pass --auto-start when selecting an assistant for fallback start" - return - fi - - workspace="$(context_resolve_workspace "$workspace")" - wait_timeout="$(normalize_turn_wait_timeout "$wait_timeout")" - if [[ -z "$agent" && -z "$workspace" ]]; then - agent="$(context_resolve_agent "")" - fi - if [[ -z "$agent" && -z "$workspace" ]]; then - local active_agents_out active_agents_json active_count - if active_agents_out="$(amux_ok_json agent list)"; then - active_agents_json="$(jq -c '.data // []' <<<"$active_agents_out")" - active_count="$(jq -r 'length' <<<"$active_agents_json")" - if [[ "$active_count" == "1" ]]; then - agent="$(jq -r '.[0].agent_id // ""' <<<"$active_agents_json")" - workspace="$(jq -r '.[0].workspace_id // ""' <<<"$active_agents_json")" - elif [[ "$active_count" =~ ^[0-9]+$ ]] && [[ "$active_count" -gt 1 ]]; then - local first_agent first_workspace lines - first_agent="$(jq -r '.[0].agent_id // ""' <<<"$active_agents_json")" - first_workspace="$(jq -r '.[0].workspace_id // ""' <<<"$active_agents_json")" - - RESULT_OK=false - RESULT_COMMAND="continue" - RESULT_STATUS="attention" - RESULT_SUMMARY="Multiple active agents found ($active_count)" - RESULT_NEXT_ACTION="Choose one active agent to continue." - RESULT_SUGGESTED_COMMAND="" - if [[ -n "$first_agent" ]]; then - RESULT_SUGGESTED_COMMAND="skills/amux/scripts/openclaw-dx.sh continue --agent $(shell_quote "$first_agent") --text \"Continue from current state and report status plus next action.\" --enter" - elif [[ -n "$first_workspace" ]]; then - RESULT_SUGGESTED_COMMAND="skills/amux/scripts/openclaw-dx.sh continue --workspace $(shell_quote "$first_workspace") --text \"Continue from current state and report status plus next action.\" --enter" - fi - - local actions='[]' - while IFS= read -r row; do - [[ -z "${row// }" ]] && continue - local row_index row_agent row_workspace row_session continue_cmd label - row_index="$(jq -r '.index // ""' <<<"$row")" - row_agent="$(jq -r '.agent_id // ""' <<<"$row")" - row_workspace="$(jq -r '.workspace_id // ""' <<<"$row")" - row_session="$(jq -r '.session_name // ""' <<<"$row")" - if [[ -z "$row_agent" ]]; then - continue - fi - continue_cmd="skills/amux/scripts/openclaw-dx.sh continue --agent $(shell_quote "$row_agent") --text \"Continue from current state and report status plus next action.\" --enter" - label="Continue #$row_index" - actions="$(append_action "$actions" "continue_${row_index}" "$label" "$continue_cmd" "primary" "Continue $row_agent in $row_workspace $row_session")" - done < <(jq -c 'to_entries | map({index: (.key + 1), agent_id: (.value.agent_id // ""), workspace_id: (.value.workspace_id // ""), session_name: (.value.session_name // "")}) | .[0:6][]' <<<"$active_agents_json") - actions="$(append_action "$actions" "status" "Status" "skills/amux/scripts/openclaw-dx.sh status" "primary" "See all active agents and alerts")" - RESULT_QUICK_ACTIONS="$actions" - - RESULT_DATA="$(jq -cn --argjson active_count "$active_count" --argjson agents "$active_agents_json" '{reason: "multiple_active_agents", active_count: $active_count, agents: $agents}')" - lines="$(jq -r '. | to_entries | map("\(.key + 1). \(.value.agent_id // "") (\(.value.workspace_id // "unknown"))") | join("\n")' <<<"$(jq -c '.[0:6]' <<<"$active_agents_json")")" - RESULT_MESSAGE="⚠️ Multiple active agents found"$'\n'"$lines"$'\n'"Next: $RESULT_NEXT_ACTION" - emit_result - return - fi - fi - fi - if [[ -z "$agent" && -z "$workspace" ]]; then - emit_error "continue" "command_error" "missing target" "provide --agent/--workspace or set active context" - return - fi - - if [[ -z "$agent" && -n "$workspace" ]]; then - if ! workspace_require_exists "continue" "$workspace"; then - return - fi - context_set_workspace_with_lookup "$workspace" "" - agent="$(agent_for_workspace "$workspace")" - if [[ -z "$agent" ]]; then - if [[ "$auto_start" == "true" ]]; then - local resolved_assistant - resolved_assistant="$start_assistant" - if [[ -z "$resolved_assistant" ]]; then - resolved_assistant="$(default_assistant_for_workspace "$workspace")" - fi - if [[ -z "$resolved_assistant" ]]; then - resolved_assistant="$(context_assistant_hint "$workspace")" - fi - if [[ -z "$resolved_assistant" ]]; then - resolved_assistant="codex" - fi - if ! assistant_require_known "continue" "$resolved_assistant"; then - return - fi - - local start_json - if ! start_json="$(run_self_json start --workspace "$workspace" --assistant "$resolved_assistant" --prompt "$text" --max-steps "$max_steps" --turn-budget "$turn_budget" --wait-timeout "$wait_timeout" --idle-threshold "$idle_threshold")"; then - emit_error "continue" "command_error" "failed auto-start continuation" "unable to launch start fallback" - return - fi - jq -c --arg command "continue" --arg workflow "auto_start_turn" '. + {command: $command, workflow: $workflow, auto_started: true}' <<<"$start_json" - return - fi - - RESULT_OK=false - RESULT_COMMAND="continue" - RESULT_STATUS="attention" - RESULT_SUMMARY="No active agent found for workspace $workspace" - RESULT_NEXT_ACTION="Start a new agent turn in this workspace, then continue. You can also use --auto-start." - RESULT_SUGGESTED_COMMAND="skills/amux/scripts/openclaw-dx.sh continue --workspace $(shell_quote "$workspace") --auto-start --assistant codex --text \"Resume work and provide status plus next action.\"" - RESULT_DATA="$(jq -cn --arg workspace "$workspace" '{workspace: $workspace, reason: "no_active_agent"}')" - local start_cmd - start_cmd="skills/amux/scripts/openclaw-dx.sh start --workspace $(shell_quote "$workspace") --assistant codex --prompt \"Resume work and provide status plus next action.\"" - RESULT_QUICK_ACTIONS="$(jq -cn --arg cmd "$RESULT_SUGGESTED_COMMAND" --arg start_cmd "$start_cmd" ' - [ - {id:"auto_start", label:"Auto Start", command:$cmd, style:"success", prompt:"Auto-start and continue in one command"}, - {id:"start", label:"Start", command:$start_cmd, style:"primary", prompt:"Start a new coding turn"} - ]')" - RESULT_MESSAGE="⚠️ No active agent in workspace $workspace"$'\n'"Next: $RESULT_NEXT_ACTION" - emit_result - return - fi - fi - - if [[ -n "$agent" ]]; then - context_set_agent "$agent" "$workspace" "" - fi - - if [[ ! -x "$TURN_SCRIPT" ]]; then - emit_error "continue" "command_error" "turn script is not executable" "$TURN_SCRIPT" - return - fi - - local turn_args=( - "$TURN_SCRIPT" send - --agent "$agent" - --text "$text" - --max-steps "$max_steps" - --turn-budget "$turn_budget" - --wait-timeout "$wait_timeout" - --idle-threshold "$idle_threshold" - ) - if [[ "$enter" == "true" ]]; then - turn_args+=(--enter) - fi - - local turn_json - turn_json="$(OPENCLAW_TURN_SKIP_PRESENT=true "${turn_args[@]}" 2>&1 || true)" - turn_json="$(recover_timeout_turn_once "$turn_json" "$wait_timeout" "$idle_threshold")" - - emit_turn_passthrough "continue" "followup_turn" "$turn_json" -} - -cmd_status() { - local result_command="${OPENCLAW_DX_STATUS_RESULT_COMMAND:-status}" - case "$result_command" in - status|alerts) ;; - *) result_command="status" ;; - esac - local project="" - local workspace="" - local limit=12 - local capture_lines="${OPENCLAW_DX_STATUS_CAPTURE_LINES:-120}" - local capture_agents_default="${OPENCLAW_DX_STATUS_CAPTURE_AGENTS:-6}" - local capture_agents="$capture_agents_default" - local capture_agents_explicit=false - local older_than="${OPENCLAW_DX_STATUS_ALERT_OLDER_THAN:-24h}" - local recent_workspaces="${OPENCLAW_DX_STATUS_RECENT_WORKSPACES:-4}" - local alerts_only=false - local include_stale_alerts=false - - while [[ $# -gt 0 ]]; do - case "$1" in - --project) - project="$2"; shift 2 ;; - --workspace) - workspace="$2"; shift 2 ;; - --limit) - limit="$2"; shift 2 ;; - --capture-lines) - capture_lines="$2"; shift 2 ;; - --capture-agents) - capture_agents="$2" - capture_agents_explicit=true - shift 2 ;; - --older-than) - older_than="$2"; shift 2 ;; - --recent-workspaces) - recent_workspaces="$2"; shift 2 ;; - --alerts-only) - alerts_only=true; shift ;; - --include-stale) - include_stale_alerts=true; shift ;; - *) - emit_error "$result_command" "command_error" "unknown flag" "$1" - return - ;; - esac - done - - if [[ "${OPENCLAW_DX_FORCE_ALERTS_ONLY:-false}" == "true" ]]; then - alerts_only=true - fi - if [[ "${OPENCLAW_DX_STATUS_INCLUDE_STALE_ALERTS:-false}" == "true" ]]; then - include_stale_alerts=true - fi - - if ! is_positive_int "$limit"; then - limit=12 - fi - if ! is_positive_int "$capture_lines"; then - capture_lines=120 - fi - if [[ "$capture_agents_explicit" != "true" ]]; then - if [[ -n "$workspace" ]]; then - capture_agents="${OPENCLAW_DX_STATUS_CAPTURE_AGENTS_WORKSPACE:-1}" - elif [[ -n "$project" ]]; then - capture_agents="${OPENCLAW_DX_STATUS_CAPTURE_AGENTS_PROJECT:-2}" - fi - fi - if ! is_positive_int "$capture_agents"; then - capture_agents="$capture_agents_default" - if ! is_positive_int "$capture_agents"; then - capture_agents=6 - fi - fi - if [[ ! "$recent_workspaces" =~ ^[0-9]+$ ]]; then - recent_workspaces=4 - fi - - local projects_out ws_out agents_out terms_out sessions_out prune_out - if ! projects_out="$(amux_ok_json project list)"; then - emit_amux_error "$result_command" - return - fi - - local ws_args=(workspace list) - if [[ -n "$project" ]]; then - ws_args+=(--repo "$project") - fi - if ! ws_out="$(amux_ok_json "${ws_args[@]}")"; then - emit_amux_error "$result_command" - return - fi - - local agents_args=(agent list) - if [[ -n "$workspace" ]]; then - agents_args+=(--workspace "$workspace") - fi - if ! agents_out="$(amux_ok_json "${agents_args[@]}")"; then - emit_amux_error "$result_command" - return - fi - - local term_args=(terminal list) - if [[ -n "$workspace" ]]; then - term_args+=(--workspace "$workspace") - fi - if ! terms_out="$(amux_ok_json "${term_args[@]}")"; then - emit_amux_error "$result_command" - return - fi - - if ! sessions_out="$(amux_ok_json session list)"; then - emit_amux_error "$result_command" - return - fi - - if ! prune_out="$(amux_ok_json session prune --older-than "$older_than")"; then - prune_out='{"ok":true,"data":{"dry_run":true,"pruned":[],"total":0,"errors":[]}}' - fi - - local ws_json agents_json terms_json workspace_total_count recent_workspaces_applied=false - ws_json="$(jq -c '.data // []' <<<"$ws_out")" - if [[ -n "$workspace" ]]; then - ws_json="$(jq -c --arg id "$workspace" 'map(select(.id == $id))' <<<"$ws_json")" - if [[ "$(jq -r 'length' <<<"$ws_json")" -eq 0 ]]; then - emit_error "$result_command" "command_error" "workspace not found" "$workspace" - return - fi - context_set_workspace_with_lookup "$workspace" "" - fi - workspace_total_count="$(jq -r 'length' <<<"$ws_json")" - if [[ -n "$project" && -z "$workspace" && "$recent_workspaces" -gt 0 ]]; then - ws_json="$(jq -c --argjson n "$recent_workspaces" 'sort_by(.created // "") | reverse | .[:$n]' <<<"$ws_json")" - recent_workspaces_applied=true - fi - agents_json="$(jq -c '.data // []' <<<"$agents_out")" - terms_json="$(jq -c '.data // []' <<<"$terms_out")" - if [[ -n "$project" && -z "$workspace" ]]; then - local scoped_workspace_ids - scoped_workspace_ids="$(jq -c 'map(.id)' <<<"$ws_json")" - agents_json="$(jq -c --argjson ids "$scoped_workspace_ids" 'map(select((.workspace_id // "") as $wid | ($ids | index($wid)) != null))' <<<"$agents_json")" - terms_json="$(jq -c --argjson ids "$scoped_workspace_ids" 'map(select((.workspace_id // "") as $wid | ($ids | index($wid)) != null))' <<<"$terms_json")" - fi - local workspace_order - workspace_order="$(jq -c 'sort_by(.created // "") | reverse | map(.id)' <<<"$ws_json")" - agents_json="$(jq -c --argjson order "$workspace_order" 'sort_by(. as $a | (($order | index($a.workspace_id // "")) // 999999), ($a.session_name // ""))' <<<"$agents_json")" - - local project_count workspace_count agent_count terminal_count session_count prune_total - project_count="$(jq -r '.data // [] | length' <<<"$projects_out")" - workspace_count="$(jq -r 'length' <<<"$ws_json")" - agent_count="$(jq -r 'length' <<<"$agents_json")" - terminal_count="$(jq -r 'length' <<<"$terms_json")" - session_count="$(jq -r '.data // [] | length' <<<"$sessions_out")" - if [[ -n "$project" && -z "$workspace" ]]; then - session_count="$agent_count" - fi - prune_total="$(jq -r '.data.total // 0' <<<"$prune_out")" - - local alerts='[]' - local captures='[]' - - while IFS= read -r session_name; do - [[ -z "$session_name" ]] && continue - - local capture_out - if ! capture_out="$(amux_ok_json agent capture "$session_name" --lines "$capture_lines")"; then - continue - fi - - local capture_status capture_summary capture_needs_input capture_hint - capture_status="$(jq -r '.data.status // "captured"' <<<"$capture_out")" - capture_summary="$(jq -r '.data.summary // .data.latest_line // ""' <<<"$capture_out")" - capture_needs_input="$(jq -r '.data.needs_input // false' <<<"$capture_out")" - capture_hint="$(jq -r '.data.input_hint // ""' <<<"$capture_out")" - if [[ "$capture_needs_input" == "true" ]]; then - local capture_hint_lc capture_summary_lc - capture_hint_lc="$(printf '%s' "$capture_hint" | tr '[:upper:]' '[:lower:]')" - capture_summary_lc="$(printf '%s' "$capture_summary" | tr '[:upper:]' '[:lower:]')" - if [[ "$capture_hint_lc" == "what can i do for you?"* || "$capture_summary_lc" == *"needs input: what can i do for you?"* ]]; then - capture_needs_input=false - fi - fi - - local agent_row agent_row_json agent_id workspace_id - agent_row="$(jq -c --arg s "$session_name" '.[] | select(.session_name == $s)' <<<"$agents_json" | head -n 1)" - agent_row_json='{}' - if [[ -n "${agent_row// }" ]]; then - agent_row_json="$agent_row" - fi - agent_id="$(jq -r '.agent_id // ""' <<<"$agent_row_json")" - workspace_id="$(jq -r '.workspace_id // ""' <<<"$agent_row_json")" - - captures="$(jq -cn --argjson captures "$captures" --arg session "$session_name" --arg agent_id "$agent_id" --arg workspace_id "$workspace_id" --arg status "$capture_status" --arg summary "$capture_summary" --arg hint "$capture_hint" --argjson needs_input "$capture_needs_input" '$captures + [{session_name: $session, agent_id: $agent_id, workspace_id: $workspace_id, status: $status, summary: $summary, needs_input: $needs_input, input_hint: $hint}]')" - - if [[ "$capture_needs_input" == "true" ]]; then - alerts="$(jq -cn --argjson alerts "$alerts" --arg type "needs_input" --arg session "$session_name" --arg agent_id "$agent_id" --arg workspace_id "$workspace_id" --arg summary "$capture_summary" --arg input_hint "$capture_hint" '$alerts + [{type: $type, session_name: $session, agent_id: $agent_id, workspace_id: $workspace_id, summary: $summary, input_hint: $input_hint}]')" - continue - fi - - if [[ "$capture_status" == "session_exited" ]]; then - alerts="$(jq -cn --argjson alerts "$alerts" --arg type "session_exited" --arg session "$session_name" --arg agent_id "$agent_id" --arg workspace_id "$workspace_id" --arg summary "$capture_summary" '$alerts + [{type: $type, session_name: $session, agent_id: $agent_id, workspace_id: $workspace_id, summary: $summary}]')" - continue - fi - - if completion_signal_present "$capture_summary"; then - alerts="$(jq -cn --argjson alerts "$alerts" --arg type "completed" --arg session "$session_name" --arg agent_id "$agent_id" --arg workspace_id "$workspace_id" --arg summary "$capture_summary" '$alerts + [{type: $type, session_name: $session, agent_id: $agent_id, workspace_id: $workspace_id, summary: $summary}]')" - fi - done < <(jq -r --argjson cap "$capture_agents" '.[:$cap][]?.session_name' <<<"$agents_json") - - if [[ "$include_stale_alerts" == "true" ]] && [[ -z "$workspace" ]] && [[ -z "$project" ]] && [[ "$prune_total" =~ ^[0-9]+$ ]] && [[ "$prune_total" -gt 0 ]]; then - alerts="$(jq -cn --argjson alerts "$alerts" --arg older_than "$older_than" --argjson total "$prune_total" '$alerts + [{type: "stale_sessions", total: $total, older_than: $older_than}]')" - fi - - local needs_input_count completed_count stale_alert_count alert_count - needs_input_count="$(jq -r '[.[] | select(.type == "needs_input")] | length' <<<"$alerts")" - completed_count="$(jq -r '[.[] | select(.type == "completed")] | length' <<<"$alerts")" - stale_alert_count="$(jq -r '[.[] | select(.type == "stale_sessions")] | length' <<<"$alerts")" - alert_count="$(jq -r 'length' <<<"$alerts")" - - local status="ok" - if [[ "$needs_input_count" -gt 0 ]]; then - status="needs_input" - elif [[ "$alert_count" -gt 0 ]]; then - status="attention" - fi - - local summary - if [[ "$status" == "ok" ]]; then - summary="All clear: $agent_count agent(s), $terminal_count terminal(s), $workspace_count workspace(s)." - else - summary="$alert_count alert(s): $needs_input_count need input, $completed_count completed, $stale_alert_count stale session alert(s)." - fi - - local next_action suggested_command - next_action="Review active agents and continue where needed." - local refresh_cmd - refresh_cmd="skills/amux/scripts/openclaw-dx.sh $result_command" - if [[ -n "$project" ]]; then - refresh_cmd+=" --project $(shell_quote "$project")" - fi - if [[ -n "$workspace" ]]; then - refresh_cmd+=" --workspace $(shell_quote "$workspace")" - fi - if [[ "$include_stale_alerts" == "true" ]]; then - refresh_cmd+=" --include-stale" - fi - if [[ -n "$project" && -z "$workspace" && "$recent_workspaces" -gt 0 ]]; then - refresh_cmd+=" --recent-workspaces $(shell_quote "$recent_workspaces")" - fi - if [[ "$alerts_only" == "true" && "$result_command" != "alerts" ]]; then - refresh_cmd+=" --alerts-only" - fi - suggested_command="$refresh_cmd" - - local first_needs_input_agent first_completed_workspace first_completed_agent - first_needs_input_agent="$(jq -r '.[] | select(.type == "needs_input") | .agent_id // empty' <<<"$alerts" | head -n 1)" - first_completed_workspace="$(jq -r '.[] | select(.type == "completed") | .workspace_id // empty' <<<"$alerts" | head -n 1)" - first_completed_agent="$(jq -r '.[] | select(.type == "completed") | .agent_id // empty' <<<"$alerts" | head -n 1)" - if [[ -n "$first_needs_input_agent" ]]; then - next_action="Reply to the blocked agent prompt first." - suggested_command="skills/amux/scripts/openclaw-dx.sh continue --agent $(shell_quote "$first_needs_input_agent") --text \"Continue using the safest option and then report status plus next action.\" --enter" - elif [[ "$completed_count" -gt 0 ]]; then - next_action="Review recently completed agent work and ship if clean." - if [[ -n "$first_completed_workspace" ]]; then - suggested_command="skills/amux/scripts/openclaw-dx.sh review --workspace $(shell_quote "$first_completed_workspace") --assistant codex" - elif [[ -n "$first_completed_agent" ]]; then - suggested_command="skills/amux/scripts/openclaw-dx.sh continue --agent $(shell_quote "$first_completed_agent") --text \"Summarize final changes, tests, and remaining risks in 5 bullets.\" --enter" - fi - elif [[ "$stale_alert_count" -gt 0 ]]; then - next_action="Clean stale sessions to reduce noise." - suggested_command="skills/amux/scripts/openclaw-dx.sh cleanup --older-than $(shell_quote "$older_than") --yes" - fi - - local ws_enriched ws_preview ws_lines - ws_enriched="$(jq -cn --argjson ws "$ws_json" --argjson agents "$agents_json" --argjson terms "$terms_json" ' - $ws - | map( - . as $w - | $w + { - agent_count: ($agents | map(select(.workspace_id == $w.id)) | length), - terminal_count: ($terms | map(select(.workspace_id == $w.id)) | length) - } - ) - | sort_by(.created) - | reverse - ')" - ws_preview="$(jq -c --argjson limit "$limit" '.[0:$limit]' <<<"$ws_enriched")" - ws_lines="$(jq -r '. | map("- \(.id) \(.name) (a:\(.agent_count), t:\(.terminal_count))") | join("\n")' <<<"$ws_preview")" - - local alert_lines - alert_lines="$(jq -r --argjson limit "$limit" '.[:$limit] | map( - if .type == "needs_input" then - "- ❓ " + (.workspace_id // "") + " " + (.agent_id // "") + ": " + (.summary // "needs input") - elif .type == "session_exited" then - "- 🛑 " + (.workspace_id // "") + " " + (.agent_id // "") + ": session exited" - elif .type == "completed" then - "- ✅ " + (.workspace_id // "") + " " + (.agent_id // "") + ": " + (.summary // "completed") - elif .type == "stale_sessions" then - "- 🧹 stale sessions: " + ((.total // 0) | tostring) + " older than " + (.older_than // "") - else - "- ⚠️ " + (.type // "alert") - end - ) | join("\n")' <<<"$alerts")" - - local actions='[]' - actions="$(append_action "$actions" "refresh" "Refresh" "$refresh_cmd" "primary" "Refresh agent/workspace status")" - if [[ -n "$first_needs_input_agent" ]]; then - actions="$(append_action "$actions" "reply" "Reply" "skills/amux/scripts/openclaw-dx.sh continue --agent $(shell_quote "$first_needs_input_agent") --text \"Continue using the safest option and report status and blockers.\" --enter" "danger" "Reply to blocked agent")" - fi - if [[ "$completed_count" -gt 0 ]]; then - if [[ -n "$first_completed_workspace" ]]; then - actions="$(append_action "$actions" "review_done" "Review Done" "skills/amux/scripts/openclaw-dx.sh review --workspace $(shell_quote "$first_completed_workspace") --assistant codex" "success" "Review completed workspace changes")" - actions="$(append_action "$actions" "ship_done" "Ship Done" "skills/amux/scripts/openclaw-dx.sh git ship --workspace $(shell_quote "$first_completed_workspace") --push" "primary" "Commit and push completed workspace changes")" - elif [[ -n "$first_completed_agent" ]]; then - actions="$(append_action "$actions" "summary_done" "Summarize" "skills/amux/scripts/openclaw-dx.sh continue --agent $(shell_quote "$first_completed_agent") --text \"Summarize final changes, tests, and risks.\" --enter" "primary" "Capture final summary for completed agent")" - fi - fi - if [[ "$stale_alert_count" -gt 0 ]]; then - actions="$(append_action "$actions" "cleanup" "Cleanup" "skills/amux/scripts/openclaw-dx.sh cleanup --older-than $(shell_quote "$older_than") --yes" "danger" "Prune stale sessions")" - fi - local first_ws - first_ws="$(jq -r '.[0].id // ""' <<<"$ws_enriched")" - if [[ -n "$first_ws" ]]; then - actions="$(append_action "$actions" "continue_ws" "Continue WS" "skills/amux/scripts/openclaw-dx.sh continue --workspace $(shell_quote "$first_ws") --text \"Status update and next action.\" --enter" "success" "Continue active work in top workspace")" - fi - - RESULT_OK=true - RESULT_COMMAND="$result_command" - RESULT_STATUS="$status" - RESULT_SUMMARY="$summary" - RESULT_NEXT_ACTION="$next_action" - RESULT_SUGGESTED_COMMAND="$suggested_command" - RESULT_QUICK_ACTIONS="$actions" - - RESULT_DATA="$(jq -cn \ - --argjson counts "$(jq -cn --argjson project_count "$project_count" --argjson workspace_count "$workspace_count" --argjson workspace_total_count "$workspace_total_count" --argjson agent_count "$agent_count" --argjson terminal_count "$terminal_count" --argjson session_count "$session_count" --argjson prune_total "$prune_total" --argjson completed_count "$completed_count" --argjson include_stale_alerts "$include_stale_alerts" --argjson recent_workspaces "$recent_workspaces" --argjson recent_workspaces_applied "$recent_workspaces_applied" '{projects: $project_count, workspaces: $workspace_count, workspace_total: $workspace_total_count, agents: $agent_count, terminals: $terminal_count, sessions: $session_count, prune_candidates: $prune_total, completed_alerts: $completed_count, include_stale_alerts: $include_stale_alerts, recent_workspaces: $recent_workspaces, recent_workspaces_applied: $recent_workspaces_applied}')" \ - --argjson workspaces "$ws_enriched" \ - --argjson alerts "$alerts" \ - --argjson captures "$captures" \ - '{counts: $counts, workspaces: $workspaces, alerts: $alerts, captures: $captures}')" - - RESULT_MESSAGE="$(printf '%s %s' "$(if [[ "$status" == "ok" ]]; then printf '✅'; elif [[ "$status" == "needs_input" ]]; then printf '❓'; else printf '⚠️'; fi)" "$summary")" - RESULT_MESSAGE+=$'\n'"Counts: projects=$project_count workspaces=$workspace_count agents=$agent_count terminals=$terminal_count sessions=$session_count" - if [[ "$recent_workspaces_applied" == "true" ]]; then - RESULT_MESSAGE+=$'\n'"Scope: showing $workspace_count of $workspace_total_count most recent project workspace(s)" - fi - if [[ "$alert_count" -gt 0 ]] && [[ -n "${alert_lines// }" ]]; then - RESULT_MESSAGE+=$'\n'"Alerts:"$'\n'"$alert_lines" - fi - if [[ "$alerts_only" != "true" ]] && [[ -n "${ws_lines// }" ]]; then - RESULT_MESSAGE+=$'\n'"Workspaces:"$'\n'"$ws_lines" - fi - RESULT_MESSAGE+=$'\n'"Next: $next_action" - - if [[ "$status" == "ok" ]]; then - RESULT_DELIVERY_ACTION="edit" - RESULT_DELIVERY_PRIORITY=2 - RESULT_DELIVERY_RETRY_AFTER_SECONDS=20 - RESULT_DELIVERY_REPLACE_PREVIOUS=true - RESULT_DELIVERY_DROP_PENDING=false - else - RESULT_DELIVERY_ACTION="send" - RESULT_DELIVERY_PRIORITY=0 - RESULT_DELIVERY_RETRY_AFTER_SECONDS=0 - RESULT_DELIVERY_REPLACE_PREVIOUS=false - RESULT_DELIVERY_DROP_PENDING=true - fi - - emit_result -} - -cmd_alerts() { - OPENCLAW_DX_FORCE_ALERTS_ONLY=true OPENCLAW_DX_STATUS_RESULT_COMMAND=alerts cmd_status "$@" -} - -cmd_terminal_run() { - local workspace="" - local text="" - local enter=false - - while [[ $# -gt 0 ]]; do - case "$1" in - --workspace) - workspace="$2"; shift 2 ;; - --text) - shift - if [[ $# -eq 0 ]]; then - emit_error "terminal.run" "command_error" "missing value for --text" - return - fi - text="$1"; shift - while [[ $# -gt 0 && "$1" != --* ]]; do - text+=" $1" - shift - done - ;; - --enter) - enter=true; shift ;; - *) - emit_error "terminal.run" "command_error" "unknown flag" "$1" - return - ;; - esac - done - - workspace="$(context_resolve_workspace "$workspace")" - if [[ -z "$workspace" || -z "$text" ]]; then - emit_error "terminal.run" "command_error" "missing required flags" "terminal run requires --text and a workspace (pass --workspace or set active context)" - return - fi - context_set_workspace_with_lookup "$workspace" "" - - local args=(terminal run --workspace "$workspace" --text "$text") - if [[ "$enter" == "true" ]]; then - args+=(--enter=true) - fi - - local out - if ! out="$(amux_ok_json "${args[@]}")"; then - emit_amux_error "terminal.run" - return - fi - - local session_name created - session_name="$(jq -r '.data.session_name // ""' <<<"$out")" - created="$(jq -r '.data.created // false' <<<"$out")" - - RESULT_OK=true - RESULT_COMMAND="terminal.run" - RESULT_STATUS="ok" - RESULT_SUMMARY="Terminal command sent to workspace $workspace" - RESULT_NEXT_ACTION="Check terminal logs for command output." - RESULT_SUGGESTED_COMMAND="skills/amux/scripts/openclaw-dx.sh terminal logs --workspace $(shell_quote "$workspace") --lines 120" - RESULT_DATA="$(jq -cn --argjson result "$(jq -c '.data' <<<"$out")" '{terminal: $result}')" - - local actions='[]' - actions="$(append_action "$actions" "logs" "Logs" "$RESULT_SUGGESTED_COMMAND" "primary" "Capture terminal output")" - actions="$(append_action "$actions" "status" "Status" "skills/amux/scripts/openclaw-dx.sh status --workspace $(shell_quote "$workspace")" "primary" "Check workspace status")" - RESULT_QUICK_ACTIONS="$actions" - - RESULT_MESSAGE="✅ Terminal command sent"$'\n'"Workspace: $workspace"$'\n'"Session: $session_name"$'\n'"Created: $created"$'\n'"Command: $text"$'\n'"Next: $RESULT_NEXT_ACTION" - emit_result -} - -cmd_terminal_preset() { - local workspace="" - local kind="nextjs" - local port="${OPENCLAW_DX_TERMINAL_PORT:-3000}" - local host="${OPENCLAW_DX_TERMINAL_HOST:-0.0.0.0}" - local manager="auto" - - while [[ $# -gt 0 ]]; do - case "$1" in - --workspace) - workspace="$2"; shift 2 ;; - --kind|--preset) - kind="$2"; shift 2 ;; - --port) - port="$2"; shift 2 ;; - --host) - host="$2"; shift 2 ;; - --manager) - manager="$2"; shift 2 ;; - *) - emit_error "terminal.preset" "command_error" "unknown flag" "$1" - return - ;; - esac - done - - workspace="$(context_resolve_workspace "$workspace")" - if [[ -z "$workspace" ]]; then - emit_error "terminal.preset" "command_error" "missing required flag: --workspace (or set active context workspace)" - return - fi - context_set_workspace_with_lookup "$workspace" "" - if ! is_positive_int "$port"; then - port=3000 - fi - if ! is_valid_hostname "$host"; then - host="0.0.0.0" - fi - - local launch_cmd="" - case "$kind" in - nextjs) - case "$manager" in - auto) - launch_cmd="export NEXT_TELEMETRY_DISABLED=1; if [ -f pnpm-lock.yaml ] && command -v pnpm >/dev/null 2>&1; then pnpm dev -- --port \"$port\" --hostname \"$host\"; elif [ -f yarn.lock ] && command -v yarn >/dev/null 2>&1; then yarn dev --port \"$port\" --hostname \"$host\"; elif { [ -f bun.lockb ] || [ -f bun.lock ]; } && command -v bun >/dev/null 2>&1; then bun run dev -- --port \"$port\" --hostname \"$host\"; else npm run dev -- --port \"$port\" --hostname \"$host\"; fi" - ;; - pnpm) - launch_cmd="export NEXT_TELEMETRY_DISABLED=1; pnpm dev -- --port \"$port\" --hostname \"$host\"" - ;; - yarn) - launch_cmd="export NEXT_TELEMETRY_DISABLED=1; yarn dev --port \"$port\" --hostname \"$host\"" - ;; - bun) - launch_cmd="export NEXT_TELEMETRY_DISABLED=1; bun run dev -- --port \"$port\" --hostname \"$host\"" - ;; - npm) - launch_cmd="export NEXT_TELEMETRY_DISABLED=1; npm run dev -- --port \"$port\" --hostname \"$host\"" - ;; - *) - emit_error "terminal.preset" "command_error" "--manager must be auto|npm|pnpm|yarn|bun" - return - ;; - esac - ;; - *) - emit_error "terminal.preset" "command_error" "--kind must be nextjs" - return - ;; - esac - - local out - if ! out="$(amux_ok_json terminal run --workspace "$workspace" --text "$launch_cmd" --enter=true)"; then - emit_amux_error "terminal.preset" - return - fi - - local session_name created - session_name="$(jq -r '.data.session_name // ""' <<<"$out")" - created="$(jq -r '.data.created // false' <<<"$out")" - - RESULT_OK=true - RESULT_COMMAND="terminal.preset" - RESULT_STATUS="ok" - RESULT_SUMMARY="Started $kind preset in workspace $workspace" - RESULT_NEXT_ACTION="Watch logs for server readiness and continue coding." - RESULT_SUGGESTED_COMMAND="skills/amux/scripts/openclaw-dx.sh terminal logs --workspace $(shell_quote "$workspace") --lines 120" - RESULT_DATA="$(jq -cn --arg workspace "$workspace" --arg kind "$kind" --arg manager "$manager" --arg host "$host" --argjson port "$port" --arg command "$launch_cmd" --arg session_name "$session_name" --argjson created "$created" --argjson terminal "$(jq -c '.data' <<<"$out")" '{workspace: $workspace, kind: $kind, manager: $manager, host: $host, port: $port, command: $command, session_name: $session_name, created: $created, terminal: $terminal}')" - - local actions='[]' - actions="$(append_action "$actions" "logs" "Logs" "$RESULT_SUGGESTED_COMMAND" "primary" "Tail terminal logs for startup")" - actions="$(append_action "$actions" "status" "Status" "skills/amux/scripts/openclaw-dx.sh status --workspace $(shell_quote "$workspace")" "primary" "Check workspace status")" - actions="$(append_action "$actions" "alerts" "Alerts" "skills/amux/scripts/openclaw-dx.sh alerts --workspace $(shell_quote "$workspace")" "primary" "Check blockers requiring attention")" - RESULT_QUICK_ACTIONS="$actions" - - RESULT_MESSAGE="✅ Terminal preset started: $kind"$'\n'"Workspace: $workspace"$'\n'"Session: $session_name"$'\n'"Created: $created"$'\n'"Host/Port: $host:$port"$'\n'"Next: $RESULT_NEXT_ACTION" - emit_result -} - -cmd_terminal_logs() { - local workspace="" - local lines=200 - local retry_attempts="${OPENCLAW_DX_TERMINAL_LOGS_RETRIES:-4}" - local retry_delay_seconds="${OPENCLAW_DX_TERMINAL_LOGS_RETRY_DELAY:-1}" - - while [[ $# -gt 0 ]]; do - case "$1" in - --workspace) - workspace="$2"; shift 2 ;; - --lines) - lines="$2"; shift 2 ;; - *) - emit_error "terminal.logs" "command_error" "unknown flag" "$1" - return - ;; - esac - done - - workspace="$(context_resolve_workspace "$workspace")" - if [[ -z "$workspace" ]]; then - emit_error "terminal.logs" "command_error" "missing required flag: --workspace (or set active context workspace)" - return - fi - context_set_workspace_with_lookup "$workspace" "" - if ! is_positive_int "$lines"; then - lines=200 - fi - if ! is_positive_int "$retry_attempts"; then - retry_attempts=4 - fi - if ! is_positive_int "$retry_delay_seconds"; then - retry_delay_seconds=1 - fi - - local out - local attempt=1 - while true; do - if out="$(amux_ok_json terminal logs --workspace "$workspace" --lines "$lines")"; then - break - fi - local err_out err_code err_message - err_out="$AMUX_ERROR_OUTPUT" - if [[ -z "${err_out// }" ]] && [[ -n "${AMUX_ERROR_CAPTURE_FILE:-}" ]] && [[ -f "$AMUX_ERROR_CAPTURE_FILE" ]]; then - err_out="$(cat "$AMUX_ERROR_CAPTURE_FILE" 2>/dev/null || true)" - fi - err_code="" - err_message="" - if jq -e . >/dev/null 2>&1 <<<"$err_out"; then - err_code="$(jq -r '.error.code // ""' <<<"$err_out")" - err_message="$(jq -r '.error.message // ""' <<<"$err_out")" - fi - if [[ "$err_code" == "capture_failed" && "$attempt" -lt "$retry_attempts" ]]; then - sleep "$retry_delay_seconds" - attempt=$((attempt + 1)) - continue - fi - if { [[ "$err_code" == "not_found" && "$err_message" == *"no terminal session found for workspace"* ]]; } || [[ "$err_out" == *"no terminal session found for workspace"* ]]; then - RESULT_OK=false - RESULT_COMMAND="terminal.logs" - RESULT_STATUS="attention" - RESULT_SUMMARY="No terminal session found for workspace $workspace" - RESULT_NEXT_ACTION="Start a terminal command or preset first, then fetch logs." - RESULT_SUGGESTED_COMMAND="skills/amux/scripts/openclaw-dx.sh terminal run --workspace $(shell_quote "$workspace") --text \"pwd\" --enter" - RESULT_DATA="$(jq -cn --arg workspace "$workspace" --argjson error "$(normalize_json_or_default "$err_out" '{}')" '{workspace: $workspace, error: $error, reason: "no_terminal_session"}')" - - local actions='[]' - actions="$(append_action "$actions" "term_run" "Run Cmd" "$RESULT_SUGGESTED_COMMAND" "primary" "Start a terminal session with a quick command")" - actions="$(append_action "$actions" "preset" "Preset" "skills/amux/scripts/openclaw-dx.sh terminal preset --workspace $(shell_quote "$workspace") --kind nextjs" "success" "Start a Next.js dev terminal preset")" - actions="$(append_action "$actions" "status" "Status" "skills/amux/scripts/openclaw-dx.sh status --workspace $(shell_quote "$workspace")" "primary" "Check workspace status")" - RESULT_QUICK_ACTIONS="$actions" - RESULT_MESSAGE="⚠️ No terminal session found for workspace $workspace"$'\n'"Next: $RESULT_NEXT_ACTION" - emit_result - return - fi - emit_amux_error "terminal.logs" - return - done - - local content excerpt - content="$(jq -r '.data.content // ""' <<<"$out")" - excerpt="$(printf '%s\n' "$content" | tail -n 20)" - - RESULT_OK=true - RESULT_COMMAND="terminal.logs" - RESULT_STATUS="ok" - RESULT_SUMMARY="Captured terminal logs for workspace $workspace" - RESULT_NEXT_ACTION="Continue coding or run another terminal command." - RESULT_SUGGESTED_COMMAND="skills/amux/scripts/openclaw-dx.sh terminal run --workspace $(shell_quote "$workspace") --text \"npm test\" --enter" - RESULT_DATA="$(jq -cn --argjson result "$(jq -c '.data' <<<"$out")" '{terminal: $result}')" - - local actions='[]' - actions="$(append_action "$actions" "term_run" "Run Cmd" "$RESULT_SUGGESTED_COMMAND" "primary" "Run a follow-up terminal command")" - actions="$(append_action "$actions" "status" "Status" "skills/amux/scripts/openclaw-dx.sh status --workspace $(shell_quote "$workspace")" "primary" "Check workspace status")" - RESULT_QUICK_ACTIONS="$actions" - - RESULT_MESSAGE="✅ Terminal logs captured"$'\n'"Workspace: $workspace" - if [[ -n "${excerpt// }" ]]; then - RESULT_MESSAGE+=$'\n'"Logs:"$'\n'"$excerpt" - fi - RESULT_MESSAGE+=$'\n'"Next: $RESULT_NEXT_ACTION" - emit_result -} - -cmd_cleanup() { - local older_than="${OPENCLAW_DX_CLEANUP_OLDER_THAN:-24h}" - local yes=false - - while [[ $# -gt 0 ]]; do - case "$1" in - --older-than) - older_than="$2"; shift 2 ;; - --yes) - yes=true; shift ;; - *) - emit_error "cleanup" "command_error" "unknown flag" "$1" - return - ;; - esac - done - - local args=(session prune --older-than "$older_than") - if [[ "$yes" == "true" ]]; then - args+=(--yes) - fi - - local out - if ! out="$(amux_ok_json "${args[@]}")"; then - emit_amux_error "cleanup" - return - fi - - local total dry_run - total="$(jq -r '.data.total // 0' <<<"$out")" - dry_run="$(jq -r '.data.dry_run // false' <<<"$out")" - - RESULT_OK=true - RESULT_COMMAND="cleanup" - RESULT_STATUS="ok" - if [[ "$dry_run" == "true" ]]; then - RESULT_SUMMARY="Session cleanup dry-run result: $total" - else - RESULT_SUMMARY="Session cleanup result: $total" - fi - RESULT_NEXT_ACTION="Refresh status to verify active sessions and agents." - RESULT_SUGGESTED_COMMAND="skills/amux/scripts/openclaw-dx.sh status" - RESULT_DATA="$(jq -cn --argjson prune "$(jq -c '.data' <<<"$out")" '{prune: $prune}')" - - local actions='[]' - if [[ "$dry_run" == "true" && "$total" -gt 0 ]]; then - actions="$(append_action "$actions" "confirm" "Confirm" "skills/amux/scripts/openclaw-dx.sh cleanup --older-than $(shell_quote "$older_than") --yes" "danger" "Prune stale sessions now")" - fi - actions="$(append_action "$actions" "status" "Status" "skills/amux/scripts/openclaw-dx.sh status" "primary" "Refresh global status")" - RESULT_QUICK_ACTIONS="$actions" - - RESULT_MESSAGE="✅ Cleanup $(if [[ "$dry_run" == "true" ]]; then printf '(dry run)'; else printf 'completed'; fi)"$'\n'"Older than: $older_than"$'\n'"Total: $total"$'\n'"Next: $RESULT_NEXT_ACTION" - - emit_result -} - -cmd_review() { - local workspace="" - local assistant="${OPENCLAW_DX_REVIEW_ASSISTANT:-codex}" - local prompt="${OPENCLAW_DX_REVIEW_PROMPT:-Review current uncommitted changes. Return findings first ordered by severity with file references, then residual risks and test gaps.}" - local max_steps="${OPENCLAW_DX_REVIEW_MAX_STEPS:-2}" - local turn_budget="${OPENCLAW_DX_REVIEW_TURN_BUDGET:-180}" - local wait_timeout="${OPENCLAW_DX_WAIT_TIMEOUT:-60s}" - local idle_threshold="${OPENCLAW_DX_IDLE_THRESHOLD:-10s}" - - while [[ $# -gt 0 ]]; do - case "$1" in - --workspace) - workspace="$2"; shift 2 ;; - --assistant) - assistant="$2"; shift 2 ;; - --prompt) - shift - if [[ $# -eq 0 ]]; then - emit_error "review" "command_error" "missing value for --prompt" - return - fi - prompt="$1"; shift - while [[ $# -gt 0 && "$1" != --* ]]; do - prompt+=" $1" - shift - done - ;; - --max-steps) - max_steps="$2"; shift 2 ;; - --turn-budget) - turn_budget="$2"; shift 2 ;; - --wait-timeout) - wait_timeout="$2"; shift 2 ;; - --idle-threshold) - idle_threshold="$2"; shift 2 ;; - *) - emit_error "review" "command_error" "unknown flag" "$1" - return - ;; - esac - done - - workspace="$(context_resolve_workspace "$workspace")" - if [[ -z "$workspace" ]]; then - emit_error "review" "command_error" "missing required flag: --workspace (or set active context workspace)" - return - fi - if ! workspace_require_exists "review" "$workspace"; then - return - fi - if ! assistant_require_known "review" "$assistant"; then - return - fi - context_set_workspace_with_lookup "$workspace" "$assistant" - - if [[ ! -x "$TURN_SCRIPT" ]]; then - emit_error "review" "command_error" "turn script is not executable" "$TURN_SCRIPT" - return - fi - - local turn_json - turn_json="$(OPENCLAW_TURN_SKIP_PRESENT=true "$TURN_SCRIPT" run \ - --workspace "$workspace" \ - --assistant "$assistant" \ - --prompt "$prompt" \ - --max-steps "$max_steps" \ - --turn-budget "$turn_budget" \ - --wait-timeout "$wait_timeout" \ - --idle-threshold "$idle_threshold" 2>&1 || true)" - - emit_turn_passthrough "review" "review_turn" "$turn_json" -} - -cmd_git_ship() { - local workspace="" - local message="" - local push=false - - while [[ $# -gt 0 ]]; do - case "$1" in - --workspace) - workspace="$2"; shift 2 ;; - --message) - message="$2"; shift 2 ;; - --push) - push=true; shift ;; - *) - emit_error "git.ship" "command_error" "unknown flag" "$1" - return - ;; - esac - done - - workspace="$(context_resolve_workspace "$workspace")" - if [[ -z "$workspace" ]]; then - emit_error "git.ship" "command_error" "missing required flag: --workspace (or set active context workspace)" - return - fi - - local ws_row - if ! ws_row="$(workspace_row_by_id "$workspace")"; then - emit_amux_error "git.ship" - return - fi - if [[ -z "${ws_row// }" ]]; then - emit_error "git.ship" "command_error" "workspace not found" "$workspace" - return - fi - local ws_name ws_repo ws_assistant - ws_name="$(jq -r '.name // ""' <<<"$ws_row")" - ws_repo="$(jq -r '.repo // ""' <<<"$ws_row")" - ws_assistant="$(jq -r '.assistant // ""' <<<"$ws_row")" - context_set_workspace "$workspace" "$ws_name" "$ws_repo" "$ws_assistant" - - local ws_root - ws_root="$(jq -r '.root // ""' <<<"$ws_row")" - if [[ -z "$ws_root" || ! -d "$ws_root" ]]; then - emit_error "git.ship" "command_error" "workspace root is unavailable" "$ws_root" - return - fi - - local porcelain - porcelain="$(git -C "$ws_root" status --porcelain --untracked-files=all 2>/dev/null || true)" - if [[ -z "${porcelain// }" ]]; then - local branch upstream_ref has_upstream=false has_origin=false ahead_count=0 pushed=false push_error="" - branch="$(git -C "$ws_root" rev-parse --abbrev-ref HEAD 2>/dev/null || true)" - upstream_ref="" - if upstream_ref="$(git -C "$ws_root" rev-parse --abbrev-ref --symbolic-full-name '@{u}' 2>/dev/null)"; then - has_upstream=true - ahead_count="$(git -C "$ws_root" rev-list --count '@{u}..HEAD' 2>/dev/null || true)" - if ! [[ "$ahead_count" =~ ^[0-9]+$ ]]; then - ahead_count=0 - fi - fi - if git -C "$ws_root" remote get-url origin >/dev/null 2>&1; then - has_origin=true - fi - - if [[ "$push" == "true" ]]; then - local push_cmd=() - if [[ "$has_upstream" == "true" ]]; then - if [[ "$ahead_count" -gt 0 ]]; then - push_cmd=(git -C "$ws_root" push) - fi - elif [[ "$has_origin" == "true" ]]; then - push_cmd=(git -C "$ws_root" push -u origin HEAD) - else - push_error="origin remote is not configured" - fi - if [[ "${#push_cmd[@]}" -gt 0 ]]; then - if ! "${push_cmd[@]}" >/dev/null 2>&1; then - push_error="git push failed" - else - pushed=true - fi - fi - fi - - local suggest_push=false - if [[ "$push" != "true" ]] && { [[ "$ahead_count" -gt 0 ]] || { [[ "$has_upstream" != "true" ]] && [[ "$has_origin" == "true" ]]; }; }; then - suggest_push=true - fi - - RESULT_OK=true - RESULT_COMMAND="git.ship" - RESULT_STATUS="ok" - RESULT_SUMMARY="No changes to commit in workspace $workspace" - RESULT_NEXT_ACTION="Continue coding or run a review workflow." - RESULT_SUGGESTED_COMMAND="skills/amux/scripts/openclaw-dx.sh review --workspace $(shell_quote "$workspace")" - - if [[ "$push" == "true" && "$pushed" == "true" ]]; then - RESULT_SUMMARY="No new changes to commit; pushed existing commits for $workspace" - RESULT_NEXT_ACTION="Run review or continue implementation." - RESULT_SUGGESTED_COMMAND="skills/amux/scripts/openclaw-dx.sh review --workspace $(shell_quote "$workspace")" - elif [[ "$push" == "true" && -n "$push_error" ]]; then - RESULT_STATUS="attention" - RESULT_SUMMARY="No new changes to commit; push failed for $workspace" - RESULT_NEXT_ACTION="Fix push issues, then retry push." - RESULT_SUGGESTED_COMMAND="skills/amux/scripts/openclaw-dx.sh git ship --workspace $(shell_quote "$workspace") --push" - elif [[ "$push" == "true" && "$has_upstream" == "true" && "$ahead_count" -eq 0 ]]; then - RESULT_SUMMARY="No new changes to commit; branch is already pushed" - RESULT_NEXT_ACTION="Continue coding or run review." - elif [[ "$suggest_push" == "true" ]]; then - RESULT_STATUS="attention" - if [[ "$has_upstream" == "true" ]]; then - RESULT_SUMMARY="No new changes to commit; $ahead_count commit(s) are ready to push" - else - RESULT_SUMMARY="No new changes to commit; branch has no upstream push target" - fi - RESULT_NEXT_ACTION="Push current commits to remote." - RESULT_SUGGESTED_COMMAND="skills/amux/scripts/openclaw-dx.sh git ship --workspace $(shell_quote "$workspace") --push" - fi - - local actions='[]' - if [[ "$push" != "true" && "$suggest_push" == "true" ]]; then - actions="$(append_action "$actions" "push" "Push" "skills/amux/scripts/openclaw-dx.sh git ship --workspace $(shell_quote "$workspace") --push" "success" "Push existing commits to remote")" - fi - actions="$(append_action "$actions" "review" "Review" "skills/amux/scripts/openclaw-dx.sh review --workspace $(shell_quote "$workspace")" "primary" "Run review workflow")" - actions="$(append_action "$actions" "status" "Status" "skills/amux/scripts/openclaw-dx.sh status --workspace $(shell_quote "$workspace")" "primary" "Check workspace status")" - RESULT_QUICK_ACTIONS="$actions" - - RESULT_DATA="$(jq -cn \ - --arg workspace "$workspace" \ - --arg root "$ws_root" \ - --arg branch "$branch" \ - --arg upstream "$upstream_ref" \ - --argjson has_upstream "$has_upstream" \ - --argjson has_origin "$has_origin" \ - --argjson ahead_count "$ahead_count" \ - --argjson committed false \ - --argjson pushed "$pushed" \ - --arg push_error "$push_error" \ - --argjson push_requested "$push" \ - '{workspace: $workspace, root: $root, branch: $branch, upstream: $upstream, has_upstream: $has_upstream, has_origin: $has_origin, ahead_count: $ahead_count, committed: $committed, pushed: $pushed, push_requested: $push_requested, push_error: $push_error, reason: "no_changes"}')" - - local message_prefix="✅" - if [[ "$RESULT_STATUS" != "ok" ]]; then - message_prefix="⚠️" - fi - RESULT_MESSAGE="$message_prefix No new changes to commit in workspace $workspace" - if [[ "$push" == "true" && "$pushed" == "true" ]]; then - RESULT_MESSAGE+=$'\n'"Push: success" - elif [[ "$push" == "true" && -n "$push_error" ]]; then - RESULT_MESSAGE+=$'\n'"Push: failed ($push_error)" - elif [[ "$suggest_push" == "true" ]]; then - if [[ "$has_upstream" == "true" ]]; then - RESULT_MESSAGE+=$'\n'"Unpushed commits: $ahead_count" - else - RESULT_MESSAGE+=$'\n'"Unpushed branch: no upstream configured" - fi - fi - RESULT_MESSAGE+=$'\n'"Next: $RESULT_NEXT_ACTION" - emit_result - return - fi - - local file_count - file_count="$(printf '%s\n' "$porcelain" | sed '/^$/d' | wc -l | tr -d ' ')" - if [[ -z "$message" ]]; then - message="chore(amux): update $workspace ($file_count files)" - fi - - if ! git -C "$ws_root" add -A >/dev/null 2>&1; then - emit_error "git.ship" "command_error" "git add failed" "$ws_root" - return - fi - - local commit_output - if ! commit_output="$(git -C "$ws_root" commit -m "$message" 2>&1)"; then - emit_error "git.ship" "command_error" "git commit failed" "$commit_output" - return - fi - - local commit_hash branch - commit_hash="$(git -C "$ws_root" rev-parse --short HEAD 2>/dev/null || true)" - branch="$(git -C "$ws_root" rev-parse --abbrev-ref HEAD 2>/dev/null || true)" - - local pushed=false - local push_error="" - if [[ "$push" == "true" ]]; then - local push_cmd - if git -C "$ws_root" rev-parse --abbrev-ref --symbolic-full-name '@{u}' >/dev/null 2>&1; then - push_cmd=(git -C "$ws_root" push) - else - push_cmd=(git -C "$ws_root" push -u origin HEAD) - fi - if ! "${push_cmd[@]}" >/dev/null 2>&1; then - push_error="git push failed" - else - pushed=true - fi - fi - - RESULT_OK=true - RESULT_COMMAND="git.ship" - RESULT_STATUS="ok" - if [[ -n "$push_error" ]]; then - RESULT_STATUS="attention" - fi - - RESULT_SUMMARY="Committed $file_count file(s) in $workspace" - if [[ "$pushed" == "true" ]]; then - RESULT_SUMMARY+=" and pushed" - fi - - RESULT_NEXT_ACTION="Run a review pass or continue implementation." - RESULT_SUGGESTED_COMMAND="skills/amux/scripts/openclaw-dx.sh review --workspace $(shell_quote "$workspace")" - if [[ "$pushed" != "true" ]]; then - RESULT_SUGGESTED_COMMAND="skills/amux/scripts/openclaw-dx.sh git ship --workspace $(shell_quote "$workspace") --push" - fi - - local actions='[]' - if [[ "$pushed" != "true" ]]; then - actions="$(append_action "$actions" "push" "Push" "skills/amux/scripts/openclaw-dx.sh git ship --workspace $(shell_quote "$workspace") --push" "success" "Push latest commit")" - fi - actions="$(append_action "$actions" "review" "Review" "skills/amux/scripts/openclaw-dx.sh review --workspace $(shell_quote "$workspace")" "primary" "Run review workflow")" - actions="$(append_action "$actions" "status" "Status" "skills/amux/scripts/openclaw-dx.sh status --workspace $(shell_quote "$workspace")" "primary" "Check workspace status")" - RESULT_QUICK_ACTIONS="$actions" - - RESULT_DATA="$(jq -cn --arg workspace "$workspace" --arg root "$ws_root" --arg commit_hash "$commit_hash" --arg branch "$branch" --arg message "$message" --argjson file_count "$file_count" --argjson pushed "$pushed" --arg push_error "$push_error" '{workspace: $workspace, root: $root, commit_hash: $commit_hash, branch: $branch, message: $message, file_count: $file_count, pushed: $pushed, push_error: $push_error}')" - - RESULT_MESSAGE="✅ Commit created"$'\n'"Workspace: $workspace"$'\n'"Branch: $branch"$'\n'"Commit: $commit_hash"$'\n'"Files: $file_count" - if [[ "$pushed" == "true" ]]; then - RESULT_MESSAGE+=$'\n'"Push: success" - elif [[ -n "$push_error" ]]; then - RESULT_MESSAGE+=$'\n'"Push: failed" - else - RESULT_MESSAGE+=$'\n'"Push: skipped" - fi - RESULT_MESSAGE+=$'\n'"Next: $RESULT_NEXT_ACTION" - - emit_result -} - -cmd_workflow_kickoff() { - local name="" - local project="" - local from_workspace="" - local scope="" - local assistant="" - local prompt="" - local base="" - local max_steps="${OPENCLAW_DX_MAX_STEPS:-3}" - local turn_budget="${OPENCLAW_DX_TURN_BUDGET:-180}" - local wait_timeout="${OPENCLAW_DX_WAIT_TIMEOUT:-60s}" - local idle_threshold="${OPENCLAW_DX_IDLE_THRESHOLD:-10s}" - - while [[ $# -gt 0 ]]; do - case "$1" in - --name|--workspace-name) - name="$2"; shift 2 ;; - --project) - project="$2"; shift 2 ;; - --from-workspace) - from_workspace="$2"; shift 2 ;; - --scope) - scope="$2"; shift 2 ;; - --assistant) - assistant="$2"; shift 2 ;; - --prompt) - shift - if [[ $# -eq 0 ]]; then - emit_error "workflow.kickoff" "command_error" "missing value for --prompt" - return - fi - prompt="$1"; shift - while [[ $# -gt 0 && "$1" != --* ]]; do - prompt+=" $1" - shift - done - ;; - --base) - base="$2"; shift 2 ;; - --max-steps) - max_steps="$2"; shift 2 ;; - --turn-budget) - turn_budget="$2"; shift 2 ;; - --wait-timeout) - wait_timeout="$2"; shift 2 ;; - --idle-threshold) - idle_threshold="$2"; shift 2 ;; - *) - emit_error "workflow.kickoff" "command_error" "unknown flag" "$1" - return - ;; - esac - done - - if [[ -z "$name" || -z "$prompt" ]]; then - emit_error "workflow.kickoff" "command_error" "missing required flags" "workflow kickoff requires --name and --prompt" - return - fi - if [[ -n "$assistant" ]]; then - if ! assistant_require_known "workflow.kickoff" "$assistant"; then - return - fi - fi - if [[ -z "$project" && -z "$from_workspace" ]]; then - project="$(context_resolve_project "")" - fi - if [[ -z "$project" && -z "$from_workspace" ]]; then - emit_error "workflow.kickoff" "command_error" "missing project context" "provide --project or --from-workspace" - return - fi - - local project_data='null' - if [[ -n "$project" ]]; then - local ensured_project - if ! ensured_project="$(ensure_project_registered "$project")"; then - emit_amux_error "workflow.kickoff" - return - fi - project_data="$(normalize_json_or_default "$ensured_project" 'null')" - local ensured_path - ensured_path="$(jq -r '.path // ""' <<<"$project_data")" - if [[ -n "$ensured_path" ]]; then - project="$ensured_path" - context_set_project "$project" "$(jq -r '.name // ""' <<<"$project_data")" - fi - fi - - local ws_args=(workspace create --name "$name") - if [[ -n "$project" ]]; then - ws_args+=(--project "$project") - fi - if [[ -n "$from_workspace" ]]; then - ws_args+=(--from-workspace "$from_workspace") - fi - if [[ -n "$scope" ]]; then - ws_args+=(--scope "$scope") - fi - if [[ -n "$assistant" ]]; then - ws_args+=(--assistant "$assistant") - fi - if [[ -n "$base" ]]; then - ws_args+=(--base "$base") - fi - - local ws_json - if ! ws_json="$(run_self_json "${ws_args[@]}")"; then - emit_error "workflow.kickoff" "command_error" "failed to run workspace.create subcommand" "${ws_args[*]}" - return - fi - - local ws_ok - ws_ok="$(jq -r '.ok // false' <<<"$ws_json")" - if [[ "$ws_ok" != "true" ]]; then - jq -c --arg command "workflow.kickoff" --arg workflow "kickoff" --arg phase "workspace" '. + {command: $command, workflow: $workflow, phase: $phase}' <<<"$ws_json" - return - fi - - local workspace_id - workspace_id="$(jq -r '.data.workspace.id // .data.id // .workspace_id // ""' <<<"$ws_json")" - if [[ -z "$workspace_id" ]]; then - emit_error "workflow.kickoff" "command_error" "workspace id missing from workspace.create result" "$ws_json" - return - fi - - local start_args=( - start - --workspace "$workspace_id" - --prompt "$prompt" - --max-steps "$max_steps" - --turn-budget "$turn_budget" - --wait-timeout "$wait_timeout" - --idle-threshold "$idle_threshold" - ) - if [[ -n "$assistant" ]]; then - start_args+=(--assistant "$assistant") - fi - - local start_json - if ! start_json="$(run_self_json "${start_args[@]}")"; then - emit_error "workflow.kickoff" "command_error" "failed to run start subcommand" "${start_args[*]}" - return - fi - - local kickoff_payload - kickoff_payload="$(jq -cn \ - --argjson project "$project_data" \ - --argjson workspace "$(jq -c '.data.workspace // .data // {}' <<<"$ws_json")" \ - --arg workspace_id "$workspace_id" \ - '{project: $project, workspace: $workspace, workspace_id: $workspace_id}')" - - local kickoff_json - kickoff_json="$(jq -c \ - --arg command "workflow.kickoff" \ - --arg workflow "kickoff" \ - --argjson kickoff "$kickoff_payload" \ - --arg workspace_id "$workspace_id" \ - ' - def turn_snapshot: - { - mode: (.mode // ""), - turn_id: (.turn_id // ""), - status: (.status // ""), - overall_status: (.overall_status // ""), - summary: (.summary // ""), - next_action: (.next_action // ""), - suggested_command: (.suggested_command // ""), - agent_id: (.agent_id // ""), - workspace_id: (.workspace_id // ""), - assistant: (.assistant // ""), - steps_used: (.steps_used // null), - max_steps: (.max_steps // null), - elapsed_seconds: (.elapsed_seconds // null), - milestones: (.milestones // []) - }; - ((.quick_actions // []) + [ - { - id: "status_ws", - label: "Status", - command: ("skills/amux/scripts/openclaw-dx.sh status --workspace " + $workspace_id), - style: "primary", - prompt: "Check workspace status" - }, - { - id: "review_ws", - label: "Review", - command: ("skills/amux/scripts/openclaw-dx.sh review --workspace " + $workspace_id + " --assistant codex"), - style: "primary", - prompt: "Run review on uncommitted changes" - } - ]) as $actions - | .quick_actions = ($actions | unique_by(.id)) - | .data = ((.data // {}) + { - kickoff: $kickoff, - project: ($kickoff.project // null), - workspace: ($kickoff.workspace // null), - workspace_id: $workspace_id, - turn: (turn_snapshot) - }) - | . + { - command: $command, - workflow: $workflow, - kickoff: $kickoff, - phase: "start" - } - | del(.openclaw, .quick_action_by_id, .quick_action_prompts_by_id) - ' <<<"$start_json")" - - printf '%s\n' "$kickoff_json" -} - -cmd_workflow_dual() { - local workspace="" - local implement_assistant="" - local implement_prompt="${OPENCLAW_DX_IMPLEMENT_PROMPT:-Identify the highest-impact technical-debt item in this workspace, implement the fix, run targeted validation, and summarize changed files plus remaining risks.}" - local review_assistant="${OPENCLAW_DX_REVIEW_ASSISTANT:-codex}" - local review_prompt="${OPENCLAW_DX_REVIEW_PROMPT:-Review current uncommitted changes. Return findings first ordered by severity with file references, then residual risks and test gaps.}" - local auto_continue_impl="${OPENCLAW_DX_DUAL_AUTO_CONTINUE_IMPL:-true}" - local auto_continue_impl_prompt="${OPENCLAW_DX_DUAL_AUTO_CONTINUE_PROMPT:-Continue using the safest option and report status plus next action.}" - local implement_needs_input_retry="${OPENCLAW_DX_IMPLEMENT_NEEDS_INPUT_RETRY:-true}" - local implement_needs_input_fallback_assistant="${OPENCLAW_DX_IMPLEMENT_NEEDS_INPUT_FALLBACK_ASSISTANT:-codex}" - local review_needs_input_retry="${OPENCLAW_DX_REVIEW_NEEDS_INPUT_RETRY:-true}" - local review_needs_input_fallback_assistant="${OPENCLAW_DX_REVIEW_NEEDS_INPUT_FALLBACK_ASSISTANT:-codex}" - local max_steps="${OPENCLAW_DX_MAX_STEPS:-3}" - local turn_budget="${OPENCLAW_DX_TURN_BUDGET:-180}" - local wait_timeout="${OPENCLAW_DX_WAIT_TIMEOUT:-60s}" - local idle_threshold="${OPENCLAW_DX_IDLE_THRESHOLD:-10s}" - local progress_stderr="${OPENCLAW_DX_PROGRESS_STDERR:-true}" - local dual_started_at - dual_started_at="$(date +%s)" - - dx_dual_progress() { - local message="$1" - if [[ "$progress_stderr" == "false" ]]; then - return - fi - local now elapsed - now="$(date +%s)" - elapsed="$((now - dual_started_at))" - printf '[openclaw-dx][workflow dual][%ss] %s\n' "$elapsed" "$message" >&2 - } - - while [[ $# -gt 0 ]]; do - case "$1" in - --workspace) - workspace="$2"; shift 2 ;; - --implement-assistant) - implement_assistant="$2"; shift 2 ;; - --implement-prompt) - shift - if [[ $# -eq 0 ]]; then - emit_error "workflow.dual" "command_error" "missing value for --implement-prompt" - return - fi - implement_prompt="$1"; shift - while [[ $# -gt 0 && "$1" != --* ]]; do - implement_prompt+=" $1" - shift - done - ;; - --review-assistant) - review_assistant="$2"; shift 2 ;; - --review-prompt) - shift - if [[ $# -eq 0 ]]; then - emit_error "workflow.dual" "command_error" "missing value for --review-prompt" - return - fi - review_prompt="$1"; shift - while [[ $# -gt 0 && "$1" != --* ]]; do - review_prompt+=" $1" - shift - done - ;; - --max-steps) - max_steps="$2"; shift 2 ;; - --turn-budget) - turn_budget="$2"; shift 2 ;; - --wait-timeout) - wait_timeout="$2"; shift 2 ;; - --idle-threshold) - idle_threshold="$2"; shift 2 ;; - --auto-continue-impl) - auto_continue_impl="$2"; shift 2 ;; - --no-auto-continue-impl) - auto_continue_impl="false"; shift ;; - --auto-continue-impl-prompt) - shift - if [[ $# -eq 0 ]]; then - emit_error "workflow.dual" "command_error" "missing value for --auto-continue-impl-prompt" - return - fi - auto_continue_impl_prompt="$1"; shift - while [[ $# -gt 0 && "$1" != --* ]]; do - auto_continue_impl_prompt+=" $1" - shift - done - ;; - *) - emit_error "workflow.dual" "command_error" "unknown flag" "$1" - return - ;; - esac - done - - local auto_continue_impl_lc - auto_continue_impl_lc="$(printf '%s' "$auto_continue_impl" | tr '[:upper:]' '[:lower:]')" - case "$auto_continue_impl_lc" in - true|1|yes|on) - auto_continue_impl="true" - ;; - false|0|no|off) - auto_continue_impl="false" - ;; - *) - auto_continue_impl="true" - ;; - esac - - workspace="$(context_resolve_workspace "$workspace")" - if [[ -z "$workspace" ]]; then - emit_error "workflow.dual" "command_error" "missing required flag: --workspace (or set active context workspace)" - return - fi - if ! workspace_require_exists "workflow.dual" "$workspace"; then - return - fi - - if [[ -z "$implement_assistant" ]]; then - implement_assistant="$(default_assistant_for_workspace "$workspace")" - fi - if [[ -z "$implement_assistant" ]]; then - implement_assistant="$(context_assistant_hint "$workspace")" - fi - if [[ -z "$implement_assistant" ]]; then - implement_assistant="codex" - fi - if ! assistant_require_known "workflow.dual" "$implement_assistant"; then - return - fi - if [[ -z "$review_assistant" ]]; then - review_assistant="codex" - fi - if ! assistant_require_known "workflow.dual" "$review_assistant"; then - return - fi - - local implement_args=( - start - --workspace "$workspace" - --assistant "$implement_assistant" - --prompt "$implement_prompt" - --max-steps "$max_steps" - --turn-budget "$turn_budget" - --wait-timeout "$wait_timeout" - --idle-threshold "$idle_threshold" - ) - - local implementation_json - dx_dual_progress "implementation phase starting (assistant=$implement_assistant workspace=$workspace)" - if ! implementation_json="$(run_self_json "${implement_args[@]}")"; then - dx_dual_progress "implementation phase failed to execute" - emit_error "workflow.dual" "command_error" "failed to run implementation phase" "${implement_args[*]}" - return - fi - - local impl_ok impl_status impl_summary impl_next impl_cmd - local effective_implement_assistant - effective_implement_assistant="$implement_assistant" - impl_ok="$(jq -r '.ok // false' <<<"$implementation_json")" - impl_status="$(jq -r '.overall_status // .status // "unknown"' <<<"$implementation_json")" - impl_summary="$(jq -r '.summary // ""' <<<"$implementation_json")" - impl_next="$(jq -r '.next_action // ""' <<<"$implementation_json")" - impl_cmd="$(jq -r '.suggested_command // ""' <<<"$implementation_json")" - dx_dual_progress "implementation phase finished (status=$impl_status)" - - if [[ "$implement_needs_input_retry" != "false" ]] \ - && [[ "$impl_status" == "needs_input" ]] \ - && [[ -n "${implement_needs_input_fallback_assistant// }" ]] \ - && [[ "$implement_needs_input_fallback_assistant" != "$effective_implement_assistant" ]]; then - dx_dual_progress "implementation needs input; retrying with fallback assistant=$implement_needs_input_fallback_assistant" - local impl_retry_args impl_retry_json - impl_retry_args=( - start - --workspace "$workspace" - --assistant "$implement_needs_input_fallback_assistant" - --prompt "$implement_prompt" - --max-steps "$max_steps" - --turn-budget "$turn_budget" - --wait-timeout "$wait_timeout" - --idle-threshold "$idle_threshold" - ) - if impl_retry_json="$(run_self_json "${impl_retry_args[@]}")"; then - implementation_json="$impl_retry_json" - impl_ok="$(jq -r '.ok // false' <<<"$implementation_json")" - impl_status="$(jq -r '.overall_status // .status // "unknown"' <<<"$implementation_json")" - impl_summary="$(jq -r '.summary // ""' <<<"$implementation_json")" - impl_next="$(jq -r '.next_action // ""' <<<"$implementation_json")" - impl_cmd="$(jq -r '.suggested_command // ""' <<<"$implementation_json")" - effective_implement_assistant="$implement_needs_input_fallback_assistant" - dx_dual_progress "fallback implementation finished (status=$impl_status assistant=$effective_implement_assistant)" - fi - fi - - if [[ "$auto_continue_impl" == "true" ]] \ - && [[ "$impl_ok" == "true" ]] \ - && [[ "$impl_status" == "needs_input" ]]; then - local impl_agent_id - impl_agent_id="$(jq -r '.agent_id // ""' <<<"$implementation_json")" - if [[ -n "${impl_agent_id// }" ]] && [[ -x "$STEP_SCRIPT_PATH" ]]; then - dx_dual_progress "implementation needs input; auto-continuing once" - local impl_auto_json - impl_auto_json="$("$STEP_SCRIPT_PATH" send \ - --agent "$impl_agent_id" \ - --text "$auto_continue_impl_prompt" \ - --enter \ - --wait-timeout "$wait_timeout" \ - --idle-threshold "$idle_threshold" 2>&1 || true)" - if jq -e . >/dev/null 2>&1 <<<"$impl_auto_json"; then - implementation_json="$impl_auto_json" - impl_ok="$(jq -r '.ok // false' <<<"$implementation_json")" - impl_status="$(jq -r '.overall_status // .status // "unknown"' <<<"$implementation_json")" - impl_summary="$(jq -r '.summary // ""' <<<"$implementation_json")" - impl_next="$(jq -r '.next_action // ""' <<<"$implementation_json")" - impl_cmd="$(jq -r '.suggested_command // ""' <<<"$implementation_json")" - dx_dual_progress "auto-continue implementation finished (status=$impl_status)" - else - dx_dual_progress "auto-continue implementation returned non-json output" - fi - fi - fi - - local review_json='null' - local review_skipped_reason="" - local effective_review_assistant - effective_review_assistant="$review_assistant" - if [[ "$impl_ok" == "true" ]] && [[ "$impl_status" != "needs_input" ]] && [[ "$impl_status" != "session_exited" ]] && [[ "$impl_status" != "command_error" ]] && [[ "$impl_status" != "agent_error" ]]; then - local review_args=( - review - --workspace "$workspace" - --assistant "$review_assistant" - --prompt "$review_prompt" - --max-steps "$max_steps" - --turn-budget "$turn_budget" - --wait-timeout "$wait_timeout" - --idle-threshold "$idle_threshold" - ) - dx_dual_progress "review phase starting (assistant=$review_assistant workspace=$workspace)" - if ! review_json="$(run_self_json "${review_args[@]}")"; then - review_json='null' - review_skipped_reason="review_phase_failed" - dx_dual_progress "review phase failed to execute" - fi - else - review_skipped_reason="implementation_not_ready" - dx_dual_progress "review phase skipped (reason=$review_skipped_reason)" - fi - - local review_ok="false" - local review_status="skipped" - local review_summary="Review phase was skipped." - local review_next="" - local review_cmd="" - if [[ "$review_json" != "null" ]]; then - review_ok="$(jq -r '.ok // false' <<<"$review_json")" - review_status="$(jq -r '.overall_status // .status // "unknown"' <<<"$review_json")" - review_summary="$(jq -r '.summary // ""' <<<"$review_json")" - review_next="$(jq -r '.next_action // ""' <<<"$review_json")" - review_cmd="$(jq -r '.suggested_command // ""' <<<"$review_json")" - dx_dual_progress "review phase finished (status=$review_status)" - - if [[ "$review_needs_input_retry" != "false" ]] \ - && [[ "$review_status" == "needs_input" || "$review_status" == "timed_out" || "$review_status" == "partial" || "$review_status" == "partial_budget" ]] \ - && [[ -n "${review_needs_input_fallback_assistant// }" ]] \ - && [[ "$review_needs_input_fallback_assistant" != "$effective_review_assistant" ]]; then - dx_dual_progress "review returned status=$review_status; retrying with fallback assistant=$review_needs_input_fallback_assistant" - local review_retry_args review_retry_json - review_retry_args=( - review - --workspace "$workspace" - --assistant "$review_needs_input_fallback_assistant" - --prompt "$review_prompt" - --max-steps "$max_steps" - --turn-budget "$turn_budget" - --wait-timeout "$wait_timeout" - --idle-threshold "$idle_threshold" - ) - if review_retry_json="$(run_self_json "${review_retry_args[@]}")"; then - review_json="$review_retry_json" - review_ok="$(jq -r '.ok // false' <<<"$review_json")" - review_status="$(jq -r '.overall_status // .status // "unknown"' <<<"$review_json")" - review_summary="$(jq -r '.summary // ""' <<<"$review_json")" - review_next="$(jq -r '.next_action // ""' <<<"$review_json")" - review_cmd="$(jq -r '.suggested_command // ""' <<<"$review_json")" - effective_review_assistant="$review_needs_input_fallback_assistant" - dx_dual_progress "fallback review finished (status=$review_status assistant=$effective_review_assistant)" - fi - fi - fi - - RESULT_OK=true - RESULT_COMMAND="workflow.dual" - RESULT_STATUS="ok" - RESULT_SUMMARY="Dual-pass finished: implement=$impl_status review=$review_status" - RESULT_NEXT_ACTION="Ship or continue implementation based on review findings." - RESULT_SUGGESTED_COMMAND="skills/amux/scripts/openclaw-dx.sh git ship --workspace $(shell_quote "$workspace")" - local codex_continue_cmd - codex_continue_cmd="skills/amux/scripts/openclaw-dx.sh start --workspace $(shell_quote "$workspace") --assistant codex --prompt \"Continue from current state and provide concise status plus next action.\"" - local impl_needs_input_prefers_codex=false - - if [[ "$impl_ok" != "true" ]]; then - RESULT_STATUS="attention" - RESULT_SUMMARY="Implementation phase failed." - RESULT_NEXT_ACTION="${impl_next:-Fix implementation blockers and rerun dual workflow.}" - if [[ -n "$impl_cmd" ]]; then - RESULT_SUGGESTED_COMMAND="$impl_cmd" - else - RESULT_SUGGESTED_COMMAND="skills/amux/scripts/openclaw-dx.sh start --workspace $(shell_quote "$workspace") --assistant $(shell_quote "$implement_assistant") --prompt $(shell_quote "$implement_prompt")" - fi - elif [[ "$impl_status" == "needs_input" ]]; then - RESULT_STATUS="needs_input" - RESULT_SUMMARY="Implementation needs input before review can run." - RESULT_NEXT_ACTION="${impl_next:-Reply to implementation prompt first.}" - if [[ "$implement_assistant" != "codex" ]] && { [[ -z "$impl_cmd" ]] || [[ "$impl_cmd" == *"openclaw-step.sh send --agent"* ]] || [[ "$impl_cmd" == *"openclaw-step.sh send --agent"* ]]; }; then - impl_needs_input_prefers_codex=true - RESULT_SUGGESTED_COMMAND="$codex_continue_cmd" - elif [[ -n "$impl_cmd" ]]; then - RESULT_SUGGESTED_COMMAND="$impl_cmd" - else - RESULT_SUGGESTED_COMMAND="$codex_continue_cmd" - fi - elif [[ "$impl_status" == "session_exited" || "$impl_status" == "command_error" || "$impl_status" == "agent_error" ]]; then - RESULT_STATUS="attention" - RESULT_SUMMARY="Implementation ended early with status: $impl_status" - RESULT_NEXT_ACTION="${impl_next:-Restart implementation and continue.}" - if [[ -n "$impl_cmd" ]]; then - RESULT_SUGGESTED_COMMAND="$impl_cmd" - fi - elif [[ "$impl_status" == "timed_out" || "$impl_status" == "partial" || "$impl_status" == "partial_budget" ]]; then - RESULT_STATUS="attention" - RESULT_SUMMARY="Implementation returned partial progress (status: $impl_status)." - RESULT_NEXT_ACTION="${impl_next:-Continue implementation to completion, then rerun review if needed.}" - if [[ -n "$impl_cmd" ]]; then - RESULT_SUGGESTED_COMMAND="$impl_cmd" - fi - elif [[ "$review_json" == "null" ]]; then - RESULT_STATUS="attention" - RESULT_SUMMARY="Implementation finished, but review phase did not run." - RESULT_NEXT_ACTION="Run review to validate uncommitted changes." - RESULT_SUGGESTED_COMMAND="skills/amux/scripts/openclaw-dx.sh review --workspace $(shell_quote "$workspace") --assistant $(shell_quote "$effective_review_assistant")" - elif [[ "$review_ok" != "true" ]]; then - RESULT_STATUS="attention" - RESULT_SUMMARY="Review phase failed." - RESULT_NEXT_ACTION="${review_next:-Rerun review and inspect failures.}" - if [[ -n "$review_cmd" ]]; then - RESULT_SUGGESTED_COMMAND="$review_cmd" - fi - elif [[ "$review_status" == "needs_input" ]]; then - RESULT_STATUS="needs_input" - RESULT_SUMMARY="Review needs input." - RESULT_NEXT_ACTION="${review_next:-Reply to review prompt first.}" - if [[ -n "$review_cmd" ]]; then - RESULT_SUGGESTED_COMMAND="$review_cmd" - fi - elif [[ "$review_status" == "session_exited" || "$review_status" == "command_error" || "$review_status" == "agent_error" ]]; then - RESULT_STATUS="attention" - RESULT_SUMMARY="Review ended early with status: $review_status" - RESULT_NEXT_ACTION="${review_next:-Rerun review or continue implementation.}" - if [[ -n "$review_cmd" ]]; then - RESULT_SUGGESTED_COMMAND="$review_cmd" - fi - elif [[ "$review_status" == "timed_out" || "$review_status" == "partial" || "$review_status" == "partial_budget" ]]; then - RESULT_STATUS="attention" - RESULT_SUMMARY="Review returned partial progress." - RESULT_NEXT_ACTION="${review_next:-Continue review for a full pass.}" - if [[ -n "$review_cmd" ]]; then - RESULT_SUGGESTED_COMMAND="$review_cmd" - fi - fi - - local actions='[]' - if [[ "$impl_status" == "needs_input" && "$impl_needs_input_prefers_codex" == "true" ]]; then - actions="$(append_action "$actions" "switch_codex" "Switch Codex" "$codex_continue_cmd" "danger" "Switch to a non-interactive implementation assistant")" - elif [[ "$impl_status" == "needs_input" && -n "$impl_cmd" ]]; then - actions="$(append_action "$actions" "continue_impl" "Continue Impl" "$impl_cmd" "danger" "Reply to implementation prompt")" - elif [[ "$impl_status" == "needs_input" ]]; then - actions="$(append_action "$actions" "switch_codex" "Switch Codex" "$codex_continue_cmd" "danger" "Switch to a non-interactive implementation assistant")" - fi - if [[ "$review_json" == "null" && "$review_skipped_reason" != "implementation_not_ready" ]]; then - actions="$(append_action "$actions" "run_review" "Run Review" "skills/amux/scripts/openclaw-dx.sh review --workspace $(shell_quote "$workspace") --assistant $(shell_quote "$effective_review_assistant")" "primary" "Run review phase now")" - elif [[ "$review_status" == "needs_input" && -n "$review_cmd" ]]; then - actions="$(append_action "$actions" "continue_review" "Continue Review" "$review_cmd" "danger" "Reply to review prompt")" - elif [[ ("$review_status" == "timed_out" || "$review_status" == "partial" || "$review_status" == "partial_budget") && -n "$review_cmd" ]]; then - actions="$(append_action "$actions" "finish_review" "Finish Review" "$review_cmd" "primary" "Continue review to completion")" - fi - actions="$(append_action "$actions" "status" "Status" "skills/amux/scripts/openclaw-dx.sh status --workspace $(shell_quote "$workspace")" "primary" "Check workspace status")" - actions="$(append_action "$actions" "alerts" "Alerts" "skills/amux/scripts/openclaw-dx.sh alerts --workspace $(shell_quote "$workspace")" "primary" "Check blocking alerts")" - if [[ "$RESULT_STATUS" == "ok" ]]; then - actions="$(append_action "$actions" "ship" "Ship" "skills/amux/scripts/openclaw-dx.sh git ship --workspace $(shell_quote "$workspace")" "success" "Commit current changes")" - fi - RESULT_QUICK_ACTIONS="$actions" - - local implementation_compact review_compact - implementation_compact="$(jq -c '{ - ok, command, workflow, status, overall_status, summary, next_action, suggested_command, - agent_id, workspace_id, assistant, steps_used, max_steps, elapsed_seconds, progress_percent, - quick_actions - }' <<<"$(normalize_json_or_default "$implementation_json" '{}')" 2>/dev/null || printf '{}')" - review_compact="$(jq -c ' - if . == null then - null - else - { - ok, command, workflow, status, overall_status, summary, next_action, suggested_command, - agent_id, workspace_id, assistant, steps_used, max_steps, elapsed_seconds, progress_percent, - quick_actions - } - end - ' <<<"$(normalize_json_or_default "$review_json" 'null')" 2>/dev/null || printf 'null')" - - RESULT_DATA="$(jq -cn \ - --arg workspace "$workspace" \ - --arg implement_assistant "$effective_implement_assistant" \ - --arg review_assistant "$effective_review_assistant" \ - --arg review_skipped_reason "$review_skipped_reason" \ - --argjson implementation "$implementation_compact" \ - --argjson review "$review_compact" \ - '{ - workspace: $workspace, - implement_assistant: $implement_assistant, - review_assistant: $review_assistant, - review_skipped_reason: $review_skipped_reason, - implementation: $implementation, - review: $review - }')" - - RESULT_MESSAGE="✅ Dual-pass workflow completed"$'\n'"Workspace: $workspace" - RESULT_MESSAGE+=$'\n'"Implement ($effective_implement_assistant): $impl_status" - if [[ -n "${impl_summary// }" ]]; then - RESULT_MESSAGE+=$'\n'" $impl_summary" - fi - if [[ "$review_json" == "null" ]]; then - RESULT_MESSAGE+=$'\n'"Review ($effective_review_assistant): skipped" - else - RESULT_MESSAGE+=$'\n'"Review ($effective_review_assistant): $review_status" - if [[ -n "${review_summary// }" ]]; then - RESULT_MESSAGE+=$'\n'" $review_summary" - fi - fi - RESULT_MESSAGE+=$'\n'"Next: $RESULT_NEXT_ACTION" - - emit_result -} - -cmd_workflow() { - if [[ $# -lt 1 ]]; then - emit_error "workflow" "command_error" "missing workflow subcommand" - return - fi - - local sub="$1" - shift - case "$sub" in - kickoff) - cmd_workflow_kickoff "$@" - ;; - dual) - cmd_workflow_dual "$@" - ;; - *) - emit_error "workflow" "command_error" "unknown workflow subcommand" "$sub" - ;; - esac -} - -cmd_assistants() { - local config_path - config_path="${AMUX_HOME:-$HOME/.amux}/config.json" - local workspace="" - local probe=false - local limit="${OPENCLAW_DX_ASSISTANTS_LIMIT:-6}" - local probe_prompt="${OPENCLAW_DX_ASSISTANTS_PROBE_PROMPT:-Reply in one line with READY and the top current objective for this workspace.}" - local max_steps="${OPENCLAW_DX_MAX_STEPS:-2}" - local turn_budget="${OPENCLAW_DX_TURN_BUDGET:-150}" - local wait_timeout="${OPENCLAW_DX_WAIT_TIMEOUT:-45s}" - local idle_threshold="${OPENCLAW_DX_IDLE_THRESHOLD:-8s}" - - while [[ $# -gt 0 ]]; do - case "$1" in - --workspace) - workspace="$2"; shift 2 ;; - --probe) - probe=true; shift ;; - --limit) - limit="$2"; shift 2 ;; - --prompt) - shift - if [[ $# -eq 0 ]]; then - emit_error "assistants" "command_error" "missing value for --prompt" - return - fi - probe_prompt="$1"; shift - while [[ $# -gt 0 && "$1" != --* ]]; do - probe_prompt+=" $1" - shift - done - ;; - --max-steps) - max_steps="$2"; shift 2 ;; - --turn-budget) - turn_budget="$2"; shift 2 ;; - --wait-timeout) - wait_timeout="$2"; shift 2 ;; - --idle-threshold) - idle_threshold="$2"; shift 2 ;; - *) - emit_error "assistants" "command_error" "unknown flag" "$1" - return - ;; - esac - done - - if ! is_positive_int "$limit"; then - limit=6 - fi - workspace="$(context_resolve_workspace "$workspace")" - if [[ "$probe" == "true" && -z "$workspace" ]]; then - emit_error "assistants" "command_error" "--probe requires --workspace (or active context workspace)" - return - fi - if [[ "$probe" == "true" ]]; then - if ! workspace_require_exists "assistants" "$workspace"; then - return - fi - fi - - local assistant_cmds - assistant_cmds='{"claude":"claude","codex":"codex","gemini":"gemini","amp":"amp","opencode":"opencode","droid":"droid","cline":"cline","cursor":"agent","pi":"pi"}' - - if [[ -f "$config_path" ]] && jq -e . >/dev/null 2>&1 <"$config_path"; then - while IFS=$'\t' read -r id cmd; do - [[ -z "$id" ]] && continue - if [[ -n "${cmd// }" ]]; then - assistant_cmds="$(jq -cn --argjson cmds "$assistant_cmds" --arg id "$id" --arg command "$cmd" '$cmds + {($id): $command}')" - fi - done < <(jq -r '.assistants // {} | to_entries[] | "\(.key)\t\(.value.command // "")"' "$config_path") - fi - - local names - names="$(jq -r 'keys[]' <<<"$assistant_cmds" | sort)" - - local assistants='[]' - local ready_count=0 - local missing_count=0 - - while IFS= read -r name; do - [[ -z "$name" ]] && continue - local cmd bin_path binary status - cmd="$(jq -r --arg name "$name" '.[$name] // ""' <<<"$assistant_cmds")" - binary="$(printf '%s\n' "$cmd" | awk '{print $1}')" - bin_path="" - status="missing" - if [[ -n "$binary" ]]; then - bin_path="$(command -v "$binary" 2>/dev/null || true)" - if [[ -n "$bin_path" ]]; then - status="ready" - fi - fi - - if [[ "$status" == "ready" ]]; then - ready_count=$((ready_count + 1)) - else - missing_count=$((missing_count + 1)) - fi - - assistants="$(jq -cn --argjson list "$assistants" --arg name "$name" --arg command "$cmd" --arg binary "$binary" --arg path "$bin_path" --arg status "$status" '$list + [{name: $name, command: $command, binary: $binary, path: $path, status: $status}]')" - done <<<"$names" - - local probe_results='[]' - local probe_passed=0 - local probe_needs_input=0 - local probe_failed=0 - local probe_count=0 - - if [[ "$probe" == "true" ]]; then - if [[ ! -x "$TURN_SCRIPT" ]]; then - emit_error "assistants" "command_error" "turn script is not executable" "$TURN_SCRIPT" - return - fi - while IFS= read -r ready_name; do - [[ -z "$ready_name" ]] && continue - if [[ "$probe_count" -ge "$limit" ]]; then - break - fi - - local turn_json turn_ok turn_status turn_overall turn_summary normalized_result - turn_json="$(OPENCLAW_TURN_SKIP_PRESENT=true "$TURN_SCRIPT" run \ - --workspace "$workspace" \ - --assistant "$ready_name" \ - --prompt "$probe_prompt" \ - --max-steps "$max_steps" \ - --turn-budget "$turn_budget" \ - --wait-timeout "$wait_timeout" \ - --idle-threshold "$idle_threshold" 2>&1 || true)" - - turn_ok="false" - turn_status="command_error" - turn_overall="command_error" - turn_summary="assistant probe failed" - - if jq -e . >/dev/null 2>&1 <<<"$turn_json"; then - turn_ok="$(jq -r '.ok // false' <<<"$turn_json")" - turn_status="$(jq -r '.status // "unknown"' <<<"$turn_json")" - turn_overall="$(jq -r '.overall_status // .status // "unknown"' <<<"$turn_json")" - turn_summary="$(jq -r '.summary // ""' <<<"$turn_json")" - else - turn_summary="$turn_json" - fi - - normalized_result="failed" - if [[ "$turn_ok" == "true" && ( "$turn_overall" == "completed" || "$turn_status" == "idle" ) ]]; then - normalized_result="passed" - probe_passed=$((probe_passed + 1)) - elif [[ "$turn_overall" == "needs_input" || "$turn_status" == "needs_input" ]]; then - normalized_result="needs_input" - probe_needs_input=$((probe_needs_input + 1)) - else - probe_failed=$((probe_failed + 1)) - fi - - probe_results="$(jq -cn --argjson probes "$probe_results" --arg assistant "$ready_name" --arg result "$normalized_result" --arg status "$turn_status" --arg overall_status "$turn_overall" --arg summary "$turn_summary" '$probes + [{assistant: $assistant, result: $result, status: $status, overall_status: $overall_status, summary: $summary}]')" - probe_count=$((probe_count + 1)) - done < <(jq -r '.[] | select(.status == "ready") | .name' <<<"$assistants") - fi - - local overall_status="ok" - if [[ "$missing_count" -gt 0 ]]; then - overall_status="attention" - fi - if [[ "$probe_failed" -gt 0 ]]; then - overall_status="attention" - fi - if [[ "$probe_needs_input" -gt 0 ]]; then - overall_status="needs_input" - fi - - local lines - lines="$(jq -r '. | map((if .status == "ready" then "- ✅ " else "- ⚠️ " end) + .name + " → " + .command) | join("\n")' <<<"$assistants")" - local probe_lines - probe_lines="$(jq -r '. | map("- " + (if .result == "passed" then "✅ " elif .result == "needs_input" then "❓ " else "⚠️ " end) + .assistant + ": " + (.summary // .overall_status // .status)) | join("\n")' <<<"$probe_results")" - - local first_ready claude_ready codex_ready first_probe_passed claude_probe_passed codex_probe_passed - first_ready="$(jq -r '.[] | select(.status == "ready") | .name' <<<"$assistants" | head -n 1)" - claude_ready="$(jq -r '[.[] | select(.name == "claude" and .status == "ready")] | length' <<<"$assistants")" - codex_ready="$(jq -r '[.[] | select(.name == "codex" and .status == "ready")] | length' <<<"$assistants")" - first_probe_passed="$(jq -r '.[] | select(.result == "passed") | .assistant' <<<"$probe_results" | head -n 1)" - claude_probe_passed="$(jq -r '[.[] | select(.assistant == "claude" and .result == "passed")] | length' <<<"$probe_results")" - codex_probe_passed="$(jq -r '[.[] | select(.assistant == "codex" and .result == "passed")] | length' <<<"$probe_results")" - - RESULT_OK=true - RESULT_COMMAND="assistants" - RESULT_STATUS="$overall_status" - RESULT_SUMMARY="$ready_count ready, $missing_count missing" - if [[ "$probe" == "true" ]]; then - RESULT_SUMMARY+=", probe: $probe_passed passed, $probe_needs_input needs input, $probe_failed failed" - fi - RESULT_NEXT_ACTION="Use ready assistants for implementation/review handoffs." - if [[ "$missing_count" -gt 0 ]]; then - RESULT_NEXT_ACTION="Install or remap missing assistant binaries in ~/.amux/config.json." - fi - if [[ "$probe_needs_input" -gt 0 ]]; then - RESULT_NEXT_ACTION="Some assistants need interactive permission input. Use codex for non-interactive mobile flows." - elif [[ "$probe_failed" -gt 0 ]]; then - RESULT_NEXT_ACTION="Investigate failing assistant probes before relying on those assistants." - fi - - local dual_ready=false - if [[ "$probe" == "true" ]]; then - if [[ "$claude_probe_passed" -gt 0 && "$codex_probe_passed" -gt 0 ]]; then - dual_ready=true - fi - elif [[ "$claude_ready" -gt 0 && "$codex_ready" -gt 0 ]]; then - dual_ready=true - fi - - local preferred_assistant="" - if [[ "$probe" == "true" ]]; then - if [[ "$codex_probe_passed" -gt 0 ]]; then - preferred_assistant="codex" - elif [[ -n "$first_probe_passed" ]]; then - preferred_assistant="$first_probe_passed" - fi - elif [[ -n "$first_ready" ]]; then - preferred_assistant="$first_ready" - fi - - RESULT_SUGGESTED_COMMAND="skills/amux/scripts/openclaw-dx.sh status" - if [[ -n "$workspace" && "$dual_ready" == "true" ]]; then - RESULT_SUGGESTED_COMMAND="skills/amux/scripts/openclaw-dx.sh workflow dual --workspace $(shell_quote "$workspace") --implement-assistant claude --review-assistant codex" - elif [[ -n "$workspace" && -n "$preferred_assistant" ]]; then - RESULT_SUGGESTED_COMMAND="skills/amux/scripts/openclaw-dx.sh start --workspace $(shell_quote "$workspace") --assistant $(shell_quote "$preferred_assistant") --prompt \"Summarize current status and next action in one line.\"" - elif [[ -n "$workspace" && -n "$first_ready" ]]; then - RESULT_SUGGESTED_COMMAND="skills/amux/scripts/openclaw-dx.sh start --workspace $(shell_quote "$workspace") --assistant $(shell_quote "$first_ready") --prompt \"Summarize current status and next action in one line.\"" - fi - - local actions='[]' - actions="$(append_action "$actions" "status" "Status" "skills/amux/scripts/openclaw-dx.sh status" "primary" "Show current work/agent status")" - actions="$(append_action "$actions" "review" "Review" "skills/amux/scripts/openclaw-dx.sh review --workspace --assistant codex" "primary" "Run a review workflow")" - if [[ "$probe" != "true" && -n "$workspace" ]]; then - actions="$(append_action "$actions" "probe" "Probe" "skills/amux/scripts/openclaw-dx.sh assistants --workspace $(shell_quote "$workspace") --probe --limit $(shell_quote "$limit")" "primary" "Run readiness probes for ready assistants")" - fi - if [[ -n "$workspace" && "$dual_ready" == "true" ]]; then - actions="$(append_action "$actions" "dual" "Dual Pass" "skills/amux/scripts/openclaw-dx.sh workflow dual --workspace $(shell_quote "$workspace") --implement-assistant claude --review-assistant codex" "success" "Implement with claude and review with codex")" - elif [[ -n "$workspace" && -n "$preferred_assistant" ]]; then - actions="$(append_action "$actions" "start_ready" "Start Ready" "skills/amux/scripts/openclaw-dx.sh start --workspace $(shell_quote "$workspace") --assistant $(shell_quote "$preferred_assistant") --prompt \"Summarize current status and next action in one line.\"" "primary" "Start with best probe-passed assistant")" - elif [[ -n "$workspace" && -n "$first_ready" ]]; then - actions="$(append_action "$actions" "start_ready" "Start Ready" "skills/amux/scripts/openclaw-dx.sh start --workspace $(shell_quote "$workspace") --assistant $(shell_quote "$first_ready") --prompt \"Summarize current status and next action in one line.\"" "primary" "Start with first ready assistant")" - fi - RESULT_QUICK_ACTIONS="$actions" - - RESULT_DATA="$(jq -cn \ - --arg config_path "$config_path" \ - --arg workspace "$workspace" \ - --argjson probe "$probe" \ - --argjson limit "$limit" \ - --argjson ready_count "$ready_count" \ - --argjson missing_count "$missing_count" \ - --argjson probe_count "$probe_count" \ - --argjson probe_passed "$probe_passed" \ - --argjson probe_needs_input "$probe_needs_input" \ - --argjson probe_failed "$probe_failed" \ - --argjson assistants "$assistants" \ - --argjson probes "$probe_results" \ - '{ - config_path: $config_path, - workspace: $workspace, - probe: $probe, - limit: $limit, - ready_count: $ready_count, - missing_count: $missing_count, - probe_count: $probe_count, - probe_passed: $probe_passed, - probe_needs_input: $probe_needs_input, - probe_failed: $probe_failed, - assistants: $assistants, - probes: $probes - }')" - - RESULT_MESSAGE="$(if [[ "$overall_status" == "ok" ]]; then printf '✅'; else printf '⚠️'; fi) Assistant readiness: $ready_count ready, $missing_count missing" - if [[ "$probe" == "true" ]]; then - RESULT_MESSAGE+=$'\n'"Probe: passed=$probe_passed needs_input=$probe_needs_input failed=$probe_failed" - fi - if [[ -n "${lines// }" ]]; then - RESULT_MESSAGE+=$'\n'"$lines" - fi - if [[ "$probe" == "true" ]] && [[ -n "${probe_lines// }" ]]; then - RESULT_MESSAGE+=$'\n'"Probes:"$'\n'"$probe_lines" - fi - RESULT_MESSAGE+=$'\n'"Next: $RESULT_NEXT_ACTION" - emit_result -} - -require_prereqs() { - if ! command -v jq >/dev/null 2>&1; then - printf '{"ok":false,"command":"unknown","status":"command_error","summary":"jq is required","error":"missing binary: jq"}\n' - exit 0 - fi - if ! command -v amux >/dev/null 2>&1; then - printf '{"ok":false,"command":"unknown","status":"command_error","summary":"amux is required","error":"missing binary: amux"}\n' - exit 0 - fi -} - -if [[ $# -lt 1 ]]; then - usage - emit_error "help" "command_error" "missing command" - exit 0 -fi - -require_prereqs - -SCRIPT_SOURCE="${BASH_SOURCE[0]:-$0}" -SCRIPT_DIR="$(cd "$(dirname "$SCRIPT_SOURCE")" >/dev/null 2>&1 && pwd -P)" -SCRIPT_PATH="$SCRIPT_DIR/$(basename "$SCRIPT_SOURCE")" - -TURN_SCRIPT="${OPENCLAW_DX_TURN_SCRIPT:-$SCRIPT_DIR/openclaw-turn.sh}" -if [[ ! -x "$TURN_SCRIPT" ]]; then - TURN_SCRIPT="$SCRIPT_DIR/openclaw-turn.sh" -fi -SELF_SCRIPT="${OPENCLAW_DX_SELF_SCRIPT:-$SCRIPT_DIR/openclaw-dx.sh}" -if [[ ! -x "$SELF_SCRIPT" ]]; then - SELF_SCRIPT="$SCRIPT_PATH" -fi -STEP_SCRIPT_PATH="${OPENCLAW_DX_STEP_SCRIPT:-$SCRIPT_DIR/openclaw-step.sh}" -if [[ ! -x "$STEP_SCRIPT_PATH" ]]; then - STEP_SCRIPT_PATH="$SCRIPT_DIR/openclaw-step.sh" -fi -OPENCLAW_PRESENT_SCRIPT="${OPENCLAW_PRESENT_SCRIPT:-$SCRIPT_DIR/openclaw-present.sh}" - -DX_CMD_REF="${OPENCLAW_DX_CMD_REF:-skills/amux/scripts/openclaw-dx.sh}" -TURN_CMD_REF="${OPENCLAW_DX_TURN_CMD_REF:-skills/amux/scripts/openclaw-turn.sh}" -STEP_CMD_REF="${OPENCLAW_DX_STEP_CMD_REF:-skills/amux/scripts/openclaw-step.sh}" - -top_cmd="$1" -shift - -case "$top_cmd" in - project) - if [[ $# -lt 1 ]]; then - emit_error "project" "command_error" "missing project subcommand" - exit 0 - fi - sub="$1" - shift - case "$sub" in - add) - cmd_project_add "$@" - ;; - list|ls) - cmd_project_list "$@" - ;; - pick) - cmd_project_pick "$@" - ;; - *) - emit_error "project" "command_error" "unknown project subcommand" "$sub" - ;; - esac - ;; - workspace) - if [[ $# -lt 1 ]]; then - emit_error "workspace" "command_error" "missing workspace subcommand" - exit 0 - fi - sub="$1" - shift - case "$sub" in - create) - cmd_workspace_create "$@" - ;; - list|ls) - cmd_workspace_list "$@" - ;; - decide) - cmd_workspace_decide "$@" - ;; - *) - emit_error "workspace" "command_error" "unknown workspace subcommand" "$sub" - ;; - esac - ;; - start) - cmd_start "$@" - ;; - continue) - cmd_continue "$@" - ;; - status) - cmd_status "$@" - ;; - alerts) - cmd_alerts "$@" - ;; - terminal) - if [[ $# -lt 1 ]]; then - emit_error "terminal" "command_error" "missing terminal subcommand" - exit 0 - fi - sub="$1" - shift - case "$sub" in - run) - cmd_terminal_run "$@" - ;; - preset) - cmd_terminal_preset "$@" - ;; - logs) - cmd_terminal_logs "$@" - ;; - *) - emit_error "terminal" "command_error" "unknown terminal subcommand" "$sub" - ;; - esac - ;; - cleanup) - cmd_cleanup "$@" - ;; - review) - cmd_review "$@" - ;; - guide) - cmd_guide "$@" - ;; - git) - if [[ $# -lt 1 ]]; then - emit_error "git" "command_error" "missing git subcommand" - exit 0 - fi - sub="$1" - shift - case "$sub" in - ship) - cmd_git_ship "$@" - ;; - *) - emit_error "git" "command_error" "unknown git subcommand" "$sub" - ;; - esac - ;; - workflow) - cmd_workflow "$@" - ;; - assistants) - cmd_assistants "$@" - ;; - help|-h|--help) - usage - RESULT_OK=true - RESULT_COMMAND="help" - RESULT_STATUS="ok" - RESULT_SUMMARY="openclaw-dx help" - RESULT_MESSAGE="ℹ️ openclaw-dx help printed to stderr" - RESULT_DATA='{}' - RESULT_QUICK_ACTIONS='[]' - emit_result - ;; - *) - emit_error "unknown" "command_error" "unknown command" "$top_cmd" - ;; -esac - -exit 0 diff --git a/skills/amux/scripts/openclaw-present.sh b/skills/amux/scripts/openclaw-present.sh deleted file mode 100755 index 6095f5dd..00000000 --- a/skills/amux/scripts/openclaw-present.sh +++ /dev/null @@ -1,279 +0,0 @@ -#!/usr/bin/env bash -# openclaw-present.sh — augment amux chat payloads with channel-neutral render data. -# -# Reads one JSON object from stdin and emits the same object plus: -# - normalized quick action ids (`action_id`) -# - `quick_action_by_id` and `quick_action_prompts_by_id` -# - `.openclaw` with channel-specific presentation payloads - -set -euo pipefail - -if ! command -v jq >/dev/null 2>&1; then - cat - exit 0 -fi - -TMP_INPUT="$(mktemp "${TMPDIR:-/tmp}/openclaw-present.XXXXXX")" -cleanup_tmp() { - rm -f "$TMP_INPUT" >/dev/null 2>&1 || true -} -trap cleanup_tmp EXIT - -cat >"$TMP_INPUT" -if [[ ! -s "$TMP_INPUT" ]]; then - cat "$TMP_INPUT" - exit 0 -fi - -if ! grep -q '[^[:space:]]' "$TMP_INPUT"; then - cat "$TMP_INPUT" - exit 0 -fi - -if ! jq -e . >/dev/null 2>&1 <"$TMP_INPUT"; then - cat "$TMP_INPUT" - exit 0 -fi - -TARGET_CHANNEL="${OPENCLAW_CHANNEL:-}" - -jq -c --arg target_channel "$TARGET_CHANNEL" ' - def normalize_style($style): - if $style == "success" or $style == "danger" or $style == "primary" then - $style - else - "primary" - end; - def sanitize_action_id($raw; $idx): - (($raw // ("action_" + (($idx + 1) | tostring))) | tostring | ascii_downcase - | gsub("[^a-z0-9:_-]"; "_") - | gsub("_+"; "_") - | .[0:64] - ) as $id - | if ($id | length) == 0 then - ("action_" + (($idx + 1) | tostring)) - else - $id - end; - def sanitize_callback_data($raw; $action_id): - (($raw // ("qa:" + $action_id)) | tostring | .[0:64]); - def normalize_actions($actions): - ($actions // []) - | to_entries - | map( - .key as $idx - | (.value // {}) as $action - | (sanitize_action_id(($action.action_id // $action.id // $action.callback_data); $idx)) as $action_id - | { - id: (($action.id // $action_id) | tostring), - action_id: $action_id, - label: (($action.label // "Action") | tostring), - command: (($action.command // "") | tostring), - style: normalize_style(($action.style // "primary")), - prompt: (($action.prompt // "") | tostring), - callback_data: sanitize_callback_data(($action.callback_data // null); $action_id) - } - ); - def action_rows($actions; $size): - if ($actions | length) == 0 then - [] - else - [range(0; ($actions | length); $size) as $idx - | ($actions[$idx:($idx + $size)]) - ] - end; - def action_fallback($actions): - if ($actions | length) == 0 then - "" - else - "Actions: " - + ($actions | map((.action_id // .id) + "=" + (.label // "")) | join(" | ")) - end; - def text_payload($message; $chunks; $chunks_meta; $actions): - { - message: $message, - chunks: $chunks, - chunks_meta: $chunks_meta, - actions: $actions, - action_tokens: ($actions | map(.action_id)), - actions_fallback: action_fallback($actions) - }; - def slack_style($style): - if $style == "danger" then - "danger" - elif $style == "success" then - "primary" - else - "" - end; - def discord_style($style): - if $style == "primary" then - 1 - elif $style == "success" then - 3 - elif $style == "danger" then - 4 - else - 2 - end; - def normalize_target_channel($raw): - (($raw // "") | ascii_downcase | gsub("[^a-z0-9_-]"; "")) as $id - | if $id == "teams" then "msteams" else $id end; - (normalize_actions(.quick_actions // [])) as $quick_actions - | ((.channel.message // .message // .summary // "") | tostring) as $message - | ((.channel.chunks_meta // []) | if length > 0 then . else [{index: 1, total: 1, text: $message}] end) as $chunks_meta - | ($chunks_meta | map(.text)) as $chunks - | (text_payload($message; $chunks; $chunks_meta; $quick_actions)) as $base - | ((.channel // {}) as $channel - | def build_presentation($channel_id): - if $channel_id == "telegram" then - $channel + { - message: ($channel.message // $message), - chunks: ($channel.chunks // $chunks), - chunks_meta: ($channel.chunks_meta // $chunks_meta), - callback_data_max_bytes: ($channel.callback_data_max_bytes // 64), - inline_buttons: ( - if $channel.inline_buttons != null then - $channel.inline_buttons - elif ($channel.inline_buttons_enabled // true) then - ( - action_rows($quick_actions; 2) - | map(map({text: .label, callback_data: .callback_data, style: .style})) - ) - else - [] - end - ), - action_tokens: ($channel.action_tokens // ($quick_actions | map(.callback_data))), - actions_fallback: ($channel.actions_fallback // action_fallback($quick_actions)) - } - elif $channel_id == "slack" then - $base + { - blocks: ( - if ($quick_actions | length) == 0 then - [] - else - [ - { - type: "actions", - elements: ( - $quick_actions[0:5] - | map( - { - type: "button", - text: {type: "plain_text", text: (.label[0:75])}, - value: .action_id, - action_id: .action_id, - style: slack_style(.style) - } - | if .style == "" then del(.style) else . end - ) - ) - } - ] - end - ) - } - elif $channel_id == "discord" then - $base + { - components: ( - if ($quick_actions | length) == 0 then - [] - else - [ - { - type: 1, - components: ( - $quick_actions[0:5] - | map({ - type: 2, - style: discord_style(.style), - label: (.label[0:80]), - custom_id: .action_id - }) - ) - } - ] - end - ) - } - elif $channel_id == "msteams" then - $base + { - suggested_actions: ( - $quick_actions - | map({type: "imBack", title: (.label[0:80]), value: (.command // "")}) - ) - } - elif $channel_id == "webchat" then - $base + { - quick_replies: ( - $quick_actions - | map({id: .action_id, label: .label, value: .command}) - ) - } - else - $base - end; - [ - "generic", - "telegram", - "slack", - "discord", - "msteams", - "webchat", - "whatsapp", - "signal", - "line", - "googlechat", - "mattermost", - "matrix", - "irc", - "feishu", - "nextcloud_talk", - "nostr", - "tlon", - "twitch", - "zalo", - "zalouser", - "bluebubbles", - "imessage" - ] as $supported_channels - | (normalize_target_channel($target_channel)) as $preferred - | ( - if ($preferred | length) > 0 and (($supported_channels | index($preferred)) != null) then - $preferred - else - "generic" - end - ) as $selected_channel - | (build_presentation($selected_channel)) as $selected_presentation - | ( - if $selected_channel == "generic" then - {generic: $base} - else - {generic: $base, ($selected_channel): $selected_presentation} - end - ) as $channels - | .quick_actions = $quick_actions - | .quick_action_by_id = ($quick_actions | map({key: .action_id, value: .command}) | from_entries) - | .quick_action_prompts_by_id = ( - $quick_actions - | map({key: .action_id, value: .prompt}) - | from_entries - ) - | .openclaw = { - schema_version: "amux.openclaw.channel-ux.v1", - supported_channels: $supported_channels, - target_channel: $preferred, - selected_channel: $selected_channel, - channels: $channels, - presentation: $selected_presentation, - actions: { - list: $quick_actions, - map: ($quick_actions | map({key: .action_id, value: .command}) | from_entries), - prompts: ($quick_actions | map({key: .action_id, value: .prompt}) | from_entries), - fallback: action_fallback($quick_actions) - } - } - ) -' "$TMP_INPUT" diff --git a/skills/amux/scripts/openclaw-step.sh b/skills/amux/scripts/openclaw-step.sh deleted file mode 100755 index d3b4a5cf..00000000 --- a/skills/amux/scripts/openclaw-step.sh +++ /dev/null @@ -1,1277 +0,0 @@ -#!/usr/bin/env bash -# openclaw-step.sh — Bounded amux step runner for chat/orchestrator flows. -# -# Usage: -# openclaw-step.sh run --workspace --assistant --prompt [--wait-timeout 60s] [--idle-threshold 10s] -# openclaw-step.sh send --agent --text [--enter] [--wait-timeout 60s] [--idle-threshold 10s] -# -# Emits a normalized JSON object for easy chat orchestration: -# { -# "ok": true|false, -# "mode": "run"|"send", -# "status": "idle|needs_input|timed_out|session_exited|command_error|agent_error", -# "summary": "...", -# ... -# } -# -# Notes: -# - Always performs exactly one bounded --wait step. -# - Uses short, bounded internal recovery polling only when a timeout returns no visible output. -# - Surfaces permission-mode input gates with explicit hints. - -set -euo pipefail - -shell_quote() { - printf '%q' "$1" -} - -SCRIPT_SOURCE="${BASH_SOURCE[0]:-$0}" -SCRIPT_DIR="$(cd "$(dirname "$SCRIPT_SOURCE")" >/dev/null 2>&1 && pwd -P)" -SCRIPT_PATH="$SCRIPT_DIR/$(basename "$SCRIPT_SOURCE")" -STEP_SCRIPT_REF="${OPENCLAW_STEP_CMD_REF:-skills/amux/scripts/openclaw-step.sh}" -STEP_SCRIPT_CMD="$(shell_quote "$STEP_SCRIPT_REF")" -OPENCLAW_PRESENT_SCRIPT="${OPENCLAW_PRESENT_SCRIPT:-$SCRIPT_DIR/openclaw-present.sh}" - -usage() { - cat >&2 <<'EOF' -Usage: - openclaw-step.sh run --workspace --assistant --prompt [--wait-timeout 60s] [--idle-threshold 10s] - openclaw-step.sh send --agent --text [--enter] [--wait-timeout 60s] [--idle-threshold 10s] -EOF -} - -duration_to_seconds() { - local value="$1" - local fallback="$2" - if [[ "$value" =~ ^[[:space:]]*([0-9]+)[[:space:]]*$ ]]; then - echo "${BASH_REMATCH[1]}" - return - fi - if [[ "$value" =~ ^[[:space:]]*([0-9]+)s[[:space:]]*$ ]]; then - echo "${BASH_REMATCH[1]}" - return - fi - if [[ "$value" =~ ^[[:space:]]*([0-9]+)m[[:space:]]*$ ]]; then - echo "$(( ${BASH_REMATCH[1]} * 60 ))" - return - fi - if [[ "$value" =~ ^[[:space:]]*([0-9]+)h[[:space:]]*$ ]]; then - echo "$(( ${BASH_REMATCH[1]} * 3600 ))" - return - fi - echo "$fallback" -} - -hash_text() { - local value="$1" - if command -v sha256sum >/dev/null 2>&1; then - printf '%s' "$value" | sha256sum | awk '{print $1}' - return - fi - if command -v shasum >/dev/null 2>&1; then - printf '%s' "$value" | shasum -a 256 | awk '{print $1}' - return - fi - if command -v openssl >/dev/null 2>&1; then - printf '%s' "$value" | openssl dgst -sha256 -r | awk '{print $1}' - return - fi - # Last-resort fallback if hash tools are unavailable. - printf '%s' "$value" | awk '{print length($0)}' -} - -json_escape() { - if command -v jq >/dev/null 2>&1; then - printf '%s' "$1" | jq -Rsa . - return - fi - local escaped - escaped="$(printf '%s' "$1" | sed -e 's/\\/\\\\/g' -e 's/"/\\"/g' -e 's/\r/\\r/g' -e ':a;N;$!ba;s/\n/\\n/g')" - printf '"%s"' "$escaped" -} - -run_with_deadline() { - local timeout_sec="$1" - shift - local out_file - local exit_file - out_file="$(mktemp -t openclaw-step.out.XXXXXX)" - exit_file="$(mktemp -t openclaw-step.exit.XXXXXX)" - - local cmd_pid - local watchdog_pid - local wait_status - local recorded_exit - - ( - set +e - "$@" >"$out_file" 2>&1 - cmd_status=$? - printf '%s' "$cmd_status" >"$exit_file" - exit 0 - ) & - cmd_pid=$! - - ( - sleep "$timeout_sec" - if kill -0 "$cmd_pid" 2>/dev/null; then - kill -TERM "$cmd_pid" 2>/dev/null || true - sleep 2 - kill -KILL "$cmd_pid" 2>/dev/null || true - fi - ) >/dev/null 2>&1 & - watchdog_pid=$! - - set +e - wait "$cmd_pid" 2>/dev/null - wait_status=$? - set -e - kill "$watchdog_pid" >/dev/null 2>&1 || true - set +e - wait "$watchdog_pid" >/dev/null 2>&1 - set -e - - RAW_OUTPUT="$(cat "$out_file" 2>/dev/null || true)" - recorded_exit="" - if [[ -s "$exit_file" ]]; then - recorded_exit="$(cat "$exit_file" 2>/dev/null || true)" - fi - rm -f "$out_file" "$exit_file" - - if [[ -z "$recorded_exit" ]]; then - COMMAND_TIMED_OUT=true - CMD_EXIT="$wait_status" - return - fi - - COMMAND_TIMED_OUT=false - CMD_EXIT="$recorded_exit" -} - -print_json_error() { - local mode="$1" - local status="$2" - local summary="$3" - local detail="$4" - printf '{' - printf '"ok":false,' - printf '"mode":%s,' "$(json_escape "$mode")" - printf '"status":%s,' "$(json_escape "$status")" - printf '"summary":%s,' "$(json_escape "$summary")" - printf '"error":%s' "$(json_escape "$detail")" - printf '}\n' -} - -strip_ansi_text() { - local input="$1" - printf '%s' "$input" | sed \ - -e 's/\x1b\[[0-9;]*[a-zA-Z]//g' \ - -e 's/\x1b\][^\x07]*\x07//g' \ - -e 's/\x1b\][^\x1b]*\x1b\\//g' \ - -e 's/\x1b[()][0-9A-B]//g' \ - -e 's/\x1b[=>]//g' \ - -e 's/\r//g' -} - -trim_line() { - local line="$1" - line="${line#"${line%%[![:space:]]*}"}" - line="${line%"${line##*[![:space:]]}"}" - printf '%s' "$line" -} - -is_chrome_line() { - local line="$1" - case "$line" in - ""|"|"|✻|"╭"*|"╰"*|"│"*|"─"*|"└ "*|"⎿ "*|"↳ Interacted with "*|"› "*|"❯ "*|"? for shortcuts"*|"✶ "*|"✻ "*|"▟"*|"▐"*|"▝"*|"▘"*|"Tip:"*|"model:"*|"directory:"*|"cwd:"*|"workspace:"*|"• Explored"|"• Exploring"|"• Working ("*|"Working ("*|"Thinking "*|*" no sandbox "*|*"/model "*|"~/.amux/"*|*"sandbox "*|*"sandbox "*")"|"shift+tab to accept edits"*|"/ commands · @ files · ! shell"*|*"? for help"*|*"▄▄▄▄"*|*"███"*|*"▀▀▀"*|"> Type your message or @path/to/file"*) - return 0 - ;; - *) - return 1 - ;; - esac -} - -extract_latest_useful_line() { - local raw="$1" - local cleaned line - local latest="" - cleaned="$(strip_ansi_text "$raw")" - while IFS= read -r line; do - line="$(trim_line "$line")" - if [[ -z "$line" ]]; then - continue - fi - if is_chrome_line "$line"; then - continue - fi - latest="$line" - done < <(printf '%s\n' "$cleaned") - if [[ -n "${latest:-}" ]]; then - printf '%s' "$latest" - return - fi - - # Fallback to the last non-empty trimmed line, even if chrome. - while IFS= read -r line; do - line="$(trim_line "$line")" - if [[ -z "$line" ]]; then - continue - fi - latest="$line" - done < <(printf '%s\n' "$cleaned") - printf '%s' "${latest:-}" -} - -compact_agent_text() { - local raw="$1" - local cleaned line out - out="" - cleaned="$(strip_ansi_text "$raw")" - while IFS= read -r line; do - line="$(trim_line "$line")" - if [[ -z "$line" ]]; then - continue - fi - if is_chrome_line "$line"; then - continue - fi - if [[ -n "$out" ]]; then - out+=$'\n' - fi - out+="$line" - done < <(printf '%s\n' "$cleaned") - printf '%s' "$out" -} - -last_nonempty_line() { - local raw="$1" - local line last - last="" - while IFS= read -r line; do - line="$(trim_line "$line")" - if [[ -z "$line" ]]; then - continue - fi - last="$line" - done < <(printf '%s\n' "$raw") - printf '%s' "$last" -} - -is_agent_progress_line() { - local line="$1" - case "$line" in - "Search "*|"Read "*|"List "*|"Working "*|"Thinking "*|"• I "*|"• I'll "*|"• I’ll "*|"If you want"*|"No explicit TODO/FIXME debt markers were found"*) - return 0 - ;; - *) - return 1 - ;; - esac -} - -is_jsonish_fragment() { - local line="$1" - local trimmed - trimmed="$(trim_line "$line")" - case "$trimmed" in - "- {"*|"{"*) - if [[ "$trimmed" == *'":"'* || "$trimmed" == *"{\""* || "$trimmed" == *",\""* ]]; then - return 0 - fi - ;; - esac - return 1 -} - -is_wrapped_fragment_line() { - local line="$1" - local trimmed - trimmed="$(trim_line "$line")" - if [[ -z "$trimmed" ]]; then - return 1 - fi - case "$trimmed" in - "- "*|"• "*) - return 1 - ;; - esac - if [[ "$trimmed" =~ ^[a-z0-9] ]] && line_has_file_signal "$trimmed"; then - return 0 - fi - if [[ "$trimmed" =~ ^[a-z0-9] ]] && [[ "$trimmed" == *"): "* ]]; then - return 0 - fi - return 1 -} - -is_file_only_bullet() { - local line="$1" - local trimmed value - trimmed="$(trim_line "$line")" - case "$trimmed" in - "- "*|"• "*) - ;; - *) - return 1 - ;; - esac - value="${trimmed#- }" - value="${value#• }" - value="$(trim_line "$value")" - if [[ -z "$value" || "$value" == *" "* || "$value" == *":"* ]]; then - return 1 - fi - case "$value" in - *".go"|*".md"|*".sh"|*".py"|*".ts"|*".tsx"|*".js"|*".jsx"|*".json"|*".yaml"|*".yml"|*".toml"|*"Makefile"|*"/"*) - return 0 - ;; - esac - return 1 -} - -summary_is_weak() { - local summary="$1" - local trimmed lower - trimmed="$(trim_line "$summary")" - if [[ -z "$trimmed" ]]; then - return 0 - fi - if [[ "${#trimmed}" -lt 24 ]]; then - return 0 - fi - lower="$(printf '%s' "$trimmed" | tr '[:upper:]' '[:lower:]')" - case "$lower" in - "output tracking."|"effort to fix these."|"these."|"done"|"done."|"ok"|"ok."|"complete"|"complete.") - return 0 - ;; - esac - return 1 -} - -line_has_file_signal() { - local value="$1" - case "$value" in - *".go"*|*".md"*|*".sh"*|*"internal/"*|*"cmd/"*|*"skills/"*|*"README."*|*"Makefile"*) - return 0 - ;; - *) - return 1 - ;; - esac -} - -extract_delta_summary_candidate() { - local raw="$1" - local line candidate fragment - candidate="" - fragment="" - while IFS= read -r line; do - line="$(trim_line "$line")" - if [[ -z "$line" ]]; then - continue - fi - if is_agent_progress_line "$line"; then - continue - fi - if is_jsonish_fragment "$line"; then - continue - fi - if is_wrapped_fragment_line "$line"; then - if [[ -z "$candidate" ]]; then - candidate="$line" - fi - fragment="$line" - continue - fi - if [[ -n "$fragment" && "$line" != "- "* && "$line" != "• "* ]]; then - fragment="" - fi - if [[ -n "$fragment" && ( "$line" == "- "* || "$line" == "• "* ) ]]; then - line="$(trim_line "$line $fragment")" - if [[ "$line" == *":" ]]; then - if [[ -z "$candidate" ]]; then - candidate="$line" - fi - continue - fi - printf '%s' "$line" - return - fi - if [[ "$line" == "- "* || "$line" == "• "* ]]; then - if [[ "$line" == *"/"* || "$line" == *".go"* || "$line" == *".md"* || "$line" == *".sh"* || "$line" == *":"* ]]; then - if is_file_only_bullet "$line"; then - if [[ -z "$candidate" ]]; then - candidate="$line" - fi - continue - fi - if [[ "$line" == *":" ]]; then - if [[ -z "$candidate" ]]; then - candidate="$line" - fi - continue - fi - printf '%s' "$line" - return - fi - if [[ -z "$candidate" ]]; then - candidate="$line" - fi - continue - fi - if [[ -z "$candidate" && "${#line}" -ge 32 ]]; then - candidate="$line" - fi - done < <(printf '%s\n' "$raw" | awk 'NF { lines[++n]=$0 } END { for (i=n; i>=1; i--) print lines[i] }') - if [[ -n "$candidate" && "$candidate" == *":" ]]; then - candidate="$(trim_line "${candidate%:}")" - fi - printf '%s' "$candidate" -} - -sanitize_summary_text() { - local raw="$1" - local text lower - text="$(trim_line "$(strip_ansi_text "$raw")")" - if [[ -z "${text// }" ]]; then - printf '' - return - fi - - # Drop known hosted-UI landing chrome that sometimes leaks into captures. - lower="$(printf '%s' "$text" | tr '[:upper:]' '[:lower:]')" - case "$lower" in - *"visit https://chatgpt.com/codex"*|*"app-landing-page=true"*|*"continue in your browser"*) - printf '' - return - ;; - esac - if is_jsonish_fragment "$text"; then - printf '' - return - fi - - # Trim escaped/plain JSON tails that can leak from tool/event payload fragments. - text="$(printf '%s' "$text" | sed -E \ - -e 's/\\",[[:space:]]*\\\"(ok|mode|status|summary|latest_line|next_action|suggested_command|agent_id|workspace_id|assistant|message|delta|needs_input|input_hint|timed_out|session_exited|changed|response|data|error)\\\"[[:space:]]*:[[:space:]].*$//' \ - -e 's/",[[:space:]]*"(ok|mode|status|summary|latest_line|next_action|suggested_command|agent_id|workspace_id|assistant|message|delta|needs_input|input_hint|timed_out|session_exited|changed|response|data|error)"[[:space:]]*:[[:space:]].*$//' \ - -e 's/[[:space:]]+$//')" - text="$(trim_line "$text")" - if is_chrome_line "$text"; then - printf '' - return - fi - printf '%s' "$text" -} - -build_delta_excerpt() { - local raw="$1" - local max_lines="$2" - if ! [[ "$max_lines" =~ ^[0-9]+$ ]] || [[ "$max_lines" -le 0 ]]; then - max_lines=3 - fi - printf '%s\n' "$raw" | awk -v max_lines="$max_lines" ' - function trim(s) { - sub(/^[[:space:]]+/, "", s) - sub(/[[:space:]]+$/, "", s) - return s - } - { - line = trim($0) - if (line == "") next - if (line ~ /^(Search |Read |List |Working |Thinking )/) next - if (line ~ /^• I[[:space:]]/) next - lines[++n] = line - } - END { - if (n == 0) exit - start = n - max_lines + 1 - if (start < 1) start = 1 - for (i = start; i <= n; i++) print lines[i] - if (start > 1) print "..." - } - ' -} - -normalize_verbosity_level() { - local value="${1:-normal}" - case "$value" in - quiet|normal|detailed) - printf '%s' "$value" - ;; - *) - printf 'normal' - ;; - esac -} - -normalize_inline_buttons_scope() { - local value="${1:-allowlist}" - case "$value" in - off|dm|group|all|allowlist) - printf '%s' "$value" - ;; - *) - printf 'allowlist' - ;; - esac -} - -redact_secrets_text() { - local input="$1" - # Best-effort masking for common token/key patterns before OpenClaw delivery. - printf '%s' "$input" | sed -E \ - -e 's/(sk-ant-api[0-9]*-[A-Za-z0-9_-]{10})[A-Za-z0-9_-]*/\1***/g' \ - -e 's/(sk-[A-Za-z0-9_-]{20})[A-Za-z0-9_-]*/\1***/g' \ - -e 's/(ghp_[A-Za-z0-9]{5})[A-Za-z0-9]*/\1***/g' \ - -e 's/(gho_[A-Za-z0-9]{5})[A-Za-z0-9]*/\1***/g' \ - -e 's/(github_pat_[A-Za-z0-9_]{5})[A-Za-z0-9_]*/\1***/g' \ - -e 's/(ghs_[A-Za-z0-9]{5})[A-Za-z0-9]*/\1***/g' \ - -e 's/(glpat-[A-Za-z0-9_-]{5})[A-Za-z0-9_-]*/\1***/g' \ - -e 's/(xoxb-[A-Za-z0-9]{5})[A-Za-z0-9-]*/\1***/g' \ - -e 's/(AKIA[0-9A-Z]{4})[0-9A-Z]{12}/\1************/g' \ - -e 's/(Bearer )[A-Za-z0-9+/_=.-]{8,}/\1***/g' \ - -e 's/((TOKEN|SECRET|PASSWORD|API_KEY|APIKEY|AUTH_TOKEN|PRIVATE_KEY|ACCESS_KEY|CLIENT_SECRET|WEBHOOK_SECRET)=)[^[:space:]'"'"'"]{8,}/\1***/g' -} - -if [[ $# -lt 1 ]]; then - usage - exit 2 -fi - -MODE="$1" -shift - -WAIT_TIMEOUT="60s" -IDLE_THRESHOLD="10s" -IDEMPOTENCY_KEY="" - -WORKSPACE="" -ASSISTANT="" -PROMPT="" - -AGENT_ID="" -TEXT="" -ENTER=false - -while [[ $# -gt 0 ]]; do - case "$1" in - --wait-timeout) - WAIT_TIMEOUT="$2"; shift 2 ;; - --idle-threshold) - IDLE_THRESHOLD="$2"; shift 2 ;; - --idempotency-key) - IDEMPOTENCY_KEY="$2"; shift 2 ;; - --workspace) - WORKSPACE="$2"; shift 2 ;; - --assistant) - ASSISTANT="$2"; shift 2 ;; - --prompt) - PROMPT="$2"; shift 2 ;; - --agent) - AGENT_ID="$2"; shift 2 ;; - --text) - TEXT="$2"; shift 2 ;; - --enter) - ENTER=true; shift ;; - *) - usage - print_json_error "$MODE" "command_error" "Invalid flag" "unknown flag: $1" - exit 2 ;; - esac -done - -AUTO_IDEMPOTENCY="${OPENCLAW_STEP_AUTO_IDEMPOTENCY:-true}" -if [[ -z "$IDEMPOTENCY_KEY" && "$AUTO_IDEMPOTENCY" != "false" ]]; then - idempotency_base="$MODE|$WAIT_TIMEOUT|$IDLE_THRESHOLD|$WORKSPACE|$ASSISTANT|$PROMPT|$AGENT_ID|$TEXT|$ENTER" - idempotency_hash="$(hash_text "$idempotency_base")" - IDEMPOTENCY_KEY="tgstep-${idempotency_hash:0:20}" -fi - -if ! command -v amux >/dev/null 2>&1; then - print_json_error "$MODE" "command_error" "amux is not installed" "missing binary: amux" - exit 127 -fi - -if ! command -v jq >/dev/null 2>&1; then - print_json_error "$MODE" "command_error" "jq is required" "missing binary: jq" - exit 127 -fi - -cmd=(amux --json) -case "$MODE" in - run) - if [[ -z "$WORKSPACE" || -z "$ASSISTANT" || -z "$PROMPT" ]]; then - usage - print_json_error "$MODE" "command_error" "Missing required flags" "run requires --workspace, --assistant, --prompt" - exit 2 - fi - cmd+=(agent run --workspace "$WORKSPACE" --assistant "$ASSISTANT" --prompt "$PROMPT" --wait --wait-timeout "$WAIT_TIMEOUT" --idle-threshold "$IDLE_THRESHOLD") - ;; - send) - if [[ -z "$AGENT_ID" || -z "$TEXT" ]]; then - usage - print_json_error "$MODE" "command_error" "Missing required flags" "send requires --agent and --text" - exit 2 - fi - cmd+=(agent send --agent "$AGENT_ID" --text "$TEXT" --wait --wait-timeout "$WAIT_TIMEOUT" --idle-threshold "$IDLE_THRESHOLD") - if [[ "$ENTER" == "true" ]]; then - cmd+=(--enter) - fi - ;; - *) - usage - print_json_error "$MODE" "command_error" "Invalid mode" "mode must be run or send" - exit 2 - ;; -esac - -if [[ -n "$IDEMPOTENCY_KEY" ]]; then - cmd+=(--idempotency-key "$IDEMPOTENCY_KEY") -fi - -WAIT_TIMEOUT_SECONDS="$(duration_to_seconds "$WAIT_TIMEOUT" 60)" -# Allow bounded extra headroom for agent startup/prompt readiness. -HARD_TIMEOUT_BUFFER_SECONDS="$(duration_to_seconds "${OPENCLAW_STEP_HARD_TIMEOUT_BUFFER:-180}" 180)" -if ! [[ "$HARD_TIMEOUT_BUFFER_SECONDS" =~ ^[0-9]+$ ]] || [[ "$HARD_TIMEOUT_BUFFER_SECONDS" -lt 0 ]]; then - HARD_TIMEOUT_BUFFER_SECONDS=180 -fi -HARD_TIMEOUT_SECONDS=$((WAIT_TIMEOUT_SECONDS + HARD_TIMEOUT_BUFFER_SECONDS)) -HARD_TIMEOUT_CAP_SECONDS="$(duration_to_seconds "${OPENCLAW_STEP_HARD_TIMEOUT_CAP:-600}" 600)" -if [[ "$HARD_TIMEOUT_CAP_SECONDS" -gt 0 && "$HARD_TIMEOUT_SECONDS" -gt "$HARD_TIMEOUT_CAP_SECONDS" ]]; then - HARD_TIMEOUT_SECONDS="$HARD_TIMEOUT_CAP_SECONDS" -fi -RAW_OUTPUT="" -CMD_EXIT=0 -COMMAND_TIMED_OUT=false -run_with_deadline "$HARD_TIMEOUT_SECONDS" "${cmd[@]}" - -if [[ "$COMMAND_TIMED_OUT" == "true" ]]; then - detail="hard timeout (${HARD_TIMEOUT_SECONDS}s) exceeded while running amux step" - if [[ -n "${RAW_OUTPUT// }" ]]; then - detail="$detail"$'\n'"$RAW_OUTPUT" - fi - print_json_error "$MODE" "command_error" "amux command exceeded hard timeout" "$detail" - exit 124 -fi - -if [[ $CMD_EXIT -ne 0 ]]; then - print_json_error "$MODE" "command_error" "amux command failed" "$RAW_OUTPUT" - exit "$CMD_EXIT" -fi - -if ! jq -e . >/dev/null 2>&1 <<<"$RAW_OUTPUT"; then - print_json_error "$MODE" "command_error" "amux returned non-JSON output" "$RAW_OUTPUT" - exit 65 -fi - -OK="$(jq -r '.ok // false' <<<"$RAW_OUTPUT")" -if [[ "$OK" != "true" ]]; then - ERR_CODE="$(jq -r '.error.code // "unknown_error"' <<<"$RAW_OUTPUT")" - ERR_MSG="$(jq -r '.error.message // "agent step failed"' <<<"$RAW_OUTPUT")" - print_json_error "$MODE" "agent_error" "$ERR_CODE" "$ERR_MSG" - exit 1 -fi - -STATUS="$(jq -r '.data.response.status // "unknown"' <<<"$RAW_OUTPUT")" -SESSION_NAME="$(jq -r '.data.session_name // ""' <<<"$RAW_OUTPUT")" -AGENT_ID_OUT="$(jq -r '.data.agent_id // ""' <<<"$RAW_OUTPUT")" -WORKSPACE_ID_OUT="$(jq -r '.data.workspace_id // .data.id // ""' <<<"$RAW_OUTPUT")" -ASSISTANT_OUT="$(jq -r '.data.assistant // ""' <<<"$RAW_OUTPUT")" -LATEST_LINE="$(jq -r '.data.response.latest_line // ""' <<<"$RAW_OUTPUT")" -RESPONSE_SUMMARY="$(jq -r '.data.response.summary // ""' <<<"$RAW_OUTPUT")" -DELTA="$(jq -r '.data.response.delta // ""' <<<"$RAW_OUTPUT")" -NEEDS_INPUT="$(jq -r '.data.response.needs_input // false' <<<"$RAW_OUTPUT")" -INPUT_HINT="$(jq -r '.data.response.input_hint // ""' <<<"$RAW_OUTPUT")" -TIMED_OUT="$(jq -r '.data.response.timed_out // false' <<<"$RAW_OUTPUT")" -SESSION_EXITED="$(jq -r '.data.response.session_exited // false' <<<"$RAW_OUTPUT")" -CHANGED="$(jq -r '.data.response.changed // false' <<<"$RAW_OUTPUT")" - -# `agent send` responses may omit workspace_id. Derive it from agent id when possible. -if [[ -z "${WORKSPACE_ID_OUT// }" ]]; then - if [[ -n "${AGENT_ID_OUT// }" && "$AGENT_ID_OUT" == *:* ]]; then - WORKSPACE_ID_OUT="${AGENT_ID_OUT%%:*}" - elif [[ -n "${AGENT_ID// }" && "$AGENT_ID" == *:* ]]; then - WORKSPACE_ID_OUT="${AGENT_ID%%:*}" - fi -fi - -if [[ "$STATUS" == "timed_out" ]]; then - if [[ "$LATEST_LINE" == "(no output yet)" ]]; then - LATEST_LINE="" - fi - if [[ "$RESPONSE_SUMMARY" == "(no output yet)" ]]; then - RESPONSE_SUMMARY="" - fi -fi - -LATEST_LINE="$(redact_secrets_text "$LATEST_LINE")" -RESPONSE_SUMMARY="$(redact_secrets_text "$RESPONSE_SUMMARY")" -DELTA="$(redact_secrets_text "$DELTA")" -INPUT_HINT="$(redact_secrets_text "$INPUT_HINT")" - -LATEST_LINE_TRIMMED="$(trim_line "$LATEST_LINE")" -if is_chrome_line "$LATEST_LINE_TRIMMED"; then - LATEST_LINE="" -fi -RESPONSE_SUMMARY_TRIMMED="$(trim_line "$RESPONSE_SUMMARY")" -if is_chrome_line "$RESPONSE_SUMMARY_TRIMMED"; then - RESPONSE_SUMMARY="" -fi - -DELTA_COMPACT="$(compact_agent_text "$DELTA")" -SUMMARY="$RESPONSE_SUMMARY" -if [[ -z "${SUMMARY// }" ]]; then - SUMMARY="$LATEST_LINE" -fi -if [[ -z "${SUMMARY// }" && -n "${DELTA_COMPACT// }" ]]; then - SUMMARY="$(last_nonempty_line "$DELTA_COMPACT")" -fi -SUMMARY_TRIMMED="$(trim_line "$SUMMARY")" -if is_chrome_line "$SUMMARY_TRIMMED"; then - SUMMARY="" -fi -if [[ -z "${LATEST_LINE// }" && -n "${DELTA_COMPACT// }" ]]; then - LATEST_LINE="$(last_nonempty_line "$DELTA_COMPACT")" -fi -if [[ -z "${RESPONSE_SUMMARY// }" && -n "${SUMMARY// }" ]]; then - RESPONSE_SUMMARY="$SUMMARY" -fi - -SUBSTANTIVE_OUTPUT=false -if [[ -n "${SUMMARY// }" || -n "${LATEST_LINE// }" || -n "${DELTA_COMPACT// }" ]]; then - SUBSTANTIVE_OUTPUT=true -fi - -# Some assistants report needs_input after producing a substantive answer, with no -# actionable input hint (or a generic conversational re-prompt). For mobile DX, -# treat these as completed step output instead of blocked state. -if [[ "$STATUS" == "needs_input" && "$NEEDS_INPUT" == "true" ]]; then - INPUT_HINT_TRIMMED="$(trim_line "$INPUT_HINT")" - INPUT_HINT_LOWER="$(printf '%s' "$INPUT_HINT_TRIMMED" | tr '[:upper:]' '[:lower:]')" - NEEDS_INPUT_IS_GENERIC=false - if [[ "$SUBSTANTIVE_OUTPUT" == "true" && -z "${INPUT_HINT_TRIMMED// }" ]]; then - NEEDS_INPUT_IS_GENERIC=true - fi - case "$INPUT_HINT_LOWER" in - "what can i do for you?"*|"anything else?"*|"how would you like to proceed?"*) - NEEDS_INPUT_IS_GENERIC=true - ;; - esac - if [[ "$NEEDS_INPUT_IS_GENERIC" == "true" ]]; then - STATUS="idle" - NEEDS_INPUT=false - INPUT_HINT="" - fi -fi - -RECOVERED_FROM_CAPTURE=false -RECOVERY_ATTEMPTED=false -RECOVERY_POLLS_USED=0 -if [[ "$STATUS" == "timed_out" && "$SUBSTANTIVE_OUTPUT" != "true" && -n "$SESSION_NAME" ]]; then - RECOVERY_ATTEMPTED=true - RECOVERY_POLLS="${OPENCLAW_STEP_TIMEOUT_RECOVERY_POLLS:-6}" - RECOVERY_INTERVAL="${OPENCLAW_STEP_TIMEOUT_RECOVERY_INTERVAL:-5}" - RECOVERY_LINES="${OPENCLAW_STEP_TIMEOUT_RECOVERY_LINES:-160}" - if ! [[ "$RECOVERY_POLLS" =~ ^[0-9]+$ ]]; then - RECOVERY_POLLS=6 - fi - if ! [[ "$RECOVERY_INTERVAL" =~ ^[0-9]+$ ]]; then - RECOVERY_INTERVAL=5 - fi - if ! [[ "$RECOVERY_LINES" =~ ^[0-9]+$ ]]; then - RECOVERY_LINES=160 - fi - - for ((i=1; i<=RECOVERY_POLLS; i++)); do - RECOVERY_POLLS_USED="$i" - if [[ "$RECOVERY_INTERVAL" -gt 0 ]]; then - sleep "$RECOVERY_INTERVAL" - fi - capture_json="$(amux --json agent capture "$SESSION_NAME" --lines "$RECOVERY_LINES" 2>/dev/null || true)" - if ! jq -e '.ok == true' >/dev/null 2>&1 <<<"$capture_json"; then - continue - fi - capture_content="$(jq -r '.data.content // ""' <<<"$capture_json")" - capture_compact="$(compact_agent_text "$capture_content")" - recovered_line="$(last_nonempty_line "$capture_compact")" - if [[ -z "${recovered_line// }" ]]; then - continue - fi - - RECOVERED_FROM_CAPTURE=true - SUBSTANTIVE_OUTPUT=true - if [[ -z "${SUMMARY// }" ]]; then - SUMMARY="$recovered_line" - fi - if [[ -z "${LATEST_LINE// }" ]]; then - LATEST_LINE="$recovered_line" - fi - if [[ -z "${RESPONSE_SUMMARY// }" ]]; then - RESPONSE_SUMMARY="$recovered_line" - fi - if [[ -z "${DELTA_COMPACT// }" ]]; then - DELTA_COMPACT="$capture_compact" - fi - if [[ -z "${DELTA// }" ]]; then - DELTA="$capture_compact" - fi - CHANGED=true - break - done -fi - -DELTA_SUMMARY_CANDIDATE="$(extract_delta_summary_candidate "$DELTA_COMPACT")" -DELTA_SUMMARY_CANDIDATE="$(sanitize_summary_text "$DELTA_SUMMARY_CANDIDATE")" -if [[ -n "${DELTA_SUMMARY_CANDIDATE// }" ]]; then - if line_has_file_signal "$DELTA_SUMMARY_CANDIDATE" && ! line_has_file_signal "$SUMMARY"; then - SUMMARY="$DELTA_SUMMARY_CANDIDATE" - elif summary_is_weak "$SUMMARY"; then - SUMMARY="$DELTA_SUMMARY_CANDIDATE" - fi - if line_has_file_signal "$DELTA_SUMMARY_CANDIDATE" && ! line_has_file_signal "$RESPONSE_SUMMARY"; then - RESPONSE_SUMMARY="$DELTA_SUMMARY_CANDIDATE" - elif summary_is_weak "$RESPONSE_SUMMARY"; then - RESPONSE_SUMMARY="$DELTA_SUMMARY_CANDIDATE" - fi - if line_has_file_signal "$DELTA_SUMMARY_CANDIDATE" && ! line_has_file_signal "$LATEST_LINE"; then - LATEST_LINE="$DELTA_SUMMARY_CANDIDATE" - elif summary_is_weak "$LATEST_LINE"; then - LATEST_LINE="$DELTA_SUMMARY_CANDIDATE" - fi -fi - -SUMMARY="$(sanitize_summary_text "$SUMMARY")" -RESPONSE_SUMMARY="$(sanitize_summary_text "$RESPONSE_SUMMARY")" -LATEST_LINE="$(sanitize_summary_text "$LATEST_LINE")" - -if [[ -z "${SUMMARY// }" ]]; then - case "$STATUS" in - timed_out) SUMMARY="Timed out waiting for first visible output; agent may still be starting." ;; - session_exited) SUMMARY="Agent session exited while waiting." ;; - needs_input) SUMMARY="Agent needs input." ;; - idle) SUMMARY="Agent step completed." ;; - *) SUMMARY="Agent step completed with status: $STATUS." ;; - esac -fi - -BLOCKED_PERMISSION_MODE=false -NEXT_ACTION="" -SUGGESTED_COMMAND="" -if [[ "$NEEDS_INPUT" == "true" && "$INPUT_HINT" == "Assistant is waiting for local permission-mode selection." ]]; then - BLOCKED_PERMISSION_MODE=true - NEXT_ACTION="Switch to a non-interactive assistant (e.g. codex) for this step." -elif [[ "$STATUS" == "timed_out" ]]; then - if [[ "$SUBSTANTIVE_OUTPUT" == "true" ]]; then - NEXT_ACTION="Send one focused follow-up prompt on the same agent and continue from the latest output." - else - NEXT_ACTION="Agent may still be starting. Run one bounded follow-up send on the same agent to force a short status update." - fi - if [[ -n "$AGENT_ID_OUT" ]]; then - SUGGESTED_COMMAND="$STEP_SCRIPT_CMD send --agent $(shell_quote "$AGENT_ID_OUT") --text \"Continue from current state and provide a one-line status update.\" --enter --wait-timeout 60s --idle-threshold 10s" - fi -elif [[ "$STATUS" == "session_exited" ]]; then - NEXT_ACTION="Restart the agent in the same workspace, then continue with a focused follow-up prompt." - if [[ -n "$WORKSPACE_ID_OUT" && -n "$ASSISTANT_OUT" ]]; then - SUGGESTED_COMMAND="$STEP_SCRIPT_CMD run --workspace $(shell_quote "$WORKSPACE_ID_OUT") --assistant $(shell_quote "$ASSISTANT_OUT") --prompt \"Continue from where you left off and provide a concise progress update.\" --wait-timeout 60s --idle-threshold 10s" - fi -elif [[ "$STATUS" == "idle" && "$SUBSTANTIVE_OUTPUT" != "true" ]]; then - NEXT_ACTION="No substantive output captured yet. Run one bounded follow-up send step on the same agent." - if [[ -n "$AGENT_ID_OUT" ]]; then - SUGGESTED_COMMAND="$STEP_SCRIPT_CMD send --agent $(shell_quote "$AGENT_ID_OUT") --text \"Provide a one-line progress status.\" --enter --wait-timeout 60s --idle-threshold 10s" - fi -elif [[ "$STATUS" == "needs_input" ]]; then - NEXT_ACTION="Ask the user to answer the pending prompt, then run one follow-up send step." -fi - -SUMMARY="$(redact_secrets_text "$SUMMARY")" -LATEST_LINE="$(redact_secrets_text "$LATEST_LINE")" -RESPONSE_SUMMARY="$(redact_secrets_text "$RESPONSE_SUMMARY")" -DELTA_COMPACT="$(redact_secrets_text "$DELTA_COMPACT")" -NEXT_ACTION="$(redact_secrets_text "$NEXT_ACTION")" -SUGGESTED_COMMAND="$(redact_secrets_text "$SUGGESTED_COMMAND")" -INPUT_HINT="$(redact_secrets_text "$INPUT_HINT")" - -STEP_VERBOSITY="$(normalize_verbosity_level "${OPENCLAW_STEP_VERBOSITY:-normal}")" -STEP_DETAIL_LINES="${OPENCLAW_STEP_DETAIL_LINES:-}" -if [[ -z "$STEP_DETAIL_LINES" ]]; then - case "$STEP_VERBOSITY" in - quiet) STEP_DETAIL_LINES=0 ;; - normal) STEP_DETAIL_LINES=3 ;; - detailed) STEP_DETAIL_LINES=8 ;; - esac -fi -if ! [[ "$STEP_DETAIL_LINES" =~ ^[0-9]+$ ]]; then - case "$STEP_VERBOSITY" in - quiet) STEP_DETAIL_LINES=0 ;; - normal) STEP_DETAIL_LINES=3 ;; - detailed) STEP_DETAIL_LINES=8 ;; - esac -fi - -TIMED_OUT_STARTUP=false -if [[ "$STATUS" == "timed_out" && "$SUBSTANTIVE_OUTPUT" != "true" ]]; then - TIMED_OUT_STARTUP=true -fi - -STATUS_EMOJI="ℹ️" -case "$STATUS" in - idle) STATUS_EMOJI="✅" ;; - needs_input) STATUS_EMOJI="❓" ;; - timed_out) STATUS_EMOJI="⏱️" ;; - session_exited) STATUS_EMOJI="🛑" ;; -esac - -OPENCLAW_DELTA_EXCERPT="" -if [[ "$STEP_DETAIL_LINES" -gt 0 && -n "${DELTA_COMPACT// }" ]]; then - OPENCLAW_DELTA_EXCERPT="$(build_delta_excerpt "$DELTA_COMPACT" "$STEP_DETAIL_LINES")" -fi -OPENCLAW_MESSAGE="$STATUS_EMOJI $SUMMARY" -case "$STEP_VERBOSITY" in - quiet) - if [[ "$NEEDS_INPUT" == "true" && -n "${INPUT_HINT// }" ]]; then - OPENCLAW_MESSAGE+=$'\n'"Input: $INPUT_HINT" - fi - ;; - normal) - if [[ -n "${NEXT_ACTION// }" ]]; then - OPENCLAW_MESSAGE+=$'\n'"Next: $NEXT_ACTION" - fi - if [[ "$NEEDS_INPUT" == "true" && -n "${INPUT_HINT// }" ]]; then - OPENCLAW_MESSAGE+=$'\n'"Input: $INPUT_HINT" - fi - if [[ -n "${OPENCLAW_DELTA_EXCERPT// }" ]]; then - OPENCLAW_MESSAGE+=$'\n'"Details:"$'\n'"$OPENCLAW_DELTA_EXCERPT" - fi - if [[ -n "${SUGGESTED_COMMAND// }" ]]; then - OPENCLAW_MESSAGE+=$'\n'"Command: $SUGGESTED_COMMAND" - fi - ;; - detailed) - if [[ -n "${NEXT_ACTION// }" ]]; then - OPENCLAW_MESSAGE+=$'\n'"Next: $NEXT_ACTION" - fi - if [[ "$NEEDS_INPUT" == "true" && -n "${INPUT_HINT// }" ]]; then - OPENCLAW_MESSAGE+=$'\n'"Input: $INPUT_HINT" - fi - if [[ -n "${OPENCLAW_DELTA_EXCERPT// }" ]]; then - OPENCLAW_MESSAGE+=$'\n'"Details:"$'\n'"$OPENCLAW_DELTA_EXCERPT" - fi - if [[ -n "${SUGGESTED_COMMAND// }" ]]; then - OPENCLAW_MESSAGE+=$'\n'"Command: $SUGGESTED_COMMAND" - fi - OPENCLAW_MESSAGE+=$'\n'"Meta: status=$STATUS changed=$CHANGED agent=${AGENT_ID_OUT:-none} workspace=${WORKSPACE_ID_OUT:-none}" - if [[ "$RECOVERY_ATTEMPTED" == "true" ]]; then - OPENCLAW_MESSAGE+=$'\n'"Recovery: attempted=true polls=$RECOVERY_POLLS_USED" - fi - ;; -esac -OPENCLAW_CHUNK_CHARS="${OPENCLAW_STEP_CHUNK_CHARS:-1200}" -if ! [[ "$OPENCLAW_CHUNK_CHARS" =~ ^[0-9]+$ ]]; then - OPENCLAW_CHUNK_CHARS=1200 -fi -if [[ "$OPENCLAW_CHUNK_CHARS" -le 0 ]]; then - OPENCLAW_CHUNK_CHARS=1200 -fi - -INLINE_BUTTONS_SCOPE="$(normalize_inline_buttons_scope "${OPENCLAW_INLINE_BUTTONS_SCOPE:-allowlist}")" -INLINE_BUTTONS_ENABLED=true -if [[ "$INLINE_BUTTONS_SCOPE" == "off" ]]; then - INLINE_BUTTONS_ENABLED=false -fi - -CONTEXT_LOWER="$(printf '%s\n%s\n%s' "$SUMMARY" "$RESPONSE_SUMMARY" "$DELTA_COMPACT" | tr '[:upper:]' '[:lower:]')" -TEST_REMEDIATION_COMMAND="" -LINT_REMEDIATION_COMMAND="" -SECURITY_REVIEW_COMMAND="" -REVIEW_CHANGES_COMMAND="" -if [[ -n "$AGENT_ID_OUT" ]]; then - if [[ "$CONTEXT_LOWER" == *"test"* ]] && [[ "$CONTEXT_LOWER" == *"fail"* || "$CONTEXT_LOWER" == *"panic"* || "$CONTEXT_LOWER" == *"error"* ]]; then - TEST_REMEDIATION_COMMAND="$STEP_SCRIPT_CMD send --agent $(shell_quote "$AGENT_ID_OUT") --text \"Investigate failing tests, fix root causes, and report changed files plus exact test command/results.\" --enter --wait-timeout 60s --idle-threshold 10s" - fi - if [[ "$CONTEXT_LOWER" == *"lint"* || "$CONTEXT_LOWER" == *"format"* || "$CONTEXT_LOWER" == *"gofumpt"* || "$CONTEXT_LOWER" == *"style"* ]]; then - LINT_REMEDIATION_COMMAND="$STEP_SCRIPT_CMD send --agent $(shell_quote "$AGENT_ID_OUT") --text \"Resolve lint and formatting issues, then provide a concise summary of fixes.\" --enter --wait-timeout 60s --idle-threshold 10s" - fi - if [[ "$CONTEXT_LOWER" == *"secret"* || "$CONTEXT_LOWER" == *"token"* || "$CONTEXT_LOWER" == *"credential"* || "$CONTEXT_LOWER" == *"key leak"* ]]; then - SECURITY_REVIEW_COMMAND="$STEP_SCRIPT_CMD send --agent $(shell_quote "$AGENT_ID_OUT") --text \"Run a focused security pass for exposed credentials/secrets and propose concrete remediation.\" --enter --wait-timeout 60s --idle-threshold 10s" - fi - if [[ "$CHANGED" == "true" ]] && { line_has_file_signal "$SUMMARY" || line_has_file_signal "$DELTA_COMPACT" || [[ "$CONTEXT_LOWER" == *"changed file"* || "$CONTEXT_LOWER" == *"modified"* || "$CONTEXT_LOWER" == *"refactor"* || "$CONTEXT_LOWER" == *"patched"* ]]; }; then - REVIEW_CHANGES_COMMAND="$STEP_SCRIPT_CMD send --agent $(shell_quote "$AGENT_ID_OUT") --text \"Summarize changed files, rationale, and any remaining risks in 5 bullets.\" --enter --wait-timeout 60s --idle-threshold 10s" - fi -fi - -STATUS_SEND_COMMAND="" -if [[ -n "$AGENT_ID_OUT" ]]; then - STATUS_SEND_COMMAND="$STEP_SCRIPT_CMD send --agent $(shell_quote "$AGENT_ID_OUT") --text \"Provide a one-line progress status.\" --enter --wait-timeout 60s --idle-threshold 10s" -fi - -RESTART_COMMAND="" -if [[ "$STATUS" == "session_exited" && -n "$WORKSPACE_ID_OUT" && -n "$ASSISTANT_OUT" ]]; then - RESTART_COMMAND="$STEP_SCRIPT_CMD run --workspace $(shell_quote "$WORKSPACE_ID_OUT") --assistant $(shell_quote "$ASSISTANT_OUT") --prompt \"Continue from where you left off and provide a concise progress update.\" --wait-timeout 60s --idle-threshold 10s" -fi - -DELIVERY_KEY="mode:${MODE}" -if [[ -n "$AGENT_ID_OUT" ]]; then - DELIVERY_KEY="agent:${AGENT_ID_OUT}" -elif [[ -n "$SESSION_NAME" ]]; then - DELIVERY_KEY="session:${SESSION_NAME}" -elif [[ -n "$WORKSPACE_ID_OUT" ]]; then - DELIVERY_KEY="workspace:${WORKSPACE_ID_OUT}" -fi - -DELIVERY_ACTION="send" -DELIVERY_PRIORITY=1 -DELIVERY_RETRY_AFTER_SECONDS=0 -DELIVERY_REPLACE_PREVIOUS=false -DELIVERY_DROP_PENDING=false - -case "$STATUS" in - timed_out) - DELIVERY_ACTION="edit" - DELIVERY_PRIORITY=2 - DELIVERY_REPLACE_PREVIOUS=true - DELIVERY_RETRY_AFTER_SECONDS=5 - if [[ "$TIMED_OUT_STARTUP" == "true" ]]; then - DELIVERY_RETRY_AFTER_SECONDS=8 - fi - ;; - needs_input) - DELIVERY_ACTION="send" - DELIVERY_PRIORITY=0 - DELIVERY_DROP_PENDING=true - ;; - session_exited) - DELIVERY_ACTION="send" - DELIVERY_PRIORITY=0 - DELIVERY_DROP_PENDING=true - ;; - idle) - if [[ "$SUBSTANTIVE_OUTPUT" == "true" ]]; then - DELIVERY_ACTION="send" - DELIVERY_PRIORITY=1 - DELIVERY_DROP_PENDING=true - else - DELIVERY_ACTION="edit" - DELIVERY_PRIORITY=2 - DELIVERY_REPLACE_PREVIOUS=true - DELIVERY_RETRY_AFTER_SECONDS=5 - fi - ;; -esac - -OPENCLAW_PAYLOAD="$(jq -n \ - --arg mode "$MODE" \ - --arg status "$STATUS" \ - --arg summary "$SUMMARY" \ - --arg session_name "$SESSION_NAME" \ - --arg agent_id "$AGENT_ID_OUT" \ - --arg workspace_id "$WORKSPACE_ID_OUT" \ - --arg latest_line "$LATEST_LINE" \ - --arg response_summary "$RESPONSE_SUMMARY" \ - --arg delta "$DELTA" \ - --arg delta_compact "$DELTA_COMPACT" \ - --arg input_hint "$INPUT_HINT" \ - --argjson needs_input "$NEEDS_INPUT" \ - --argjson timed_out "$TIMED_OUT" \ - --argjson timed_out_startup "$TIMED_OUT_STARTUP" \ - --argjson session_exited "$SESSION_EXITED" \ - --argjson changed "$CHANGED" \ - --argjson substantive_output "$SUBSTANTIVE_OUTPUT" \ - --argjson blocked_permission_mode "$BLOCKED_PERMISSION_MODE" \ - --argjson recovered_from_capture "$RECOVERED_FROM_CAPTURE" \ - --argjson recovery_attempted "$RECOVERY_ATTEMPTED" \ - --argjson recovery_polls_used "$RECOVERY_POLLS_USED" \ - --arg next_action "$NEXT_ACTION" \ - --arg suggested_command "$SUGGESTED_COMMAND" \ - --arg status_emoji "$STATUS_EMOJI" \ - --arg channel_message "$OPENCLAW_MESSAGE" \ - --arg idempotency_key "$IDEMPOTENCY_KEY" \ - --arg assistant "$ASSISTANT_OUT" \ - --arg delivery_key "$DELIVERY_KEY" \ - --arg delivery_action "$DELIVERY_ACTION" \ - --argjson delivery_priority "$DELIVERY_PRIORITY" \ - --argjson delivery_retry_after_seconds "$DELIVERY_RETRY_AFTER_SECONDS" \ - --argjson delivery_replace_previous "$DELIVERY_REPLACE_PREVIOUS" \ - --argjson delivery_drop_pending "$DELIVERY_DROP_PENDING" \ - --arg step_verbosity "$STEP_VERBOSITY" \ - --arg inline_buttons_scope "$INLINE_BUTTONS_SCOPE" \ - --argjson inline_buttons_enabled "$INLINE_BUTTONS_ENABLED" \ - --argjson channel_chunk_chars "$OPENCLAW_CHUNK_CHARS" \ - --arg status_send_command "$STATUS_SEND_COMMAND" \ - --arg restart_command "$RESTART_COMMAND" \ - --arg test_remediation_command "$TEST_REMEDIATION_COMMAND" \ - --arg lint_remediation_command "$LINT_REMEDIATION_COMMAND" \ - --arg security_review_command "$SECURITY_REVIEW_COMMAND" \ - --arg review_changes_command "$REVIEW_CHANGES_COMMAND" \ - ' - def rindex_compat($s): - indices($s) | if length == 0 then null else .[-1] end; - def smart_split($txt; $size): - def next_cut($source): - ($source[0:$size]) as $head - | ($head | rindex_compat("\n\n")) as $double - | ($head | rindex_compat("\n")) as $single - | ($head | rindex_compat(" ")) as $space - | ($double // $single // $space) as $idx - | if $idx == null or $idx < ($size / 3) then $size else ($idx + 1) end; - def split_rec($source): - if ($source | length) <= $size then - [($source | ltrimstr("\n"))] - else - (next_cut($source)) as $cut - | [($source[0:$cut])] + split_rec($source[$cut:]) - end; - if ($txt | length) == 0 then - [] - else - split_rec($txt) - | map(select(length > 0)) - end; - def annotate_chunks($chunks): - ($chunks | length) as $count - | [range(0; $count) as $idx - | { - index: ($idx + 1), - total: $count, - text: ( - if $idx == 0 then - $chunks[$idx] - else - "continued (" + (($idx + 1) | tostring) + "/" + ($count | tostring) + ")\n" + $chunks[$idx] - end - ) - } - ]; - def build_action_rows($actions; $size): - if ($actions | length) == 0 then - [] - else - [range(0; ($actions | length); $size) as $idx - | ($actions[$idx:($idx + $size)] | map({text: .label, callback_data: .callback_data, style: .style})) - ] - end; - def action_tokens_text($actions): - ($actions | map(.callback_data) | join(" | ")); - def quick_action($id; $lbl; $command; $style; $prompt): - { - id: $id, - label: $lbl, - command: $command, - style: $style, - callback_data: ("qa:" + $id), - prompt: $prompt - }; - ( - [ - (if ($test_remediation_command | length) > 0 - then quick_action("fix_tests"; "Fix Tests"; $test_remediation_command; "success"; "Investigate and fix failing tests") - else empty end), - (if ($lint_remediation_command | length) > 0 - then quick_action("fix_lint"; "Fix Lint"; $lint_remediation_command; "success"; "Resolve lint and formatting issues") - else empty end), - (if ($security_review_command | length) > 0 - then quick_action("security"; "Security"; $security_review_command; "danger"; "Run a focused security remediation pass") - else empty end), - (if ($review_changes_command | length) > 0 - then quick_action("review"; "Review"; $review_changes_command; "primary"; "Review and summarize recent code changes") - else empty end), - (if ($suggested_command | length) > 0 - then quick_action("suggested"; "Continue"; $suggested_command; "primary"; "Continue from current state") - else empty end), - (if ($status_send_command | length) > 0 - then quick_action("status"; "Status"; $status_send_command; "primary"; "Request a one-line status update") - else empty end), - (if ($restart_command | length) > 0 - then quick_action( - "restart"; - "Restart"; - $restart_command; - "danger"; - "Restart the agent in the current workspace" - ) - else empty end) - ] - ) as $quick_actions - | { - ok: true, - mode: $mode, - status: $status, - status_emoji: $status_emoji, - verbosity: $step_verbosity, - summary: $summary, - session_name: $session_name, - agent_id: $agent_id, - workspace_id: $workspace_id, - idempotency_key: $idempotency_key, - response: { - latest_line: $latest_line, - summary: $response_summary, - delta: $delta, - delta_compact: $delta_compact, - needs_input: $needs_input, - input_hint: $input_hint, - timed_out: $timed_out, - timed_out_startup: $timed_out_startup, - session_exited: $session_exited, - changed: $changed, - substantive_output: $substantive_output - }, - blocked_permission_mode: $blocked_permission_mode, - recovered_from_capture: $recovered_from_capture, - recovery: { - attempted: $recovery_attempted, - polls_used: $recovery_polls_used - }, - delivery: { - key: $delivery_key, - action: $delivery_action, - priority: $delivery_priority, - retry_after_seconds: $delivery_retry_after_seconds, - replace_previous: $delivery_replace_previous, - drop_pending: $delivery_drop_pending, - coalesce: true - }, - next_action: $next_action, - suggested_command: $suggested_command, - quick_actions: $quick_actions, - quick_action_map: ($quick_actions | map({key: .callback_data, value: .command}) | from_entries), - quick_action_prompts: ($quick_actions | map({key: .callback_data, value: .prompt}) | from_entries), - channel: ( - smart_split($channel_message; $channel_chunk_chars) as $chunks_raw - | annotate_chunks($chunks_raw) as $chunks_meta - | { - message: $channel_message, - verbosity: $step_verbosity, - chunk_chars: $channel_chunk_chars, - chunks: ($chunks_meta | map(.text)), - chunks_meta: $chunks_meta, - inline_buttons_scope: $inline_buttons_scope, - inline_buttons_enabled: $inline_buttons_enabled, - callback_data_max_bytes: 64, - inline_buttons: ( - if $inline_buttons_enabled then - build_action_rows($quick_actions; 2) - else - [] - end - ), - action_tokens: ($quick_actions | map(.callback_data)), - actions_fallback: ( - if ($quick_actions | length) == 0 then - "" - else - "Actions: " + action_tokens_text($quick_actions) - end - ) - } - ) - }')" - -if [[ "${OPENCLAW_STEP_SKIP_PRESENT:-false}" == "true" ]]; then - printf '%s\n' "$OPENCLAW_PAYLOAD" -elif [[ -x "$OPENCLAW_PRESENT_SCRIPT" ]]; then - "$OPENCLAW_PRESENT_SCRIPT" <<<"$OPENCLAW_PAYLOAD" -else - printf '%s\n' "$OPENCLAW_PAYLOAD" -fi diff --git a/skills/amux/scripts/openclaw-turn.sh b/skills/amux/scripts/openclaw-turn.sh deleted file mode 100755 index 15b99f2c..00000000 --- a/skills/amux/scripts/openclaw-turn.sh +++ /dev/null @@ -1,829 +0,0 @@ -#!/usr/bin/env bash -# openclaw-turn.sh — Multi-step bounded OpenClaw coding turn for amux. -# -# Usage: -# openclaw-turn.sh run --workspace --assistant --prompt [--max-steps 3] [--turn-budget 180] [--wait-timeout 60s] [--idle-threshold 10s] -# openclaw-turn.sh send --agent --text [--enter] [--max-steps 3] [--turn-budget 180] [--wait-timeout 60s] [--idle-threshold 10s] -# -# Behavior: -# - Executes bounded steps via openclaw-step.sh. -# - Coalesces duplicate milestone summaries by default. -# - Stops on needs_input/session_exited, repeated timeouts, step cap, or turn budget. -# - Emits a final normalized JSON object with events/milestones/next action. - -set -euo pipefail - -usage() { - cat >&2 <<'EOF' -Usage: - openclaw-turn.sh run --workspace --assistant --prompt [--max-steps 3] [--turn-budget 180] [--wait-timeout 60s] [--idle-threshold 10s] - openclaw-turn.sh send --agent --text [--enter] [--max-steps 3] [--turn-budget 180] [--wait-timeout 60s] [--idle-threshold 10s] -EOF -} - -shell_quote() { - printf '%q' "$1" -} - -duration_to_seconds() { - local value="$1" - local fallback="$2" - if [[ "$value" =~ ^[[:space:]]*([0-9]+)[[:space:]]*$ ]]; then - echo "${BASH_REMATCH[1]}" - return - fi - if [[ "$value" =~ ^[[:space:]]*([0-9]+)s[[:space:]]*$ ]]; then - echo "${BASH_REMATCH[1]}" - return - fi - if [[ "$value" =~ ^[[:space:]]*([0-9]+)m[[:space:]]*$ ]]; then - echo "$(( ${BASH_REMATCH[1]} * 60 ))" - return - fi - if [[ "$value" =~ ^[[:space:]]*([0-9]+)h[[:space:]]*$ ]]; then - echo "$(( ${BASH_REMATCH[1]} * 3600 ))" - return - fi - echo "$fallback" -} - -normalize_verbosity_level() { - local value="${1:-normal}" - case "$value" in - quiet|normal|detailed) - printf '%s' "$value" - ;; - *) - printf 'normal' - ;; - esac -} - -normalize_inline_buttons_scope() { - local value="${1:-allowlist}" - case "$value" in - off|dm|group|all|allowlist) - printf '%s' "$value" - ;; - *) - printf 'allowlist' - ;; - esac -} - -redact_secrets_text() { - local input="$1" - printf '%s' "$input" | sed -E \ - -e 's/(sk-ant-api[0-9]*-[A-Za-z0-9_-]{10})[A-Za-z0-9_-]*/\1***/g' \ - -e 's/(sk-[A-Za-z0-9_-]{20})[A-Za-z0-9_-]*/\1***/g' \ - -e 's/(ghp_[A-Za-z0-9]{5})[A-Za-z0-9]*/\1***/g' \ - -e 's/(gho_[A-Za-z0-9]{5})[A-Za-z0-9]*/\1***/g' \ - -e 's/(github_pat_[A-Za-z0-9_]{5})[A-Za-z0-9_]*/\1***/g' \ - -e 's/(ghs_[A-Za-z0-9]{5})[A-Za-z0-9]*/\1***/g' \ - -e 's/(glpat-[A-Za-z0-9_-]{5})[A-Za-z0-9_-]*/\1***/g' \ - -e 's/(xoxb-[A-Za-z0-9]{5})[A-Za-z0-9-]*/\1***/g' \ - -e 's/(AKIA[0-9A-Z]{4})[0-9A-Z]{12}/\1************/g' \ - -e 's/(Bearer )[A-Za-z0-9+/_=.-]{8,}/\1***/g' \ - -e 's/((TOKEN|SECRET|PASSWORD|API_KEY|APIKEY|AUTH_TOKEN|PRIVATE_KEY|ACCESS_KEY|CLIENT_SECRET|WEBHOOK_SECRET)=)[^[:space:]'"'"'"]{8,}/\1***/g' -} - -line_has_file_signal() { - local value="$1" - case "$value" in - *".go"*|*".md"*|*".sh"*|*"internal/"*|*"cmd/"*|*"skills/"*|*"README."*|*"Makefile"*) - return 0 - ;; - *) - return 1 - ;; - esac -} - -workspace_root_for_turn() { - local workspace_id="$1" - if [[ -z "$workspace_id" ]]; then - printf '' - return 0 - fi - if ! command -v amux >/dev/null 2>&1 || ! command -v jq >/dev/null 2>&1; then - printf '' - return 0 - fi - local ws_json root - ws_json="$(amux --json workspace list --archived 2>/dev/null || true)" - if ! jq -e '.ok == true' >/dev/null 2>&1 <<<"$ws_json"; then - printf '' - return 0 - fi - root="$(jq -r --arg id "$workspace_id" '.data // [] | map(select(.id == $id)) | .[0].root // ""' <<<"$ws_json")" - printf '%s' "$root" -} - -if [[ $# -lt 1 ]]; then - usage - exit 2 -fi - -MODE="$1" -shift - -WAIT_TIMEOUT="60s" -IDLE_THRESHOLD="10s" -MAX_STEPS="${OPENCLAW_TURN_MAX_STEPS:-3}" -TURN_BUDGET_SECONDS="${OPENCLAW_TURN_BUDGET_SECONDS:-180}" -FOLLOWUP_TEXT="${OPENCLAW_TURN_FOLLOWUP_TEXT:-Continue from current state and provide a concise status update and next action.}" -TIMEOUT_STREAK_LIMIT="${OPENCLAW_TURN_TIMEOUT_STREAK_LIMIT:-2}" -COALESCE_MILESTONES="${OPENCLAW_TURN_COALESCE_MILESTONES:-true}" -FINAL_RESERVE_SECONDS="${OPENCLAW_TURN_FINAL_RESERVE_SECONDS:-20}" -OPENCLAW_CHUNK_CHARS="${OPENCLAW_TURN_CHUNK_CHARS:-1200}" -TURN_VERBOSITY="$(normalize_verbosity_level "${OPENCLAW_TURN_VERBOSITY:-normal}")" - -SCRIPT_SOURCE="${BASH_SOURCE[0]:-$0}" -SCRIPT_DIR="$(cd "$(dirname "$SCRIPT_SOURCE")" >/dev/null 2>&1 && pwd -P)" -SCRIPT_PATH="$SCRIPT_DIR/$(basename "$SCRIPT_SOURCE")" -TURN_SCRIPT_REF="${OPENCLAW_TURN_CMD_REF:-skills/amux/scripts/openclaw-turn.sh}" -TURN_SCRIPT_CMD="$(shell_quote "$TURN_SCRIPT_REF")" -STEP_SCRIPT="${OPENCLAW_TURN_STEP_SCRIPT:-$SCRIPT_DIR/openclaw-step.sh}" -if [[ ! -x "$STEP_SCRIPT" ]]; then - STEP_SCRIPT="$SCRIPT_DIR/openclaw-step.sh" -fi -STEP_SCRIPT_REF="${OPENCLAW_TURN_STEP_CMD_REF:-skills/amux/scripts/openclaw-step.sh}" -STEP_SCRIPT_CMD="$(shell_quote "$STEP_SCRIPT_REF")" -OPENCLAW_PRESENT_SCRIPT="${OPENCLAW_PRESENT_SCRIPT:-$SCRIPT_DIR/openclaw-present.sh}" - -WORKSPACE="" -ASSISTANT="" -PROMPT="" -AGENT_ID="" -TEXT="" -ENTER=false - -while [[ $# -gt 0 ]]; do - case "$1" in - --wait-timeout) - WAIT_TIMEOUT="$2"; shift 2 ;; - --idle-threshold) - IDLE_THRESHOLD="$2"; shift 2 ;; - --max-steps) - MAX_STEPS="$2"; shift 2 ;; - --turn-budget) - TURN_BUDGET_SECONDS="$2"; shift 2 ;; - --followup-text) - FOLLOWUP_TEXT="$2"; shift 2 ;; - --workspace) - WORKSPACE="$2"; shift 2 ;; - --assistant) - ASSISTANT="$2"; shift 2 ;; - --prompt) - PROMPT="$2"; shift 2 ;; - --agent) - AGENT_ID="$2"; shift 2 ;; - --text) - TEXT="$2"; shift 2 ;; - --enter) - ENTER=true; shift ;; - *) - usage - echo "unknown flag: $1" >&2 - exit 2 ;; - esac -done - -if ! command -v jq >/dev/null 2>&1; then - echo '{"ok":false,"status":"command_error","summary":"jq is required","error":"missing binary: jq"}' - exit 0 -fi - -if [[ ! -x "$STEP_SCRIPT" ]]; then - echo '{"ok":false,"status":"command_error","summary":"openclaw-step.sh is not executable","error":"invalid step script path"}' - exit 0 -fi - -if ! [[ "$MAX_STEPS" =~ ^[0-9]+$ ]] || [[ "$MAX_STEPS" -le 0 ]]; then - MAX_STEPS=3 -fi -if ! [[ "$TIMEOUT_STREAK_LIMIT" =~ ^[0-9]+$ ]] || [[ "$TIMEOUT_STREAK_LIMIT" -le 0 ]]; then - TIMEOUT_STREAK_LIMIT=2 -fi -if ! [[ "$FINAL_RESERVE_SECONDS" =~ ^[0-9]+$ ]] || [[ "$FINAL_RESERVE_SECONDS" -lt 0 ]]; then - FINAL_RESERVE_SECONDS=20 -fi -if ! [[ "$OPENCLAW_CHUNK_CHARS" =~ ^[0-9]+$ ]] || [[ "$OPENCLAW_CHUNK_CHARS" -le 0 ]]; then - OPENCLAW_CHUNK_CHARS=1200 -fi -INLINE_BUTTONS_SCOPE="$(normalize_inline_buttons_scope "${OPENCLAW_INLINE_BUTTONS_SCOPE:-allowlist}")" -INLINE_BUTTONS_ENABLED=true -if [[ "$INLINE_BUTTONS_SCOPE" == "off" ]]; then - INLINE_BUTTONS_ENABLED=false -fi - -TURN_BUDGET_SECONDS="$(duration_to_seconds "$TURN_BUDGET_SECONDS" 180)" -if ! [[ "$TURN_BUDGET_SECONDS" =~ ^[0-9]+$ ]] || [[ "$TURN_BUDGET_SECONDS" -le 0 ]]; then - TURN_BUDGET_SECONDS=180 -fi - -case "$MODE" in - run) - if [[ -z "$WORKSPACE" || -z "$ASSISTANT" || -z "$PROMPT" ]]; then - usage - echo '{"ok":false,"status":"command_error","summary":"Missing required flags","error":"run requires --workspace, --assistant, --prompt"}' - exit 0 - fi - ;; - send) - if [[ -z "$AGENT_ID" || -z "$TEXT" ]]; then - usage - echo '{"ok":false,"status":"command_error","summary":"Missing required flags","error":"send requires --agent and --text"}' - exit 0 - fi - ;; - *) - usage - echo '{"ok":false,"status":"command_error","summary":"Invalid mode","error":"mode must be run or send"}' - exit 0 - ;; -esac - -TURN_ID="tgturn-$(date +%s)-$$" -START_TS="$(date +%s)" -STEPS_USED=0 -TIMEOUT_STREAK=0 -BUDGET_EXHAUSTED=false - -EVENTS_JSON='[]' -MILESTONES_JSON='[]' -LAST_MILESTONE_SUMMARY="" - -CURRENT_MODE="$MODE" -CURRENT_WORKSPACE="$WORKSPACE" -CURRENT_ASSISTANT="$ASSISTANT" -CURRENT_PROMPT="$PROMPT" -CURRENT_AGENT="$AGENT_ID" -CURRENT_TEXT="$TEXT" -CURRENT_ENTER="$ENTER" - -LAST_STEP_JSON='{}' -LAST_STATUS="unknown" -LAST_SUMMARY="" -LAST_NEXT_ACTION="" -LAST_SUGGESTED_COMMAND="" -LAST_AGENT_ID="$AGENT_ID" -LAST_WORKSPACE_ID="$WORKSPACE" -LAST_ASSISTANT_OUT="$ASSISTANT" -LAST_SUBSTANTIVE_OUTPUT=false -LAST_NEEDS_INPUT=false -STEP_EVENT_JSON='{}' - -while [[ "$STEPS_USED" -lt "$MAX_STEPS" ]]; do - NOW_TS="$(date +%s)" - ELAPSED="$((NOW_TS - START_TS))" - REMAINING="$((TURN_BUDGET_SECONDS - ELAPSED))" - # Always allow at least one step even on tight budgets. - if [[ "$REMAINING" -le "$FINAL_RESERVE_SECONDS" && "$STEPS_USED" -gt 0 ]]; then - BUDGET_EXHAUSTED=true - break - fi - - STEP_INDEX="$((STEPS_USED + 1))" - STEP_IDEMPOTENCY_KEY="${TURN_ID}-step-${STEP_INDEX}" - STEP_EXIT=0 - - if [[ "$CURRENT_MODE" == "run" ]]; then - STEP_JSON="$(OPENCLAW_STEP_SKIP_PRESENT=true "$STEP_SCRIPT" run \ - --workspace "$CURRENT_WORKSPACE" \ - --assistant "$CURRENT_ASSISTANT" \ - --prompt "$CURRENT_PROMPT" \ - --wait-timeout "$WAIT_TIMEOUT" \ - --idle-threshold "$IDLE_THRESHOLD" \ - --idempotency-key "$STEP_IDEMPOTENCY_KEY")" || STEP_EXIT=$? - else - STEP_ARGS=( - "$STEP_SCRIPT" send - --agent "$CURRENT_AGENT" - --text "$CURRENT_TEXT" - --wait-timeout "$WAIT_TIMEOUT" - --idle-threshold "$IDLE_THRESHOLD" - --idempotency-key "$STEP_IDEMPOTENCY_KEY" - ) - if [[ "$CURRENT_ENTER" == "true" ]]; then - STEP_ARGS+=(--enter) - fi - STEP_JSON="$(OPENCLAW_STEP_SKIP_PRESENT=true "${STEP_ARGS[@]}")" || STEP_EXIT=$? - fi - - if [[ "$STEP_EXIT" -ne 0 && -z "${STEP_JSON// }" ]]; then - STEP_JSON="$(jq -cn --argjson step_exit "$STEP_EXIT" '{ - ok: false, - status: "command_error", - summary: "Step script exited without JSON output.", - step_exit_code: $step_exit - }')" - fi - - if ! jq -e . >/dev/null 2>&1 <<<"$STEP_JSON"; then - STEP_JSON="$(jq -cn --arg raw "$STEP_JSON" --argjson step_exit "$STEP_EXIT" '{ - ok: false, status: "command_error", - summary: "Step script produced invalid JSON output.", - raw_output: ($raw | .[0:2000]), - step_exit_code: (if $step_exit > 0 then $step_exit else null end) - }')" - fi - - STEP_EVENT_JSON="$(jq -c '{ - ok: (.ok // false), - mode: (.mode // ""), - status: (.status // "unknown"), - summary: (.summary // ""), - next_action: (.next_action // ""), - suggested_command: (.suggested_command // ""), - agent_id: (.agent_id // ""), - workspace_id: (.workspace_id // ""), - assistant: (.assistant // ""), - response: { - substantive_output: (.response.substantive_output // false), - needs_input: (.response.needs_input // false), - timed_out: (.response.timed_out // false), - session_exited: (.response.session_exited // false), - changed: (.response.changed // false) - } - }' <<<"$STEP_JSON")" - - STEPS_USED="$STEP_INDEX" - LAST_STEP_JSON="$STEP_JSON" - EVENTS_JSON="$(jq -cn --argjson events "$EVENTS_JSON" --argjson step "$STEP_EVENT_JSON" '$events + [$step]')" - - LAST_STATUS="$(jq -r '.status // "unknown"' <<<"$STEP_JSON")" - LAST_SUMMARY="$(jq -r '.summary // ""' <<<"$STEP_JSON")" - LAST_NEXT_ACTION="$(jq -r '.next_action // ""' <<<"$STEP_JSON")" - LAST_SUGGESTED_COMMAND="$(jq -r '.suggested_command // ""' <<<"$STEP_JSON")" - LAST_AGENT_ID="$(jq -r '.agent_id // empty' <<<"$STEP_JSON")" - LAST_WORKSPACE_ID="$(jq -r '.workspace_id // empty' <<<"$STEP_JSON")" - LAST_ASSISTANT_OUT="$(jq -r '.assistant // empty' <<<"$STEP_JSON")" - LAST_SUBSTANTIVE_OUTPUT="$(jq -r '.response.substantive_output // false' <<<"$STEP_JSON")" - LAST_NEEDS_INPUT="$(jq -r '.response.needs_input // false' <<<"$STEP_JSON")" - LAST_SUMMARY="$(redact_secrets_text "$LAST_SUMMARY")" - LAST_NEXT_ACTION="$(redact_secrets_text "$LAST_NEXT_ACTION")" - LAST_SUGGESTED_COMMAND="$(redact_secrets_text "$LAST_SUGGESTED_COMMAND")" - - ADD_MILESTONE=true - if [[ "$COALESCE_MILESTONES" == "true" && -n "$LAST_SUMMARY" && "$LAST_SUMMARY" == "$LAST_MILESTONE_SUMMARY" ]]; then - ADD_MILESTONE=false - fi - if [[ "$ADD_MILESTONE" == "true" ]]; then - MILESTONES_JSON="$( - jq -cn \ - --argjson milestones "$MILESTONES_JSON" \ - --argjson step "$STEP_INDEX" \ - --arg status "$LAST_STATUS" \ - --arg summary "$LAST_SUMMARY" \ - --arg next_action "$LAST_NEXT_ACTION" \ - --arg suggested_command "$LAST_SUGGESTED_COMMAND" \ - '$milestones + [{ - step: $step, - status: $status, - summary: $summary, - next_action: $next_action, - suggested_command: $suggested_command - }]' - )" - LAST_MILESTONE_SUMMARY="$LAST_SUMMARY" - fi - - if [[ "$LAST_STATUS" == "timed_out" ]]; then - TIMEOUT_STREAK="$((TIMEOUT_STREAK + 1))" - else - TIMEOUT_STREAK=0 - fi - - if [[ "$LAST_STATUS" == "needs_input" || "$LAST_NEEDS_INPUT" == "true" ]]; then - break - fi - if [[ "$LAST_STATUS" == "session_exited" ]]; then - break - fi - if [[ "$LAST_STATUS" == "idle" && "$LAST_SUBSTANTIVE_OUTPUT" == "true" ]]; then - break - fi - if [[ "$TIMEOUT_STREAK" -ge "$TIMEOUT_STREAK_LIMIT" ]]; then - break - fi - if [[ -z "$LAST_AGENT_ID" ]]; then - break - fi - - CURRENT_MODE="send" - CURRENT_AGENT="$LAST_AGENT_ID" - CURRENT_TEXT="$FOLLOWUP_TEXT" - CURRENT_ENTER=true -done - -END_TS="$(date +%s)" -ELAPSED_FINAL="$((END_TS - START_TS))" - -OVERALL_STATUS="partial" -if [[ "$LAST_STATUS" == "idle" && "$LAST_SUBSTANTIVE_OUTPUT" == "true" ]]; then - OVERALL_STATUS="completed" -elif [[ "$LAST_STATUS" == "needs_input" || "$LAST_NEEDS_INPUT" == "true" ]]; then - OVERALL_STATUS="needs_input" -elif [[ "$LAST_STATUS" == "session_exited" ]]; then - OVERALL_STATUS="session_exited" -elif [[ "$LAST_STATUS" == "timed_out" ]]; then - OVERALL_STATUS="timed_out" -fi -if [[ "$BUDGET_EXHAUSTED" == "true" && "$OVERALL_STATUS" != "completed" ]]; then - OVERALL_STATUS="partial_budget" -fi - -FINAL_SUMMARY="$LAST_SUMMARY" -if [[ -z "${FINAL_SUMMARY// }" ]]; then - FINAL_SUMMARY="Turn ended with status: $LAST_STATUS." -fi -if [[ "$OVERALL_STATUS" == "completed" ]]; then - FINAL_SUMMARY="Completed in $STEPS_USED step(s). $FINAL_SUMMARY" -else - FINAL_SUMMARY="Partial after $STEPS_USED step(s). $FINAL_SUMMARY" -fi -if [[ "$OVERALL_STATUS" == "completed" && -n "$LAST_WORKSPACE_ID" ]] && line_has_file_signal "$LAST_SUMMARY"; then - WORKSPACE_ROOT_CANDIDATE="$(workspace_root_for_turn "$LAST_WORKSPACE_ID")" - if [[ -n "$WORKSPACE_ROOT_CANDIDATE" && -d "$WORKSPACE_ROOT_CANDIDATE" ]]; then - if git -C "$WORKSPACE_ROOT_CANDIDATE" rev-parse --is-inside-work-tree >/dev/null 2>&1; then - PORCELAIN_STATUS="$(git -C "$WORKSPACE_ROOT_CANDIDATE" status --porcelain --untracked-files=all 2>/dev/null || true)" - if [[ -z "${PORCELAIN_STATUS// }" ]]; then - OVERALL_STATUS="partial" - FINAL_SUMMARY="Partial after $STEPS_USED step(s). Claimed file updates, but no workspace changes were detected." - LAST_NEXT_ACTION="Ask for exact changed files and apply the requested edits." - if [[ -n "$LAST_AGENT_ID" ]]; then - LAST_SUGGESTED_COMMAND="$TURN_SCRIPT_CMD send --agent $(shell_quote "$LAST_AGENT_ID") --text \"List exact files changed and apply the missing edits now.\" --enter --max-steps 2 --turn-budget 180 --wait-timeout 60s --idle-threshold 10s" - fi - fi - fi - fi -fi -FINAL_SUMMARY="$(redact_secrets_text "$FINAL_SUMMARY")" -LAST_NEXT_ACTION="$(redact_secrets_text "$LAST_NEXT_ACTION")" -LAST_SUGGESTED_COMMAND="$(redact_secrets_text "$LAST_SUGGESTED_COMMAND")" -if [[ "$MAX_STEPS" -gt 0 ]]; then - STEP_PROGRESS_PERCENT="$((STEPS_USED * 100 / MAX_STEPS))" -else - STEP_PROGRESS_PERCENT=0 -fi - -STATUS_EMOJI="ℹ️" -case "$OVERALL_STATUS" in - completed) STATUS_EMOJI="✅" ;; - needs_input) STATUS_EMOJI="❓" ;; - timed_out|partial_budget) STATUS_EMOJI="⏱️" ;; - session_exited) STATUS_EMOJI="🛑" ;; -esac - -OPENCLAW_MESSAGE="$STATUS_EMOJI $FINAL_SUMMARY" -case "$TURN_VERBOSITY" in - quiet) - ;; - normal) - if [[ -n "${LAST_NEXT_ACTION// }" ]]; then - OPENCLAW_MESSAGE+=$'\n'"Next: $LAST_NEXT_ACTION" - fi - if [[ -n "${LAST_SUGGESTED_COMMAND// }" ]]; then - OPENCLAW_MESSAGE+=$'\n'"Command: $LAST_SUGGESTED_COMMAND" - fi - OPENCLAW_MESSAGE+=$'\n'"Progress: $STEPS_USED/$MAX_STEPS steps ($STEP_PROGRESS_PERCENT%)" - ;; - detailed) - if [[ -n "${LAST_NEXT_ACTION// }" ]]; then - OPENCLAW_MESSAGE+=$'\n'"Next: $LAST_NEXT_ACTION" - fi - if [[ -n "${LAST_SUGGESTED_COMMAND// }" ]]; then - OPENCLAW_MESSAGE+=$'\n'"Command: $LAST_SUGGESTED_COMMAND" - fi - OPENCLAW_MESSAGE+=$'\n'"Progress: $STEPS_USED/$MAX_STEPS steps ($STEP_PROGRESS_PERCENT%)" - OPENCLAW_MESSAGE+=$'\n'"Meta: elapsed=${ELAPSED_FINAL}s budget=${TURN_BUDGET_SECONDS}s timeout_streak=${TIMEOUT_STREAK}/${TIMEOUT_STREAK_LIMIT}" - ;; -esac - -TURN_CONTEXT_LOWER="$(printf '%s\n%s\n%s' "$FINAL_SUMMARY" "$LAST_NEXT_ACTION" "$LAST_SUGGESTED_COMMAND" | tr '[:upper:]' '[:lower:]')" -TURN_TEST_REMEDIATION_COMMAND="" -TURN_LINT_REMEDIATION_COMMAND="" -TURN_SECURITY_REVIEW_COMMAND="" -TURN_REVIEW_CHANGES_COMMAND="" -if [[ -n "$LAST_AGENT_ID" ]]; then - if [[ "$TURN_CONTEXT_LOWER" == *"test"* ]] && [[ "$TURN_CONTEXT_LOWER" == *"fail"* || "$TURN_CONTEXT_LOWER" == *"panic"* || "$TURN_CONTEXT_LOWER" == *"error"* ]]; then - TURN_TEST_REMEDIATION_COMMAND="$TURN_SCRIPT_CMD send --agent $(shell_quote "$LAST_AGENT_ID") --text \"Investigate failing tests, fix root causes, and report changed files plus exact test command/results.\" --enter --max-steps 2 --turn-budget 180 --wait-timeout 60s --idle-threshold 10s" - fi - if [[ "$TURN_CONTEXT_LOWER" == *"lint"* || "$TURN_CONTEXT_LOWER" == *"format"* || "$TURN_CONTEXT_LOWER" == *"gofumpt"* || "$TURN_CONTEXT_LOWER" == *"style"* ]]; then - TURN_LINT_REMEDIATION_COMMAND="$TURN_SCRIPT_CMD send --agent $(shell_quote "$LAST_AGENT_ID") --text \"Resolve lint and formatting issues, then provide a concise summary of fixes.\" --enter --max-steps 2 --turn-budget 180 --wait-timeout 60s --idle-threshold 10s" - fi - if [[ "$TURN_CONTEXT_LOWER" == *"secret"* || "$TURN_CONTEXT_LOWER" == *"token"* || "$TURN_CONTEXT_LOWER" == *"credential"* || "$TURN_CONTEXT_LOWER" == *"key leak"* ]]; then - TURN_SECURITY_REVIEW_COMMAND="$TURN_SCRIPT_CMD send --agent $(shell_quote "$LAST_AGENT_ID") --text \"Run a focused security pass for exposed credentials/secrets and propose concrete remediation.\" --enter --max-steps 2 --turn-budget 180 --wait-timeout 60s --idle-threshold 10s" - fi - if [[ "$OVERALL_STATUS" == "completed" ]] && { line_has_file_signal "$FINAL_SUMMARY" || [[ "$TURN_CONTEXT_LOWER" == *"changed file"* || "$TURN_CONTEXT_LOWER" == *"modified"* || "$TURN_CONTEXT_LOWER" == *"refactor"* || "$TURN_CONTEXT_LOWER" == *"patched"* ]]; }; then - TURN_REVIEW_CHANGES_COMMAND="$TURN_SCRIPT_CMD send --agent $(shell_quote "$LAST_AGENT_ID") --text \"Summarize changed files, rationale, and remaining risks in 5 bullets.\" --enter --max-steps 2 --turn-budget 180 --wait-timeout 60s --idle-threshold 10s" - fi -fi - -STATUS_PING_COMMAND="" -if [[ -n "$LAST_AGENT_ID" ]]; then - STATUS_PING_COMMAND="$STEP_SCRIPT_CMD send --agent $(shell_quote "$LAST_AGENT_ID") --text \"Provide a one-line progress status.\" --enter --wait-timeout 60s --idle-threshold 10s" -fi - -DELIVERY_KEY="turn:${TURN_ID}" -if [[ -n "$LAST_AGENT_ID" ]]; then - DELIVERY_KEY="agent:${LAST_AGENT_ID}" -elif [[ -n "$LAST_WORKSPACE_ID" ]]; then - DELIVERY_KEY="workspace:${LAST_WORKSPACE_ID}" -fi - -DELIVERY_ACTION="send" -DELIVERY_PRIORITY=1 -DELIVERY_RETRY_AFTER_SECONDS=0 -DELIVERY_REPLACE_PREVIOUS=false -DELIVERY_DROP_PENDING=true - -case "$OVERALL_STATUS" in - timed_out|partial|partial_budget) - DELIVERY_ACTION="edit" - DELIVERY_PRIORITY=2 - DELIVERY_RETRY_AFTER_SECONDS=8 - DELIVERY_REPLACE_PREVIOUS=true - DELIVERY_DROP_PENDING=false - ;; - needs_input|session_exited) - DELIVERY_ACTION="send" - DELIVERY_PRIORITY=0 - DELIVERY_DROP_PENDING=true - ;; - completed) - DELIVERY_ACTION="send" - DELIVERY_PRIORITY=1 - DELIVERY_DROP_PENDING=true - ;; -esac - -OPENCLAW_PAYLOAD="$(jq -n \ - --arg mode "$MODE" \ - --arg status "$LAST_STATUS" \ - --arg overall_status "$OVERALL_STATUS" \ - --arg summary "$FINAL_SUMMARY" \ - --arg status_emoji "$STATUS_EMOJI" \ - --arg agent_id "$LAST_AGENT_ID" \ - --arg workspace_id "$LAST_WORKSPACE_ID" \ - --arg assistant "$LAST_ASSISTANT_OUT" \ - --arg next_action "$LAST_NEXT_ACTION" \ - --arg suggested_command "$LAST_SUGGESTED_COMMAND" \ - --arg turn_id "$TURN_ID" \ - --arg channel_message "$OPENCLAW_MESSAGE" \ - --arg turn_verbosity "$TURN_VERBOSITY" \ - --arg delivery_key "$DELIVERY_KEY" \ - --arg delivery_action "$DELIVERY_ACTION" \ - --argjson delivery_priority "$DELIVERY_PRIORITY" \ - --argjson delivery_retry_after_seconds "$DELIVERY_RETRY_AFTER_SECONDS" \ - --argjson delivery_replace_previous "$DELIVERY_REPLACE_PREVIOUS" \ - --argjson delivery_drop_pending "$DELIVERY_DROP_PENDING" \ - --argjson events "$EVENTS_JSON" \ - --argjson milestones "$MILESTONES_JSON" \ - --argjson steps_used "$STEPS_USED" \ - --argjson max_steps "$MAX_STEPS" \ - --argjson elapsed_seconds "$ELAPSED_FINAL" \ - --argjson turn_budget_seconds "$TURN_BUDGET_SECONDS" \ - --argjson timeout_streak "$TIMEOUT_STREAK" \ - --argjson timeout_streak_limit "$TIMEOUT_STREAK_LIMIT" \ - --argjson budget_exhausted "$BUDGET_EXHAUSTED" \ - --argjson step_progress_percent "$STEP_PROGRESS_PERCENT" \ - --arg inline_buttons_scope "$INLINE_BUTTONS_SCOPE" \ - --argjson inline_buttons_enabled "$INLINE_BUTTONS_ENABLED" \ - --argjson channel_chunk_chars "$OPENCLAW_CHUNK_CHARS" \ - --arg test_remediation_command "$TURN_TEST_REMEDIATION_COMMAND" \ - --arg lint_remediation_command "$TURN_LINT_REMEDIATION_COMMAND" \ - --arg security_review_command "$TURN_SECURITY_REVIEW_COMMAND" \ - --arg review_changes_command "$TURN_REVIEW_CHANGES_COMMAND" \ - --arg status_ping_command "$STATUS_PING_COMMAND" \ - ' - def rindex_compat($s): - indices($s) | if length == 0 then null else .[-1] end; - def smart_split($txt; $size): - def next_cut($source): - ($source[0:$size]) as $head - | ($head | rindex_compat("\n\n")) as $double - | ($head | rindex_compat("\n")) as $single - | ($head | rindex_compat(" ")) as $space - | ($double // $single // $space) as $idx - | if $idx == null or $idx < ($size / 3) then $size else ($idx + 1) end; - def split_rec($source): - if ($source | length) <= $size then - [($source | ltrimstr("\n"))] - else - (next_cut($source)) as $cut - | [($source[0:$cut])] + split_rec($source[$cut:]) - end; - if ($txt | length) == 0 then - [] - else - split_rec($txt) - | map(select(length > 0)) - end; - def annotate_chunks($chunks): - ($chunks | length) as $count - | [range(0; $count) as $idx - | { - index: ($idx + 1), - total: $count, - text: ( - if $idx == 0 then - $chunks[$idx] - else - "continued (" + (($idx + 1) | tostring) + "/" + ($count | tostring) + ")\n" + $chunks[$idx] - end - ) - } - ]; - def build_action_rows($actions; $size): - if ($actions | length) == 0 then - [] - else - [range(0; ($actions | length); $size) as $idx - | ($actions[$idx:($idx + $size)] | map({text: .label, callback_data: .callback_data, style: .style})) - ] - end; - def action_tokens_text($actions): - ($actions | map(.callback_data) | join(" | ")); - def quick_action($id; $lbl; $command; $style; $prompt): - { - id: $id, - label: $lbl, - command: $command, - style: $style, - callback_data: ("qa:" + $id), - prompt: $prompt - }; - def quick_actions_list: - [ - (if ($test_remediation_command | length) > 0 - then quick_action("fix_tests"; "Fix Tests"; $test_remediation_command; "success"; "Investigate and fix failing tests") - else empty end), - (if ($lint_remediation_command | length) > 0 - then quick_action("fix_lint"; "Fix Lint"; $lint_remediation_command; "success"; "Resolve lint and formatting issues") - else empty end), - (if ($security_review_command | length) > 0 - then quick_action("security"; "Security"; $security_review_command; "danger"; "Run a focused security remediation pass") - else empty end), - (if ($review_changes_command | length) > 0 - then quick_action("review"; "Review"; $review_changes_command; "primary"; "Review and summarize recent code changes") - else empty end), - (if ($suggested_command | length) > 0 - then quick_action("continue"; "Continue"; $suggested_command; "primary"; "Continue from current state") - else empty end), - (if ($status_ping_command | length) > 0 - then quick_action( - "status"; - "Status"; - $status_ping_command; - "primary"; - "Request a one-line status update" - ) - else empty end) - ]; - { - ok: true, - mode: $mode, - turn_id: $turn_id, - status: $status, - overall_status: $overall_status, - status_emoji: $status_emoji, - verbosity: $turn_verbosity, - summary: $summary, - agent_id: $agent_id, - workspace_id: $workspace_id, - assistant: $assistant, - steps_used: $steps_used, - max_steps: $max_steps, - elapsed_seconds: $elapsed_seconds, - turn_budget_seconds: $turn_budget_seconds, - budget_exhausted: $budget_exhausted, - progress_percent: $step_progress_percent, - timeout_streak: $timeout_streak, - timeout_streak_limit: $timeout_streak_limit, - next_action: $next_action, - suggested_command: $suggested_command, - delivery: { - key: $delivery_key, - action: $delivery_action, - priority: $delivery_priority, - retry_after_seconds: $delivery_retry_after_seconds, - replace_previous: $delivery_replace_previous, - drop_pending: $delivery_drop_pending, - coalesce: true - }, - events: $events, - milestones: $milestones, - progress_updates: ( - $milestones - | to_entries - | map( - . as $entry - | $entry.value as $m - | { - step: $m.step, - status: $m.status, - summary: $m.summary, - next_action: $m.next_action, - suggested_command: $m.suggested_command, - progress: { - step: $m.step, - max_steps: $max_steps, - percent: (if $max_steps > 0 then (($m.step * 100 / $max_steps) | floor) else 0 end) - }, - message: ( - (if $m.status == "idle" then "✅" - elif $m.status == "needs_input" then "❓" - elif $m.status == "timed_out" then "⏱️" - elif $m.status == "session_exited" then "🛑" - else "ℹ️" end) - + " " + ($m.summary // "") - ), - delivery: { - key: $delivery_key, - action: ( - if ($entry.key == (($milestones | length) - 1)) - and ($overall_status == "completed" or $overall_status == "needs_input" or $overall_status == "session_exited") - then "send" - else "edit" - end - ), - priority: ( - if $m.status == "needs_input" or $m.status == "session_exited" then 0 - elif $m.status == "timed_out" then 2 - else 1 - end - ), - replace_previous: ( - if ($entry.key == (($milestones | length) - 1)) - and ($overall_status == "completed" or $overall_status == "needs_input" or $overall_status == "session_exited") - then false - else true - end - ), - coalesce: true - } - } - ) - ), - quick_actions: quick_actions_list, - quick_action_map: (quick_actions_list | map({key: .callback_data, value: .command}) | from_entries), - quick_action_prompts: (quick_actions_list | map({key: .callback_data, value: .prompt}) | from_entries), - channel: ( - smart_split($channel_message; $channel_chunk_chars) as $chunks_raw - | annotate_chunks($chunks_raw) as $chunks_meta - | { - message: $channel_message, - verbosity: $turn_verbosity, - chunk_chars: $channel_chunk_chars, - chunks: ($chunks_meta | map(.text)), - chunks_meta: $chunks_meta, - inline_buttons_scope: $inline_buttons_scope, - inline_buttons_enabled: $inline_buttons_enabled, - callback_data_max_bytes: 64, - inline_buttons: ( - if $inline_buttons_enabled then - build_action_rows(quick_actions_list; 2) - else - [] - end - ), - action_tokens: (quick_actions_list | map(.callback_data)), - actions_fallback: ( - if (quick_actions_list | length) == 0 then - "" - else - "Actions: " + action_tokens_text(quick_actions_list) - end - ), - progress_updates: ( - $milestones - | map( - { - step, - status, - progress_percent: (if $max_steps > 0 then ((.step * 100 / $max_steps) | floor) else 0 end), - message: ( - (if .status == "idle" then "✅" - elif .status == "needs_input" then "❓" - elif .status == "timed_out" then "⏱️" - elif .status == "session_exited" then "🛑" - else "ℹ️" end) - + " " + (.summary // "") - ) - } - ) - ) - } - ) - } - ')" - -if [[ "${OPENCLAW_TURN_SKIP_PRESENT:-false}" == "true" ]]; then - printf '%s\n' "$OPENCLAW_PAYLOAD" -elif [[ -x "$OPENCLAW_PRESENT_SCRIPT" ]]; then - "$OPENCLAW_PRESENT_SCRIPT" <<<"$OPENCLAW_PAYLOAD" -else - printf '%s\n' "$OPENCLAW_PAYLOAD" -fi diff --git a/skills/amux/scripts/poll-agent.sh b/skills/amux/scripts/poll-agent.sh deleted file mode 100755 index d905a507..00000000 --- a/skills/amux/scripts/poll-agent.sh +++ /dev/null @@ -1,63 +0,0 @@ -#!/usr/bin/env bash -# poll-agent.sh — Poll agent capture with change detection. -# Fallback for environments without `amux agent watch`. -# -# Usage: poll-agent.sh --session [--lines 100] [--interval 2] [--timeout 120] -# -# Polls amux agent capture and prints only new/changed content. -# Exits when idle (no changes for --timeout seconds) or session disappears. - -set -euo pipefail - -SESSION="" -LINES=100 -INTERVAL=2 -TIMEOUT=120 - -while [[ $# -gt 0 ]]; do - case "$1" in - --session) SESSION="$2"; shift 2 ;; - --lines) LINES="$2"; shift 2 ;; - --interval) INTERVAL="$2"; shift 2 ;; - --timeout) TIMEOUT="$2"; shift 2 ;; - *) echo "Unknown flag: $1" >&2; exit 2 ;; - esac -done - -if [[ -z "$SESSION" ]]; then - echo "Usage: poll-agent.sh --session [--lines 100] [--interval 2] [--timeout 120]" >&2 - exit 2 -fi - -last_hash="" -idle_since="" - -while true; do - result=$(amux --json agent capture "$SESSION" --lines "$LINES" 2>&1) || true - - ok=$(echo "$result" | jq -r '.ok // false' 2>/dev/null) || ok="false" - if [[ "$ok" != "true" ]]; then - echo '{"type":"exited","ts":"'"$(date -u +%Y-%m-%dT%H:%M:%SZ)"'"}' - exit 0 - fi - - content=$(echo "$result" | jq -r '.data.content // ""') - current_hash=$(echo -n "$content" | md5sum 2>/dev/null | cut -d' ' -f1 || echo -n "$content" | md5 2>/dev/null) - - if [[ "$current_hash" != "$last_hash" ]]; then - echo "$content" - last_hash="$current_hash" - idle_since=$(date +%s) - else - now=$(date +%s) - if [[ -n "$idle_since" ]]; then - elapsed=$((now - idle_since)) - if [[ $elapsed -ge $TIMEOUT ]]; then - echo '{"type":"idle","idle_seconds":'"$elapsed"',"ts":"'"$(date -u +%Y-%m-%dT%H:%M:%SZ)"'"}' - exit 0 - fi - fi - fi - - sleep "$INTERVAL" -done diff --git a/skills/amux/scripts/wait-for-idle.sh b/skills/amux/scripts/wait-for-idle.sh deleted file mode 100755 index 7120ce87..00000000 --- a/skills/amux/scripts/wait-for-idle.sh +++ /dev/null @@ -1,76 +0,0 @@ -#!/usr/bin/env bash -# wait-for-idle.sh — Wait for an agent to become idle. -# -# Usage: wait-for-idle.sh --session [--timeout 300] [--idle-threshold 10] -# -# Polls agent capture, detects idle state (no output change for --idle-threshold seconds). -# Returns last capture content when idle. Exits with error if --timeout exceeded. - -set -euo pipefail - -SESSION="" -TIMEOUT=300 -IDLE_THRESHOLD=10 -POLL_INTERVAL=2 - -while [[ $# -gt 0 ]]; do - case "$1" in - --session) SESSION="$2"; shift 2 ;; - --timeout) TIMEOUT="$2"; shift 2 ;; - --idle-threshold) IDLE_THRESHOLD="$2"; shift 2 ;; - *) echo "Unknown flag: $1" >&2; exit 2 ;; - esac -done - -if [[ -z "$SESSION" ]]; then - echo "Usage: wait-for-idle.sh --session [--timeout 300] [--idle-threshold 10]" >&2 - exit 2 -fi - -last_hash="" -last_content="" -idle_since="" -start_time=$(date +%s) - -while true; do - now=$(date +%s) - elapsed=$((now - start_time)) - if [[ $elapsed -ge $TIMEOUT ]]; then - echo "Timeout after ${TIMEOUT}s waiting for idle" >&2 - # Still output last content on timeout - if [[ -n "$last_content" ]]; then - echo "$last_content" - fi - exit 1 - fi - - result=$(amux --json agent capture "$SESSION" --lines 100 2>&1) || true - - ok=$(echo "$result" | jq -r '.ok // false' 2>/dev/null) || ok="false" - if [[ "$ok" != "true" ]]; then - # Session gone — treat as idle - if [[ -n "$last_content" ]]; then - echo "$last_content" - fi - exit 0 - fi - - content=$(echo "$result" | jq -r '.data.content // ""') - current_hash=$(echo -n "$content" | md5sum 2>/dev/null | cut -d' ' -f1 || echo -n "$content" | md5 2>/dev/null) - - if [[ "$current_hash" != "$last_hash" ]]; then - last_hash="$current_hash" - last_content="$content" - idle_since=$(date +%s) - else - if [[ -n "$idle_since" ]]; then - idle_elapsed=$((now - idle_since)) - if [[ $idle_elapsed -ge $IDLE_THRESHOLD ]]; then - echo "$last_content" - exit 0 - fi - fi - fi - - sleep "$POLL_INTERVAL" -done