Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <id> [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 <id> "1"` from racing the approval overlay and submitting `1` as composer text or interrupting the resumed turn.

## [1.10.9] - 2026-07-02

### Fixed
Expand Down
139 changes: 139 additions & 0 deletions cmd/agent-deck/session_approve_cmd.go
Original file line number Diff line number Diff line change
@@ -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 <id|title> [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,
)
}
3 changes: 3 additions & 0 deletions cmd/agent-deck/session_cmd.go
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,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":
Expand Down Expand Up @@ -111,6 +113,7 @@ func printSessionHelp() {
fmt.Println(" switch-account <id> <account> Switch Claude account and migrate the conversation")
fmt.Println(" move <id> <path> Move session to a new path (migrates Claude history)")
fmt.Println(" send <id> <message> Send a message to a running session")
fmt.Println(" approve <id> [choice] Resolve a visible Codex approval prompt")
fmt.Println(" output <id> Get the last response from a session")
fmt.Println(" children [id] List sub-sessions with status + last completion")
fmt.Println(" search <query> Search message content across Claude sessions")
Expand Down
2 changes: 2 additions & 0 deletions conductor/conductor-claude.md
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ You are the **Conductor** for the **{PROFILE}** profile, a persistent Claude Cod
| `agent-deck -p {PROFILE} session send <id_or_title> "message"` | Send a message. Has built-in 60s wait for agent readiness. |
| `agent-deck -p {PROFILE} session send <id_or_title> "message" --wait -q --timeout 300s` | Single-call send + wait + raw output (preferred when you need the reply now). |
| `agent-deck -p {PROFILE} session send <id_or_title> "message" --no-wait` | Send immediately without waiting for ready state. |
| `agent-deck -p {PROFILE} session approve <id_or_title> [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 |
Expand Down Expand Up @@ -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 <id> <choice>`. 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.
Expand Down
2 changes: 2 additions & 0 deletions conductor/conductor-hermes.md
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ You are the **Conductor** for the **{PROFILE}** profile, a persistent Hermes ses
| `agent-deck -p {PROFILE} session send <id_or_title> "message"` | Send a message. Has built-in 60s wait for agent readiness. |
| `agent-deck -p {PROFILE} session send <id_or_title> "message" --wait -q --timeout 300s` | Single-call send + wait + raw output (preferred when you need the reply now). |
| `agent-deck -p {PROFILE} session send <id_or_title> "message" --no-wait` | Send immediately without waiting for ready state. |
| `agent-deck -p {PROFILE} session approve <id_or_title> [once|always|session|N]` | Resolve a visible Codex approval prompt with one keypress. Never use `session send "1"` for Codex approvals. |

### Session Control

Expand Down Expand Up @@ -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 <id> <choice>`. 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.
Loading