diff --git a/CHANGELOG.md b/CHANGELOG.md index 8ca56b55..fcc49a45 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- **Prompt-aware Codex approval command.** `agent-deck session approve [once|always|session|N]` resolves a currently visible Codex approval menu with one digit keypress and no trailing Enter. It requires a live numbered approval overlay, revalidates the same prompt immediately before dispatch, and verifies that the original prompt clears without blindly retrying. This prevents `session send "1"` from racing the approval overlay and submitting `1` as composer text or interrupting the resumed turn. + ### Fixed - **A manual session rename no longer reverts to the folder default after a reload race.** When a rename's save was skipped (`isReloading=true`), the title was queued in `pendingTitleChanges` and re-applied after the storage-watcher reload — but only the title *string* was queued, not its `TitleLocked` intent. The reapplied title therefore came back **unlocked**, so the very next `#572` Claude-name sync overwrote it with Claude Code 2.1.19x's auto-derived cwd-folder name (e.g. `myproject` → `myproject-3a`). The queue now carries the lock state alongside the title: a user rename is restored **locked** (survives the sync), while a sync-sourced title stays unlocked (keeps tracking Claude). Pinned by `TestHomeRenamePendingChangeRestoresTitleLock`. (related to [#697](https://github.com/asheshgoplani/agent-deck/issues/697)) diff --git a/cmd/agent-deck/session_approve_cmd.go b/cmd/agent-deck/session_approve_cmd.go new file mode 100644 index 00000000..38b6ba7b --- /dev/null +++ b/cmd/agent-deck/session_approve_cmd.go @@ -0,0 +1,139 @@ +package main + +import ( + "flag" + "fmt" + "os" + "time" + + "github.com/asheshgoplani/agent-deck/internal/send" + "github.com/asheshgoplani/agent-deck/internal/session" +) + +// handleSessionApprove resolves one visibly active Codex approval overlay with +// a single keypress. It is intentionally separate from `session send`: an +// approval is a TUI decision event, not composer text followed by Enter. +func handleSessionApprove(profile string, args []string) { + fs := flag.NewFlagSet("session approve", flag.ExitOnError) + fs.SetOutput(os.Stdout) + choiceFlag := fs.String("choice", "", "Approval choice: once, always, session, or displayed option number") + timeout := fs.Duration("timeout", 5*time.Second, "Max time to verify that the original approval prompt cleared") + jsonOutput := fs.Bool("json", false, "Output as JSON") + quiet := fs.Bool("q", false, "Quiet mode") + + fs.Usage = func() { + fmt.Println("Usage: agent-deck session approve [choice] [options]") + fmt.Println() + fmt.Println("Resolve one currently visible Codex approval prompt with exactly one") + fmt.Println("keypress. Fails closed when no stable approval menu is visible.") + fmt.Println() + fmt.Println("Choices:") + fmt.Println(" once Select \"Yes, proceed\" (default)") + fmt.Println(" always Select the persistent/prefix approval, when offered") + fmt.Println(" session Select the session-scoped approval, when offered") + fmt.Println(" 1-9 Select that displayed option directly") + fmt.Println() + fmt.Println("Options:") + fs.PrintDefaults() + fmt.Println() + fmt.Println("Examples:") + fmt.Println(" agent-deck session approve worker") + fmt.Println(" agent-deck session approve worker always") + fmt.Println(" agent-deck session approve worker 2 --json") + } + + if err := fs.Parse(normalizeArgs(fs, args)); err != nil { + os.Exit(1) + } + remaining := fs.Args() + out := NewCLIOutput(*jsonOutput, *quiet) + + if len(remaining) < 1 { + fs.Usage() + out.Error("session is required", ErrCodeInvalidOperation) + os.Exit(1) + } + if len(remaining) > 2 { + fs.Usage() + out.Error("at most one approval choice may be specified", ErrCodeInvalidOperation) + os.Exit(1) + } + if *timeout <= 0 { + out.Error("--timeout must be greater than zero", ErrCodeInvalidOperation) + os.Exit(1) + } + + sessionRef := remaining[0] + choice := *choiceFlag + if len(remaining) == 2 { + if choice != "" { + out.Error("approval choice must be provided either positionally or with --choice, not both", ErrCodeInvalidOperation) + os.Exit(1) + } + choice = remaining[1] + } + if choice == "" { + choice = "once" + } + + _, instances, _, err := loadSessionData(profile) + if err != nil { + out.Error(err.Error(), ErrCodeNotFound) + os.Exit(1) + } + + inst, errMsg, errCode := ResolveSession(sessionRef, instances) + if inst == nil { + out.Error(errMsg, errCode) + if errCode == ErrCodeNotFound { + os.Exit(2) + } + os.Exit(1) + return + } + if !session.IsCodexCompatible(inst.Tool) { + out.Error( + fmt.Sprintf("session approve currently supports Codex sessions; '%s' uses %s", inst.Title, inst.Tool), + ErrCodeInvalidOperation, + ) + os.Exit(1) + } + if !inst.Exists() { + out.Error(fmt.Sprintf("session '%s' is not running", inst.Title), ErrCodeInvalidOperation) + os.Exit(1) + } + + tmuxSess := inst.GetTmuxSession() + if tmuxSess == nil { + out.Error("could not determine tmux session", ErrCodeInvalidOperation) + os.Exit(1) + } + + result, approveErr := send.ApproveCodexPrompt(tmuxSess, choice, send.CodexApprovalOptions{ + VerifyTimeout: *timeout, + }) + data := map[string]interface{}{ + "success": approveErr == nil, + "session_id": inst.ID, + "session_title": inst.Title, + "choice": result.Choice, + "option_number": result.OptionNumber, + "option_label": result.OptionLabel, + "key_sent": result.KeySent, + "verified": result.Verified, + "next_prompt_seen": result.NextPromptSeen, + } + if approveErr != nil { + code := ErrCodeInvalidOperation + if result.KeySent { + code = ErrCodeDeliveryFailed + } + out.ErrorWithData(fmt.Sprintf("failed to approve Codex prompt: %v", approveErr), code, data) + os.Exit(1) + } + + out.Success( + fmt.Sprintf("Approved option %d in '%s'", result.OptionNumber, inst.Title), + data, + ) +} diff --git a/cmd/agent-deck/session_cmd.go b/cmd/agent-deck/session_cmd.go index 10ffcaca..38070332 100644 --- a/cmd/agent-deck/session_cmd.go +++ b/cmd/agent-deck/session_cmd.go @@ -80,6 +80,8 @@ func handleSession(profile string, args []string) { handleSessionMove(profile, args[1:]) case "send": handleSessionSend(profile, args[1:]) + case "approve": + handleSessionApprove(profile, args[1:]) case "send-keys": handleSessionSendKeys(profile, args[1:]) case "output": @@ -120,6 +122,7 @@ func printSessionHelp() { fmt.Println(" switch-account Switch Claude account and migrate the conversation") fmt.Println(" move Move session to a new path (migrates Claude history)") fmt.Println(" send Send a message to a running session") + fmt.Println(" approve [choice] Resolve a visible Codex approval prompt") fmt.Println(" output Get the last response from a session") fmt.Println(" children [id] List sub-sessions with status + last completion") fmt.Println(" search Search message content across Claude sessions") diff --git a/conductor/conductor-claude.md b/conductor/conductor-claude.md index b5f606fe..002b8308 100644 --- a/conductor/conductor-claude.md +++ b/conductor/conductor-claude.md @@ -44,6 +44,7 @@ You are the **Conductor** for the **{PROFILE}** profile, a persistent Claude Cod | `agent-deck -p {PROFILE} session send "message"` | Send a message. Has built-in 60s wait for agent readiness. | | `agent-deck -p {PROFILE} session send "message" --wait -q --timeout 300s` | Single-call send + wait + raw output (preferred when you need the reply now). | | `agent-deck -p {PROFILE} session send "message" --no-wait` | Send immediately without waiting for ready state. | +| `agent-deck -p {PROFILE} session approve [once|always|session|N]` | Resolve a visible Codex approval prompt with one keypress. Never use `session send "1"` for Codex approvals. | ### Session Control | Command | Description | @@ -209,6 +210,7 @@ When you first start (or after a restart): - You cannot directly access other sessions' files. Use `session output` to read their latest response. - Prefer `launch ... -m "prompt"` over separate `add` + `session start` + `session send` when creating a new task session. - `session send` waits up to 60 seconds for the agent to be ready. If the session is running (busy), the send will wait. +- When a Codex child shows a numbered approval menu, use `session approve `. A digit sent through `session send` is composer text plus Enter and can interrupt the resumed turn. - The bridge sends with `session send --wait -q` and waits in a single CLI call. Reply promptly. - Your own session can be restarted by the bridge if it detects you're in an error state. - Keep state.json small (no large output dumps). Store summaries, not full text. diff --git a/conductor/conductor-hermes.md b/conductor/conductor-hermes.md index 335e5c86..ba6ed5c6 100644 --- a/conductor/conductor-hermes.md +++ b/conductor/conductor-hermes.md @@ -47,6 +47,7 @@ You are the **Conductor** for the **{PROFILE}** profile, a persistent Hermes ses | `agent-deck -p {PROFILE} session send "message"` | Send a message. Has built-in 60s wait for agent readiness. | | `agent-deck -p {PROFILE} session send "message" --wait -q --timeout 300s` | Single-call send + wait + raw output (preferred when you need the reply now). | | `agent-deck -p {PROFILE} session send "message" --no-wait` | Send immediately without waiting for ready state. | +| `agent-deck -p {PROFILE} session approve [once|always|session|N]` | Resolve a visible Codex approval prompt with one keypress. Never use `session send "1"` for Codex approvals. | ### Session Control @@ -268,6 +269,7 @@ When you first start (or after a restart): - You cannot directly access other sessions' files. Use `session output` to read their latest response. - Prefer `launch ... -m "prompt"` over separate `add` + `session start` + `session send` when creating a new task session. - `session send` waits up to 60 seconds for the agent to be ready. If the session is running (busy), the send will wait. +- When a Codex child shows a numbered approval menu, use `session approve `. A digit sent through `session send` is composer text plus Enter and can interrupt the resumed turn. - Keep state.json small (no large output dumps). Store summaries, not full text. - Your own session can be restarted by the bridge if it detects you're in an error state. - Hermes Kanban tasks assigned to a profile are auto-dispatched by the gateway's built-in dispatcher — you don't need to start them manually. diff --git a/internal/send/codex_approval.go b/internal/send/codex_approval.go new file mode 100644 index 00000000..cfe20cc9 --- /dev/null +++ b/internal/send/codex_approval.go @@ -0,0 +1,269 @@ +package send + +import ( + "fmt" + "regexp" + "strconv" + "strings" + "time" + + "github.com/asheshgoplani/agent-deck/internal/tmux" +) + +// CodexApprovalTarget is the minimum tmux surface needed to safely resolve a +// Codex approval overlay. SendNamedKey emits one tmux key event without an +// implicit Enter or message/paste semantics. +type CodexApprovalTarget interface { + CapturePaneFresh() (string, error) + SendNamedKey(string) error +} + +// CodexApprovalOptions controls the bounded post-key verification. +type CodexApprovalOptions struct { + VerifyTimeout time.Duration + PollInterval time.Duration +} + +// CodexApprovalResult describes the option selected and whether the original +// approval overlay was observed disappearing. +type CodexApprovalResult struct { + Choice string + OptionNumber int + OptionLabel string + KeySent bool + Verified bool + NextPromptSeen bool +} + +type codexApprovalOption struct { + number int + label string +} + +type codexApprovalPrompt struct { + fingerprint string + options []codexApprovalOption +} + +var codexApprovalOptionPattern = regexp.MustCompile(`^\s*(›\s*)?([1-9][0-9]*)\.\s+(.+?)\s*$`) + +// ApproveCodexPrompt resolves one currently visible Codex approval menu. +// +// choice accepts a displayed option number, or one of "once", "always", and +// "session". The displayed number is sent as one literal keypress; Enter is +// intentionally not sent because Codex selects numbered approval options on +// the digit KeyEvent itself. +func ApproveCodexPrompt(target CodexApprovalTarget, choice string, opts CodexApprovalOptions) (CodexApprovalResult, error) { + var result CodexApprovalResult + if target == nil { + return result, fmt.Errorf("approval target is nil") + } + if opts.VerifyTimeout <= 0 { + opts.VerifyTimeout = 5 * time.Second + } + if opts.PollInterval <= 0 { + opts.PollInterval = 50 * time.Millisecond + } + + first, err := captureCodexApprovalPrompt(target) + if err != nil { + return result, err + } + if first == nil { + return result, fmt.Errorf("no active Codex approval prompt found") + } + + selected, normalizedChoice, err := selectCodexApprovalOption(first, choice) + if err != nil { + return result, err + } + result.Choice = normalizedChoice + result.OptionNumber = selected.number + result.OptionLabel = selected.label + + // Re-read immediately before dispatch. This closes the widest part of the + // capture→send race and, importantly, fails closed rather than typing a + // digit into Codex's normal composer when the overlay has changed. + second, err := captureCodexApprovalPrompt(target) + if err != nil { + return result, err + } + if second == nil || second.fingerprint != first.fingerprint { + return result, fmt.Errorf("Codex approval prompt changed before the key could be sent") + } + if _, _, err := selectCodexApprovalOption(second, strconv.Itoa(selected.number)); err != nil { + return result, fmt.Errorf("Codex approval option changed before dispatch: %w", err) + } + + if err := target.SendNamedKey(strconv.Itoa(selected.number)); err != nil { + return result, fmt.Errorf("send Codex approval key: %w", err) + } + result.KeySent = true + + deadline := time.Now().Add(opts.VerifyTimeout) + for { + current, captureErr := captureCodexApprovalPrompt(target) + if captureErr == nil { + if current == nil { + result.Verified = true + return result, nil + } + if current.fingerprint != first.fingerprint { + result.Verified = true + result.NextPromptSeen = true + return result, nil + } + } + if !time.Now().Before(deadline) { + return result, fmt.Errorf( + "approval key %d was sent, but the original Codex prompt did not clear within %s; not retrying automatically", + selected.number, opts.VerifyTimeout, + ) + } + time.Sleep(opts.PollInterval) + } +} + +func captureCodexApprovalPrompt(target CodexApprovalTarget) (*codexApprovalPrompt, error) { + raw, err := target.CapturePaneFresh() + if err != nil { + return nil, fmt.Errorf("capture Codex pane: %w", err) + } + return detectCodexApprovalPrompt(tmux.StripANSI(raw)), nil +} + +// detectCodexApprovalPrompt recognizes only a live numbered approval overlay. +// Requiring a selected "› N." row plus both affirmative and negative options +// avoids confusing the ordinary "›" composer or stale approval history for a +// current decision gate. +func detectCodexApprovalPrompt(content string) *codexApprovalPrompt { + lines := strings.Split(content, "\n") + start := len(lines) - 40 + if start < 0 { + start = 0 + } + + selectedLine := -1 + for i := len(lines) - 1; i >= start; i-- { + match := codexApprovalOptionPattern.FindStringSubmatch(lines[i]) + if match != nil && strings.TrimSpace(match[1]) != "" { + selectedLine = i + break + } + } + if selectedLine < 0 { + return nil + } + + blockStart := selectedLine - 12 + if blockStart < start { + blockStart = start + } + blockEnd := selectedLine + 16 + if blockEnd >= len(lines) { + blockEnd = len(lines) - 1 + } + + var options []codexApprovalOption + hasYes := false + hasNo := false + firstOptionLine := -1 + lastOptionLine := selectedLine + for i := blockStart; i <= blockEnd; i++ { + match := codexApprovalOptionPattern.FindStringSubmatch(lines[i]) + if match == nil { + continue + } + number, err := strconv.Atoi(match[2]) + if err != nil { + continue + } + label := strings.Join(strings.Fields(match[3]), " ") + lower := strings.ToLower(label) + hasYes = hasYes || strings.HasPrefix(lower, "yes") + hasNo = hasNo || strings.HasPrefix(lower, "no") + options = append(options, codexApprovalOption{number: number, label: label}) + if firstOptionLine < 0 { + firstOptionLine = i + } + lastOptionLine = i + } + if len(options) < 2 || !hasYes || !hasNo { + return nil + } + + // Include the stable request context above the menu, not just its generic + // option labels, so a queued second approval is recognized as a new prompt. + contextStart := firstOptionLine - 12 + if contextStart < start { + contextStart = start + } + var fingerprintLines []string + for i := contextStart; i <= lastOptionLine; i++ { + line := strings.TrimSpace(lines[i]) + line = strings.TrimSpace(strings.TrimPrefix(line, "›")) + normalized := strings.Join(strings.Fields(line), " ") + if normalized != "" { + fingerprintLines = append(fingerprintLines, normalized) + } + } + + return &codexApprovalPrompt{ + fingerprint: strings.Join(fingerprintLines, "\n"), + options: options, + } +} + +func selectCodexApprovalOption(prompt *codexApprovalPrompt, choice string) (codexApprovalOption, string, error) { + if prompt == nil { + return codexApprovalOption{}, "", fmt.Errorf("no Codex approval prompt") + } + normalized := strings.ToLower(strings.TrimSpace(choice)) + if normalized == "" { + normalized = "once" + } + + if number, err := strconv.Atoi(normalized); err == nil { + if number < 1 || number > 9 { + return codexApprovalOption{}, normalized, fmt.Errorf( + "Codex approval option %d cannot be sent as a single keypress", number, + ) + } + for _, option := range prompt.options { + if option.number == number { + return option, strconv.Itoa(number), nil + } + } + return codexApprovalOption{}, normalized, fmt.Errorf("Codex approval option %d is not displayed", number) + } + + for _, option := range prompt.options { + label := strings.ToLower(option.label) + switch normalized { + case "once": + if strings.HasPrefix(label, "yes") && + (strings.Contains(label, "proceed") || strings.Contains(label, "just this once")) { + return option, normalized, nil + } + case "always", "prefix": + if strings.HasPrefix(label, "yes") && + !strings.Contains(label, "this session") && + !strings.Contains(label, "this conversation") && + (strings.Contains(label, "don't ask again") || strings.Contains(label, "in the future")) { + return option, "always", nil + } + case "session": + if strings.HasPrefix(label, "yes") && + (strings.Contains(label, "this session") || strings.Contains(label, "this conversation")) { + return option, normalized, nil + } + default: + return codexApprovalOption{}, normalized, fmt.Errorf( + "invalid approval choice %q (use once, always, session, or a displayed option number)", + choice, + ) + } + } + + return codexApprovalOption{}, normalized, fmt.Errorf("Codex approval prompt does not offer choice %q", normalized) +} diff --git a/internal/send/codex_approval_test.go b/internal/send/codex_approval_test.go new file mode 100644 index 00000000..26b1bdac --- /dev/null +++ b/internal/send/codex_approval_test.go @@ -0,0 +1,189 @@ +package send + +import ( + "errors" + "strings" + "testing" + "time" +) + +const codexExecApprovalPrompt = `• Running curl https://example.com + + Would you like to run the following command? + + $ curl https://example.com + +› 1. Yes, proceed (y) + 2. Yes, and don't ask again for commands that start with ` + "`curl`" + ` (p) + 3. No, and tell Codex what to do differently (esc) +` + +type fakeCodexApprovalTarget struct { + captures []string + index int + sent []string + sendErr error +} + +func (f *fakeCodexApprovalTarget) CapturePaneFresh() (string, error) { + if len(f.captures) == 0 { + return "", errors.New("no captures configured") + } + idx := f.index + if idx >= len(f.captures) { + idx = len(f.captures) - 1 + } + f.index++ + return f.captures[idx], nil +} + +func (f *fakeCodexApprovalTarget) SendNamedKey(key string) error { + f.sent = append(f.sent, key) + return f.sendErr +} + +func TestDetectCodexApprovalPrompt_RequiresLiveSelectedMenu(t *testing.T) { + if got := detectCodexApprovalPrompt(codexExecApprovalPrompt); got == nil { + t.Fatal("expected live Codex approval prompt to be detected") + } + + staleHistoryAndComposer := strings.Replace(codexExecApprovalPrompt, "› 1.", " 1.", 1) + + "\n› 1\n gpt-5.4 · /tmp/project\n" + if got := detectCodexApprovalPrompt(staleHistoryAndComposer); got != nil { + t.Fatal("stale approval history plus a normal composer must not be detected") + } +} + +func TestApproveCodexPrompt_OnceSendsOneDigitWithoutEnter(t *testing.T) { + target := &fakeCodexApprovalTarget{ + captures: []string{ + codexExecApprovalPrompt, + codexExecApprovalPrompt, + "• Running curl https://example.com\n press esc to interrupt\n", + }, + } + + result, err := ApproveCodexPrompt(target, "once", CodexApprovalOptions{ + VerifyTimeout: 50 * time.Millisecond, + PollInterval: time.Millisecond, + }) + if err != nil { + t.Fatalf("ApproveCodexPrompt: %v", err) + } + if len(target.sent) != 1 || target.sent[0] != "1" { + t.Fatalf("sent keys = %v, want exactly [1]", target.sent) + } + if !result.KeySent || !result.Verified || result.OptionNumber != 1 { + t.Fatalf("unexpected result: %+v", result) + } +} + +func TestApproveCodexPrompt_AlwaysSelectsDisplayedPersistentOption(t *testing.T) { + target := &fakeCodexApprovalTarget{ + captures: []string{ + codexExecApprovalPrompt, + codexExecApprovalPrompt, + "› Continue with another task\n", + }, + } + + result, err := ApproveCodexPrompt(target, "always", CodexApprovalOptions{ + VerifyTimeout: 50 * time.Millisecond, + PollInterval: time.Millisecond, + }) + if err != nil { + t.Fatalf("ApproveCodexPrompt: %v", err) + } + if len(target.sent) != 1 || target.sent[0] != "2" { + t.Fatalf("sent keys = %v, want exactly [2]", target.sent) + } + if result.OptionNumber != 2 { + t.Fatalf("option number = %d, want 2", result.OptionNumber) + } +} + +func TestApproveCodexPrompt_NumericChoiceMustBeDisplayed(t *testing.T) { + target := &fakeCodexApprovalTarget{captures: []string{codexExecApprovalPrompt}} + + _, err := ApproveCodexPrompt(target, "4", CodexApprovalOptions{}) + if err == nil || !strings.Contains(err.Error(), "not displayed") { + t.Fatalf("expected not-displayed error, got %v", err) + } + if len(target.sent) != 0 { + t.Fatalf("must fail before sending; sent %v", target.sent) + } +} + +func TestApproveCodexPrompt_RejectsMultiDigitOption(t *testing.T) { + prompt := strings.Replace(codexExecApprovalPrompt, + " 3. No, and tell Codex what to do differently (esc)", + " 10. No, and tell Codex what to do differently (esc)", 1) + target := &fakeCodexApprovalTarget{captures: []string{prompt}} + + _, err := ApproveCodexPrompt(target, "10", CodexApprovalOptions{}) + if err == nil || !strings.Contains(err.Error(), "single keypress") { + t.Fatalf("expected single-keypress error, got %v", err) + } + if len(target.sent) != 0 { + t.Fatalf("multi-digit option must fail before sending; sent %v", target.sent) + } +} + +func TestApproveCodexPrompt_HighlightMovementDoesNotVerify(t *testing.T) { + moved := strings.Replace(codexExecApprovalPrompt, "› 1.", " 1.", 1) + moved = strings.Replace(moved, " 2.", "› 2.", 1) + target := &fakeCodexApprovalTarget{ + captures: []string{codexExecApprovalPrompt, codexExecApprovalPrompt, moved}, + } + + result, err := ApproveCodexPrompt(target, "1", CodexApprovalOptions{ + VerifyTimeout: 2 * time.Millisecond, + PollInterval: 100 * time.Microsecond, + }) + if err == nil || !strings.Contains(err.Error(), "did not clear") { + t.Fatalf("highlight movement must not verify the prompt, got %v", err) + } + if !result.KeySent || result.Verified { + t.Fatalf("unexpected result: %+v", result) + } +} + +func TestApproveCodexPrompt_FailsClosedWhenPromptChangesBeforeSend(t *testing.T) { + changed := strings.Replace( + codexExecApprovalPrompt, + "curl https://example.com", + "curl https://openai.com", + -1, + ) + target := &fakeCodexApprovalTarget{ + captures: []string{codexExecApprovalPrompt, changed}, + } + + _, err := ApproveCodexPrompt(target, "1", CodexApprovalOptions{}) + if err == nil || !strings.Contains(err.Error(), "changed before") { + t.Fatalf("expected changed-prompt error, got %v", err) + } + if len(target.sent) != 0 { + t.Fatalf("changed prompt must not receive a key; sent %v", target.sent) + } +} + +func TestApproveCodexPrompt_DoesNotRetryUnverifiedKey(t *testing.T) { + target := &fakeCodexApprovalTarget{ + captures: []string{codexExecApprovalPrompt}, + } + + result, err := ApproveCodexPrompt(target, "1", CodexApprovalOptions{ + VerifyTimeout: 2 * time.Millisecond, + PollInterval: 100 * time.Microsecond, + }) + if err == nil || !strings.Contains(err.Error(), "not retrying automatically") { + t.Fatalf("expected bounded verification error, got %v", err) + } + if len(target.sent) != 1 || target.sent[0] != "1" { + t.Fatalf("sent keys = %v, want exactly one [1]", target.sent) + } + if !result.KeySent || result.Verified { + t.Fatalf("unexpected result: %+v", result) + } +} diff --git a/internal/session/conductor_templates.go b/internal/session/conductor_templates.go index 9ef65971..f0435464 100644 --- a/internal/session/conductor_templates.go +++ b/internal/session/conductor_templates.go @@ -30,6 +30,7 @@ Each conductor has its own identity in its subdirectory and its own policy in PO | ` + "`" + `agent-deck -p session send "message"` + "`" + ` | Send a message. Has built-in 60s wait for agent readiness. | | ` + "`" + `agent-deck -p session send "message" --wait -q --timeout 300s` + "`" + ` | Single-call send + wait + raw output (preferred when you need the reply now). | | ` + "`" + `agent-deck -p session send "message" --no-wait` + "`" + ` | Send immediately without waiting for ready state. | +| ` + "`" + `agent-deck -p session approve [once|always|session|N]` + "`" + ` | Resolve a visible Codex approval prompt with one keypress. Never use ` + "`" + `session send "1"` + "`" + ` for Codex approvals. | ### Session Control | Command | Description | @@ -202,6 +203,7 @@ If the bridge cannot resolve a name (temporary API failure), the raw Slack ID ap - Keep parent linkage for event routing; if you need a specific group, pass ` + "`" + `-g ` + "`" + ` explicitly (it overrides inherited parent group). - Transition notifications are parent-linked. If ` + "`" + `parent_session_id` + "`" + ` is empty or points elsewhere, this conductor will not receive child completion events. - ` + "`" + `session send` + "`" + ` waits up to ~80 seconds for the agent to be ready. If the session is running (busy), the send will wait. +- When a Codex child shows a numbered approval menu, use ` + "`" + `session approve ` + "`" + `. A digit sent through ` + "`" + `session send` + "`" + ` is composer text plus Enter and can interrupt the resumed turn. - For periodic nudges/heartbeats where blocking is harmful, prefer ` + "`" + `session send --no-wait -q` + "`" + `. - Remote channels send with ` + "`" + `session send --wait -q` + "`" + ` and wait in a single CLI call. Reply promptly. - Your own session can be restarted by the bridge if it detects you're in an error state. diff --git a/skills/agent-deck/references/cli-reference.md b/skills/agent-deck/references/cli-reference.md index e21e3b2f..49e6b2a7 100644 --- a/skills/agent-deck/references/cli-reference.md +++ b/skills/agent-deck/references/cli-reference.md @@ -248,6 +248,18 @@ Default behavior: - If Claude leaves a pasted prompt unsent (`[Pasted text ...]`), retries `Enter` automatically. - Avoids unnecessary retry `Enter` presses when session is already `waiting`/`idle`. +### session approve + +```bash +agent-deck session approve [once|always|session|N] [--timeout 5s] [-q] [--json] +``` + +Resolves one currently visible Codex numbered approval menu. It validates that +the same menu is still visible immediately before sending one digit keypress, +then verifies that the original prompt clears. It never sends Enter or retries +the decision automatically. Do not use `session send "1"` for a Codex +approval: that path sends composer text followed by Enter. + ### session output ```bash diff --git a/skills/fleet/SKILL.md b/skills/fleet/SKILL.md index 2d4a26ca..16f6a494 100644 --- a/skills/fleet/SKILL.md +++ b/skills/fleet/SKILL.md @@ -118,6 +118,9 @@ with `--no-transition-notify`. To answer it: agent-deck session output --json # Send the answer (child keeps running afterward): agent-deck session send "" + +# Codex numbered approval prompt: send one decision key, not composer text: +agent-deck session approve once ``` `session send` flags: `--wait` (block until it finishes the turn, then print @@ -200,6 +203,10 @@ All read-only / on-demand — none of them block your session: - `agent-deck session output --json` — a child's latest full response. - `agent-deck session send "" [--wait|--stream|--no-wait|--draft]` — send a follow-up / answer a `waiting` child. +- `agent-deck session approve [once|always|session|N]` — resolve one + visibly active Codex approval menu. Do not use `session send "1"`: + Codex consumes the digit as a decision key, while `session send` adds a + trailing Enter that can land in the resumed turn. - `agent-deck status -q` — global count of sessions currently `waiting`; a cheap coarse heartbeat across everything, not just your children. - `agent-deck inbox drain --json ` — **consumes** the pushed